Compare commits

...

48 Commits

Author SHA1 Message Date
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
GHXX cdab5151be fix parameter url decoding 2024-07-13 06:32:59 +02:00
GHXX b645d4d654 make dispose public 2024-07-07 19:11:55 +02:00
GHXX f20ba933dc threadsafety improvements 2024-05-25 23:54:57 +02:00
GHXX c91714a6af add svg staticserve content type handling 2024-02-02 05:07:20 +01:00
GHXX a9436bfda8 Revert "add ipv6 listener prefix"
This reverts commit f14294387e.
2024-01-31 05:14:03 +01:00
GHXX f14294387e add ipv6 listener prefix 2024-01-31 05:00:27 +01:00
GHXX 0bfe34ab6b improve static serve jail 2024-01-26 19:49:04 +01:00
GHXX 6cc849bf01 add ParsedParameters to RequestContext, cleanup error handling 2024-01-16 23:52:28 +01:00
GHXX 8cdff9268a fix some serving issues 2024-01-16 17:30:28 +01:00
GHXX 94b23cadc5 Add missing Close() statement 2024-01-15 21:09:17 +01:00
GHXX 0814bc6b2d Add static serving 2024-01-15 19:56:14 +01:00
GHXX 8545ed80e9 wip static serving 2024-01-15 04:28:17 +01:00
GHXX 4fad2d648e Replace argon2, add threadsafe SHA256 method, rename some variables 2024-01-14 23:15:12 +01:00
GHXX ea74cb899c move type to different folder 2024-01-14 21:50:49 +01:00
GHXX 92e472d526 Add tests for parameter optionality 2024-01-14 03:07:34 +01:00
GHXX 14ab546d4d Fix server producing a timeout on setting status code without returning a body 2024-01-14 02:58:01 +01:00
GHXX e1e1596e54 add query arg test 2024-01-14 02:31:45 +01:00
GHXX 6d74d659f6 fix query args 2024-01-14 02:31:31 +01:00
GHXX 0b4975e74b Improve error handling 2024-01-14 02:31:06 +01:00
GHXX d6190b024d Add missing string parameter converter 2024-01-14 02:30:20 +01:00
GHXX aa06679742 add tests for normalization and serving multiple pages 2024-01-14 00:56:56 +01:00
GHXX 020075ad54 normalize urls during registering and requesting so that they all start with a single slash 2024-01-14 00:55:43 +01:00
GHXX f0c9754fb2 fix spelling mistake 2024-01-14 00:55:11 +01:00
GHXX 7ad2b5185b add log output checking in tests 2024-01-13 19:07:00 +01:00
GHXX 08003d1fc3 Simplify tests 2024-01-13 03:14:22 +01:00
GHXX a2a70e8339 Catch exception on shutdown 2024-01-13 03:14:10 +01:00
GHXX 09fa3b8734 Huge refactor; Passing tests 2024-01-13 01:31:30 +01:00
29 changed files with 1495 additions and 437 deletions

21
LICENSE Normal file
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.

View File

@ -0,0 +1,21 @@
global using static SimpleHttpServer.GlobalUsings;
using SimpleHttpServer.Types.Exceptions;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace SimpleHttpServer;
internal static class GlobalUsings {
[DebuggerHidden]
internal static void Assert([DoesNotReturnIf(false)] bool b, string? message = null) {
if (!b) {
if (message == null)
throw new AssertionFailedException("An assertion has failed!");
else
throw new AssertionFailedException($"An assertion has failed: {message}");
}
}
[DebuggerHidden]
internal static void AssertImplies(bool x, bool y, string? message = null) => Assert(!x || y, message);
}

View File

@ -1,22 +0,0 @@
using SimpleHttpServer.Internal;
namespace SimpleHttpServer;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpEndpoint<T> : Attribute where T : IAuthorizer {
public HttpRequestType Type { get; private set; }
public string Location { get; private set; }
public Type Authorizer { get; private set; }
public HttpEndpoint(HttpRequestType type, string location) {
Type = type;
Location = location;
Authorizer = typeof(T);
}
}
[AttributeUsage(AttributeTargets.Method)]
public class HttpEndpoint : HttpEndpoint<DefaultAuthorizer> {
public HttpEndpoint(HttpRequestType type, string location) : base(type, location) { }
}

View File

@ -0,0 +1,15 @@
using SimpleHttpServer.Types;
namespace SimpleHttpServer;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpEndpointAttribute : Attribute {
public HttpRequestType RequestMethod { get; private set; }
public string[] Locations { get; private set; }
public HttpEndpointAttribute(HttpRequestType requestMethod, params string[] locations) {
RequestMethod = requestMethod;
Locations = locations;
}
}

View File

