Author SHA1 Message Date
GHXX be9dd343bc add wildcard fallback handlers 2026-06-21 07:24:47 +02:00
GHXX fb91d099ac fix listener prefix to force ipv4 localhost connection 2026-05-28 01:40:15 +02:00
GHXX 4571b979fd fix SetStatusCodeAndDisposeAsync(HttpStatusCode.NotModified) breaking 2026-05-27 23:40:29 +02:00
GHXX d2a3a82b95 Add option to ignore trailing slashes in requests 2025-06-02 17:23:35 +02:00
GHXX 6ad805841d remove Assert preventing registration of multiple nested paths 2025-04-14 12:09:55 +02:00
GHXX d152b8f3ae Add SetStatusCodeWriteLineDisposeAsync helper function 2025-04-14 02:22:32 +02:00
GHXX 81dd1f8bd5 fix path parameter conversion not working 2024-08-29 01:00:29 +02:00
GHXX c4db0f2d2c Add license 2024-08-15 04:37:57 +02:00
GHXX f01672f714 autoformat 2024-08-11 22:23:43 +02:00
GHXX 03ebfa1321 Merge branch 'feature/pathparams' 2024-08-11 06:45:06 +02:00
GHXX 8d0419b6ac fix inaccessible nodes behaving incorrectly 2024-08-11 06:44:33 +02:00
GHXX 7a516668bf Merge branch 'master' into feature/pathparams 2024-08-11 04:45:55 +02:00
GHXX fd88bde403 make assertions more debug friendly 2024-08-11 04:43:42 +02:00
GHXX fecd40cd57 finish implementing path parameters 2024-08-11 04:43:20 +02:00
GHXX a24543063b work towards path parameters 2024-07-30 08:42:46 +02:00
00asdf 2e4570a560 initialize endpoint attributes (untested, yeet) 2024-07-27 00:16:38 +02:00
00asdf 30daf382ba shared variables for checker attributes 2024-07-26 02:32:50 +02:00
GHXX 2cf6cd4a7d make stuff nonstatic 2024-07-25 07:30:35 +02:00
GHXX 29eecc7887 fix incorrect check which might break when registering an endpoint class that contains no endpoints 2024-07-25 04:00:06 +02:00
GHXX a4ae359df0 cleanup some old auth stuff 2024-07-25 03:41:53 +02:00
GHXX 176c5e7197 fix required GET args being present not triggering a 400 when no GET args were passed at all 2024-07-21 06:45:13 +02:00
GHXX d7a934e25c cleanup 2024-07-20 08:02:06 +02:00
GHXX c75d29a1ba Switch over to a check-based system with multi attribute support 2024-07-19 03:31:04 +02:00
GHXX fa79134d02 Move file 2024-07-19 03:28:56 +02:00
16 changed files with 793 additions and 351 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 00asdf, GHXX
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+4
View File
@@ -1,9 +1,12 @@
global using static SimpleHttpServer.GlobalUsings; global using static SimpleHttpServer.GlobalUsings;
using SimpleHttpServer.Types.Exceptions; using SimpleHttpServer.Types.Exceptions;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
namespace SimpleHttpServer; namespace SimpleHttpServer;
internal static class GlobalUsings { internal static class GlobalUsings {
[DebuggerHidden]
internal static void Assert([DoesNotReturnIf(false)] bool b, string? message = null) { internal static void Assert([DoesNotReturnIf(false)] bool b, string? message = null) {
if (!b) { if (!b) {
if (message == null) if (message == null)
@@ -13,5 +16,6 @@ internal static class GlobalUsings {
} }
} }
[DebuggerHidden]
internal static void AssertImplies(bool x, bool y, string? message = null) => Assert(!x || y, message); internal static void AssertImplies(bool x, bool y, string? message = null) => Assert(!x || y, message);
} }
+2 -10
View File
@@ -1,23 +1,15 @@
using SimpleHttpServer.Internal; using SimpleHttpServer.Types;
using SimpleHttpServer.Types;
namespace SimpleHttpServer; namespace SimpleHttpServer;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpEndpointAttribute<T> : Attribute where T : IAuthorizer { public class HttpEndpointAttribute : Attribute {
public HttpRequestType RequestMethod { get; private set; } public HttpRequestType RequestMethod { get; private set; }
public string[] Locations { get; private set; } public string[] Locations { get; private set; }
public Type Authorizer { get; private set; }
public HttpEndpointAttribute(HttpRequestType requestMethod, params string[] locations) { public HttpEndpointAttribute(HttpRequestType requestMethod, params string[] locations) {
RequestMethod = requestMethod; RequestMethod = requestMethod;
Locations = locations; Locations = locations;
Authorizer = typeof(T);
} }
} }
[AttributeUsage(AttributeTargets.Method)]
public class HttpEndpointAttribute : HttpEndpointAttribute<DefaultAuthorizer> {
public HttpEndpointAttribute(HttpRequestType type, params string[] locations) : base(type, locations) { }
}
+163 -35
View File
@@ -5,6 +5,7 @@ using System.Net;
using System.Numerics; using System.Numerics;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
using static SimpleHttpServer.Types.EndpointInvocationInfo;
namespace SimpleHttpServer; namespace SimpleHttpServer;
@@ -23,7 +24,7 @@ public sealed class HttpServer {
Port = port; Port = port;
conf = configuration; conf = configuration;
listener = new HttpListener(); listener = new HttpListener();
listener.Prefixes.Add($"http://localhost:{port}/"); listener.Prefixes.Add($"http://127.0.0.1:{port}/");
mainLogger = new(LogOutputTopic.Main, conf); mainLogger = new(LogOutputTopic.Main, conf);
requestLogger = new(LogOutputTopic.Request, conf); requestLogger = new(LogOutputTopic.Request, conf);
} }
@@ -83,59 +84,119 @@ public sealed class HttpServer {
RegisterConverter<decimal>(); RegisterConverter<decimal>();
} }
private readonly Dictionary<(string path, string rType), EndpointInvocationInfo> simpleEndpointMethodInfos = new(); private readonly MultiKeyDictionary<string, string, EndpointInvocationInfo> simpleEndpointMethodInfos = new(); // requestmethod, path
private readonly MultiKeyDictionary<string, string, EndpointInvocationInfo> pathEndpointMethodInfos = new(); // requestmethod, path
private readonly Dictionary<string, PathTree<EndpointInvocationInfo>> pathEndpointMethodInfosTrees = new(); // reqmethod : pathtree
private static readonly Type[] expectedEndpointParameterTypes = new[] { typeof(RequestContext) }; private static readonly Type[] expectedEndpointParameterTypes = new[] { typeof(RequestContext) };
public void RegisterEndpointsFromType<T>() { internal static readonly int expectedEndpointParameterPrefixCount = expectedEndpointParameterTypes.Length;
if (simpleEndpointMethodInfos.Count == 0) private readonly Dictionary<string, Func<RequestContext, Task<bool>>> wildcardFallbackHandlers = new(); // reqmethod : handler
public void RegisterEndpointsFromType<T>(Func<T>? instanceFactory = null) where T : class { // T cannot be static, as generic args must be nonstatic
if (stringToTypeParameterConverters.Count == 0)
RegisterDefaultConverters(); RegisterDefaultConverters();
var t = typeof(T); var t = typeof(T);
foreach (var (mi, attrib) in t.GetMethods() var mis = t.GetMethods()
.ToDictionary(x => x, x => x.GetCustomAttributes(typeof(HttpEndpointAttribute<>))) .ToDictionary(x => x, x => x.GetCustomAttributes<HttpEndpointAttribute>())
.Where(x => x.Value.Any()).ToDictionary(x => x.Key, x => (HttpEndpointAttribute) x.Value.Single())) { .Where(x => x.Value.Any()).ToDictionary(x => x.Key, x => x.Value.Single());
var isStatic = mis.All(x => x.Key.IsStatic); // if all are static then there is no point in having a constructor as no instance data is accessible, but we allow passing a factory anyway
Assert(isStatic || (instanceFactory != null), $"You must provide an instance factory if any methods of the given type ({typeof(T).FullName}) are non-static");
T? classInstance = instanceFactory?.Invoke();
foreach (var (mi, attrib) in mis) {
string GetFancyMethodName() => mi.DeclaringType!.FullName + "#" + mi.Name; string GetFancyMethodName() => mi.DeclaringType!.FullName + "#" + mi.Name;
Assert(mi.IsStatic, $"Method tagged with HttpEndpointAttribute must be static! ({GetFancyMethodName()})"); //Assert(mi.IsStatic, $"Method tagged with HttpEndpointAttribute must be static! ({GetFancyMethodName()})");
Assert(mi.IsPublic, $"Method tagged with HttpEndpointAttribute must be public! ({GetFancyMethodName()})"); Assert(mi.IsPublic, $"Method tagged with HttpEndpointAttribute must be public! ({GetFancyMethodName()})");
var methodParams = mi.GetParameters(); var methodParams = mi.GetParameters();
// check the mandatory prefix parameters
Assert(methodParams.Length >= expectedEndpointParameterTypes.Length); Assert(methodParams.Length >= expectedEndpointParameterTypes.Length);
for (int i = 0; i < expectedEndpointParameterTypes.Length; i++) { for (int i = 0; i < expectedEndpointParameterTypes.Length; i++) {
Assert(methodParams[i].ParameterType.IsAssignableFrom(expectedEndpointParameterTypes[i]), Assert(methodParams[i].ParameterType.IsAssignableFrom(expectedEndpointParameterTypes[i]),
$"Parameter at index {i} of {GetFancyMethodName()} is of a type that cannot contain the expected type {expectedEndpointParameterTypes[i].FullName}."); $"Parameter at index {i} of {GetFancyMethodName()} is of a type that cannot contain the expected type {expectedEndpointParameterTypes[i].FullName}.");
} }
// check return type
Assert(mi.ReturnType == typeof(Task), $"Return type of {GetFancyMethodName()} is not {typeof(Task)}!"); Assert(mi.ReturnType == typeof(Task), $"Return type of {GetFancyMethodName()} is not {typeof(Task)}!");
// check the rest of the method parameters
var qparams = new List<(string, (Type type, bool isOptional))>(); var qparams = new List<QueryParameterInfo>();
var pparams = new List<PathParameterInfo>();
int mParamIndex = expectedEndpointParameterTypes.Length;
for (int i = expectedEndpointParameterTypes.Length; i < methodParams.Length; i++) { for (int i = expectedEndpointParameterTypes.Length; i < methodParams.Length; i++) {
var par = methodParams[i]; var par = methodParams[i];
var attr = par.GetCustomAttribute<ParameterAttribute>(false); var attr = par.GetCustomAttribute<ParameterAttribute>(false);
qparams.Add((attr?.Name ?? par.Name ?? throw new ArgumentException($"C# variable name of parameter at index {i} of method {GetFancyMethodName()} is null!"), var pathAttr = par.GetCustomAttribute<PathParameterAttribute>(false);
(par.ParameterType, attr?.IsOptional ?? false)));
if (attr != null && pathAttr != null) {
throw new ArgumentException($"A method argument cannot be tagged with both {nameof(ParameterAttribute)} and {nameof(PathParameterAttribute)}");
}
if (!stringToTypeParameterConverters.ContainsKey(par.ParameterType)) { if (!stringToTypeParameterConverters.ContainsKey(par.ParameterType)) {
throw new MissingParameterConverterException($"Parameter converter for type {par.ParameterType} has not been registered (yet)!"); throw new MissingParameterConverterException($"Parameter converter for type {par.ParameterType} for parameter at index {i} of method {GetFancyMethodName()} has not been registered (yet)!");
}
if (pathAttr != null) { // parameter is a path param
pparams.Add(new(
pathAttr?.Name ?? throw new ArgumentException($"C# variable name of path parameter at index {i} of method {GetFancyMethodName()} is null!"),
par.ParameterType,
mParamIndex++
)
);
} else { // parameter is a normal query param
qparams.Add(new(
attr?.Name ?? par.Name ?? throw new ArgumentException($"C# variable name of query parameter at index {i} of method {GetFancyMethodName()} is null!"),
par.ParameterType,
mParamIndex++,
attr?.IsOptional ?? false)
);
} }
} }
// stores the check attributes that are defined on the method and on the containing class
InternalEndpointCheckAttribute[] requiredChecks = mi.GetCustomAttributes<InternalEndpointCheckAttribute>(true)
.Concat(mi.DeclaringType?.GetCustomAttributes<InternalEndpointCheckAttribute>(true) ?? Enumerable.Empty<Attribute>())
.Where(a => a.GetType().IsAssignableTo(typeof(InternalEndpointCheckAttribute)))
.Cast<InternalEndpointCheckAttribute>().ToArray();
InternalEndpointCheckAttribute.Initialize(classInstance, requiredChecks);
foreach (var location in attrib.Locations) { foreach (var location in attrib.Locations) {
var normLocation = NormalizeUrlPath(location); var normLocation = NormalizeUrlPath(location);
int idx = normLocation.IndexOf('{');
if (idx >= 0) {
// this path contains path parameters
throw new NotImplementedException("Path parameters are not yet implemented!");
}
var reqMethod = Enum.GetName(attrib.RequestMethod) ?? throw new ArgumentException("Request method was undefined"); var reqMethod = Enum.GetName(attrib.RequestMethod) ?? throw new ArgumentException("Request method was undefined");
mainLogger.Information($"Registered endpoint: '{reqMethod} {normLocation}'");
simpleEndpointMethodInfos.Add((normLocation, reqMethod), new EndpointInvocationInfo(mi, qparams)); var pparamsCopy = new List<PathParameterInfo>(pparams);
var splittedLocation = location[1..].Split('/');
for (int i = 0; i < pparamsCopy.Count; i++) {
var pp = pparamsCopy[i];
var idx = Array.IndexOf(splittedLocation, pp.Name);
Assert(idx != -1, "Path parameter name was incorrect?");
pp.SegmentStartPos = idx;
pparamsCopy[i] = pp;
}
var epInvocInfo = new EndpointInvocationInfo(mi, pparamsCopy, qparams, requiredChecks, classInstance);
if (pparams.Any()) {
mainLogger.Information($"Registered path endpoint: '{reqMethod} {normLocation}'");
Assert(normLocation[0] == '/');
pathEndpointMethodInfos.Add(reqMethod, normLocation[1..], epInvocInfo);
} else {
mainLogger.Information($"Registered simple endpoint: '{reqMethod} {normLocation}'");
simpleEndpointMethodInfos.Add(reqMethod, normLocation, epInvocInfo);
} }
} }
} }
// rebuild path trees
pathEndpointMethodInfosTrees.Clear();
foreach (var (reqMethod, d2) in pathEndpointMethodInfos.backingDict)
pathEndpointMethodInfosTrees.Add(reqMethod, new(d2));
}
/// <summary> /// <summary>
/// Serves all files located in <paramref name="filesystemDirectory"/> on a website path that is relative to <paramref name="requestPath"/>, /// Serves all files located in <paramref name="filesystemDirectory"/> on a website path that is relative to <paramref name="requestPath"/>,
/// while restricting requests to inside the local filesystem directory. Static serving has a lower priority than registering an endpoint. /// while restricting requests to inside the local filesystem directory. Static serving has a lower priority than registering an endpoint.
@@ -149,11 +210,11 @@ public sealed class HttpServer {
staticServePaths.Add(npath, absPath); staticServePaths.Add(npath, absPath);
} }
private readonly Dictionary<string, string> staticServePaths = new Dictionary<string, string>(); private readonly Dictionary<string, string> staticServePaths = new();
private readonly Dictionary<Type, IParameterConverter> stringToTypeParameterConverters = new(); private readonly Dictionary<Type, IParameterConverter> stringToTypeParameterConverters = new();
private static string NormalizeUrlPath(string url) { private string NormalizeUrlPath(string url) {
var fwdSlashUrl = url.Replace('\\', '/'); var fwdSlashUrl = url.Replace('\\', '/');
var segments = fwdSlashUrl.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries).ToList(); var segments = fwdSlashUrl.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries).ToList();
@@ -182,7 +243,12 @@ public sealed class HttpServer {
} }
rv.AppendJoin('/', simplifiedSegmentsReversed.Reverse<string>()); rv.AppendJoin('/', simplifiedSegmentsReversed.Reverse<string>());
return '/' + (rv.ToString().TrimEnd('/') + (fwdSlashUrl.EndsWith('/') ? "/" : "")).TrimStart('/'); var suffix = (rv.ToString().TrimEnd('/') + (fwdSlashUrl.EndsWith('/') ? "/" : "")).TrimStart('/');
if (conf.TrimTrailingSlash) {
suffix = suffix.TrimEnd('/');
}
return '/' + suffix;
} }
private async Task ProcessRequestAsync(HttpListenerContext ctx) { private async Task ProcessRequestAsync(HttpListenerContext ctx) {
@@ -199,15 +265,30 @@ public sealed class HttpServer {
} }
try { try {
if (simpleEndpointMethodInfos.TryGetValue((reqPath, requestMethod), out var endpointInvocationInfo)) { /* Finding the endpoint that should process the request:
* 1. Try to see if there is a simple endpoint where request method and path match
* 2. Otherwise, try to see if a path-parameter-endpoint matches (duplicates throw an error on startup)
* 3. Otherwise, check if wildcardFallbackHandlers contains a handler for the current requestMethod. If this method returns false 'request was not handled', continue, otherwise skip all future steps.
* 4. Otherwise, check if it is inside a static serve path
* 5. Otherwise, show 404 page */
EndpointInvocationInfo? pathEndpointInvocationInfo = null;
if (simpleEndpointMethodInfos.TryGetValue(requestMethod, reqPath, out var simpleEndpointInvocationInfo) ||
pathEndpointMethodInfosTrees.TryGetValue(requestMethod, out var pt) && pt.TryGetPath(reqPath, out pathEndpointInvocationInfo)) { // try to find simple or pathparam-endpoint
var endpointInvocationInfo = simpleEndpointInvocationInfo ?? pathEndpointInvocationInfo ?? throw new Exception("retrieved endpoint is somehow null");
var mi = endpointInvocationInfo.methodInfo; var mi = endpointInvocationInfo.methodInfo;
var qparams = endpointInvocationInfo.queryParameters; var qparams = endpointInvocationInfo.queryParameters;
var pparams = endpointInvocationInfo.pathParameters;
var args = splitted.Length == 2 ? splitted[1] : null; var args = splitted.Length == 2 ? splitted[1] : null;
var parsedQParams = new Dictionary<string, string>(); var parsedQParams = new Dictionary<string, string>();
var convertedQParamValues = new object[qparams.Count + 1]; var convertedMParamValues = new object[expectedEndpointParameterTypes.Length + pparams.Count + qparams.Count];
// TODO add authcheck here // run the checks to see if the client is allowed to make this request
if (!endpointInvocationInfo.CheckAll(rc.ListenerContext.Request)) { // if any check failed return Forbidden
await HandleDefaultErrorPageAsync(rc, HttpStatusCode.Forbidden, "Client is not allowed to access this resource");
return;
}
if (args != null) { if (args != null) {
var queryStringArgs = args.Split('&', StringSplitOptions.None); var queryStringArgs = args.Split('&', StringSplitOptions.None);
@@ -224,31 +305,68 @@ public sealed class HttpServer {
} }
for (int i = 0; i < qparams.Count;) { for (int i = 0; i < qparams.Count;) {
var (qparamName, qparamInfo) = qparams[i]; var qparam = qparams[i];
i++; i++;
if (parsedQParams.TryGetValue(qparamName, out var qparamValue)) { if (parsedQParams.TryGetValue(qparam.Name, out var qparamValue)) {
if (stringToTypeParameterConverters[qparamInfo.type].TryConvertFromString(qparamValue, out object objRes)) { if (stringToTypeParameterConverters[qparam.Type].TryConvertFromString(qparamValue, out object objRes)) {
convertedQParamValues[i] = objRes; convertedMParamValues[qparam.ArgPos] = objRes;
} else { } else {
await HandleDefaultErrorPageAsync(rc, HttpStatusCode.BadRequest); await HandleDefaultErrorPageAsync(rc, HttpStatusCode.BadRequest);
return; return;
} }
} else { } else {
if (qparamInfo.isOptional) { if (qparam.IsOptional) {
convertedQParamValues[i] = null!; convertedMParamValues[qparam.ArgPos] = null!;
} else { } else {
await HandleDefaultErrorPageAsync(rc, HttpStatusCode.BadRequest, $"Missing required query parameter {qparamName}"); await HandleDefaultErrorPageAsync(rc, HttpStatusCode.BadRequest, $"Missing required query parameter {qparam.Name}");
return; return;
} }
} }
} }
} else { // check for missing query parameters
var requiredParams = qparams.Where(x => !x.IsOptional).Select(x => $"'{x.Name}'").ToList();
if (requiredParams.Any()) {
await HandleDefaultErrorPageAsync(rc, HttpStatusCode.BadRequest, $"Missing required query parameter(s): {string.Join(",", requiredParams)}");
return;
} }
convertedQParamValues[0] = rc; }
if (pparams.Count != 0) {
var splittedReqPath = reqPath[1..].Split('/');
for (int i = 0; i < pparams.Count; i++) {
var pparam = pparams[i];
string paramValue;
if (pparam.IsCatchAll)
paramValue = string.Join('/', splittedReqPath[pparam.SegmentStartPos..]);
else
paramValue = splittedReqPath[pparam.SegmentStartPos];
if (stringToTypeParameterConverters[pparam.Type].TryConvertFromString(paramValue, out var res))
convertedMParamValues[pparam.ArgPos] = res;
else {
await HandleDefaultErrorPageAsync(rc, HttpStatusCode.BadRequest);
return;
}
}
}
convertedMParamValues[0] = rc;
rc.ParsedParameters = parsedQParams.AsReadOnly(); rc.ParsedParameters = parsedQParams.AsReadOnly();
await (Task) (mi.Invoke(null, convertedQParamValues) ?? throw new NullReferenceException("Website func returned null unexpectedly")); // todo read and convert pathparams
await (Task) (mi.Invoke(endpointInvocationInfo.typeInstanceReference, convertedMParamValues) ?? throw new NullReferenceException("Website func returned null unexpectedly"));
} else { } else {
// ---------------- check for fallback wildcard handler ----------------
if (wildcardFallbackHandlers.TryGetValue(requestMethod, out var handler)) {
var handledByWildcard = await handler.Invoke(rc);
if (handledByWildcard) { // if that handler was able to handle the request, end execution
return;
}
}
// ---------------------------------------------------------------------
// try to find suitable static serve path
if (requestMethod == "GET") if (requestMethod == "GET")
foreach (var (k, v) in staticServePaths) { foreach (var (k, v) in staticServePaths) {
if (reqPath.StartsWith(k)) { // do a static serve if (reqPath.StartsWith(k)) { // do a static serve
@@ -309,4 +427,14 @@ public sealed class HttpServer {
} }
} catch (ObjectDisposedException) { } } catch (ObjectDisposedException) { }
} }
/// <summary>
/// Adds a wildcard fallback handler that receives any request that was not matched by a more explicit endpoint.
/// The handler is supposed to return 'true' if the request was handled correctly and thus execution shall end. Return 'false' to try to serve the file from a static-serve path.
/// </summary>
/// <param name="reqMethod"></param>
/// <param name="requestHandler">This method must return whether the reuqest was handled; returning false makes handling act as if this handler never ran</param>
public void RegisterWildcardFallbackHandler(HttpRequestType reqMethod, Func<RequestContext, Task<bool>> requestHandler) {
wildcardFallbackHandlers.Add(Enum.GetName(reqMethod) ?? throw new NotImplementedException(), requestHandler);
}
} }
-7
View File
@@ -1,7 +0,0 @@
using System.Net;
namespace SimpleHttpServer;
public interface IAuthorizer {
public abstract (bool auth, object? data) IsAuthenticated(HttpListenerContext contect);
}
@@ -1,7 +0,0 @@
using System.Net;
namespace SimpleHttpServer.Internal;
public sealed class DefaultAuthorizer : IAuthorizer {
public (bool auth, object? data) IsAuthenticated(HttpListenerContext contect) => (true, null);
}
@@ -1,73 +1,73 @@
using Newtonsoft.Json; //using Newtonsoft.Json;
using System.Collections; //using System.Collections;
using System.Net; //using System.Net;
using System.Reflection; //using System.Reflection;
namespace SimpleHttpServer.Internal; //namespace SimpleHttpServer.Internal;
internal class HttpEndpointHandler { //internal class HttpEndpointHandler {
private static readonly DefaultAuthorizer defaultAuth = new(); // private static readonly DefaultAuthorizer defaultAuth = new();
private readonly IAuthorizer auth; // private readonly IAuthorizer auth;
private readonly MethodInfo handler; // private readonly MethodInfo handler;
private readonly Dictionary<string, (int pindex, Type type, int pparamIdx)> @params; // private readonly Dictionary<string, (int pindex, Type type, int pparamIdx)> @params;
private readonly Func<Exception, HttpResponseBuilder> errorPageBuilder; // private readonly Func<Exception, HttpResponseBuilder> errorPageBuilder;
public HttpEndpointHandler() { // public HttpEndpointHandler() {
auth = defaultAuth; // auth = defaultAuth;
} // }
public HttpEndpointHandler(IAuthorizer auth) { // public HttpEndpointHandler(IAuthorizer auth) {
} // }
public virtual void Handle(HttpListenerContext ctx) { // public virtual void Handle(HttpListenerContext ctx) {
try { // try {
var (isAuth, authData) = auth.IsAuthenticated(ctx); // var (isAuth, authData) = auth.IsAuthenticated(ctx);
if (!isAuth) { // if (!isAuth) {
throw new HttpHandlingException(401, "Authorization required!"); // throw new HttpHandlingException(401, "Authorization required!");
} // }
// collect parameters // // collect parameters
var invokeParams = new object?[@params.Count + 1]; // var invokeParams = new object?[@params.Count + 1];
var set = new BitArray(@params.Count); // var set = new BitArray(@params.Count);
invokeParams[0] = ctx; // invokeParams[0] = ctx;
// read pparams // // read pparams
// read qparams // // read qparams
var qst = ctx.Request.QueryString; // var qst = ctx.Request.QueryString;
foreach (var qelem in ctx.Request.QueryString.AllKeys) { // foreach (var qelem in ctx.Request.QueryString.AllKeys) {
if (@params.ContainsKey(qelem!)) { // if (@params.ContainsKey(qelem!)) {
var (pindex, type, isPParam) = @params[qelem!]; // var (pindex, type, isPParam) = @params[qelem!];
if (type == typeof(string)) { // if (type == typeof(string)) {
invokeParams[pindex] = ctx.Request.QueryString[qelem!]; // invokeParams[pindex] = ctx.Request.QueryString[qelem!];
set.Set(pindex - 1, true); // set.Set(pindex - 1, true);
} else { // } else {
var elem = JsonConvert.DeserializeObject(ctx.Request.QueryString[qelem!]!, type); // var elem = JsonConvert.DeserializeObject(ctx.Request.QueryString[qelem!]!, type);
if (elem != null) { // if (elem != null) {
invokeParams[pindex] = elem; // invokeParams[pindex] = elem;
set.Set(pindex - 1, true); // set.Set(pindex - 1, true);
} // }
} // }
} // }
} // }
// fill with defaults // // fill with defaults
foreach (var p in @params) { // foreach (var p in @params) {
if (!set.Get(p.Value.pindex)) { // if (!set.Get(p.Value.pindex)) {
invokeParams[p.Value.pindex] = p.Value.type.IsValueType ? Activator.CreateInstance(p.Value.type) : null; // invokeParams[p.Value.pindex] = p.Value.type.IsValueType ? Activator.CreateInstance(p.Value.type) : null;
} // }
} // }
var builder = handler.Invoke(null, invokeParams) as HttpResponseBuilder; // var builder = handler.Invoke(null, invokeParams) as HttpResponseBuilder;
builder!.SendResponse(ctx.Response); // builder!.SendResponse(ctx.Response);
} catch (Exception e) { // } catch (Exception e) {
if (e is TargetInvocationException tex) { // if (e is TargetInvocationException tex) {
e = tex.InnerException!; // e = tex.InnerException!;
} // }
errorPageBuilder(e).SendResponse(ctx.Response); // errorPageBuilder(e).SendResponse(ctx.Response);
} // }
} // }
} //}
+213 -213
View File
@@ -1,245 +1,245 @@
using Newtonsoft.Json; //using Newtonsoft.Json;
using System.Diagnostics.CodeAnalysis; //using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography; //using System.Security.Cryptography;
using System.Text; //using System.Text;
namespace SimpleHttpServer.Login; //namespace SimpleHttpServer.Login;
internal struct SerialLoginData { //internal struct SerialLoginData {
public string passwordSalt; // public string passwordSalt;
public string extraDataSalt; // public string extraDataSalt;
public string pwd; // public string pwd;
public string extraData; // public string extraData;
public LoginData ToPlainData() { // public LoginData ToPlainData() {
return new LoginData { // return new LoginData {
passwordSalt = Convert.FromBase64String(passwordSalt), // passwordSalt = Convert.FromBase64String(passwordSalt),
extraDataSalt = Convert.FromBase64String(extraDataSalt) // extraDataSalt = Convert.FromBase64String(extraDataSalt)
}; // };
} // }
} //}
internal struct LoginData { //internal struct LoginData {
public byte[] passwordSalt; // public byte[] passwordSalt;
public byte[] extraDataSalt; // public byte[] extraDataSalt;
public byte[] passwordHash; // public byte[] passwordHash;
public byte[] encryptedExtraData; // public byte[] encryptedExtraData;
public SerialLoginData ToSerial() { // public SerialLoginData ToSerial() {
return new SerialLoginData { // return new SerialLoginData {
passwordSalt = Convert.ToBase64String(passwordSalt), // passwordSalt = Convert.ToBase64String(passwordSalt),
extraDataSalt = Convert.ToBase64String(extraDataSalt), // extraDataSalt = Convert.ToBase64String(extraDataSalt),
pwd = Convert.ToBase64String(passwordHash), // pwd = Convert.ToBase64String(passwordHash),
extraData = Convert.ToBase64String(encryptedExtraData) // extraData = Convert.ToBase64String(encryptedExtraData)
}; // };
} // }
} //}
internal struct LoginDataProviderConfig { //internal struct LoginDataProviderConfig {
/// <summary> // /// <summary>
/// Size of the password salt and the extradata salt. So each salt will be of size <see cref="SALT_SIZE"/>. // /// Size of the password salt and the extradata salt. So each salt will be of size <see cref="SALT_SIZE"/>.
/// </summary> // /// </summary>
public int SALT_SIZE = 32; // public int SALT_SIZE = 32;
public int KEY_LENGTH = 256 / 8; // public int KEY_LENGTH = 256 / 8;
public int PBKDF2_ITERATIONS = 600_000; // public int PBKDF2_ITERATIONS = 600_000;
public LoginDataProviderConfig() { } // public LoginDataProviderConfig() { }
} //}
public class LoginProvider<TExtraData> { //public class LoginProvider<TExtraData> {
private static readonly Func<TExtraData, byte[]> JsonSerialize = t => Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(t)); // private static readonly Func<TExtraData, byte[]> JsonSerialize = t => Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(t));
private static readonly Func<byte[], TExtraData> JsonDeserialize = b => JsonConvert.DeserializeObject<TExtraData>(Encoding.UTF8.GetString(b))!; // private static readonly Func<byte[], TExtraData> JsonDeserialize = b => JsonConvert.DeserializeObject<TExtraData>(Encoding.UTF8.GetString(b))!;
[ThreadStatic] // [ThreadStatic]
private static SHA256? _sha256PerThread; // private static SHA256? _sha256PerThread;
private static SHA256 Sha256PerThread { get => _sha256PerThread ??= SHA256.Create(); } // private static SHA256 Sha256PerThread { get => _sha256PerThread ??= SHA256.Create(); }
private readonly LoginDataProviderConfig config; // private readonly LoginDataProviderConfig config;
private readonly ReaderWriterLockSlim ldLock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion); // private readonly ReaderWriterLockSlim ldLock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
private readonly string ldPath; // private readonly string ldPath;
private readonly Dictionary<string, LoginData> loginDatas; // private readonly Dictionary<string, LoginData> loginDatas;
private Func<TExtraData, byte[]> DataSerializer = JsonSerialize; // private Func<TExtraData, byte[]> DataSerializer = JsonSerialize;
private Func<byte[], TExtraData> DataDeserializer = JsonDeserialize; // private Func<byte[], TExtraData> DataDeserializer = JsonDeserialize;
public void SetDataSerializers(Func<TExtraData, byte[]> serializer, Func<byte[], TExtraData> deserializer) { // public void SetDataSerializers(Func<TExtraData, byte[]> serializer, Func<byte[], TExtraData> deserializer) {
DataSerializer = serializer ?? JsonSerialize; // DataSerializer = serializer ?? JsonSerialize;
DataDeserializer = deserializer ?? JsonDeserialize; // DataDeserializer = deserializer ?? JsonDeserialize;
} // }
public LoginProvider(string ldPath, string confPath) { // public LoginProvider(string ldPath, string confPath) {
this.ldPath = ldPath; // this.ldPath = ldPath;
loginDatas = LoadLoginDatas(ldPath); // loginDatas = LoadLoginDatas(ldPath);
config = LoadLoginProviderConfig(confPath); // config = LoadLoginProviderConfig(confPath);
} // }
private static Dictionary<string, LoginData> LoadLoginDatas(string path) { // private static Dictionary<string, LoginData> LoadLoginDatas(string path) {
Dictionary<string, SerialLoginData> tempData; // Dictionary<string, SerialLoginData> tempData;
if (!File.Exists(path)) { // if (!File.Exists(path)) {
File.WriteAllText(path, "{}", Encoding.UTF8); // File.WriteAllText(path, "{}", Encoding.UTF8);
tempData = new(); // tempData = new();
} else { // } else {
tempData = JsonConvert.DeserializeObject<Dictionary<string, SerialLoginData>>(File.ReadAllText(path))!; // tempData = JsonConvert.DeserializeObject<Dictionary<string, SerialLoginData>>(File.ReadAllText(path))!;
if (tempData == null) { // if (tempData == null) {
throw new InvalidDataException($"could not read login data from file {path}"); // throw new InvalidDataException($"could not read login data from file {path}");
} // }
} // }
var ld = new Dictionary<string, LoginData>(); // var ld = new Dictionary<string, LoginData>();
foreach (var pair in tempData) { // foreach (var pair in tempData) {
ld.Add(pair.Key, pair.Value.ToPlainData()); // ld.Add(pair.Key, pair.Value.ToPlainData());
} // }
return ld; // return ld;
} // }
private void SaveLoginData() { // private void SaveLoginData() {
var serial = new Dictionary<string, SerialLoginData>(); // var serial = new Dictionary<string, SerialLoginData>();
ldLock.EnterWriteLock(); // ldLock.EnterWriteLock();
try { // try {
foreach (var pair in loginDatas) { // foreach (var pair in loginDatas) {
serial.Add(pair.Key, pair.Value.ToSerial()); // serial.Add(pair.Key, pair.Value.ToSerial());
} // }
} finally { // } finally {
ldLock.ExitWriteLock(); // ldLock.ExitWriteLock();
} // }
File.WriteAllText(ldPath, JsonConvert.SerializeObject(serial)); // File.WriteAllText(ldPath, JsonConvert.SerializeObject(serial));
} // }
private static LoginDataProviderConfig LoadLoginProviderConfig(string path) { // private static LoginDataProviderConfig LoadLoginProviderConfig(string path) {
if (!File.Exists(path)) { // if (!File.Exists(path)) {
var conf = new LoginDataProviderConfig(); // var conf = new LoginDataProviderConfig();
File.WriteAllText(path, JsonConvert.SerializeObject(conf)); // File.WriteAllText(path, JsonConvert.SerializeObject(conf));
return conf; // return conf;
} // }
return JsonConvert.DeserializeObject<LoginDataProviderConfig>(File.ReadAllText(path)); // return JsonConvert.DeserializeObject<LoginDataProviderConfig>(File.ReadAllText(path));
} // }
public bool AddUser(string username, string password, TExtraData additional) { // public bool AddUser(string username, string password, TExtraData additional) {
ldLock.EnterWriteLock(); // ldLock.EnterWriteLock();
try { // try {
if (loginDatas.ContainsKey(username)) { // if (loginDatas.ContainsKey(username)) {
return false; // return false;
} // }
var passwordSalt = RandomNumberGenerator.GetBytes(config.SALT_SIZE); // var passwordSalt = RandomNumberGenerator.GetBytes(config.SALT_SIZE);
var extraDataSalt = RandomNumberGenerator.GetBytes(config.SALT_SIZE); // var extraDataSalt = RandomNumberGenerator.GetBytes(config.SALT_SIZE);
LoginData ld = new LoginData() { // LoginData ld = new LoginData() {
passwordSalt = passwordSalt, // passwordSalt = passwordSalt,
extraDataSalt = extraDataSalt, // extraDataSalt = extraDataSalt,
passwordHash = ComputeSaltedSha256Hash(password, passwordSalt), // passwordHash = ComputeSaltedSha256Hash(password, passwordSalt),
encryptedExtraData = EncryptExtraData(password, extraDataSalt, additional), // encryptedExtraData = EncryptExtraData(password, extraDataSalt, additional),
}; // };
loginDatas.Add(username, ld); // loginDatas.Add(username, ld);
SaveLoginData(); // SaveLoginData();
} finally { // } finally {
ldLock.ExitWriteLock(); // ldLock.ExitWriteLock();
} // }
return true; // return true;
} // }
public bool RemoveUser(string username) { // public bool RemoveUser(string username) {
ldLock.EnterWriteLock(); // ldLock.EnterWriteLock();
try { // try {
var removed = loginDatas.Remove(username); // var removed = loginDatas.Remove(username);
if (removed) { // if (removed) {
SaveLoginData(); // SaveLoginData();
} // }
return removed; // return removed;
} finally { // } finally {
ldLock.ExitWriteLock(); // ldLock.ExitWriteLock();
} // }
} // }
public bool ModifyUser(string username, string newPassword, TExtraData newExtraData) { // public bool ModifyUser(string username, string newPassword, TExtraData newExtraData) {
ldLock.EnterWriteLock(); // ldLock.EnterWriteLock();
try { // try {
if (!loginDatas.ContainsKey(username)) { // if (!loginDatas.ContainsKey(username)) {
return false; // return false;
} // }
loginDatas.Remove(username, out var data); // loginDatas.Remove(username, out var data);
data.passwordHash = ComputeSaltedSha256Hash(newPassword, data.passwordSalt); // data.passwordHash = ComputeSaltedSha256Hash(newPassword, data.passwordSalt);
data.encryptedExtraData = EncryptExtraData(newPassword, data.extraDataSalt, newExtraData); // data.encryptedExtraData = EncryptExtraData(newPassword, data.extraDataSalt, newExtraData);
loginDatas.Add(username, data); // loginDatas.Add(username, data);
SaveLoginData(); // SaveLoginData();
} finally { // } finally {
ldLock.ExitWriteLock(); // ldLock.ExitWriteLock();
} // }
return true; // return true;
} // }
public bool TryAuthenticate(string username, string password, [MaybeNullWhen(false)] out TExtraData extraData) { // public bool TryAuthenticate(string username, string password, [MaybeNullWhen(false)] out TExtraData extraData) {
LoginData data; // LoginData data;
ldLock.EnterReadLock(); // ldLock.EnterReadLock();
try { // try {
if (!loginDatas.TryGetValue(username, out data)) { // if (!loginDatas.TryGetValue(username, out data)) {
extraData = default; // extraData = default;
return false; // return false;
} // }
} finally { // } finally {
ldLock.ExitReadLock(); // ldLock.ExitReadLock();
} // }
var hash = ComputeSaltedSha256Hash(password, data.passwordSalt); // var hash = ComputeSaltedSha256Hash(password, data.passwordSalt);
if (!hash.SequenceEqual(data.passwordHash)) { // if (!hash.SequenceEqual(data.passwordHash)) {
extraData = default; // extraData = default;
return false; // return false;
} // }
extraData = DecryptExtraData(password, data.extraDataSalt, data.encryptedExtraData); // extraData = DecryptExtraData(password, data.extraDataSalt, data.encryptedExtraData);
return true; // return true;
} // }
/// <summary> // /// <summary>
/// Threadsafe as the SHA256 instance (<see cref="Sha256PerThread"/>) is per thread. // /// Threadsafe as the SHA256 instance (<see cref="Sha256PerThread"/>) is per thread.
/// </summary> // /// </summary>
/// <param name="data"></param> // /// <param name="data"></param>
/// <param name="salt"></param> // /// <param name="salt"></param>
/// <returns></returns> // /// <returns></returns>
private static byte[] ComputeSaltedSha256Hash(string data, byte[] salt) { // private static byte[] ComputeSaltedSha256Hash(string data, byte[] salt) {
var dataBytes = Encoding.UTF8.GetBytes(data); // var dataBytes = Encoding.UTF8.GetBytes(data);
var buf = new byte[data.Length + salt.Length]; // var buf = new byte[data.Length + salt.Length];
Buffer.BlockCopy(dataBytes, 0, buf, 0, dataBytes.Length); // Buffer.BlockCopy(dataBytes, 0, buf, 0, dataBytes.Length);
Buffer.BlockCopy(salt, 0, buf, dataBytes.Length, salt.Length); // Buffer.BlockCopy(salt, 0, buf, dataBytes.Length, salt.Length);
return Sha256PerThread.ComputeHash(buf); // return Sha256PerThread.ComputeHash(buf);
} // }
private byte[] EncryptExtraData(string pwd, byte[] salt, TExtraData extraData) { // private byte[] EncryptExtraData(string pwd, byte[] salt, TExtraData extraData) {
var pbkdf2 = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(pwd), salt, config.PBKDF2_ITERATIONS, HashAlgorithmName.SHA256); // var pbkdf2 = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(pwd), salt, config.PBKDF2_ITERATIONS, HashAlgorithmName.SHA256);
var key = pbkdf2.GetBytes(config.KEY_LENGTH / 8); // var key = pbkdf2.GetBytes(config.KEY_LENGTH / 8);
var plainBytes = DataSerializer(extraData); // var plainBytes = DataSerializer(extraData);
using var aes = Aes.Create(); // using var aes = Aes.Create();
aes.KeySize = config.KEY_LENGTH; // aes.KeySize = config.KEY_LENGTH;
aes.Key = key; // aes.Key = key;
aes.Mode = CipherMode.CBC; // aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7; // aes.Padding = PaddingMode.PKCS7;
ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV); // ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); // byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
var encryptedBytes = new byte[aes.IV.Length + cipherBytes.Length]; // var encryptedBytes = new byte[aes.IV.Length + cipherBytes.Length];
Array.Copy(aes.IV, 0, encryptedBytes, 0, aes.IV.Length); // Array.Copy(aes.IV, 0, encryptedBytes, 0, aes.IV.Length);
Array.Copy(cipherBytes, 0, encryptedBytes, aes.IV.Length, cipherBytes.Length); // Array.Copy(cipherBytes, 0, encryptedBytes, aes.IV.Length, cipherBytes.Length);
return encryptedBytes; // return encryptedBytes;
} // }
private TExtraData DecryptExtraData(string pwd, byte[] salt, byte[] encryptedData) { // private TExtraData DecryptExtraData(string pwd, byte[] salt, byte[] encryptedData) {
var pbkdf2 = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(pwd), salt, config.PBKDF2_ITERATIONS, HashAlgorithmName.SHA256); // var pbkdf2 = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(pwd), salt, config.PBKDF2_ITERATIONS, HashAlgorithmName.SHA256);
var key = pbkdf2.GetBytes(config.KEY_LENGTH / 8); // var key = pbkdf2.GetBytes(config.KEY_LENGTH / 8);
using var aes = Aes.Create(); // using var aes = Aes.Create();
aes.KeySize = config.KEY_LENGTH; // aes.KeySize = config.KEY_LENGTH;
aes.Key = key; // aes.Key = key;
aes.Mode = CipherMode.CBC; // aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7; // aes.Padding = PaddingMode.PKCS7;
var iv = new byte[aes.BlockSize / 8]; // var iv = new byte[aes.BlockSize / 8];
var cipherBytes = new byte[encryptedData.Length - iv.Length]; // var cipherBytes = new byte[encryptedData.Length - iv.Length];
Array.Copy(encryptedData, 0, iv, 0, iv.Length); // Array.Copy(encryptedData, 0, iv, 0, iv.Length);
Array.Copy(encryptedData, iv.Length, cipherBytes, 0, cipherBytes.Length); // Array.Copy(encryptedData, iv.Length, cipherBytes, 0, cipherBytes.Length);
aes.IV = iv; // aes.IV = iv;
ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV); // ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
byte[] plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); // byte[] plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
return DataDeserializer(plainBytes); // return DataDeserializer(plainBytes);
} // }
} //}
@@ -13,6 +13,10 @@ public class SimpleHttpServerConfiguration {
/// See description of <see cref="DisableLogMessagePrinting"/> /// See description of <see cref="DisableLogMessagePrinting"/>
/// </summary> /// </summary>
public CustomLogMessageHandler? LogMessageHandler { get; init; } = null; public CustomLogMessageHandler? LogMessageHandler { get; init; } = null;
/// <summary>
/// If set to true, paths ending with / are identical to paths without said trailing slash. E.g. /index is then the same as /index/
/// </summary>
public bool TrimTrailingSlash { get; init; } = true;
public SimpleHttpServerConfiguration() { } public SimpleHttpServerConfiguration() { }
@@ -0,0 +1,118 @@
using System.Net;
using System.Reflection;
namespace SimpleHttpServer.Types;
public abstract class InternalEndpointCheckAttribute : Attribute {
public InternalEndpointCheckAttribute() {
CheckSharedVariables();
}
private void CheckSharedVariables() {
foreach (var f in GetType().GetRuntimeFields()) {
if (f.FieldType.IsAssignableTo(typeof(SharedVariable))) {
if (!f.IsInitOnly) {
throw new Exception($"Found non-readonly global field {f}!");
}
if (f.GetValue(this) == null) {
throw new Exception("Global fields must be assigned in the CCTOR!");
}
}
}
}
private void Initialize(object? instance, Dictionary<FieldInfo, List<(InternalEndpointCheckAttribute, SharedVariable)>> globals) {
SetInstance(instance);
foreach (var f in GetType().GetRuntimeFields()) {
if (f.FieldType.IsAssignableTo(typeof(SharedVariable))) {
SharedVariable origVal = (SharedVariable) f.GetValue(this)!;
if (globals.TryGetValue(f, out var options)) {
bool foundMatch = false;
foreach ((var checker, var gv) in options) {
if (Match(checker)) {
foundMatch = true;
// we need to unify their global variables
f.SetValue(this, gv);
}
}
if (!foundMatch) {
options.Add((this, origVal));
}
} else {
globals.Add(f, new List<(InternalEndpointCheckAttribute, SharedVariable)>() { (this, origVal) });
}
}
}
}
public static void Initialize(object? instance, IEnumerable<InternalEndpointCheckAttribute> endPointChecks) {
Dictionary<FieldInfo, List<(InternalEndpointCheckAttribute, SharedVariable)>> globals = new();
foreach (var check in endPointChecks) {
check.Initialize(instance, globals);
}
}
private interface SharedVariable {
// Tagging interface
}
/// <summary>
/// Represents a Mutable Shared Variable. Fields of this type need to be initialized in the CCtor.
/// </summary>
protected sealed class MSV<V> : SharedVariable {
private readonly V __default;
public V Val { get; set; } = default!;
public MSV() : this(default!) { }
public MSV(V _default) {
__default = _default;
}
public static implicit operator V(MSV<V> v) => v.Val;
}
/// <summary>
/// Represents an Immutable Shared Variable. Fields of this type need to be initialized in the CCtor.
/// </summary>
protected sealed class ISV<V> : SharedVariable {
private readonly V __default;
public V Val { get; } = default!;
public ISV() : this(default!) { }
public ISV(V _default) {
__default = _default;
}
public static implicit operator V(ISV<V> v) => v.Val;
}
/// <summary>
/// Executed when the endpoint is invoked. The endpoint invocation is skipped if any of the checks fail.
/// </summary>
/// <returns>True to allow invocation, false to prevent.</returns>
public abstract bool Check(HttpListenerRequest req);
protected virtual bool Match(InternalEndpointCheckAttribute other) => true;
internal abstract void SetInstance(object? instance);
}
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true, AllowMultiple = true)]
public abstract class BaseEndpointCheckAttribute<T> : InternalEndpointCheckAttribute {
/// <summary>
/// A reference to the instance of the class that this attribute is attached to.
/// Will be null iff an class factory was passed in <see cref="HttpServer.RegisterEndpointsFromType{T}(Func{T}?)"/>.
/// </summary>
protected internal T? EndpointClassInstance { get; internal set; } = default;
public BaseEndpointCheckAttribute() : base() { }
internal override void SetInstance(object? instance) {
if (instance != null)
EndpointClassInstance = (T?) instance;
}
}
@@ -1,12 +1,47 @@
using System.Reflection; using System.Net;
using System.Reflection;
namespace SimpleHttpServer.Types; namespace SimpleHttpServer.Types;
internal struct EndpointInvocationInfo { internal record EndpointInvocationInfo {
internal readonly MethodInfo methodInfo; //internal record struct QueryParameterInfo(string Name, Type Type, bool isPathParam, bool Path_isCatchAll, bool Query_IsOptional) {
internal readonly List<(string, (Type type, bool isOptional))> queryParameters; // public static QueryParameterInfo CreatePathParam(string name, Type type) => new(name, type, false, name == "$*", false);
// public static QueryParameterInfo CreateQueryParam(string name, Type type, bool isOptional) => new(name, type, false, false, isOptional);
//}
internal record struct PathParameterInfo(string Name, Type Type, int ArgPos, int SegmentStartPos, bool IsCatchAll) {
public PathParameterInfo(string name, Type type, int argPos) : this(name, type, argPos, -1, name == "$*") { }
}
internal record struct QueryParameterInfo(string Name, Type Type, int ArgPos, bool IsOptional);
internal readonly MethodInfo methodInfo;
internal readonly List<QueryParameterInfo> queryParameters;
internal readonly List<PathParameterInfo> pathParameters;
internal readonly InternalEndpointCheckAttribute[] requiredChecks;
/// <summary>
/// a reference to the object in which this method is defined (or null if the class is static)
/// </summary>
internal readonly object? typeInstanceReference;
public EndpointInvocationInfo(MethodInfo methodInfo, List<PathParameterInfo> pathParameters, List<QueryParameterInfo> queryParameters, InternalEndpointCheckAttribute[] requiredChecks,
object? typeInstanceReference) {
public EndpointInvocationInfo(MethodInfo methodInfo, List<(string, (Type type, bool isOptional))> queryParameters) {
this.methodInfo = methodInfo ?? throw new ArgumentNullException(nameof(methodInfo)); this.methodInfo = methodInfo ?? throw new ArgumentNullException(nameof(methodInfo));
this.queryParameters = queryParameters ?? throw new ArgumentNullException(nameof(queryParameters)); this.queryParameters = queryParameters ?? throw new ArgumentNullException(nameof(queryParameters));
this.pathParameters = pathParameters ?? throw new ArgumentNullException(nameof(pathParameters));
this.requiredChecks = requiredChecks;
this.typeInstanceReference = typeInstanceReference;
if (pathParameters.Any()) {
Assert(pathParameters.Count(x => x.IsCatchAll) <= 1); // at most one catchall parameter
var argPoses = pathParameters.Select(x => x.ArgPos).Concat(queryParameters.Select(x => x.ArgPos)).ToArray();
var argCnt = pathParameters.Count + queryParameters.Count;
Assert(argPoses.Distinct().Count() == argCnt); // ArgPoses must be unique
Assert(argPoses.Min() == HttpServer.expectedEndpointParameterPrefixCount); // ArgPoses must start from just after the prefix
Assert(argPoses.Max() == HttpServer.expectedEndpointParameterPrefixCount + argCnt - 1); // ArgPoses must be contiguous
Assert(pathParameters.All(x => x.SegmentStartPos != -1));
} }
} }
public bool CheckAll(HttpListenerRequest req) => requiredChecks.All(x => x.Check(req));
}
@@ -0,0 +1,22 @@
using System.Diagnostics.CodeAnalysis;
namespace SimpleHttpServer.Types;
internal class MultiKeyDictionary<K1, K2, V> where K1 : notnull where K2 : notnull {
internal readonly Dictionary<K1, Dictionary<K2, V>> backingDict = new();
public MultiKeyDictionary() { }
public void Add(K1 k1, K2 k2, V value) {
if (!backingDict.TryGetValue(k1, out var d2))
d2 = new();
d2.Add(k2, value);
backingDict[k1] = d2;
}
public bool TryGetValue(K1 k1, K2 k2, [MaybeNullWhen(false)] out V value) {
if (backingDict.TryGetValue(k1, out var d2) && d2.TryGetValue(k2, out value))
return true;
value = default;
return false;
}
}
@@ -5,9 +5,6 @@
/// </summary> /// </summary>
[AttributeUsage(AttributeTargets.Parameter, Inherited = false, AllowMultiple = false)] [AttributeUsage(AttributeTargets.Parameter, Inherited = false, AllowMultiple = false)]
public sealed class ParameterAttribute : Attribute { public sealed class ParameterAttribute : Attribute {
// See the attribute guidelines at
// http://go.microsoft.com/fwlink/?LinkId=85236
public string Name { get; } public string Name { get; }
public bool IsOptional { get; } public bool IsOptional { get; }
public ParameterAttribute(string name, bool isOptional = false) { public ParameterAttribute(string name, bool isOptional = false) {
@@ -0,0 +1,28 @@
namespace SimpleHttpServer.Types;
/// <summary>
/// Specifies the name of a http endpoint path parameter. Path parameter names must be in the format $1, $2, $3, ..., and the end of the path may be $*
/// </summary>
[AttributeUsage(AttributeTargets.Parameter, Inherited = false, AllowMultiple = false)]
public sealed class PathParameterAttribute : Attribute {
public string Name { get; }
public PathParameterAttribute(string name) {
if (string.IsNullOrWhiteSpace(name)) {
throw new ArgumentException($"'{nameof(name)}' cannot be null or whitespace.", nameof(name));
}
if (!name.StartsWith('$')) {
throw new ArgumentException($"'{nameof(name)}' must start with $.", nameof(name));
}
if (name.Contains(' ')) {
throw new ArgumentException($"'{nameof(name)}' must not contain spaces.", nameof(name));
}
if (!uint.TryParse(name[1..], out _) && name != "$*") {
throw new ArgumentException($"'{nameof(name)}' must only consist of spaces or be exactly '$*'.", nameof(name));
}
Name = name;
}
}
+104
View File
@@ -0,0 +1,104 @@
using System.Data;
using System.Diagnostics.CodeAnalysis;
namespace SimpleHttpServer.Types;
internal class PathTree<T> where T : class {
private readonly Node? rootNode = null;
public PathTree() : this(new()) { }
public PathTree(Dictionary<string, T> dict) {
if (dict == null || dict.Count == 0)
return;
rootNode = new();
var currNode = rootNode;
var unpackedPaths = dict.Keys.Select(p => p.Split('/').ToArray()).ToArray();
var unpackedLeafData = dict.Values.ToArray();
for (int i = 0; i < unpackedPaths.Length; i++) {
var path = unpackedPaths[i];
var catchallidx = Array.IndexOf(path, "$*");
if (catchallidx != -1 && catchallidx != path.Length - 1) {
throw new Exception($"Found illegal catchall-wildcard in path: '{string.Join('/', path)}'");
}
var leafdata = unpackedLeafData[i] ?? throw new ArgumentNullException("Leafdata must not be null!");
rootNode.AddSuccessor(path, leafdata);
}
}
internal bool TryGetPath(string reqPath, [MaybeNullWhen(false)] out T endpoint) {
if (rootNode == null) {
endpoint = null;
return false;
}
// try to find path-match
Node currNode = rootNode;
Assert(reqPath[0] == '/');
var splittedPath = reqPath[1..].Split("/");
Node? lastCatchallNode = null;
for (int i = 0; i < splittedPath.Length; i++) {
// keep track of the current best catchallNode
if (currNode.catchAllNext != null) {
lastCatchallNode = currNode.catchAllNext;
}
var seg = splittedPath[i];
if (currNode.next?.TryGetValue(seg, out var next) == true) { // look for an explicit path to follow greedily
currNode = next;
} else if (currNode.pathWildcardNext != null) { // otherwise look for a single-wildcard to follow
currNode = currNode.pathWildcardNext;
} else { // otherwise we are done, there is no valid path --> fall back to the most specific catchall
endpoint = lastCatchallNode?.leafData;
return lastCatchallNode != null;
}
}
// return found path
endpoint = currNode.leafData;
return endpoint != null;
}
private class Node {
public T? leafData = null; // null means that this is a node without a value (e.g. when it is just part of a path)
public Dictionary<string, Node>? next = null;
public Node? pathWildcardNext = null; // path wildcard
public Node? catchAllNext = null; // trailing-catchall wildcard
public void AddSuccessor(string[] segments, T newLeafData) {
if (segments.Length == 0) { // actually add the data to this node
Assert(leafData == null);
leafData = newLeafData;
return;
}
var seg = segments[0];
bool newIsWildcard = seg.Length > 1 && seg[0] == '$';
if (newIsWildcard) {
bool newIsCatchallWildcard = newIsWildcard && seg.Length == 2 && seg[1] == '*';
if (newIsCatchallWildcard) { // this is a catchall wildcard
Assert(catchAllNext == null);
catchAllNext = new();
catchAllNext.AddSuccessor(segments[1..], newLeafData);
return;
} else { // must be single wildcard otherwise
pathWildcardNext ??= new();
pathWildcardNext.AddSuccessor(segments[1..], newLeafData);
return;
}
}
// otherwise we want to add a new constant path successor
next ??= new();
if (next.TryGetValue(seg, out var existingNode)) {
existingNode.AddSuccessor(segments[1..], newLeafData);
} else {
var newNode = next[seg] = new();
newNode.AddSuccessor(segments[1..], newLeafData);
}
}
}
}
@@ -1,7 +1,7 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Net; using System.Net;
namespace SimpleHttpServer; namespace SimpleHttpServer.Types;
public class RequestContext : IDisposable { public class RequestContext : IDisposable {
public HttpListenerContext ListenerContext { get; } public HttpListenerContext ListenerContext { get; }
@@ -19,9 +19,11 @@ public class RequestContext : IDisposable {
/// </summary> /// </summary>
public TextWriter RespWriter => respWriter ??= TextWriter.Synchronized(new StreamWriter(ListenerContext.Response.OutputStream) { NewLine = "\n" }); public TextWriter RespWriter => respWriter ??= TextWriter.Synchronized(new StreamWriter(ListenerContext.Response.OutputStream) { NewLine = "\n" });
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
public RequestContext(HttpListenerContext listenerContext) { public RequestContext(HttpListenerContext listenerContext) {
ListenerContext = listenerContext; ListenerContext = listenerContext;
} }
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
public async Task WriteLineToRespAsync(string resp) => await RespWriter.WriteLineAsync(resp); public async Task WriteLineToRespAsync(string resp) => await RespWriter.WriteLineAsync(resp);
public async Task WriteToRespAsync(string resp) => await RespWriter.WriteAsync(resp); public async Task WriteToRespAsync(string resp) => await RespWriter.WriteAsync(resp);
@@ -33,22 +35,23 @@ public class RequestContext : IDisposable {
public void SetStatusCode(HttpStatusCode status) => SetStatusCode((int) status); public void SetStatusCode(HttpStatusCode status) => SetStatusCode((int) status);
public async Task SetStatusCodeWriteLineDisposeAsync(HttpStatusCode status, string message) {
SetStatusCode(status);
await WriteLineToRespAsync(message);
await RespWriter.FlushAsync();
}
public async Task SetStatusCodeAndDisposeAsync(int status) { public async Task SetStatusCodeAndDisposeAsync(int status) {
using (this) { using (this) {
SetStatusCode(status); SetStatusCode(status);
if (status != (int)HttpStatusCode.NotModified) { // NotModified must not write any data to response
await WriteToRespAsync("\n\n"); await WriteToRespAsync("\n\n");
await RespWriter.FlushAsync(); await RespWriter.FlushAsync();
} }
} }
public async Task SetStatusCodeAndDisposeAsync(HttpStatusCode status) {
using (this) {
SetStatusCode((int) status);
await WriteToRespAsync("\n\n");
await RespWriter.FlushAsync();
}
} }
public async Task SetStatusCodeAndDisposeAsync(HttpStatusCode status) => await SetStatusCodeAndDisposeAsync((int) status);
public async Task SetStatusCodeAndDisposeAsync(int status, string description) { public async Task SetStatusCodeAndDisposeAsync(int status, string description) {
using (this) { using (this) {