27 Commits
Author SHA1 Message Date
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
22 changed files with 893 additions and 260 deletions
+17
View File
@@ -0,0 +1,17 @@
global using static SimpleHttpServer.GlobalUsings;
using SimpleHttpServer.Types.Exceptions;
using System.Diagnostics.CodeAnalysis;
namespace SimpleHttpServer;
internal static class GlobalUsings {
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}");
}
}
internal static void AssertImplies(bool x, bool y, string? message = null) => Assert(!x || y, message);
}
-22
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) { }
}
+23
View File
@@ -0,0 +1,23 @@
using SimpleHttpServer.Internal;
using SimpleHttpServer.Types;
namespace SimpleHttpServer;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpEndpointAttribute<T> : Attribute where T : IAuthorizer {
public HttpRequestType RequestMethod { get; private set; }
public string[] Locations { get; private set; }
public Type Authorizer { get; private set; }
public HttpEndpointAttribute(HttpRequestType requestMethod, params string[] locations) {
RequestMethod = requestMethod;
Locations = locations;
Authorizer = typeof(T);
}
}
[AttributeUsage(AttributeTargets.Method)]
public class HttpEndpointAttribute : HttpEndpointAttribute<DefaultAuthorizer> {
public HttpEndpointAttribute(HttpRequestType type, params string[] locations) : base(type, locations) { }
}
+285 -107
View File
@@ -1,134 +1,312 @@
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;
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 Dictionary<(string path, string rType), EndpointInvocationInfo> simpleEndpointMethodInfos = new();
private static readonly Type[] expectedEndpointParameterTypes = new[] { typeof(RequestContext) };
public void RegisterEndpointsFromType<T>() {
if (simpleEndpointMethodInfos.Count == 0)
RegisterDefaultConverters();
var t = typeof(T);
foreach (var (mi, attrib) in t.GetMethods()
.ToDictionary(x => x, x => x.GetCustomAttributes(typeof(HttpEndpointAttribute<>)))
.Where(x => x.Value.Any()).ToDictionary(x => x.Key, x => (HttpEndpointAttribute) x.Value.Single())) {
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();
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}.");
}
Assert(mi.ReturnType == typeof(Task), $"Return type of {GetFancyMethodName()} is not {typeof(Task)}!");
var qparams = new List<(string, (Type type, bool isOptional))>();
for (int i = expectedEndpointParameterTypes.Length; i < methodParams.Length; i++) {
var par = methodParams[i];
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!"),
(par.ParameterType, attr?.IsOptional ?? false)));
if (!stringToTypeParameterConverters.ContainsKey(par.ParameterType)) {
throw new MissingParameterConverterException($"Parameter converter for type {par.ParameterType} has not been registered (yet)!");
}
}
foreach (var location in attrib.Locations) {
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");
mainLogger.Information($"Registered endpoint: '{reqMethod} {normLocation}'");
simpleEndpointMethodInfos.Add((normLocation, reqMethod), new EndpointInvocationInfo(mi, qparams));
}
}
}
/// <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 Dictionary<string, string>();
private readonly Dictionary<Type, IParameterConverter> stringToTypeParameterConverters = new();
private static 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>());
return '/' + (rv.ToString().TrimEnd('/') + (fwdSlashUrl.EndsWith('/') ? "/" : "")).TrimStart('/');
}
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) => { if (simpleEndpointMethodInfos.TryGetValue((reqPath, requestMethod), out var endpointInvocationInfo)) {
HttpRequestType type; var mi = endpointInvocationInfo.methodInfo;
if (!Enum.TryParse(localCtx.Request.HttpMethod, out type)) { var qparams = endpointInvocationInfo.queryParameters;
Default404(localCtx).SendResponse(localCtx.Response); var args = splitted.Length == 2 ? splitted[1] : null;
return;
} var parsedQParams = new Dictionary<string, string>();
var path = localCtx.Request.Url!.LocalPath.Replace('\\', '/'); var convertedQParamValues = new object[qparams.Count + 1];
HttpEndpointHandler? ep = null;
if (!_plainEndpoints.TryGetValue((path, type), out ep)) { // TODO add authcheck here
// not found among plain endpoints
foreach (var epk in _pparamEndpoints.Keys) { if (args != null) {
if (epk.rType == type && path.StartsWith(epk.path)) { var queryStringArgs = args.Split('&', StringSplitOptions.None);
ep = _pparamEndpoints[epk]; foreach (var queryKV in queryStringArgs) {
break; 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 (qparamName, qparamInfo) = qparams[i];
i++;
if (parsedQParams.TryGetValue(qparamName, out var qparamValue)) {
if (stringToTypeParameterConverters[qparamInfo.type].TryConvertFromString(qparamValue, out object objRes)) {
convertedQParamValues[i] = objRes;
} else {
await HandleDefaultErrorPageAsync(rc, HttpStatusCode.BadRequest);
return;
}
} else {
if (qparamInfo.isOptional) {
convertedQParamValues[i] = null!;
} else {
await HandleDefaultErrorPageAsync(rc, HttpStatusCode.BadRequest, $"Missing required query parameter {qparamName}");
return;
}
}
}
}
convertedQParamValues[0] = rc;
rc.ParsedParameters = parsedQParams.AsReadOnly();
await (Task) (mi.Invoke(null, convertedQParamValues) ?? throw new NullReferenceException("Website func returned null unexpectedly"));
} else {
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) { }
}
} }
@@ -8,13 +8,13 @@ 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) {
@@ -23,14 +23,14 @@ internal class HttpEndpointHandler {
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
@@ -38,8 +38,8 @@ internal class HttpEndpointHandler {
// 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);
@@ -54,20 +54,20 @@ internal class HttpEndpointHandler {
} }
// 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);
} }
} }
} }
+61
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
}
+98 -96
View File
@@ -1,73 +1,81 @@
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 extraDataSalt;
public string pwd; public string pwd;
public string additionalData; 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 {
/// <summary>
/// Size of the password salt and the extradata salt. So each salt will be of size <see cref="SALT_SIZE"/>.
/// </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 A2_ITERATIONS = 5;
public int A2_MEMORY_SIZE = 500_000;
public int A2_PARALLELISM = 8;
public int A2_HASH_LENGTH = 256 / 8;
public int A2_MAX_CONCURRENT = 4;
public int PBKDF2_ITERATIONS = 600_000; 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))!;
[ThreadStatic]
private static SHA256? _sha256PerThread;
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> loginData; private readonly Dictionary<string, LoginData> loginDatas;
private readonly SemaphoreSlim argon2Limit;
private Func<TExtraData, byte[]> DataSerializer = JsonSerialize;
private Func<byte[], TExtraData> DataDeserializer = JsonDeserialize;
public void SetDataSerializers(Func<TExtraData, byte[]> serializer, Func<byte[], TExtraData> deserializer) {
DataSerializer = serializer ?? JsonSerialize;
DataDeserializer = deserializer ?? JsonDeserialize;
}
private Func<T, byte[]> DataSerializer = JsonSerialize;
private Func<byte[], T> DataDeserializer = JsonDeserialize;
public LoginProvider(string ldPath, string confPath) { public LoginProvider(string ldPath, string confPath) {
this.ldPath = ldPath; this.ldPath = ldPath;
loginData = LoadLoginData(ldPath); loginDatas = LoadLoginDatas(ldPath);
config = LoadArgon2Config(confPath); config = LoadLoginProviderConfig(confPath);
argon2Limit = new SemaphoreSlim(config.A2_MAX_CONCURRENT);
} }
private static Dictionary<string, LoginData> LoadLoginData(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);
@@ -79,13 +87,26 @@ public class LoginProvider<T> {
} }
} }
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 static LoginDataProviderConfig LoadArgon2Config(string path) { private void SaveLoginData() {
var serial = new Dictionary<string, SerialLoginData>();
ldLock.EnterWriteLock();
try {
foreach (var pair in loginDatas) {
serial.Add(pair.Key, pair.Value.ToSerial());
}
} finally {
ldLock.ExitWriteLock();
}
File.WriteAllText(ldPath, JsonConvert.SerializeObject(serial));
}
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));
@@ -94,39 +115,22 @@ public class LoginProvider<T> {
return JsonConvert.DeserializeObject<LoginDataProviderConfig>(File.ReadAllText(path)); return JsonConvert.DeserializeObject<LoginDataProviderConfig>(File.ReadAllText(path));
} }
public void SetDataSerialization(Func<T, byte[]> serializer, Func<byte[], T> deserializer) { public bool AddUser(string username, string password, TExtraData additional) {
DataSerializer = serializer ?? JsonSerialize;
DataDeserializer = deserializer ?? JsonDeserialize;
}
private void StoreLoginData() {
var serial = new Dictionary<string, SerialLoginData>();
ldLock.EnterWriteLock(); ldLock.EnterWriteLock();
try { try {
foreach (var pair in loginData!) { if (loginDatas.ContainsKey(username)) {
serial.Add(pair.Key, pair.Value.toSerial());
}
} finally {
ldLock.ExitWriteLock();
}
File.WriteAllText(ldPath, JsonConvert.SerializeObject(serial));
}
public bool AddUser(string username, string password, T additional) {
ldLock.EnterWriteLock();
try {
if (loginData.ContainsKey(username)) {
return false; return false;
} }
var salt = RandomNumberGenerator.GetBytes(config.SALT_SIZE); var passwordSalt = RandomNumberGenerator.GetBytes(config.SALT_SIZE);
var pwdHash = HashPwd(password, salt); var extraDataSalt = RandomNumberGenerator.GetBytes(config.SALT_SIZE);
LoginData ld = new LoginData() { LoginData ld = new LoginData() {
salt = salt, passwordSalt = passwordSalt,
password = pwdHash, extraDataSalt = extraDataSalt,
encryptedData = EncryptAdditionalData(password, salt, additional) passwordHash = ComputeSaltedSha256Hash(password, passwordSalt),
encryptedExtraData = EncryptExtraData(password, extraDataSalt, additional),
}; };
loginData.Add(username, ld); loginDatas.Add(username, ld);
StoreLoginData(); SaveLoginData();
} finally { } finally {
ldLock.ExitWriteLock(); ldLock.ExitWriteLock();
} }
@@ -136,9 +140,9 @@ public class LoginProvider<T> {
public bool RemoveUser(string username) { public bool RemoveUser(string username) {
ldLock.EnterWriteLock(); ldLock.EnterWriteLock();
try { try {
var removed = loginData.Remove(username); var removed = loginDatas.Remove(username);
if (removed) { if (removed) {
StoreLoginData(); SaveLoginData();
} }
return removed; return removed;
} finally { } finally {
@@ -146,64 +150,62 @@ public class LoginProvider<T> {
} }
} }
public bool ModifyUser(string username, string newPassword, T newAdditional) { public bool ModifyUser(string username, string newPassword, TExtraData newExtraData) {
ldLock.EnterWriteLock(); ldLock.EnterWriteLock();
try { try {
if (!loginData.ContainsKey(username)) { if (!loginDatas.ContainsKey(username)) {
return false; return false;
} }
loginData.Remove(username, out var data); loginDatas.Remove(username, out var data);
data.password = HashPwd(newPassword, data.salt); data.passwordHash = ComputeSaltedSha256Hash(newPassword, data.passwordSalt);
data.encryptedData = EncryptAdditionalData(newPassword, data.salt, newAdditional); data.encryptedExtraData = EncryptExtraData(newPassword, data.extraDataSalt, newExtraData);
loginData.Add(username, data); loginDatas.Add(username, data);
StoreLoginData(); SaveLoginData();
} finally { } finally {
ldLock.ExitWriteLock(); ldLock.ExitWriteLock();
} }
return true; return true;
} }
public (bool, T) Authenticate(string username, string password) { public bool TryAuthenticate(string username, string password, [MaybeNullWhen(false)] out TExtraData extraData) {
LoginData data; LoginData data;
ldLock.EnterReadLock(); ldLock.EnterReadLock();
try { try {
if (!loginData.TryGetValue(username, out data)) { if (!loginDatas.TryGetValue(username, out data)) {
return (false, default(T)!); extraData = default;
return false;
} }
} finally { } finally {
ldLock.ExitReadLock(); ldLock.ExitReadLock();
} }
var hash = HashPwd(password, data.salt); var hash = ComputeSaltedSha256Hash(password, data.passwordSalt);
if (!hash.SequenceEqual(data.password)) { if (!hash.SequenceEqual(data.passwordHash)) {
return (false, default(T)!); extraData = default;
return false;
} }
return (true, DecryptAdditionalData(password, data.salt, data.encryptedData)); extraData = DecryptExtraData(password, data.extraDataSalt, data.encryptedExtraData);
return true;
} }
private byte[] HashPwd(string pwd, byte[] salt) { /// <summary>
byte[] hash; /// Threadsafe as the SHA256 instance (<see cref="Sha256PerThread"/>) is per thread.
argon2Limit.Wait(); /// </summary>
try { /// <param name="data"></param>
using (var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd))) { /// <param name="salt"></param>
argon2.Iterations = config.A2_ITERATIONS; /// <returns></returns>
argon2.MemorySize = config.A2_MEMORY_SIZE; private static byte[] ComputeSaltedSha256Hash(string data, byte[] salt) {
argon2.DegreeOfParallelism = config.A2_PARALLELISM; var dataBytes = Encoding.UTF8.GetBytes(data);
argon2.Salt = salt; var buf = new byte[data.Length + salt.Length];
hash = argon2.GetBytes(config.A2_HASH_LENGTH); Buffer.BlockCopy(dataBytes, 0, buf, 0, dataBytes.Length);
} Buffer.BlockCopy(salt, 0, buf, dataBytes.Length, salt.Length);
// force collection to reduce sustained memory usage if many hashes are done in close time proximity to each other return Sha256PerThread.ComputeHash(buf);
GC.Collect();
} finally {
argon2Limit.Release();
}
return hash;
} }
private byte[] EncryptAdditionalData(string pwd, byte[] salt, T data) { 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(data); 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;
@@ -219,7 +221,7 @@ public class LoginProvider<T> {
return encryptedBytes; return encryptedBytes;
} }
private T DecryptAdditionalData(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);
+148
View File
@@ -0,0 +1,148 @@
using System.Collections.ObjectModel;
using System.Net;
namespace SimpleHttpServer;
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" });
public RequestContext(HttpListenerContext listenerContext) {
ListenerContext = listenerContext;
}
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 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",
};
}
-1
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>
@@ -0,0 +1,19 @@
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;
public SimpleHttpServerConfiguration() { }
}
@@ -0,0 +1,12 @@
using System.Reflection;
namespace SimpleHttpServer.Types;
internal struct EndpointInvocationInfo {
internal readonly MethodInfo methodInfo;
internal readonly List<(string, (Type type, bool isOptional))> queryParameters;
public EndpointInvocationInfo(MethodInfo methodInfo, List<(string, (Type type, bool isOptional))> queryParameters) {
this.methodInfo = methodInfo ?? throw new ArgumentNullException(nameof(methodInfo));
this.queryParameters = queryParameters ?? throw new ArgumentNullException(nameof(queryParameters));
}
}
@@ -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) { }
}
@@ -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) {
}
}
@@ -1,4 +1,4 @@
namespace SimpleHttpServer; namespace SimpleHttpServer.Types;
public enum HttpRequestType { public enum HttpRequestType {
GET, GET,
@@ -0,0 +1,5 @@
namespace SimpleHttpServer;
public interface IParameterConverter {
bool TryConvertFromString(string value, out object result);
}
@@ -0,0 +1,21 @@
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 {
// See the attribute guidelines at
// http://go.microsoft.com/fwlink/?LinkId=85236
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;
}
}
@@ -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;
}
}
}
@@ -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;
}
}
@@ -0,0 +1,7 @@
namespace SimpleHttpServer.Types.ParameterConverters;
internal class StringParameterConverter : IParameterConverter {
public bool TryConvertFromString(string value, out object result) {
result = value;
return true;
}
}
+129 -6
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));
}
} }
} }
@@ -1,10 +0,0 @@
namespace SimpleHttpServerTest.SimpleTestServer;
internal class LdAuthorizer {
private readonly LoginProvider lprov;
internal LdAuthorizer(LoginProvider lprov) {
this.lprov = lprov;
}
}
@@ -1,4 +0,0 @@
namespace SimpleHttpServerTest.SimpleTestServer;
internal class SimpleEndpointDefinition {
}