@ -1,134 +1,418 @@
using SimpleHttpServer.Internal; using SimpleHttpServer.Types;
using SimpleHttpServer.Types.Exceptions;
using SimpleHttpServer.Types.ParameterConverters;
using System.Net; using System.Net;
using System.Numerics;
using System.Reflection; using System.Reflection;
using System.Text;
using static SimpleHttpServer.Types.EndpointInvocationInfo;
namespace SimpleHttpServer; namespace SimpleHttpServer;
public sealed class HttpServer { public sealed class HttpServer {
private Thread? _listenerThread; public int Port { get; }
private readonly HttpListener _listener;
private readonly Dictionary<(string path, HttpRequestType rType), HttpEndpointHandler> _plainEndpoints = new();
private readonly Dictionary<(string path, HttpRequestType rType), HttpEndpointHandler> _pparamEndpoints = new();
public string Url { get; private set; } private readonly HttpListener listener;
public Func<HttpListenerContext, HttpResponseBuilder> Default404 { get; private set; } private Task? listenerTask;
private readonly Logger mainLogger;
private readonly Logger requestLogger;
private readonly SimpleHttpServerConfiguration conf;
private bool shutdown = false;
public static HttpServer Create(int port, string url, params Type[] apiDefinitions) => Create(Console.Error, port, url, false, apiDefinitions); public HttpServer(int port, SimpleHttpServerConfiguration configuration) {
Port = port;
public static HttpServer Create(TextWriter error, int port, string url, bool throwOnInvalidEndpoint, params Type[] apiDefinitions) { conf = configuration;
var epDict = new Dictionary<(string, HttpRequestType), HttpEndpointHandler>(); listener = new HttpListener();
listener.Prefixes.Add($"http://localhost:{port}/");
foreach (var definition in apiDefinitions) { mainLogger = new(LogOutputTopic.Main, conf);
foreach (var endpoint in definition.GetMethods()) { requestLogger = new(LogOutputTopic.Request, conf);
var attrib = endpoint.GetCustomAttributes()
.Where(x => x.GetType().IsAssignableTo(typeof(HttpEndpoint<>)))
.Select(x => (HttpEndpoint<IAuthorizer>) x)
.SingleOrDefault();
if (attrib == null) {
continue;
}
// sanity checks
if (!endpoint.IsStatic) {
PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, "HttpEndpointAttribute is only valid on static methods!");
continue;
}
if (!endpoint.IsPublic) {
PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, $"{GetFancyMethodName(endpoint)} needs to be public!");
}
var myParams = endpoint.GetParameters();
if (myParams.Length <= 0 || !myParams[0].GetType().IsAssignableFrom(typeof(HttpListenerContext))) {
PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, $"{GetFancyMethodName(endpoint)} needs to have a HttpListenerContext as its first argument!");
continue;
}
if (!endpoint.ReturnParameter.ParameterType.IsAssignableTo(typeof(HttpResponseBuilder))) {
PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, $"{GetFancyMethodName(endpoint)} needs to have a HttpResponseBuilder as the return type!");
}
var path = attrib.Location;
int idx = path.IndexOf('{');
if (idx >= 0) {
// this path contains path parameters
throw new NotImplementedException("Implement path parameters!");
}
var qparams = new List<(string, Type)>();
}
}
return null!;
}
public void Shutdown() {
Shutdown(-1);
}
public bool Shutdown(int timeout) {
if (_listenerThread == null) {
throw new InvalidOperationException("Cannot shutdown HttpServer that has not been started");
}
_listenerThread.Interrupt();
bool exited = true;
if (timeout < 0) {
_listenerThread.Join();
} else {
exited = _listenerThread.Join(timeout);
}
_listenerThread = null;
_listener.Stop();
return exited;
} }
public void Start() { public void Start() {
_listenerThread = new Thread(RunServer); mainLogger.Information($"Starting on port {Port}...");
_listener.Start(); Assert(listenerTask == null, "Server was already started!");
_listenerThread.Start(); listener.Start();
listenerTask = Task.Run(GetContextLoopAsync);
mainLogger.Information($"Ready to handle requests!");
} }
public async Task StopAsync(CancellationToken ctok) {
mainLogger.Information("Stopping server...");
Assert(listenerTask != null, "Server was not started!");
shutdown = true;
listener.Stop();
await listenerTask.WaitAsync(ctok);
}
private void RunServer() { public async Task GetContextLoopAsync() {
while (!shutdown) {
try {
var ctx = await listener.GetContextAsync();
_ = ProcessRequestAsync(ctx);
} catch (HttpListenerException ex) when (ex.ErrorCode == 995) { //The I/O operation has been aborted because of either a thread exit or an application request
} catch (Exception ex) {
mainLogger.Fatal($"Caught otherwise uncaught exception in GetContextLoop:\n{ex}");
}
}
}
private void RegisterDefaultConverters() {
void RegisterConverter<T>() where T : IParsable<T> {
stringToTypeParameterConverters.Add(typeof(T), new ParsableParameterConverter<T>());
}
stringToTypeParameterConverters.Add(typeof(string), new StringParameterConverter());
stringToTypeParameterConverters.Add(typeof(bool), new BoolParsableParameterConverter());
RegisterConverter<char>();
RegisterConverter<byte>();
RegisterConverter<short>();
RegisterConverter<int>();
RegisterConverter<long>();
RegisterConverter<Int128>();
RegisterConverter<UInt128>();
RegisterConverter<BigInteger>();
RegisterConverter<sbyte>();
RegisterConverter<ushort>();
RegisterConverter<uint>();
RegisterConverter<ulong>();
RegisterConverter<Half>();
RegisterConverter<float>();
RegisterConverter<double>();
RegisterConverter<decimal>();
}
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) };
internal static readonly int expectedEndpointParameterPrefixCount = expectedEndpointParameterTypes.Length;
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();
var t = typeof(T);
var mis = t.GetMethods()
.ToDictionary(x => x, x => x.GetCustomAttributes<HttpEndpointAttribute>())
.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;
//Assert(mi.IsStatic, $"Method tagged with HttpEndpointAttribute must be static! ({GetFancyMethodName()})");
Assert(mi.IsPublic, $"Method tagged with HttpEndpointAttribute must be public! ({GetFancyMethodName()})");
var methodParams = mi.GetParameters();
// check the mandatory prefix parameters
Assert(methodParams.Length >= expectedEndpointParameterTypes.Length);
for (int i = 0; i < expectedEndpointParameterTypes.Length; 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}.");
}
// check return type
Assert(mi.ReturnType == typeof(Task), $"Return type of {GetFancyMethodName()} is not {typeof(Task)}!");
// check the rest of the method parameters
var qparams = new List<QueryParameterInfo>();
var pparams = new List<PathParameterInfo>();
int mParamIndex = expectedEndpointParameterTypes.Length;
for (int i = expectedEndpointParameterTypes.Length; i < methodParams.Length; i++) {
var par = methodParams[i];
var attr = par.GetCustomAttribute<ParameterAttribute>(false);
var pathAttr = par.GetCustomAttribute<PathParameterAttribute>(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)) {
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) {
var normLocation = NormalizeUrlPath(location);
var reqMethod = Enum.GetName(attrib.RequestMethod) ?? throw new ArgumentException("Request method was undefined");
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>
/// 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.
/// </summary>
/// <param name="requestPath"></param>
/// <param name="filesystemDirectory"></param>
public void RegisterStaticServePath(string requestPath, string filesystemDirectory) {
var absPath = Path.GetFullPath(filesystemDirectory);
string npath = NormalizeUrlPath(requestPath);
mainLogger.Information($"Registered static serve path: '{npath}' --> '{absPath}'");
staticServePaths.Add(npath, absPath);
}
private readonly Dictionary<string, string> staticServePaths = new();
private readonly Dictionary<Type, IParameterConverter> stringToTypeParameterConverters = new();
private string NormalizeUrlPath(string url) {
var fwdSlashUrl = url.Replace('\\', '/');
var segments = fwdSlashUrl.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries).ToList();
List<string> simplifiedSegmentsReversed = new List<string>();
int doubleDotsEncountered = 0;
for (int i = segments.Count - 1; i >= 0; i--) {
var segment = segments[i];
if (segment == ".") {
continue; // remove single dot segments
}
if (segment == "..") {
doubleDotsEncountered++; // if we encounter a doubledot, keep track of that and dont add it to the output yet
continue;
}
// otherwise only keep the segment if doubleDotsEncountered > 0
if (doubleDotsEncountered > 0) {
doubleDotsEncountered--;
continue;
}
simplifiedSegmentsReversed.Add(segment);
}
var rv = new StringBuilder();
for (int i = 0; i < doubleDotsEncountered; i++) {
rv.Append("../");
}
rv.AppendJoin('/', simplifiedSegmentsReversed.Reverse<string>());
var suffix = (rv.ToString().TrimEnd('/') + (fwdSlashUrl.EndsWith('/') ? "/" : "")).TrimStart('/');
if (conf.TrimTrailingSlash) {
suffix = suffix.TrimEnd('/');
}
return '/' + suffix;
}
private async Task ProcessRequestAsync(HttpListenerContext ctx) {
using RequestContext rc = new RequestContext(ctx);
// TODO add path escape countermeasure-unittests
var splitted = (ctx.Request.RawUrl ?? "").Split('?', 2, StringSplitOptions.None);
var reqPath = NormalizeUrlPath(WebUtility.UrlDecode(splitted.First()));
string requestMethod = ctx.Request.HttpMethod.ToUpperInvariant();
bool wasStaticlyServed = false;
void LogRequest() {
requestLogger.Information($"{rc.ListenerContext.Response.StatusCode} {(wasStaticlyServed ? "static" : "endpnt")} {requestMethod} {ctx.Request.Url}");
}
try { try {
for (; ; ) {
var ctx = _listener.GetContext();
ThreadPool.QueueUserWorkItem((localCtx) => { /* Finding the endpoint that should process the request:
HttpRequestType type; * 1. Try to see if there is a simple endpoint where request method and path match
if (!Enum.TryParse(localCtx.Request.HttpMethod, out type)) { * 2. Otherwise, try to see if a path-parameter-endpoint matches (duplicates throw an error on startup)
Default404(localCtx).SendResponse(localCtx.Response); * 3. Otherwise, check if it is inside a static serve path
return; * 4. Otherwise, show 404 page */
}
var path = localCtx.Request.Url!.LocalPath.Replace('\\', '/'); EndpointInvocationInfo? pathEndpointInvocationInfo = null;
HttpEndpointHandler? ep = null; if (simpleEndpointMethodInfos.TryGetValue(requestMethod, reqPath, out var simpleEndpointInvocationInfo) ||
if (!_plainEndpoints.TryGetValue((path, type), out ep)) { pathEndpointMethodInfosTrees.TryGetValue(requestMethod, out var pt) && pt.TryGetPath(reqPath, out pathEndpointInvocationInfo)) { // try to find simple or pathparam-endpoint
// not found among plain endpoints var endpointInvocationInfo = simpleEndpointInvocationInfo ?? pathEndpointInvocationInfo ?? throw new Exception("retrieved endpoint is somehow null");
foreach (var epk in _pparamEndpoints.Keys) { var mi = endpointInvocationInfo.methodInfo;
if (epk.rType == type && path.StartsWith(epk.path)) { var qparams = endpointInvocationInfo.queryParameters;
ep = _pparamEndpoints[epk]; var pparams = endpointInvocationInfo.pathParameters;
break; var args = splitted.Length == 2 ? splitted[1] : null;
}
var parsedQParams = new Dictionary<string, string>();
var convertedMParamValues = new object[expectedEndpointParameterTypes.Length + pparams.Count + qparams.Count];
// 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) {
var queryStringArgs = args.Split('&', StringSplitOptions.None);
foreach (var queryKV in queryStringArgs) {
var queryKVSplitted = queryKV.Split('=');
if (queryKVSplitted.Length != 2) {
await HandleDefaultErrorPageAsync(rc, HttpStatusCode.BadRequest, "Malformed request URL parameters");
return;
} }
if (ep == null) { if (!parsedQParams.TryAdd(WebUtility.UrlDecode(queryKVSplitted[0]), WebUtility.UrlDecode(queryKVSplitted[1]))) {
Default404(localCtx).SendResponse(localCtx.Response); await HandleDefaultErrorPageAsync(rc, HttpStatusCode.BadRequest, "Duplicate request URL parameters");
return; return;
} }
} }
ep.Handle(localCtx);
}, ctx, false); for (int i = 0; i < qparams.Count;) {
var qparam = qparams[i];
i++;
if (parsedQParams.TryGetValue(qparam.Name, out var qparamValue)) {
if (stringToTypeParameterConverters[qparam.Type].TryConvertFromString(qparamValue, out object objRes)) {
convertedMParamValues[qparam.ArgPos] = objRes;
} else {
await HandleDefaultErrorPageAsync(rc, HttpStatusCode.BadRequest);
return;
}
} else {
if (qparam.IsOptional) {
convertedMParamValues[qparam.ArgPos] = null!;
} else {
await HandleDefaultErrorPageAsync(rc, HttpStatusCode.BadRequest, $"Missing required query parameter {qparam.Name}");
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;
}
}
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();
// todo read and convert pathparams
await (Task) (mi.Invoke(endpointInvocationInfo.typeInstanceReference, convertedMParamValues) ?? throw new NullReferenceException("Website func returned null unexpectedly"));
} else { // try to find suitable static serve path
if (requestMethod == "GET")
foreach (var (k, v) in staticServePaths) {
if (reqPath.StartsWith(k)) { // do a static serve
wasStaticlyServed = true;
var relativeStaticReqPath = reqPath[k.Length..];
var staticResponsePath = Path.GetFullPath(Path.Join(v, relativeStaticReqPath.TrimStart('/')));
if (Path.GetRelativePath(v, staticResponsePath).Contains("..")) {
requestLogger.Warning($"Blocked GET request to {reqPath} as somehow the target file does not lie inside the static serve folder? Are you using symlinks?");
await HandleDefaultErrorPageAsync(rc, HttpStatusCode.NotFound);
return;
}
if (File.Exists(staticResponsePath)) {
rc.SetStatusCode(HttpStatusCode.OK);
if (staticResponsePath.EndsWith(".svg")) {
rc.ListenerContext.Response.AddHeader("Content-Type", "image/svg+xml");
}
using var f = File.OpenRead(staticResponsePath);
await f.CopyToAsync(rc.ListenerContext.Response.OutputStream);
} else {
await HandleDefaultErrorPageAsync(rc, HttpStatusCode.NotFound);
}
return;
}
}
// invoke 404
await HandleDefaultErrorPageAsync(rc, 404);
} }
} catch (ThreadInterruptedException) {
// this can only be reached when listener.GetContext is interrupted } catch (Exception ex) {
// safely exit main loop await HandleDefaultErrorPageAsync(rc, 500);
mainLogger.Fatal($"Caught otherwise uncaught exception while ProcessingRequest:\n{ex}");
} finally {
try { await rc.RespWriter.FlushAsync(); } catch (ObjectDisposedException) { }
rc.ListenerContext.Response.Close();
LogRequest();
} }
} }
private static void PrintErrorOrThrow(TextWriter error, MethodInfo method, bool forceThrow, string msg) { private static async Task HandleDefaultErrorPageAsync(RequestContext ctx, HttpStatusCode errorCode, string? statusDescription = null) => await HandleDefaultErrorPageAsync(ctx, (int) errorCode, statusDescription);
if (forceThrow) {
throw new Exception(msg);
} else {
error.WriteLine($"> {msg}\n skipping {GetFancyMethodName(method)} ...");
}
}
private static string GetFancyMethodName(MethodInfo method) => method.DeclaringType!.Name + "#" + method.Name; private static async Task HandleDefaultErrorPageAsync(RequestContext ctx, int errorCode, string? statusDescription = null) {
ctx.SetStatusCode(errorCode);
string desc = statusDescription != null ? $"\r\n{statusDescription}" : "";
await ctx.WriteLineToRespAsync($"""
<body>
<h1>Oh no, an error occurred!</h1>
<p>Code: {errorCode}</p>{desc}
</body>
""");
try {
if (statusDescription == null) {
await ctx.SetStatusCodeAndDisposeAsync(errorCode);
} else {
await ctx.SetStatusCodeAndDisposeAsync(errorCode, statusDescription);
}
} catch (ObjectDisposedException) { }
}
} }

