diff options
Diffstat (limited to 'lib/Plugins.Essentials')
11 files changed, 521 insertions, 221 deletions
diff --git a/lib/Plugins.Essentials/src/Accounts/AccountUtils.cs b/lib/Plugins.Essentials/src/Accounts/AccountUtils.cs index 396d496..38c62e0 100644 --- a/lib/Plugins.Essentials/src/Accounts/AccountUtils.cs +++ b/lib/Plugins.Essentials/src/Accounts/AccountUtils.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2023 Vaughn Nugent +* Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -26,7 +26,6 @@ using System; using System.Threading; using System.Threading.Tasks; using System.Security.Cryptography; -using System.Text.RegularExpressions; using System.Runtime.CompilerServices; using VNLib.Hashing; @@ -81,12 +80,6 @@ namespace VNLib.Plugins.Essentials.Accounts public const ulong MINIMUM_LEVEL = 0x0000000100000001L; - - /// <summary> - /// Speical character regual expresion for basic checks - /// </summary> - public static readonly Regex SpecialCharacters = new(@"[\r\n\t\a\b\e\f#?!@$%^&*\+\-\~`|<>\{}]", RegexOptions.Compiled); - #region Password/User helper extensions /// <summary> @@ -101,8 +94,10 @@ namespace VNLib.Plugins.Essentials.Accounts /// <returns>A value greater than 0 if successful, 0 or negative values if a failure occured</returns> public static async Task<ERRNO> ValidatePasswordAsync(this IUserManager manager, IUser user, string password, PassValidateFlags flags, CancellationToken cancellation) { - _ = manager ?? throw new ArgumentNullException(nameof(manager)); - using PrivateString ps = new(password, false); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(manager); + + using PrivateString ps = PrivateString.ToPrivateString(password, false); return await manager.ValidatePasswordAsync(user, ps, flags, cancellation).ConfigureAwait(false); } @@ -118,8 +113,10 @@ namespace VNLib.Plugins.Essentials.Accounts /// <returns>The result of the operation, the result should be 1 (aka true)</returns> public static async Task<ERRNO> UpdatePasswordAsync(this IUserManager manager, IUser user, string password, CancellationToken cancellation = default) { - _ = manager ?? throw new ArgumentNullException(nameof(manager)); - using PrivateString ps = new(password, false); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(manager); + + using PrivateString ps = PrivateString.ToPrivateString(password, false); return await manager.UpdatePasswordAsync(user, ps, cancellation).ConfigureAwait(false); } @@ -140,6 +137,7 @@ namespace VNLib.Plugins.Essentials.Accounts /// <returns>The origin of the account</returns> [MethodImpl(MethodImplOptions.AggressiveInlining)] public static string GetAccountOrigin(this IUser ud) => ud[ACC_ORIGIN_ENTRY]; + /// <summary> /// If this account was created by any means other than a local account creation. /// Implementors can use this method to specify the origin of the account. This field is not required @@ -158,7 +156,7 @@ namespace VNLib.Plugins.Essentials.Accounts /// <returns>A <see cref="PrivateString"/> that contains the new password hash</returns> public static PrivateString GetRandomPassword(this IPasswordHashingProvider hashing, int size = RANDOM_PASS_SIZE) { - _ = hashing ?? throw new ArgumentNullException(nameof(hashing)); + ArgumentNullException.ThrowIfNull(hashing); //Get random bytes using UnsafeMemoryHandle<byte> randBuffer = MemoryUtil.UnsafeAlloc(size); @@ -175,7 +173,7 @@ namespace VNLib.Plugins.Essentials.Accounts finally { //Zero the block and return to pool - MemoryUtil.InitializeBlock(randBuffer.Span); + MemoryUtil.InitializeBlock(ref randBuffer.GetReference(), size); } } @@ -190,10 +188,10 @@ namespace VNLib.Plugins.Essentials.Accounts [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool Verify(this IPasswordHashingProvider provider, PrivateString passHash, PrivateString password) { - _ = provider ?? throw new ArgumentNullException(nameof(provider)); - _ = password ?? throw new ArgumentNullException(nameof(password)); - _ = passHash ?? throw new ArgumentNullException(nameof(passHash)); - + ArgumentNullException.ThrowIfNull(provider); + ArgumentNullException.ThrowIfNull(passHash); + ArgumentNullException.ThrowIfNull(password); + //Casting PrivateStrings to spans will reference the base string directly return provider.Verify(passHash.ToReadOnlySpan(), password.ToReadOnlySpan()); } @@ -208,8 +206,8 @@ namespace VNLib.Plugins.Essentials.Accounts [MethodImpl(MethodImplOptions.AggressiveInlining)] public static PrivateString Hash(this IPasswordHashingProvider provider, PrivateString password) { - _ = provider ?? throw new ArgumentNullException(nameof(provider)); - _ = password ?? throw new ArgumentNullException(nameof(password)); + ArgumentNullException.ThrowIfNull(provider); + ArgumentNullException.ThrowIfNull(password); return provider.Hash(password.ToReadOnlySpan()); } @@ -255,7 +253,8 @@ namespace VNLib.Plugins.Essentials.Accounts /// <exception cref="InvalidOperationException"></exception> public static IClientAuthorization GenerateAuthorization(this HttpEntity entity, IClientSecInfo secInfo, IUser user) { - _ = secInfo ?? throw new ArgumentNullException(nameof(secInfo)); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(secInfo); if (!entity.Session.IsSet || entity.Session.SessionType != SessionType.Web) { @@ -383,7 +382,7 @@ namespace VNLib.Plugins.Essentials.Accounts /// <exception cref="NotSupportedException"></exception> public static ERRNO TryEncryptClientData(this HttpEntity entity, IClientSecInfo secInfo, ReadOnlySpan<byte> data, Span<byte> output) { - _ = secInfo ?? throw new ArgumentNullException(nameof(secInfo)); + ArgumentNullException.ThrowIfNull(secInfo); //Use the default sec provider IAccountSecurityProvider prov = entity.GetSecProviderOrThrow(); diff --git a/lib/Plugins.Essentials/src/Content/DirectFileStream.cs b/lib/Plugins.Essentials/src/Content/DirectFileStream.cs new file mode 100644 index 0000000..67a6524 --- /dev/null +++ b/lib/Plugins.Essentials/src/Content/DirectFileStream.cs @@ -0,0 +1,120 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: DirectFileStream.cs +* +* DirectFileStream.cs is part of VNLib.Plugins.Essentials which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Threading.Tasks; + +using Microsoft.Win32.SafeHandles; + +using VNLib.Utils; +using VNLib.Net.Http; + +#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task + +namespace VNLib.Plugins.Essentials.Content +{ + + /* + * Why does this file exist? Well, in .NET streaming files is slow as crap. + * It is by-far the largest bottleneck in this framework. + * + * I wanted more direct control over file access for future file performance + * improvments. For now using the RandomAccess class bypasses the internal + * buffering used by the filestream class. I saw almost no difference in + * per-request performance but a slight reduction in processor usage across + * profiling sessions. This class also makes use of the new IHttpStreamResponse + * interface. + */ + + internal sealed class DirectFileStream(SafeFileHandle fileHandle) : VnDisposeable, IHttpStreamResponse + { + private long _position; + + /// <summary> + /// Gets the current file pointer position + /// </summary> + public long Position => _position; + + /// <summary> + /// Gets the length of the file + /// </summary> + public readonly long Length = RandomAccess.GetLength(fileHandle); + + ///<inheritdoc/> + public async ValueTask<int> ReadAsync(Memory<byte> buffer) + { + //Read data from the file into the buffer, using the current position as the starting offset + long read = await RandomAccess.ReadAsync(fileHandle, buffer, _position, default); + + _position += read; + + return (int)read; + } + + ///<inheritdoc/> + public ValueTask DisposeAsync() + { + //Interal dispose + Dispose(); + return ValueTask.CompletedTask; + } + + ///<inheritdoc/> + protected override void Free() => fileHandle.Dispose(); + + /// <summary> + /// Equivalent to <see cref="FileStream.Seek(long, SeekOrigin)"/> but for a + /// <see cref="DirectFileStream"/> + /// </summary> + /// <param name="offset">The number in bytes to see the stream position to</param> + /// <param name="origin">The offset origin</param> + public void Seek(long offset, SeekOrigin origin) + { + switch (origin) + { + case SeekOrigin.Begin: + ArgumentOutOfRangeException.ThrowIfNegative(offset); + _position = offset; + break; + case SeekOrigin.Current: + ArgumentOutOfRangeException.ThrowIfGreaterThan(_position + offset, Length); + _position += offset; + break; + case SeekOrigin.End: + ArgumentOutOfRangeException.ThrowIfGreaterThan(offset, 0); + _position = Length + offset; + break; + } + } + + /// <summary> + /// Opens a file for direct access with default options + /// </summary> + /// <param name="fileName">The name of the file to open</param> + /// <returns>The new direct file-stream</returns> + public static DirectFileStream Open(string fileName) + => new(File.OpenHandle(fileName, options: FileOptions.SequentialScan | FileOptions.Asynchronous)); + } +}
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/EventProcessor.cs b/lib/Plugins.Essentials/src/EventProcessor.cs index 861f318..61a9b64 100644 --- a/lib/Plugins.Essentials/src/EventProcessor.cs +++ b/lib/Plugins.Essentials/src/EventProcessor.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2023 Vaughn Nugent +* Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -29,6 +29,8 @@ using System.Threading; using System.Net.Sockets; using System.Threading.Tasks; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Runtime.CompilerServices; using VNLib.Net.Http; using VNLib.Utils.IO; @@ -51,7 +53,7 @@ namespace VNLib.Plugins.Essentials /// that breaks down simple processing procedures, routing, and session /// loading. /// </summary> - public abstract class EventProcessor : IWebRoot, IWebProcessor + public abstract class EventProcessor(EventProcessorConfig config) : IWebRoot, IWebProcessor { private static readonly AsyncLocal<EventProcessor?> _currentProcessor = new(); @@ -61,24 +63,6 @@ namespace VNLib.Plugins.Essentials public static EventProcessor? Current => _currentProcessor.Value; /// <summary> - /// The filesystem entrypoint path for the site - /// </summary> - public abstract string Directory { get; } - - ///<inheritdoc/> - public abstract string Hostname { get; } - - /// <summary> - /// Gets the EP processing options - /// </summary> - public abstract IEpProcessingOptions Options { get; } - - /// <summary> - /// Event log provider - /// </summary> - protected abstract ILogProvider Log { get; } - - /// <summary> /// <para> /// Called when the server intends to process a file and requires translation from a /// uri path to a usable filesystem path @@ -125,73 +109,81 @@ namespace VNLib.Plugins.Essentials public abstract void PostProcessEntity(HttpEntity entity, ref FileProcessArgs chosenRoutine); ///<inheritdoc/> - public abstract IAccountSecurityProvider AccountSecurity { get; } - - /// <summary> - /// The table of virtual endpoints that will be used to process requests - /// </summary> - /// <remarks> - /// May be overriden to provide a custom endpoint table - /// </remarks> - public virtual IVirtualEndpointTable EndpointTable { get; } = new SemiConsistentVeTable(); - - /// <summary> - /// The middleware chain that will be used to process requests - /// </summary> - /// <remarks> - /// If derrieved, may be overriden to provide a custom middleware chain - /// </remarks> - public virtual IHttpMiddlewareChain MiddlewareChain { get; } = new SemiConistentMiddlewareChain(); - - - /// <summary> - /// An <see cref="ISessionProvider"/> that connects stateful sessions to - /// HTTP connections - /// </summary> - private ISessionProvider? Sessions; + public virtual EventProcessorConfig Options => config; + ///<inheritdoc/> + public string Hostname => config.Hostname; + + + /* + * Okay. So this is suposed to be a stupid fast lookup table for lock-free + * service pool exchanges. The goal is for future runtime service expansion. + * + * The reason lookups must be unnoticabyly fast is because the should be + * VERY rarley changed and will be read on every request. + * + * The goal of this table specifially is to make sure requesting a desired + * service is extremely fast and does not require any locks or synchronization. + */ + const int SESS_INDEX = 0; + const int ROUTER_INDEX = 1; + const int SEC_INDEX = 2; + /// <summary> - /// Sets or resets the current <see cref="ISessionProvider"/> - /// for all connections + /// The internal service pool for the processor /// </summary> - /// <param name="sp">The new <see cref="ISessionProvider"/></param> - public void SetSessionProvider(ISessionProvider? sp) => _ = Interlocked.Exchange(ref Sessions, sp); + protected readonly HttpProcessorServicePool ServicePool = new([ + typeof(ISessionProvider), //Order must match the indexes above + typeof(IPageRouter), + typeof(IAccountSecurityProvider) + ]); + + /* + * Fields are not marked as volatile because they should not + * really be updated at all in production uses, and if hot-reload + * is used, I don't consider a dirty read to be a large enough + * problem here. + */ - /// <summary> - /// An <see cref="IPageRouter"/> to route files to be processed - /// </summary> - private IPageRouter? Router; - - /// <summary> - /// Sets or resets the current <see cref="IPageRouter"/> - /// for all connections - /// </summary> - /// <param name="router"><see cref="IPageRouter"/> to route incomming connections</param> - public void SetPageRouter(IPageRouter? router) => _ = Interlocked.Exchange(ref Router, router); + private IAccountSecurityProvider? _accountSec; + private ISessionProvider? _sessions; + private IPageRouter? _router; + + ///<inheritdoc/> + public IAccountSecurityProvider? AccountSecurity + { + //Exchange the version of the account security provider + get => ServicePool.ExchangeVersion(ref _accountSec, SEC_INDEX); + } ///<inheritdoc/> public virtual async ValueTask ClientConnectedAsync(IHttpEvent httpEvent) { - //read local ref to session provider and page router - ISessionProvider? _sessions = Sessions; - IPageRouter? router = Router; + /* + * read any "volatile" properties into local copies for the duration + * of the request processing. This is to ensure that the properties + * are not changed during the processing of the request. + */ + + ISessionProvider? sessions = ServicePool.ExchangeVersion(ref _sessions, SESS_INDEX); + IPageRouter? router = ServicePool.ExchangeVersion(ref _router, ROUTER_INDEX); + + LinkedListNode<IHttpMiddleware>? mwNode = config.MiddlewareChain.GetCurrentHead(); //event cancellation token HttpEntity entity = new(httpEvent, this); - - LinkedListNode<IHttpMiddleware>? mwNode; //Set ambient processor context - _currentProcessor.Value = this; + _currentProcessor.Value = this; try { //If sessions are set, get a session for the current connection - if (_sessions != null) + if (sessions != null) { //Get the session - entity.EventSessionHandle = await _sessions.GetSessionAsync(httpEvent, entity.EventCancellation); + entity.EventSessionHandle = await sessions.GetSessionAsync(httpEvent, entity.EventCancellation); //If the processor had an error recovering the session, return the result to the processor if (entity.EventSessionHandle.EntityStatus != FileProcessArgs.Continue) @@ -206,7 +198,6 @@ namespace VNLib.Plugins.Essentials try { - //Pre-process entity PreProcessEntity(entity, out entity.EventArgs); //If preprocess returned a value, exit @@ -215,9 +206,6 @@ namespace VNLib.Plugins.Essentials goto RespondAndExit; } - //Handle middleware before file processing - mwNode = MiddlewareChain.GetCurrentHead(); - //Loop through nodes while(mwNode != null) { @@ -236,12 +224,12 @@ namespace VNLib.Plugins.Essentials } mwNode = mwNode.Next; - } + } - if (!EndpointTable.IsEmpty) + if (!config.EndpointTable.IsEmpty) { //See if the virtual file is servicable - if (EndpointTable.TryGetEndpoint(entity.Server.Path, out IVirtualEndpoint<HttpEntity>? vf)) + if (config.EndpointTable.TryGetEndpoint(entity.Server.Path, out IVirtualEndpoint<HttpEntity>? vf)) { //Invoke the page handler process method VfReturnType rt = await vf.Process(entity); @@ -283,7 +271,7 @@ namespace VNLib.Plugins.Essentials } catch (Exception ex) { - Log.Error(ex, "Exception raised while releasing the assocated session"); + config.Log.Error(ex, "Exception raised while releasing the assocated session"); } } @@ -305,17 +293,17 @@ namespace VNLib.Plugins.Essentials } catch (ResourceUpdateFailedException ruf) { - Log.Warn(ruf); + config.Log.Warn(ruf); CloseWithError(HttpStatusCode.ServiceUnavailable, httpEvent); } catch (SessionException se) { - Log.Warn(se, "An exception was raised while attempting to get or save a session"); + config.Log.Warn(se, "An exception was raised while attempting to get or save a session"); CloseWithError(HttpStatusCode.ServiceUnavailable, httpEvent); } catch (OperationCanceledException oce) { - Log.Warn(oce, "Request execution time exceeded, connection terminated"); + config.Log.Warn(oce, "Request execution time exceeded, connection terminated"); CloseWithError(HttpStatusCode.ServiceUnavailable, httpEvent); } catch (IOException ioe) when (ioe.InnerException is SocketException) @@ -324,7 +312,7 @@ namespace VNLib.Plugins.Essentials } catch (Exception ex) { - Log.Warn(ex, "Unhandled exception during application code execution."); + config.Log.Warn(ex, "Unhandled exception during application code execution."); //Invoke the root error handler CloseWithError(HttpStatusCode.InternalServerError, httpEvent); } @@ -341,7 +329,7 @@ namespace VNLib.Plugins.Essentials /// </summary> /// <param name="entity">The entity to process the file for</param> /// <param name="args">The selected <see cref="FileProcessArgs"/> to determine what file to process</param> - protected virtual void ProcessFile(IHttpEvent entity, in FileProcessArgs args) + protected virtual void ProcessFile(IHttpEvent entity, ref readonly FileProcessArgs args) { try { @@ -414,7 +402,7 @@ namespace VNLib.Plugins.Essentials //See if the last modifed header was set DateTimeOffset? ifModifedSince = entity.Server.LastModified(); - + //If the header was set, check the date, if the file has been modified since, continue sending the file if (ifModifedSince.HasValue && ifModifedSince.Value > fileLastModified) { @@ -425,109 +413,97 @@ namespace VNLib.Plugins.Essentials //Get the content type of he file ContentType fileType = HttpHelpers.GetContentTypeFromFile(filename); - + //Make sure the client accepts the content type - if (entity.Server.Accepts(fileType)) + if (!entity.Server.Accepts(fileType)) { - //set last modified time as the files last write time - entity.Server.LastModified(fileLastModified); - - //try to open the selected file for reading and allow sharing - FileStream fs = new (filename, FileMode.Open, FileAccess.Read, FileShare.Read); - - long endOffset = checked((long)entity.Server.Range.End); - long startOffset = checked((long)entity.Server.Range.Start); - - //Follows rfc7233 -> https://www.rfc-editor.org/rfc/rfc7233#section-1.2 - switch (entity.Server.Range.RangeType) - { - case HttpRangeType.FullRange: - if (endOffset > fs.Length || endOffset - startOffset < 0) - { - //Set acceptable range size - entity.Server.Headers[HttpResponseHeader.ContentRange] = $"bytes */{fs.Length}"; - - //The start offset is greater than the file length, return range not satisfiable - entity.CloseResponse(HttpStatusCode.RequestedRangeNotSatisfiable); - } - else - { - //Seek the stream to the specified start position - fs.Seek(startOffset, SeekOrigin.Begin); - - //Set range header, by passing the actual full content size - entity.SetContentRangeHeader(entity.Server.Range, fs.Length); + //Unacceptable + CloseWithError(HttpStatusCode.NotAcceptable, entity); + return; + } - //Send the response, with actual response length (diff between stream length and position) - entity.CloseResponse(HttpStatusCode.PartialContent, fileType, fs, endOffset - startOffset + 1); - } - break; - case HttpRangeType.FromStart: - if (startOffset > fs.Length) - { - //Set acceptable range size - entity.Server.Headers[HttpResponseHeader.ContentRange] = $"bytes */{fs.Length}"; + //set last modified time as the files last write time + entity.Server.LastModified(fileLastModified); - //The start offset is greater than the file length, return range not satisfiable - entity.CloseResponse(HttpStatusCode.RequestedRangeNotSatisfiable); - } - else - { - //Seek the stream to the specified start position - fs.Seek(startOffset, SeekOrigin.Begin); + //Open the file handle directly, reading will always be sequentially read and async + DirectFileStream dfs = DirectFileStream.Open(filename); - //Set range header, by passing the actual full content size - entity.SetContentRangeHeader(entity.Server.Range, fs.Length); + long endOffset = checked((long)entity.Server.Range.End); + long startOffset = checked((long)entity.Server.Range.Start); - //Send the response, with actual response length (diff between stream length and position) - entity.CloseResponse(HttpStatusCode.PartialContent, fileType, fs, fs.Length - fs.Position); - } - break; - - case HttpRangeType.FromEnd: - if (endOffset > fs.Length) - { - //Set acceptable range size - entity.Server.Headers[HttpResponseHeader.ContentRange] = $"bytes */{fs.Length}"; + //Follows rfc7233 -> https://www.rfc-editor.org/rfc/rfc7233#section-1.2 + switch (entity.Server.Range.RangeType) + { + case HttpRangeType.FullRange: + if (endOffset > dfs.Length || endOffset - startOffset < 0) + { + //The start offset is greater than the file length, return range not satisfiable + entity.Server.Headers[HttpResponseHeader.ContentRange] = $"bytes */{dfs.Length}"; + entity.CloseResponse(HttpStatusCode.RequestedRangeNotSatisfiable); + } + else + { + //Seek the stream to the specified start position + dfs.Seek(startOffset, SeekOrigin.Begin); - //The end offset is greater than the file length, return range not satisfiable - entity.CloseResponse(HttpStatusCode.RequestedRangeNotSatisfiable); - } - else - { - //Seek the stream to the specified end position, server auto range will handle the rest - fs.Seek(-endOffset, SeekOrigin.End); + //Set range header, by passing the actual full content size + entity.SetContentRangeHeader(entity.Server.Range, dfs.Length); - //Set range header, by passing the actual full content size - entity.SetContentRangeHeader(entity.Server.Range, fs.Length); + //Send the response, with actual response length (diff between stream length and position) + entity.CloseResponse(HttpStatusCode.PartialContent, fileType, dfs, endOffset - startOffset + 1); + } + break; + case HttpRangeType.FromStart: + if (startOffset > dfs.Length) + { + //The start offset is greater than the file length, return range not satisfiable + entity.Server.Headers[HttpResponseHeader.ContentRange] = $"bytes */{dfs.Length}"; + entity.CloseResponse(HttpStatusCode.RequestedRangeNotSatisfiable); + } + else + { + //Seek the stream to the specified start position + dfs.Seek(startOffset, SeekOrigin.Begin); + + entity.SetContentRangeHeader(entity.Server.Range, dfs.Length); + + entity.CloseResponse(HttpStatusCode.PartialContent, fileType, dfs, dfs.Length - dfs.Position); + } + break; - //Send the response, with actual response length (diff between stream length and position) - entity.CloseResponse(HttpStatusCode.PartialContent, fileType, fs, fs.Length - fs.Position); - } - break; - //No range or invalid range (the server is supposed to ignore invalid ranges) - default: - //send the whole file - entity.CloseResponse(HttpStatusCode.OK, fileType, fs, fs.Length); - break; - } - - } - else - { - //Unacceptable - CloseWithError(HttpStatusCode.NotAcceptable, entity); + case HttpRangeType.FromEnd: + if (endOffset > dfs.Length) + { + //The end offset is greater than the file length, return range not satisfiable + entity.Server.Headers[HttpResponseHeader.ContentRange] = $"bytes */{dfs.Length}"; + entity.CloseResponse(HttpStatusCode.RequestedRangeNotSatisfiable); + } + else + { + //Seek the stream to the specified end position, server auto range will handle the rest + dfs.Seek(-endOffset, SeekOrigin.End); + + entity.SetContentRangeHeader(entity.Server.Range, dfs.Length); + + entity.CloseResponse(HttpStatusCode.PartialContent, fileType, dfs, dfs.Length - dfs.Position); + } + break; + //No range or invalid range (the server is supposed to ignore invalid ranges) + default: + //send the whole file + entity.CloseResponse(HttpStatusCode.OK, fileType, dfs, dfs.Length); + break; } } catch (IOException ioe) { - Log.Information(ioe, "Unhandled exception during file opening."); + config.Log.Information(ioe, "Unhandled exception during file opening."); CloseWithError(HttpStatusCode.Locked, entity); return; } catch (Exception ex) { - Log.Error(ex, "Unhandled exception during file opening."); + config.Log.Error(ex, "Unhandled exception during file opening."); //Invoke the root error handler CloseWithError(HttpStatusCode.InternalServerError, entity); return; @@ -690,5 +666,83 @@ namespace VNLib.Plugins.Essentials } return false; } + + /// <summary> + /// A pool of services that an <see cref="EventProcessor"/> will use can be exchanged at runtime + /// </summary> + /// <param name="expectedTypes">An ordered array of desired types</param> + protected sealed class HttpProcessorServicePool(Type[] expectedTypes) + { + private readonly uint[] _serviceTable = new uint[expectedTypes.Length]; + private readonly WeakReference<object?>[] _objects = CreateServiceArray(expectedTypes.Length); + private readonly ImmutableArray<Type> _types = [.. expectedTypes]; + + /// <summary> + /// Gets all of the desired types for the servicec pool + /// </summary> + public ImmutableArray<Type> Types => _types; + + /// <summary> + /// Sets a desired service instance in the pool, or clears it + /// from the pool. + /// </summary> + /// <param name="service">The service type to publish</param> + /// <param name="instance">The service instance to store</param> + public void SetService(Type service, object? instance) + { + ArgumentNullException.ThrowIfNull(service); + + //Make sure the instance is of the correct type + if(instance is not null && !service.IsInstanceOfType(instance)) + { + throw new ArgumentException("The instance does not match the service type"); + } + + //If the service type is not desired, return + int index = Array.IndexOf(expectedTypes, service); + if (index != -1) + { + //Set the service as a new weak reference atomically + Volatile.Write(ref _objects[index], new(instance)); + + //Notify that the service has been updated + Interlocked.Exchange(ref _serviceTable[index], 1); + } + } + + /// <summary> + /// Determines if a desired services has been modified within + /// the pool, if it has, the service will be exchanged for the + /// new service. + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="instance">A reference to the internal instance to exhange</param> + /// <param name="tableIndex">The constant index for the service type</param> + /// <returns>The exchanged service instance</returns> + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + internal T? ExchangeVersion<T>(ref T? instance, int tableIndex) where T : class? + { + //Clear modified flag + if (Interlocked.Exchange(ref _serviceTable[tableIndex], 0) == 1) + { + //Atomic read on the reference instance + WeakReference<object?> wr = Volatile.Read(ref _objects[tableIndex]); + + //Try to get the object instance + wr.TryGetTarget(out object? value); + + instance = (T?)value; + } + + return instance; + } + + private static WeakReference<object?>[] CreateServiceArray(int size) + { + WeakReference<object?>[] arr = new WeakReference<object?>[size]; + Array.Fill(arr, new (null)); + return arr; + } + } } }
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/EventProcessorConfig.cs b/lib/Plugins.Essentials/src/EventProcessorConfig.cs new file mode 100644 index 0000000..8f401ac --- /dev/null +++ b/lib/Plugins.Essentials/src/EventProcessorConfig.cs @@ -0,0 +1,93 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials +* File: EventProcessorConfig.cs +* +* EventProcessorConfig.cs is part of VNLib.Plugins.Essentials which is part +* of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Net; +using System.Collections.Frozen; +using System.Collections.Generic; + +using VNLib.Utils.Logging; +using VNLib.Plugins.Essentials.Middleware; + +namespace VNLib.Plugins.Essentials +{ + /// <summary> + /// An immutable configuration object for the <see cref="EventProcessor"/> that services the + /// lifetieme of the processor. + /// </summary> + /// <param name="Directory"> The filesystem entrypoint path for the site</param> + /// <param name="Hostname">The hostname the server will listen for, and the hostname that will identify this root when a connection requests it</param> + /// <param name="Log">The application log provider for writing logging messages to</param> + /// <param name="Options">Gets the EP processing options</param> + public record class EventProcessorConfig(string Directory, string Hostname, ILogProvider Log, IEpProcessingOptions Options) + { + /// <summary> + /// The table of virtual endpoints that will be used to process requests + /// </summary> + /// <remarks> + /// May be overriden to provide a custom endpoint table + /// </remarks> + public IVirtualEndpointTable EndpointTable { get; init; } = new SemiConsistentVeTable(); + + /// <summary> + /// The middleware chain that will be used to process requests + /// </summary> + /// <remarks> + /// If derrieved, may be overriden to provide a custom middleware chain + /// </remarks> + public IHttpMiddlewareChain MiddlewareChain { get; init; } = new SemiConistentMiddlewareChain(); + + /// <summary> + /// The name of a default file to search for within a directory if no file is specified (index.html). + /// This array should be ordered. + /// </summary> + public IReadOnlyCollection<string> DefaultFiles { get; init; } = []; + + /// <summary> + /// File extensions that are denied from being read from the filesystem + /// </summary> + public FrozenSet<string> ExcludedExtensions { get; init; } = FrozenSet<string>.Empty; + + /// <summary> + /// File attributes that must be matched for the file to be accessed + /// </summary> + public FileAttributes AllowedAttributes { get; init; } + + /// <summary> + /// Files that match any attribute flag set will be denied + /// </summary> + public FileAttributes DissallowedAttributes { get; init; } + + /// <summary> + /// A table of known downstream servers/ports that can be trusted to proxy connections + /// </summary> + public FrozenSet<IPAddress> DownStreamServers { get; init; } = FrozenSet<IPAddress>.Empty; + + /// <summary> + /// A <see cref="TimeSpan"/> for how long a connection may remain open before all operations are cancelled + /// </summary> + public TimeSpan ExecutionTimeout { get; init; } = TimeSpan.Zero; + } +}
\ No newline at end of file diff --git a/lib/Plugins.Essentials/src/Extensions/ConnectionInfoExtensions.cs b/lib/Plugins.Essentials/src/Extensions/ConnectionInfoExtensions.cs index 5c36465..92fae08 100644 --- a/lib/Plugins.Essentials/src/Extensions/ConnectionInfoExtensions.cs +++ b/lib/Plugins.Essentials/src/Extensions/ConnectionInfoExtensions.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2023 Vaughn Nugent +* Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -165,10 +165,7 @@ namespace VNLib.Plugins.Essentials.Extensions /// <exception cref="ArgumentOutOfRangeException"></exception> public static void SetContentRangeHeader(this IHttpEvent entity, in HttpRange range, long length) { - if(length < 0) - { - throw new ArgumentOutOfRangeException(nameof(length), "Length must be greater than or equal to zero"); - } + ArgumentOutOfRangeException.ThrowIfNegative(length); ulong start; ulong end; @@ -216,6 +213,7 @@ namespace VNLib.Plugins.Essentials.Extensions /// <returns>true if the user-agent specified the cors security header</returns> [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsCors(this IConnectionInfo server) => "cors".Equals(server.Headers[SEC_HEADER_MODE], StringComparison.OrdinalIgnoreCase); + /// <summary> /// Determines if the User-Agent specified "cross-site" in the Sec-Site header, OR /// the connection spcified an origin header and the origin's host does not match the @@ -228,6 +226,7 @@ namespace VNLib.Plugins.Essentials.Extensions return "cross-site".Equals(server.Headers[SEC_HEADER_SITE], StringComparison.OrdinalIgnoreCase) || (server.Origin != null && !server.RequestUri.DnsSafeHost.Equals(server.Origin.DnsSafeHost, StringComparison.Ordinal)); } + /// <summary> /// Is the connection user-agent created, or automatic /// </summary> @@ -235,12 +234,14 @@ namespace VNLib.Plugins.Essentials.Extensions /// <returns>true if sec-user header was set to "?1"</returns> [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsUserInvoked(this IConnectionInfo server) => "?1".Equals(server.Headers[SEC_HEADER_USER], StringComparison.OrdinalIgnoreCase); + /// <summary> /// Was this request created from normal user navigation /// </summary> /// <returns>true if sec-mode set to "navigate"</returns> [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsNavigation(this IConnectionInfo server) => "navigate".Equals(server.Headers[SEC_HEADER_MODE], StringComparison.OrdinalIgnoreCase); + /// <summary> /// Determines if the client specified "no-cache" for the cache control header, signalling they do not wish to cache the entity /// </summary> @@ -250,6 +251,7 @@ namespace VNLib.Plugins.Essentials.Extensions string? cache_header = server.Headers[HttpRequestHeader.CacheControl]; return !string.IsNullOrWhiteSpace(cache_header) && cache_header.Contains("no-cache", StringComparison.OrdinalIgnoreCase); } + /// <summary> /// Sets the response cache headers to match the requested caching type. Does not check against request headers /// </summary> @@ -266,6 +268,7 @@ namespace VNLib.Plugins.Essentials.Extensions //Set the cache hader string using the http helper class server.Headers[HttpResponseHeader.CacheControl] = HttpHelpers.GetCacheString(type, maxAge); } + /// <summary> /// Sets the Cache-Control response header to <see cref="NO_CACHE_RESPONSE_HEADER_VALUE"/> /// and the pragma response header to 'no-cache' @@ -291,6 +294,7 @@ namespace VNLib.Plugins.Essentials.Extensions /// </remarks> [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool EnpointPortsMatch(this IConnectionInfo server) => server.RequestUri.Port == server.LocalEndpoint.Port; + /// <summary> /// Determines if the host of the current request URI matches the referer header host /// </summary> @@ -300,6 +304,7 @@ namespace VNLib.Plugins.Essentials.Extensions { return server.RequestUri.DnsSafeHost.Equals(server.Referer?.DnsSafeHost, StringComparison.OrdinalIgnoreCase); } + /// <summary> /// Expires a client's cookie /// </summary> @@ -314,6 +319,7 @@ namespace VNLib.Plugins.Essentials.Extensions { server.SetCookie(name, string.Empty, domain, path, TimeSpan.Zero, sameSite, false, secure); } + /// <summary> /// Sets a cookie with an infinite (session life-span) /// </summary> @@ -403,6 +409,7 @@ namespace VNLib.Plugins.Essentials.Extensions //Get user-agent and determine if its a browser return server.UserAgent != null && !server.UserAgent.Contains("bot", StringComparison.OrdinalIgnoreCase) && server.UserAgent.Contains("Mozilla", StringComparison.OrdinalIgnoreCase); } + /// <summary> /// Determines if the current connection is the loopback/internal network adapter /// </summary> @@ -443,6 +450,7 @@ namespace VNLib.Plugins.Essentials.Extensions /// <returns>The real ip of the connection</returns> [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IPAddress GetTrustedIp(this IConnectionInfo server) => GetTrustedIp(server, server.IsBehindDownStreamServer()); + /// <summary> /// Gets the real IP address of the request if behind a trusted downstream server, otherwise returns the transport remote ip address /// </summary> diff --git a/lib/Plugins.Essentials/src/Extensions/EssentialHttpEventExtensions.cs b/lib/Plugins.Essentials/src/Extensions/EssentialHttpEventExtensions.cs index 638b52a..f49af32 100644 --- a/lib/Plugins.Essentials/src/Extensions/EssentialHttpEventExtensions.cs +++ b/lib/Plugins.Essentials/src/Extensions/EssentialHttpEventExtensions.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2023 Vaughn Nugent +* Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -782,7 +782,7 @@ namespace VNLib.Plugins.Essentials.Extensions /// <exception cref="PathTooLongException"></exception> /// <exception cref="ArgumentNullException"></exception> [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static DirectoryInfo GetRootDir(this HttpEntity ev) => new(ev.RequestedRoot.Directory); + public static DirectoryInfo GetRootDir(this HttpEntity ev) => new(ev.RequestedRoot.Options.Directory); /// <summary> /// Returns the MIME string representation of the content type of the uploaded file. diff --git a/lib/Plugins.Essentials/src/HttpEntity.cs b/lib/Plugins.Essentials/src/HttpEntity.cs index f48198b..efdb2cd 100644 --- a/lib/Plugins.Essentials/src/HttpEntity.cs +++ b/lib/Plugins.Essentials/src/HttpEntity.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2023 Vaughn Nugent +* Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -49,7 +49,7 @@ namespace VNLib.Plugins.Essentials /// A container for an <see cref="HttpEvent"/> with its attached session. /// This class cannot be inherited. /// </summary> - public sealed class HttpEntity : IHttpEvent + public sealed class HttpEntity : IHttpEvent, IDisposable { /// <summary> @@ -59,7 +59,23 @@ namespace VNLib.Plugins.Essentials private readonly CancellationTokenSource EventCts; - public HttpEntity(IHttpEvent entity, IWebProcessor root) + /// <summary> + /// Creates a new <see cref="HttpEntity"/> instance with the optional + /// session handle. If the session handle is set, the session will be + /// attached to the entity + /// </summary> + /// <param name="evnt">The event to parse and wrap</param> + /// <param name="root">The processor the connection has originated from</param> + /// <param name="session">An optional session handle to attach to the entity</param> + public HttpEntity(IHttpEvent evnt, IWebProcessor root, ref readonly SessionHandle session) + :this(evnt, root) + { + //Assign optional session and attempt to attach it + EventSessionHandle = session; + AttachSession(); + } + + internal HttpEntity(IHttpEvent entity, IWebProcessor root) { Entity = entity; RequestedRoot = root; @@ -100,12 +116,9 @@ namespace VNLib.Plugins.Essentials } /// <summary> - /// Internal call to cleanup any internal resources + /// Cleans up internal resources /// </summary> - internal void Dispose() - { - EventCts.Dispose(); - } + public void Dispose() => EventCts.Dispose(); /// <summary> /// A token that has a scheduled timeout to signal the cancellation of the entity event @@ -209,6 +222,20 @@ namespace VNLib.Plugins.Essentials } ///<inheritdoc/> + ///<exception cref="ContentTypeUnacceptableException"></exception> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void CloseResponse(HttpStatusCode code, ContentType type, IHttpStreamResponse stream, long length) + { + //Verify content type matches + if (!Server.Accepts(type)) + { + throw new ContentTypeUnacceptableException("The client does not accept the content type of the response"); + } + + Entity.CloseResponse(code, type, stream, length); + } + + ///<inheritdoc/> [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetControlFlag(ulong mask) => Entity.SetControlFlag(mask); diff --git a/lib/Plugins.Essentials/src/IWebProcessorInfo.cs b/lib/Plugins.Essentials/src/IWebProcessorInfo.cs index ae920ea..a523296 100644 --- a/lib/Plugins.Essentials/src/IWebProcessorInfo.cs +++ b/lib/Plugins.Essentials/src/IWebProcessorInfo.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2023 Vaughn Nugent +* Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -33,20 +33,14 @@ namespace VNLib.Plugins.Essentials public interface IWebProcessor : IWebRoot { /// <summary> - /// The filesystem entrypoint path for the site - /// </summary> - string Directory { get; } - - /// <summary> /// Gets the EP processing options /// </summary> - IEpProcessingOptions Options { get; } + EventProcessorConfig Options { get; } /// <summary> - /// The shared <see cref="IAccountSecurityProvider"/> that provides - /// user account security operations + /// Gets the account security provider /// </summary> - IAccountSecurityProvider AccountSecurity { get; } + IAccountSecurityProvider? AccountSecurity { get; } /// <summary> /// <para> diff --git a/lib/Plugins.Essentials/src/Middleware/IHttpMiddlewareChain.cs b/lib/Plugins.Essentials/src/Middleware/IHttpMiddlewareChain.cs index 0a05c70..ed6ce3b 100644 --- a/lib/Plugins.Essentials/src/Middleware/IHttpMiddlewareChain.cs +++ b/lib/Plugins.Essentials/src/Middleware/IHttpMiddlewareChain.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2023 Vaughn Nugent +* Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -49,7 +49,7 @@ namespace VNLib.Plugins.Essentials.Middleware /// Removes a middleware handler from the chain /// </summary> /// <param name="middleware">The middleware instance to remove</param> - void RemoveMiddleware(IHttpMiddleware middleware); + void Remove(IHttpMiddleware middleware); /// <summary> /// Removes all middleware handlers from the chain diff --git a/lib/Plugins.Essentials/src/Middleware/SemiConistentMiddlewareChain.cs b/lib/Plugins.Essentials/src/Middleware/SemiConistentMiddlewareChain.cs index 5d0c472..d3bc69b 100644 --- a/lib/Plugins.Essentials/src/Middleware/SemiConistentMiddlewareChain.cs +++ b/lib/Plugins.Essentials/src/Middleware/SemiConistentMiddlewareChain.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2023 Vaughn Nugent +* Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials @@ -22,6 +22,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ +using System.Threading; using System.Reflection; using System.Collections.Generic; @@ -67,10 +68,14 @@ namespace VNLib.Plugins.Essentials.Middleware } ///<inheritdoc/> - public LinkedListNode<IHttpMiddleware>? GetCurrentHead() => _middlewares.First; + public LinkedListNode<IHttpMiddleware>? GetCurrentHead() + { + LinkedList<IHttpMiddleware> currentTable = Volatile.Read(ref _middlewares); + return currentTable.First; + } ///<inheritdoc/> - public void RemoveMiddleware(IHttpMiddleware middleware) + public void Remove(IHttpMiddleware middleware) { lock (_middlewares) { @@ -81,7 +86,7 @@ namespace VNLib.Plugins.Essentials.Middleware newTable.Remove(middleware); //Replace the current table with the new one - _middlewares = newTable; + Volatile.Write(ref _middlewares, newTable); } } } diff --git a/lib/Plugins.Essentials/src/Users/IUserManager.cs b/lib/Plugins.Essentials/src/Users/IUserManager.cs index 400a5d0..7b70f53 100644 --- a/lib/Plugins.Essentials/src/Users/IUserManager.cs +++ b/lib/Plugins.Essentials/src/Users/IUserManager.cs @@ -70,11 +70,11 @@ namespace VNLib.Plugins.Essentials.Users /// <summary> /// Attempts to get a user object without their password from the database asynchronously /// </summary> - /// <param name="emailAddress">The user's email address</param> + /// <param name="username">The user's uinque username</param> /// <param name="cancellationToken">A token to cancel the operation</param> /// <returns>The user's <see cref="IUser"/> object, null if the user was not found</returns> /// <exception cref="ArgumentNullException"></exception> - Task<IUser?> GetUserFromEmailAsync(string emailAddress, CancellationToken cancellationToken = default); + Task<IUser?> GetUserFromUsernameAsync(string username, CancellationToken cancellationToken = default); /// <summary> /// Creates a new user account in the store as per the request. The user-id field is optional, |