View File

@ -1,7 +0,0 @@
using System.Net;
namespace SimpleHttpServer;
public interface IAuthorizer {
public abstract (bool auth, object? data) IsAuthenticated(HttpListenerContext contect);
}

View File

@ -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);
}

View File

@ -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);
} // }
} // }
} //}

View File

@ -0,0 +1,61 @@
using System.Diagnostics;
namespace SimpleHttpServer;
public class Logger {
private readonly string topic;
private readonly LogOutputTopic ltopic;
private readonly bool printToConsole;
private readonly SimpleHttpServerConfiguration.CustomLogMessageHandler? externalLogMsgHandler;
internal Logger(LogOutputTopic topic, SimpleHttpServerConfiguration conf) {
this.topic = Enum.GetName(topic) ?? throw new ArgumentException("The given LogOutputTopic is not defined!");
ltopic = topic;
externalLogMsgHandler = conf.LogMessageHandler;
printToConsole = !conf.DisableLogMessagePrinting;
}
private readonly object writeLock = new();
public void Log(string message, LogOutputLevel level) {
var fgColor = level switch {
LogOutputLevel.Debug => ConsoleColor.Gray,
LogOutputLevel.Information => ConsoleColor.White,
LogOutputLevel.Warning => ConsoleColor.Yellow,
LogOutputLevel.Error => ConsoleColor.Red,
LogOutputLevel.Fatal => ConsoleColor.Magenta,
_ => throw new NotImplementedException(),
};
if (printToConsole)
lock (writeLock) {
var origColor = Console.ForegroundColor;
Console.ForegroundColor = fgColor;
Console.WriteLine($"[{topic}] {message}");
Console.ForegroundColor = origColor;
}
externalLogMsgHandler?.Invoke(ltopic, message, level);
}
[Conditional("DEBUG")]
public void Debug(string message) => Log(message, LogOutputLevel.Debug);
public void Information(string message) => Log(message, LogOutputLevel.Information);
public void Warning(string message) => Log(message, LogOutputLevel.Warning);
public void Error(string message) => Log(message, LogOutputLevel.Error);
public void Fatal(string message) => Log(message, LogOutputLevel.Fatal);
}
public enum LogOutputLevel {
Debug,
Information,
Warning,
Error,
Fatal
}
public enum LogOutputTopic {
Main,
Request,
Security
}

View File

@ -1,243 +1,245 @@
using Konscious.Security.Cryptography; //using Newtonsoft.Json;
using Newtonsoft.Json; //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 salt; // public string passwordSalt;
public string pwd; // public string extraDataSalt;
public string additionalData; // public string pwd;
// public string extraData;
public LoginData toPlainData() { // public LoginData ToPlainData() {
return new LoginData { // return new LoginData {
salt = Convert.FromBase64String(salt), // passwordSalt = Convert.FromBase64String(passwordSalt),
password = Convert.FromBase64String(pwd) // extraDataSalt = Convert.FromBase64String(extraDataSalt)
}; // };
} // }
} //}
internal struct LoginData { //internal struct LoginData {
public byte[] salt; // public byte[] passwordSalt;
public byte[] password; // public byte[] extraDataSalt;
public byte[] encryptedData; // public byte[] passwordHash;
// public byte[] encryptedExtraData;
public SerialLoginData toSerial() { // public SerialLoginData ToSerial() {
return new SerialLoginData { // return new SerialLoginData {
salt = Convert.ToBase64String(salt), // passwordSalt = Convert.ToBase64String(passwordSalt),
pwd = Convert.ToBase64String(password), // extraDataSalt = Convert.ToBase64String(extraDataSalt),
additionalData = Convert.ToBase64String(encryptedData) // pwd = Convert.ToBase64String(passwordHash),
}; // extraData = Convert.ToBase64String(encryptedExtraData)
} // };
} // }
//}
internal struct LoginDataProviderConfig { //internal struct LoginDataProviderConfig {
public int SALT_SIZE = 32; // /// <summary>
public int KEY_LENGTH = 256 / 8; // /// Size of the password salt and the extradata salt. So each salt will be of size <see cref="SALT_SIZE"/>.
public int A2_ITERATIONS = 5; // /// </summary>
public int A2_MEMORY_SIZE = 500_000; // public int SALT_SIZE = 32;
public int A2_PARALLELISM = 8; // public int KEY_LENGTH = 256 / 8;
public int A2_HASH_LENGTH = 256 / 8; // public int PBKDF2_ITERATIONS = 600_000;
public int A2_MAX_CONCURRENT = 4;
public int PBKDF2_ITERATIONS = 600_000;
public LoginDataProviderConfig() { } // public LoginDataProviderConfig() { }
} //}
public class LoginProvider<T> { //public class LoginProvider<TExtraData> {
private static readonly Func<T, 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[], T> JsonDeserialize = b => JsonConvert.DeserializeObject<T>(Encoding.UTF8.GetString(b))!; // private static readonly Func<byte[], TExtraData> JsonDeserialize = b => JsonConvert.DeserializeObject<TExtraData>(Encoding.UTF8.GetString(b))!;
private readonly LoginDataProviderConfig config; // [ThreadStatic]
private readonly ReaderWriterLockSlim ldLock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion); // private static SHA256? _sha256PerThread;
private readonly string ldPath; // private static SHA256 Sha256PerThread { get => _sha256PerThread ??= SHA256.Create(); }
private readonly Dictionary<string, LoginData> loginData;
private readonly SemaphoreSlim argon2Limit;
private Func<T, byte[]> DataSerializer = JsonSerialize; // private readonly LoginDataProviderConfig config;
private Func<byte[], T> DataDeserializer = JsonDeserialize; // private readonly ReaderWriterLockSlim ldLock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
// private readonly string ldPath;
// private readonly Dictionary<string, LoginData> loginDatas;
public LoginProvider(string ldPath, string confPath) { // private Func<TExtraData, byte[]> DataSerializer = JsonSerialize;
this.ldPath = ldPath; // private Func<byte[], TExtraData> DataDeserializer = JsonDeserialize;
loginData = LoadLoginData(ldPath); // public void SetDataSerializers(Func<TExtraData, byte[]> serializer, Func<byte[], TExtraData> deserializer) {
config = LoadArgon2Config(confPath); // DataSerializer = serializer ?? JsonSerialize;
argon2Limit = new SemaphoreSlim(config.A2_MAX_CONCURRENT); // DataDeserializer = deserializer ?? JsonDeserialize;
} // }
private static Dictionary<string, LoginData> LoadLoginData(string path) {
Dictionary<string, SerialLoginData> tempData;
if (!File.Exists(path)) {
File.WriteAllText(path, "{}", Encoding.UTF8);
tempData = new();
} else {
tempData = JsonConvert.DeserializeObject<Dictionary<string, SerialLoginData>>(File.ReadAllText(path))!;
if (tempData == null) {
throw new InvalidDataException($"could not read login data from file {path}");
}
}
var ld = new Dictionary<string, LoginData>();
foreach (var pair in tempData!) {
ld.Add(pair.Key, pair.Value.toPlainData());
}
return ld;
}
private static LoginDataProviderConfig LoadArgon2Config(string path) { // public LoginProvider(string ldPath, string confPath) {
if (!File.Exists(path)) { // this.ldPath = ldPath;
var conf = new LoginDataProviderConfig(); // loginDatas = LoadLoginDatas(ldPath);
File.WriteAllText(path, JsonConvert.SerializeObject(conf)); // config = LoadLoginProviderConfig(confPath);
return conf; // }
}
return JsonConvert.DeserializeObject<LoginDataProviderConfig>(File.ReadAllText(path));
}
public void SetDataSerialization(Func<T, byte[]> serializer, Func<byte[], T> deserializer) { // private static Dictionary<string, LoginData> LoadLoginDatas(string path) {
DataSerializer = serializer ?? JsonSerialize; // Dictionary<string, SerialLoginData> tempData;
DataDeserializer = deserializer ?? JsonDeserialize; // if (!File.Exists(path)) {
} // File.WriteAllText(path, "{}", Encoding.UTF8);
// tempData = new();
// } else {
// tempData = JsonConvert.DeserializeObject<Dictionary<string, SerialLoginData>>(File.ReadAllText(path))!;
// if (tempData == null) {
// throw new InvalidDataException($"could not read login data from file {path}");
// }
// }
// var ld = new Dictionary<string, LoginData>();
// foreach (var pair in tempData) {
// ld.Add(pair.Key, pair.Value.ToPlainData());
// }
// return ld;
// }
private void StoreLoginData() { // 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 loginData!) { // 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));
} // }
public bool AddUser(string username, string password, T additional) { // private static LoginDataProviderConfig LoadLoginProviderConfig(string path) {
ldLock.EnterWriteLock(); // if (!File.Exists(path)) {
try { // var conf = new LoginDataProviderConfig();
if (loginData.ContainsKey(username)) { // File.WriteAllText(path, JsonConvert.SerializeObject(conf));
return false; // return conf;
} // }
var salt = RandomNumberGenerator.GetBytes(config.SALT_SIZE); // return JsonConvert.DeserializeObject<LoginDataProviderConfig>(File.ReadAllText(path));
var pwdHash = HashPwd(password, salt); // }
LoginData ld = new LoginData() {
salt = salt,
password = pwdHash,
encryptedData = EncryptAdditionalData(password, salt, additional)
};
loginData.Add(username, ld);
StoreLoginData();
} finally {
ldLock.ExitWriteLock();
}
return true;
}
public bool RemoveUser(string username) { // public bool AddUser(string username, string password, TExtraData additional) {
ldLock.EnterWriteLock(); // ldLock.EnterWriteLock();
try { // try {
var removed = loginData.Remove(username); // if (loginDatas.ContainsKey(username)) {
if (removed) { // return false;
StoreLoginData(); // }
} // var passwordSalt = RandomNumberGenerator.GetBytes(config.SALT_SIZE);
return removed; // var extraDataSalt = RandomNumberGenerator.GetBytes(config.SALT_SIZE);
} finally { // LoginData ld = new LoginData() {
ldLock.ExitWriteLock(); // passwordSalt = passwordSalt,
} // extraDataSalt = extraDataSalt,
} // passwordHash = ComputeSaltedSha256Hash(password, passwordSalt),
// encryptedExtraData = EncryptExtraData(password, extraDataSalt, additional),
// };
// loginDatas.Add(username, ld);
// SaveLoginData();
// } finally {
// ldLock.ExitWriteLock();
// }
// return true;
// }
public bool ModifyUser(string username, string newPassword, T newAdditional) { // public bool RemoveUser(string username) {
ldLock.EnterWriteLock(); // ldLock.EnterWriteLock();
try { // try {
if (!loginData.ContainsKey(username)) { // var removed = loginDatas.Remove(username);
return false; // if (removed) {
} // SaveLoginData();
loginData.Remove(username, out var data); // }
data.password = HashPwd(newPassword, data.salt); // return removed;
data.encryptedData = EncryptAdditionalData(newPassword, data.salt, newAdditional); // } finally {
loginData.Add(username, data); // ldLock.ExitWriteLock();
StoreLoginData(); // }
} finally { // }
ldLock.ExitWriteLock();
}
return true;
}
public (bool, T) Authenticate(string username, string password) { // public bool ModifyUser(string username, string newPassword, TExtraData newExtraData) {
LoginData data; // ldLock.EnterWriteLock();
ldLock.EnterReadLock(); // try {
try { // if (!loginDatas.ContainsKey(username)) {
if (!loginData.TryGetValue(username, out data)) { // return false;
return (false, default(T)!); // }
} // loginDatas.Remove(username, out var data);
} finally { // data.passwordHash = ComputeSaltedSha256Hash(newPassword, data.passwordSalt);
ldLock.ExitReadLock(); // data.encryptedExtraData = EncryptExtraData(newPassword, data.extraDataSalt, newExtraData);
} // loginDatas.Add(username, data);
var hash = HashPwd(password, data.salt); // SaveLoginData();
if (!hash.SequenceEqual(data.password)) { // } finally {
return (false, default(T)!); // ldLock.ExitWriteLock();
} // }
return (true, DecryptAdditionalData(password, data.salt, data.encryptedData)); // return true;
} // }
private byte[] HashPwd(string pwd, byte[] salt) { // public bool TryAuthenticate(string username, string password, [MaybeNullWhen(false)] out TExtraData extraData) {
byte[] hash; // LoginData data;
argon2Limit.Wait(); // ldLock.EnterReadLock();
try { // try {
using (var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd))) { // if (!loginDatas.TryGetValue(username, out data)) {
argon2.Iterations = config.A2_ITERATIONS; // extraData = default;
argon2.MemorySize = config.A2_MEMORY_SIZE; // return false;
argon2.DegreeOfParallelism = config.A2_PARALLELISM; // }
argon2.Salt = salt; // } finally {
hash = argon2.GetBytes(config.A2_HASH_LENGTH); // ldLock.ExitReadLock();
} // }
// force collection to reduce sustained memory usage if many hashes are done in close time proximity to each other // var hash = ComputeSaltedSha256Hash(password, data.passwordSalt);
GC.Collect(); // if (!hash.SequenceEqual(data.passwordHash)) {
} finally { // extraData = default;
argon2Limit.Release(); // return false;
} // }
return hash; // extraData = DecryptExtraData(password, data.extraDataSalt, data.encryptedExtraData);
} // return true;
// }
private byte[] EncryptAdditionalData(string pwd, byte[] salt, T data) { // /// <summary>
var pbkdf2 = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(pwd), salt, config.PBKDF2_ITERATIONS, HashAlgorithmName.SHA256); // /// Threadsafe as the SHA256 instance (<see cref="Sha256PerThread"/>) is per thread.
var key = pbkdf2.GetBytes(config.KEY_LENGTH / 8); // /// </summary>
// /// <param name="data"></param>
// /// <param name="salt"></param>
// /// <returns></returns>
// private static byte[] ComputeSaltedSha256Hash(string data, byte[] salt) {
// var dataBytes = Encoding.UTF8.GetBytes(data);
// var buf = new byte[data.Length + salt.Length];
// Buffer.BlockCopy(dataBytes, 0, buf, 0, dataBytes.Length);
// Buffer.BlockCopy(salt, 0, buf, dataBytes.Length, salt.Length);
// return Sha256PerThread.ComputeHash(buf);
// }
var plainBytes = DataSerializer(data); // private byte[] EncryptExtraData(string pwd, byte[] salt, TExtraData extraData) {
using var aes = Aes.Create(); // var pbkdf2 = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(pwd), salt, config.PBKDF2_ITERATIONS, HashAlgorithmName.SHA256);
aes.KeySize = config.KEY_LENGTH; // var key = pbkdf2.GetBytes(config.KEY_LENGTH / 8);
aes.Key = key;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
var encryptedBytes = new byte[aes.IV.Length + cipherBytes.Length]; // var plainBytes = DataSerializer(extraData);
Array.Copy(aes.IV, 0, encryptedBytes, 0, aes.IV.Length); // using var aes = Aes.Create();
Array.Copy(cipherBytes, 0, encryptedBytes, aes.IV.Length, cipherBytes.Length); // aes.KeySize = config.KEY_LENGTH;
// aes.Key = key;
// aes.Mode = CipherMode.CBC;
// aes.Padding = PaddingMode.PKCS7;
// ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
// byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
return encryptedBytes; // var encryptedBytes = new byte[aes.IV.Length + cipherBytes.Length];
} // Array.Copy(aes.IV, 0, encryptedBytes, 0, aes.IV.Length);
// Array.Copy(cipherBytes, 0, encryptedBytes, aes.IV.Length, cipherBytes.Length);
private T DecryptAdditionalData(string pwd, byte[] salt, byte[] encryptedData) { // return encryptedBytes;
var pbkdf2 = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(pwd), salt, config.PBKDF2_ITERATIONS, HashAlgorithmName.SHA256); // }
var key = pbkdf2.GetBytes(config.KEY_LENGTH / 8);
using var aes = Aes.Create(); // private TExtraData DecryptExtraData(string pwd, byte[] salt, byte[] encryptedData) {
aes.KeySize = config.KEY_LENGTH; // var pbkdf2 = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(pwd), salt, config.PBKDF2_ITERATIONS, HashAlgorithmName.SHA256);
aes.Key = key; // var key = pbkdf2.GetBytes(config.KEY_LENGTH / 8);
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
var iv = new byte[aes.BlockSize / 8];
var cipherBytes = new byte[encryptedData.Length - iv.Length];
Array.Copy(encryptedData, 0, iv, 0, iv.Length); // using var aes = Aes.Create();
Array.Copy(encryptedData, iv.Length, cipherBytes, 0, cipherBytes.Length); // aes.KeySize = config.KEY_LENGTH;
// aes.Key = key;
// aes.Mode = CipherMode.CBC;
// aes.Padding = PaddingMode.PKCS7;
// var iv = new byte[aes.BlockSize / 8];
// var cipherBytes = new byte[encryptedData.Length - iv.Length];
aes.IV = iv; // Array.Copy(encryptedData, 0, iv, 0, iv.Length);
ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV); // Array.Copy(encryptedData, iv.Length, cipherBytes, 0, cipherBytes.Length);
byte[] plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
return DataDeserializer(plainBytes); // aes.IV = iv;
} // ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
} // byte[] plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
// return DataDeserializer(plainBytes);
// }
//}

View File

@ -7,7 +7,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup> </ItemGroup>

View File

@ -0,0 +1,23 @@
namespace SimpleHttpServer;
public class SimpleHttpServerConfiguration {
public delegate void CustomLogMessageHandler(LogOutputTopic topic, string message, LogOutputLevel logLevel);
/// <summary>
/// If set to true, log messages will not be printed to the console, and instead will only be outputted by calling <see cref="LogMessageHandler"/>.
/// If set to false, the aforementioned delegate will still be invoked, but messages will still be printed to the console.
/// Setting this to false and <see cref="LogMessageHandler"/> to null will effectively disable log output completely.
/// </summary>
public bool DisableLogMessagePrinting { get; init; } = false;
/// <summary>
/// See description of <see cref="DisableLogMessagePrinting"/>
/// </summary>
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() { }
}

View File

@ -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;
}
}

View File

@ -0,0 +1,47 @@
using System.Net;
using System.Reflection;
namespace SimpleHttpServer.Types;
internal record EndpointInvocationInfo {
//internal record struct QueryParameterInfo(string Name, Type Type, bool isPathParam, bool Path_isCatchAll, bool Query_IsOptional) {
// 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) {
this.methodInfo = methodInfo ?? throw new ArgumentNullException(nameof(methodInfo));
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));
}

View File

@ -0,0 +1,11 @@
namespace SimpleHttpServer.Types.Exceptions;
[Serializable]
public class AssertionFailedException : Exception {
public AssertionFailedException() { }
public AssertionFailedException(string message) : base(message) { }
public AssertionFailedException(string message, Exception inner) : base(message, inner) { }
protected AssertionFailedException(
System.Runtime.Serialization.SerializationInfo info,
System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
}

View File

@ -0,0 +1,17 @@
using System.Runtime.Serialization;
namespace SimpleHttpServer.Types.Exceptions;
[Serializable]
internal class MissingParameterConverterException : Exception {
public MissingParameterConverterException() {
}
public MissingParameterConverterException(string? message) : base(message) {
}
public MissingParameterConverterException(string? message, Exception? innerException) : base(message, innerException) {
}
protected MissingParameterConverterException(SerializationInfo info, StreamingContext context) : base(info, context) {
}
}

View File

@ -1,4 +1,4 @@
namespace SimpleHttpServer; namespace SimpleHttpServer.Types;
public enum HttpRequestType { public enum HttpRequestType {
GET, GET,

View File

@ -0,0 +1,5 @@
namespace SimpleHttpServer;
public interface IParameterConverter {
bool TryConvertFromString(string value, out object result);
}

View File

@ -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;
}
}

View File

@ -0,0 +1,18 @@
namespace SimpleHttpServer.Types;
/// <summary>
/// Specifies the name of a http endpoint parameter. If this attribute is not specified, the variable name is used instead.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter, Inherited = false, AllowMultiple = false)]
public sealed class ParameterAttribute : Attribute {
public string Name { get; }
public bool IsOptional { get; }
public ParameterAttribute(string name, bool isOptional = false) {
if (string.IsNullOrWhiteSpace(name)) {
throw new ArgumentException($"'{nameof(name)}' cannot be null or whitespace.", nameof(name));
}
Name = name;
IsOptional = isOptional;
}
}

View File

@ -0,0 +1,16 @@
namespace SimpleHttpServer.Types.ParameterConverters;
internal class BoolParsableParameterConverter : IParameterConverter {
public bool TryConvertFromString(string value, out object result) {
var normalized = value.ToLowerInvariant();
if (normalized is "true" or "1") {
result = true;
return true;
} else if (normalized is "false" or "0") {
result = false;
return true;
} else {
result = false;
return false;
}
}
}

View File

@ -0,0 +1,10 @@
using System.Diagnostics.CodeAnalysis;
namespace SimpleHttpServer.Types.ParameterConverters;
internal class ParsableParameterConverter<T> : IParameterConverter where T : IParsable<T> {
public bool TryConvertFromString(string value, [NotNullWhen(true)] out object result) {
bool ok = T.TryParse(value, null, out T? res);
result = res!;
return ok;
}
}

View File

@ -0,0 +1,7 @@
namespace SimpleHttpServer.Types.ParameterConverters;
internal class StringParameterConverter : IParameterConverter {
public bool TryConvertFromString(string value, out object result) {
result = value;
return true;
}
}

View File

@ -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;
}
}

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);
}
}
}
}

View File

@ -0,0 +1,156 @@
using System.Collections.ObjectModel;
using System.Net;
namespace SimpleHttpServer.Types;
public class RequestContext : IDisposable {
public HttpListenerContext ListenerContext { get; }
public ReadOnlyDictionary<string, string> ParsedParameters { get; internal set; }
private TextReader? reqReader;
/// <summary>
/// THREADSAFE
/// </summary>
public TextReader ReqReader => reqReader ??= TextReader.Synchronized(new StreamReader(ListenerContext.Request.InputStream));
private TextWriter? respWriter;
/// <summary>
/// THREADSAFE
/// </summary>
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) {
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 WriteToRespAsync(string resp) => await RespWriter.WriteAsync(resp);
public void SetStatusCode(int status) {
ListenerContext.Response.StatusCode = status;
ListenerContext.Response.StatusDescription = GetDescriptionFromStatusCode(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) {
using (this) {
SetStatusCode(status);
await WriteToRespAsync("\n\n");
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(int status, string description) {
using (this) {
ListenerContext.Response.StatusCode = status;
ListenerContext.Response.StatusDescription = description;
await WriteToRespAsync("\n\n");
await RespWriter.FlushAsync();
}
}
public async Task SetStatusCodeAndDisposeAsync(HttpStatusCode status, string description) => await SetStatusCodeAndDisposeAsync((int) status, description);
public async Task WriteRedirect302AndDisposeAsync(string url) {
ListenerContext.Response.AddHeader("Location", url);
await SetStatusCodeAndDisposeAsync(HttpStatusCode.Redirect);
}
public void Dispose() {
reqReader?.Dispose();
respWriter?.Dispose();
GC.SuppressFinalize(this);
}
// src: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
private static string GetDescriptionFromStatusCode(int status) => status switch {
100 => "Continue",
101 => "Switching Protocols",
102 => "Processing",
103 => "Early Hints",
200 => "OK",
201 => "Created",
202 => "Accepted",
203 => "Non-Authoritative Information",
204 => "No Content",
205 => "Reset Content",
206 => "Partial Content",
207 => "Multi-Status",
208 => "Already Reported",
226 => "IM Used",
300 => "Multiple Choices",
301 => "Moved Permanently",
302 => "Found",
303 => "See Other",
304 => "Not Modified",
305 => "Use Proxy",
306 => "Switch Proxy",
307 => "Temporary Redirect",
308 => "Permanent Redirect",
400 => "Bad Request",
401 => "Unauthorized",
402 => "Payment Required",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
406 => "Not Acceptable",
407 => "Proxy Authentication Required",
408 => "Request Timeout",
409 => "Conflict",
410 => "Gone",
411 => "Length Required",
412 => "Precondition Failed",
413 => "Payload Too Large",
414 => "URI Too Long",
415 => "Unsupported Media Type",
416 => "Range Not Satisfiable",
417 => "Expectation Failed",
421 => "Misdirected Request",
422 => "Unprocessable Content",
423 => "Locked",
424 => "Failed Dependency",
425 => "Too Early",
426 => "Upgrade Required",
428 => "Precondition Required",
429 => "Too Many Requests",
431 => "Request Header Fields Too Large",
451 => "Unavailable For Legal Reasons",
500 => "Internal Server Error",
501 => "Not Implemented",
502 => "Bad Gateway",
503 => "Service Unavailable",
504 => "Gateway Timeout",
505 => "HTTP Version Not Supported",
506 => "Variant Also Negotiates",
507 => "Insufficient Storage",
508 => "Loop Detected",
510 => "Not Extended",
511 => "Network Authentication Required",
_ => "Unknown",
};
}

View File

@ -1,15 +1,138 @@
using SimpleHttpServer; using SimpleHttpServer;
using SimpleHttpServerTest.SimpleTestServer; using SimpleHttpServer.Types;
using System.Net;
namespace SimpleHttpServerTest; namespace SimpleHttpServerTest;
[TestClass] [TestClass]
public class SimpleServerTest { public class SimpleServerTest {
const int PORT = 8833;
private HttpServer? activeServer = null;
private HttpClient? activeHttpClient = null;
private bool failOnLogError = true;
private static string GetRequestPath(string url) => $"http://localhost:{PORT}/{url.TrimStart('/')}";
private async Task RequestGetStringAsync(string path) => await activeHttpClient!.GetStringAsync(GetRequestPath(path));
private async Task<HttpResponseMessage> AssertGetStatusCodeAsync(string path, HttpStatusCode statusCode) {
var resp = await activeHttpClient!.GetAsync(GetRequestPath(path));
Assert.AreEqual(statusCode, resp.StatusCode);
return resp;
}
[TestInitialize]
public void Init() {
var conf = new SimpleHttpServerConfiguration() {
DisableLogMessagePrinting = false,
LogMessageHandler = (LogOutputTopic topic, string message, LogOutputLevel logLevel) => {
if (failOnLogError && logLevel is LogOutputLevel.Error or LogOutputLevel.Fatal)
Assert.Fail($"An error was thrown in the log output:\n{topic} {message}");
}
};
if (activeServer != null)
throw new InvalidOperationException("Tried to create another httpserver instance when an existing one was already running.");
Console.WriteLine("Starting server...");
failOnLogError = true;
activeServer = new HttpServer(PORT, conf);
activeServer.RegisterEndpointsFromType<TestEndpoints>();
activeServer.Start();
activeHttpClient = new HttpClient();
Console.WriteLine("Server started.");
}
[TestCleanup]
public async Task Cleanup() {
var ctokSrc = new CancellationTokenSource(TimeSpan.FromMinutes(2));
if (activeServer == null) {
throw new InvalidOperationException("Tried to shut down server when an existing one wasnt runnign yet");
}
await Console.Out.WriteLineAsync("Shutting down server...");
await activeServer.StopAsync(ctokSrc.Token);
activeHttpClient?.Dispose();
activeHttpClient = null;
await Console.Out.WriteLineAsync("Shutdown finished.");
}
static string GetHttpPageContentFromPrefix(string page)
=> $"It works!!!!!!56sg5sdf46a4sd65a412f31sdfgdf89h74g9f8h4as56d4f56as2as1f3d24f87g9d87{page}";
[TestMethod] [TestMethod]
public void RunTestServer() { public async Task CheckSimpleServe() {
var server = HttpServer.Create(8833, "localhost", typeof(SimpleEndpointDefinition)); var resp = await AssertGetStatusCodeAsync("/", HttpStatusCode.OK);
server.Start(); var str = await resp.Content.ReadAsStringAsync();
Console.WriteLine("press any key to exit"); Assert.AreEqual("It works!", str);
Assert.IsTrue(server.Shutdown(10000), "server did not exit gracefully"); }
[TestMethod]
public async Task CheckMultiServe() {
foreach (var item in "index2.html;testpage;testpage2;testpage3".Split(';')) {
await Console.Out.WriteLineAsync($"Checking page: /{item}");
var resp = await AssertGetStatusCodeAsync(item, HttpStatusCode.OK);
var str = await resp.Content.ReadAsStringAsync();
Assert.AreEqual(GetHttpPageContentFromPrefix(item), str);
}
}
[TestMethod]
public async Task CheckQueryArgs() {
foreach (var a1 in "test1;longstring2;something else with a space".Split(';')) {
foreach (var a2 in new[] { -10, 2, -2, 5, 0, 4 }) {
foreach (var a3 in new[] { -1, 9, 2, -20, 0 }) {
foreach (var a4 in new[] { -1, 9, 0 }) {
foreach (var page in "returnqueries;returnqueries2".Split(';')) {
var resp = await AssertGetStatusCodeAsync($"{page}?arg1={a1}&arg2={a2}&arg3={a3}&arg4={a4}", HttpStatusCode.OK);
var str = await resp.Content.ReadAsStringAsync();
Assert.AreEqual(TestEndpoints.GetReturnQueryPageResult(a1, a2, page == "returnqueries2" ? (a3 + a4) : a3), str);
}
}
}
}
}
}
public class TestEndpoints {
[HttpEndpoint(HttpRequestType.GET, "/", "index.html")]
public static async Task Index(RequestContext req) {
await req.RespWriter.WriteAsync("It works!");
}
[HttpEndpoint(HttpRequestType.GET, "index2.html")]
public static async Task Index2(RequestContext req) {
await req.RespWriter.WriteAsync(GetHttpPageContentFromPrefix("index2.html"));
}
[HttpEndpoint(HttpRequestType.GET, "/testpage")]
public static async Task TestPage(RequestContext req) {
await req.RespWriter.WriteAsync(GetHttpPageContentFromPrefix("testpage"));
}
[HttpEndpoint(HttpRequestType.GET, "testpage2")]
public static async Task TestPage2(RequestContext req) {
await req.RespWriter.WriteAsync(GetHttpPageContentFromPrefix("testpage2"));
}
[HttpEndpoint(HttpRequestType.GET, "/testpage3")]
public static async Task TestPage3(RequestContext req) {
await req.RespWriter.WriteAsync(GetHttpPageContentFromPrefix("testpage3"));
}
public static string GetReturnQueryPageResult(string arg1, int arg2, int arg3) => $"{arg1};{arg2 * 2 - arg3 * 5}";
[HttpEndpoint(HttpRequestType.GET, "/returnqueries")]
public static async Task ReturnQueriesPage(RequestContext req, string arg1, int arg2, int arg3) {
await req.RespWriter.WriteAsync(GetReturnQueryPageResult(arg1, arg2, arg3));
}
[HttpEndpoint(HttpRequestType.GET, "/returnqueries2")]
public static async Task ReturnQueriesPage2(RequestContext req,
[Parameter("arg2")] int arg1, [Parameter("arg1")] string arg2, int arg3, [Parameter("arg4", true)] int arg4) {
// arg4 should be equal to zero as it should get the deafult value because it is not passed to the server
await req.RespWriter.WriteAsync(GetReturnQueryPageResult(arg2, arg1, arg3 + arg4));
}
} }
} }

View File

@ -1,10 +0,0 @@
namespace SimpleHttpServerTest.SimpleTestServer;
internal class LdAuthorizer {
private readonly LoginProvider lprov;
internal LdAuthorizer(LoginProvider lprov) {
this.lprov = lprov;
}
}

View File

@ -1,4 +0,0 @@
namespace SimpleHttpServerTest.SimpleTestServer;
internal class SimpleEndpointDefinition {
}