diff --git a/WebServer.Test/Program.cs b/WebServer.Test/Program.cs index 1c6dc09..ab05510 100644 --- a/WebServer.Test/Program.cs +++ b/WebServer.Test/Program.cs @@ -1,9 +1,30 @@ using System; +using System.Collections.Generic; +using System.Net; +using System.Reflection; +using WebServer; namespace WebServer.Test { internal class Program { internal static void Main(string[] args) { Console.WriteLine("Hello, World!"); + var server = new HttpConfiguration() { + DefaultDomain = "localhost", + AutoStart = true, + Port = 8080, + DebugMode = true + }.CreateServer(new HttpLogger()); + + _ = Console.ReadLine(); } } + public class HttpLogger : IHttpLogger { + public void Log(string message) => Console.WriteLine(message); + + public void LogError(HttpListenerContext context, List methodsUsed, Exception exception) + => Console.WriteLine(exception); + + public void LogRequest(HttpListenerContext context, HttpResponse response, List methodsUsed) + => Console.WriteLine($"[{(int)response.StatusCode}] {context.Request.Url?.PathAndQuery}"); + } } \ No newline at end of file diff --git a/WebServer.Test/Views/HelloWorld.txt b/WebServer.Test/Views/localhost/HelloWorld.txt similarity index 100% rename from WebServer.Test/Views/HelloWorld.txt rename to WebServer.Test/Views/localhost/HelloWorld.txt diff --git a/WebServer.Test/Views/Test.cshtml b/WebServer.Test/Views/localhost/Test.cshtml similarity index 100% rename from WebServer.Test/Views/Test.cshtml rename to WebServer.Test/Views/localhost/Test.cshtml diff --git a/WebServer.Test/Views/localhost/_Layout.cshtml b/WebServer.Test/Views/localhost/_Layout.cshtml new file mode 100644 index 0000000..adf733e --- /dev/null +++ b/WebServer.Test/Views/localhost/_Layout.cshtml @@ -0,0 +1,31 @@ + + + + + @ViewBag.Title + + + + @RenderSection("Styles", required: false) + + + + + @RenderBody() + + \ No newline at end of file diff --git a/WebServer.Test/Views/localhost/_StatusPage.cshtml b/WebServer.Test/Views/localhost/_StatusPage.cshtml new file mode 100644 index 0000000..dfb1c7f --- /dev/null +++ b/WebServer.Test/Views/localhost/_StatusPage.cshtml @@ -0,0 +1,16 @@ +@using RazorLight +@inherits TemplatePage +@model WebServer.Models.StatusPageModel +@{ + Layout = @"_Layout.cshtml"; + ViewBag.Title = $"{Model.StatusCodeAsUShort} - {Model.Header}"; +} +
+
@Model.Header
+ @Model.Details + @if (Model.Exception != null) { +
+
@Model.Exception
+
+ } +
\ No newline at end of file diff --git a/WebServer.Test/Views/localhost/test123/index.cshtml b/WebServer.Test/Views/localhost/test123/index.cshtml new file mode 100644 index 0000000..9c6c484 --- /dev/null +++ b/WebServer.Test/Views/localhost/test123/index.cshtml @@ -0,0 +1,8 @@ +@using RazorLight +@inherits TemplatePage +@{ + Layout = "/_Layout.cshtml"; + ViewBag.Title = "Hello World!"; +} + +Hello from test123 \ No newline at end of file diff --git a/WebServer.Test/WebServer.Test.csproj b/WebServer.Test/WebServer.Test.csproj index f009d3c..64eef91 100644 --- a/WebServer.Test/WebServer.Test.csproj +++ b/WebServer.Test/WebServer.Test.csproj @@ -7,25 +7,23 @@ enable true true + true - + - + + + PreserveNewest - - - - - + - + - - + \ No newline at end of file diff --git a/WebServer.sln b/WebServer.sln index 7476302..99e6028 100644 --- a/WebServer.sln +++ b/WebServer.sln @@ -3,9 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.10.35122.118 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleWebServer", "WebServer\WebServer.csproj", "{32E22CD1-CBE3-45E5-BADF-CE83A3096624}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebServer", "WebServer\WebServer.csproj", "{32E22CD1-CBE3-45E5-BADF-CE83A3096624}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebServer.Test", "WebServer.Test\WebServer.Test.csproj", "{36053856-E83C-4040-92CD-C825B6D11BD7}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebServer.Test", "WebServer.Test\WebServer.Test.csproj", "{36053856-E83C-4040-92CD-C825B6D11BD7}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/WebServer/HttpConfiguration.cs b/WebServer/HttpConfiguration.cs index bcf7d16..b6b1049 100644 --- a/WebServer/HttpConfiguration.cs +++ b/WebServer/HttpConfiguration.cs @@ -9,5 +9,27 @@ namespace WebServer { public string DefaultDomain = "localhost"; // The domain that the server will fallback on if the public bool ShowExceptionOnErrorPages = true; // On InternalServerError(500), should it show the exception? public ushort MaxConcurrentRequests = 100; + public List UriFillers = new List() { // Todo: Needs revision, + ".html", + ".htm", + ".txt", + "./index.html", + "./index.htm", + "./index.txt", + "./default.webp", + "./default.png", + "../default.webp", + "../default.png" + }; + public Dictionary GenericHeaders = new Dictionary() { + { "x-content-type-options: nosniff", null }, + { "x-xss-protection:1; mode=block", null }, + { "x-frame-options:DENY", null } + }; + public ushort ResponseTimeout = 10; + public uint MaxCacheAge = 604800; + public bool DebugMode = false; // If true, it bypasses cache and also shows exceptions on error page (where applicable) + + public virtual HttpServer CreateServer(IHttpLogger? logger = null) => new HttpServer(this, logger); } } diff --git a/WebServer/HttpResponse.cs b/WebServer/HttpResponse.cs index d9255de..a951295 100644 --- a/WebServer/HttpResponse.cs +++ b/WebServer/HttpResponse.cs @@ -14,6 +14,7 @@ namespace WebServer { get => Encoding.UTF8.GetString(Content); set => Content = Encoding.UTF8.GetBytes(value); } + public Dictionary Headers = new Dictionary(); public HttpResponse() { } diff --git a/WebServer/HttpServer.cs b/WebServer/HttpServer.cs index c5b9cb2..0319b67 100644 --- a/WebServer/HttpServer.cs +++ b/WebServer/HttpServer.cs @@ -1,11 +1,18 @@ using System; using System.Collections.Generic; +using System.Dynamic; using System.IO; +using System.Linq; using System.Net; +using System.Reflection; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using RazorLight; +using RazorLight.Razor; +using WebServer.Models; +using WebServer.Utils; namespace WebServer { public class HttpServer { @@ -13,9 +20,12 @@ namespace WebServer { public readonly DirectoryInfo ViewsDirectory; public ushort ActivePort { get; protected set; } public HttpListener HttpListener { get; protected set; } - public IHttpLogger Logger; + public IHttpLogger? Logger; + public delegate Task Callback(HttpListenerContext context, CachedResponse? cache); - Dictionary>>> HttpCallbacks = new Dictionary>>>(); + Dictionary> HttpCallbacks = new Dictionary>(); + Dictionary RazorEngines = new Dictionary(); + public readonly RazorLightEngine DefaultRazorEngine; /* Instead of three directories (from previous render engine) * - Public/PublicTemplates (was public static stuff, also allowed views) * - Static (was used for builds) @@ -25,7 +35,7 @@ namespace WebServer { * - Views - Site source, including public/private views (views are able to be processed) */ - public HttpServer(HttpConfiguration config, IHttpLogger logger = null) { + public HttpServer(HttpConfiguration config, IHttpLogger? logger = null) { Config = config; Logger = logger; ViewsDirectory = new DirectoryInfo(Path.Combine(Environment.CurrentDirectory, "Views")); @@ -35,10 +45,26 @@ namespace WebServer { ViewsDirectory.CreateSubdirectory(config.DefaultDomain); if (config.AutoStart) _ = StartAsync(); + DefaultRazorEngine = GetOrCreateRazorEngine(config.DefaultDomain); + } + + public RazorLightEngine GetOrCreateRazorEngine(string hostname) { + hostname = hostname.Trim(' ', '/', '\\'); + if (!RazorEngines.TryGetValue(hostname, out RazorLightEngine engine)) { + RazorEngines.Add(hostname, engine = new RazorLightEngineBuilder() + .UseOptions(new RazorLightOptions() { // TODO: make this part of the config + EnableDebugMode = true + }) + .UseProject(new FileSystemRazorProject(Path.Combine(ViewsDirectory.FullName, hostname), ".cshtml")) + .UseMemoryCachingProvider() + .Build() + ); + } + return engine; } public async Task StartAsync() { - if (HttpListener?.IsListening == true) + if (HttpListener?.IsListening ?? false) await StopAsync(); HttpListener = new HttpListener() { IgnoreWriteExceptions = true // Used to crash the server, don't think it is needed anymore @@ -57,10 +83,10 @@ namespace WebServer { public int TasksCount => Tasks.Count; HashSet Tasks = new HashSet(); async Task ListenAsync(CancellationToken token) { - Tasks = new HashSet(64); - for (int i = 0; i < Tasks.Count; i++) // Create 64 tasks + Tasks = new HashSet(128); + for (int i = 0; i < 64; i++) // Create 64 tasks Tasks.Add(HttpListener.GetContextAsync()); - Logger.Log($"Listening with {Tasks.Count} worker(s)"); + Logger?.Log($"Listening with {Tasks.Count} worker(s)"); while (!token.IsCancellationRequested) { Task t = await Task.WhenAny(Tasks); Tasks.Remove(t); @@ -68,9 +94,10 @@ namespace WebServer { if (t is Task context) { if (Tasks.Count < Config.MaxConcurrentRequests) Tasks.Add(HttpListener.GetContextAsync()); - Tasks.Add(ProcessRequestAsync(context.Result, token)); + Tasks.Add(ProcessRequestAsync(context.Result, token)); // Should I really be adding this to tasks? } - else Logger.Log($"Got an unexpected task of type '{t.GetType().FullName}'"); + //ProcessRequestAsync triggers this: + // else Logger?.Log($"Got an unexpected task of type '{t.GetType().FullName}'"); } } @@ -79,6 +106,256 @@ namespace WebServer { ListenToken.Cancel(); } - public async Task ProcessRequestAsync(HttpListenerContext context, CancellationToken token) { } + public async Task ProcessRequestAsync(HttpListenerContext context, CancellationToken token) { + List methodsUsed = new List() { MethodBase.GetCurrentMethod() }; + try { + // Add generic headers + foreach (var kvp in Config.GenericHeaders) { + if (kvp.Value is null) + context.Response.Headers.Add(kvp.Key); + else context.Response.Headers.Add(kvp.Key, kvp.Value); + } + + // Get domain and path + string host = (context.Request.Url?.Host ?? Config.DefaultDomain).Trim('/', ' ').ToLowerInvariant(), + callbackKey = FormatCallbackKey(context.Request.Url?.LocalPath ?? string.Empty), + path = callbackKey.Any() ? $"/{callbackKey}" : ""; + string cacheName = $"{host}{path}"; + CachedResponse? cache = CachedResponse.Get(this, cacheName); + HttpResponse? response = null; + if (cache != null) { + if (!cache.NeedsUpdate) + response = cache; + else if (cache.UpdateMethod != null) + response = await cache.UpdateMethod(context, cache); + else { + + } + } + + // Create reponse timeout logic, this will return a string to the client but an exception on the server + CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); + Task cancelationTask = Task.Delay(Timeout.Infinite, cancellationTokenSource.Token); + if (Config.ResponseTimeout > 0) + cancellationTokenSource.CancelAfter(1000 * Config.ResponseTimeout); + + StatusPageModel? statusPageModel = null; + try { + #region Event Callbacks + var hostnames = new[] { host ?? Config.DefaultDomain, Config.DefaultDomain }.Distinct(); + + if (response == null) { + var domainCallbackMatches = hostnames.Where(name => HttpCallbacks.ContainsKey(name)); + var regexCallbacks = domainCallbackMatches.SelectMany(name => HttpCallbacks[name]); + /*var regexCallbacks = new[] { host, DefaultDomain }.Distinct() + .Where(domain => HttpCallbacks.ContainsKey(domain)) + .SelectMany(domain => HttpCallbacks[domain]);*/ + foreach (var kvp in regexCallbacks) { + bool match = kvp.Key.IsMatch(path); + if (!match) continue; + methodsUsed.Add(kvp.Value.Method); + var callback = kvp.Value(context, cache); + var completedTask = await Task.WhenAny(callback, cancelationTask); + // Check that the callback didn't throw any errors (hence it didn't complete) + if (callback.IsFaulted) throw callback.Exception; + // So no faults, check if the completed task is the callback + if (completedTask != callback) + throw new ResponseTimedOutException(cacheName, methodsUsed, cancellationTokenSource.Token); + response = callback.Result; + + if (response != null) break; + } + } + #endregion + + #region Try get Razor File + string fileName = Path.GetFileNameWithoutExtension(path); + bool isPrivateView = fileName.Length > 0 && fileName.StartsWith('_'); + if (response == null && !isPrivateView) { + foreach (var hostname in hostnames) { + // If no razor engine exists, continue to next hostname + if (!RazorEngines.TryGetValue(hostname, out var razorEngine)) + continue; + + string cwd = Path.Combine(ViewsDirectory.FullName, hostname).Replace('\\', '/'); + string[] razorPaths = new[] { $"{path}", $"{path}.cshtml", $"{path}/index.cshtml" }; + foreach (var razorPath in razorPaths) { + string fullpath = cwd + razorPath; + if (Path.GetExtension(fullpath).ToLower() != ".cshtml") continue; + bool fileExists = File.Exists(fullpath); + if (!fileExists) continue; + + cache = cache ?? new CachedResponse(this, null); + cache.StatusCode = HttpStatusCode.OK; + cache.ContentString = await razorEngine.CompileRenderAsync(razorPath, null); + cache.ContentType = "text/html"; + response = cache; + break; + } + } + } + #endregion + + #region Try Static File + // Finally get a static file + if (response == null) { + Callback getStaticFile = GetStaticFile; + methodsUsed.Add(getStaticFile.Method); + response = await getStaticFile(context, cache); + } + #endregion + } + catch (AggregateException ex) { // Also catches OperationCanceledException and ResponseTimedOutException + statusPageModel = new StatusPageModel(HttpStatusCode.ServiceUnavailable) { + Exception = ex + }; + } + catch (Exception ex) { + StatusPageModel statusModel = new StatusPageModel(HttpStatusCode.InternalServerError) { + Exception = ex + }; + } + + response ??= await GetGenericStatusPageAsync(statusPageModel ?? new StatusPageModel(HttpStatusCode.NotFound)); + + #region Ship Response + context.Response.StatusCode = (int)response.StatusCode; + if (response.StatusCode == HttpStatusCode.Redirect) { + context.Response.Redirect(response.ContentString); + } + foreach (var kvp in response.Headers) { + if (kvp.Value is null) + context.Response.Headers.Add(kvp.Key); + else context.Response.Headers.Add(kvp.Key, kvp.Value); + } + bool omitBody = new[] { "HEAD", "PUT", "DELETE" }.Contains(context.Request.HttpMethod.ToUpper()) || + (100 <= (int)response.StatusCode && (int)response.StatusCode <= 199) || + response.StatusCode == HttpStatusCode.NoContent || + response.StatusCode == HttpStatusCode.NotModified; + if (!omitBody) { + context.Response.Headers["Content-Type"] = response.ContentType; + context.Response.ContentEncoding = Encoding.UTF8; + context.Response.ContentLength64 = response.Content.Length; + await context.Response.OutputStream.WriteAsync(response.Content, 0, response.Content.Length); + } + Logger?.LogRequest(context, response, methodsUsed); + context.Response.Close(); + #endregion + + // And finally, if the response allows cache (by being a cachedResponse) + // override the path that it is on, so it can be reused + if (response is CachedResponse cachedResponse) { + cachedResponse.Path = $"{host}{path}"; // Rewrite the path, so it can be located when a new request is received + if (Config.DebugMode) cachedResponse.RaiseUpdateFlag(); + } + } + catch (Exception ex) { + Logger?.LogError(context, methodsUsed, ex); + } + } + + static string FormatCallbackKey(string key) + => string.IsNullOrEmpty(key) ? string.Empty + : key.ToLower().Replace('\\', '/').Replace("//", "/").Trim(' ', '/'); + + public async Task GetStaticFile(HttpListenerContext context, CachedResponse? cache) => await GetStaticFile(context.Request.Url?.Host, context.Request.Url?.LocalPath, cache); + + public async Task GetStaticFile(string? targetDomain, string? localPath, CachedResponse? cache) { + if (!cache?.NeedsUpdate ?? false) return cache; + string? fileName = Path.GetFileName(localPath); + if (fileName != null && fileName.StartsWith('_') && fileName.EndsWith(".cshtml")) // Is the file a private cshtml file? + localPath = localPath?.Substring(0, localPath.Length - fileName.Length); + DirectoryInfo directory = ViewsDirectory; // Might be changed later + // Works on windows, but on linux, the domain folder will need to be lowercase + targetDomain = targetDomain?.ToLower() ?? Config.DefaultDomain; + string basePath = Path.Combine(directory.FullName, targetDomain); + bool usingFallbackDomain = !Directory.Exists(basePath); + if (usingFallbackDomain) { // Only fallback to default if domain folder doesn't exist + targetDomain = Config.DefaultDomain; + basePath = Path.Combine(directory.FullName, Config.DefaultDomain); + } + string resourceIdentifier = FormatCallbackKey(localPath ?? string.Empty); + CachedResponse resource = cache ?? new CachedResponse(this, null); + string filePath = Path.Combine(basePath, resourceIdentifier); + if (File.Exists(filePath)) { + resource.StatusCode = HttpStatusCode.OK; + resource.ContentType = MimeTypeMap.GetMimeType(Path.GetExtension(filePath).ToLower()); + resource.Content = File.ReadAllBytes(filePath); + resource.Headers["cache-control"] = Config.DebugMode ? "no-store, no-cache, must-revalidate" + : "max-age=360000, s-max-age=900, stale-while-revalidate=120, stale-if-error=86400"; + resource.ClearFlag(); + return resource; + } + string? hitPath = Config.UriFillers.Select(filler => filePath + filler) + .FirstOrDefault(path => path.Contains(basePath) && File.Exists(path)); + if (hitPath != null) { + resource.StatusCode = HttpStatusCode.OK; + resource.ContentType = MimeTypeMap.GetMimeType(Path.GetExtension(hitPath).ToLower()); + resource.Content = File.ReadAllBytes(hitPath); + resource.Headers["cache-control"] = Config.DebugMode ? "no-store, no-cache, must-revalidate" + : "max-age=360000, s-max-age=900, stale-while-revalidate=120, stale-if-error=86400"; + resource.ClearFlag(); + return resource; + } + return await GetGenericStatusPageAsync(new StatusPageModel(Directory.Exists(filePath) ? HttpStatusCode.Forbidden : HttpStatusCode.NotFound), host: targetDomain); + } + + public async Task GetGenericStatusPageAsync(StatusPageModel pageModel, string? host = null, ExpandoObject? viewBag = null) { + //try { pageModel.Exception ??= throw new Exception("test"); } + //catch (Exception e) { pageModel.Exception = e; } + var potentialHosts = new string[] { host ?? Config.DefaultDomain, Config.DefaultDomain }.Distinct() + .Select(h => h.Trim(' ', '/', '\\')); + const string viewName = "_StatusPage.cshtml"; + foreach (string hostname in potentialHosts) { + if (!File.Exists(Path.Combine(ViewsDirectory.FullName, hostname, viewName))) continue; + try { + return new HttpResponse( + pageModel.StatusCode, + await GetOrCreateRazorEngine(hostname).CompileRenderAsync(viewName, pageModel, viewBag), + MimeTypeMap.GetMimeType(".cshtml") + ); + } catch (Exception ex) { + pageModel.Exception = pageModel.Exception != null + ? new AggregateException(pageModel.Exception, ex) + : ex; + break; + } + } + + // If no template was found, fallback to plain text + var bckResponse = new HttpResponse() { + StatusCode = pageModel.StatusCode, + ContentType = MimeTypeMap.GetMimeType(".txt"), + ContentString = $"{(int)pageModel.StatusCode} {pageModel.Header} - {pageModel.Details}\n{pageModel.Exception?.ToString() ?? ""}" + }; + return bckResponse; + } + + public bool ContainsEventCallback(Task callback) { + if (callback == null) return false; + return HttpCallbacks.Any(domainKvp => domainKvp.Value.Values.Any(v => v.Equals(callback))); + } + + public bool TryAddEventCallback(string host, Regex regex, Callback callback) { + if (string.IsNullOrEmpty(host) || regex == null || callback == null) return false; + host = FormatCallbackKey(host); + if (!HttpCallbacks.TryGetValue(host, out var domainCallbacks)) + HttpCallbacks.Add(host, domainCallbacks = new Dictionary()); + domainCallbacks[regex] = callback; + return true; + } + + public bool TryRemoveEventCallback(Callback method) { + if (method == null) return false; + + ushort removeCount = 0; + foreach (var callbackDict in HttpCallbacks.Values) { + foreach (var kvp in callbackDict.Where(kvp => kvp.Value == method)) { + callbackDict.Remove(kvp.Key); + removeCount++; + } + } + return removeCount > 0; + } } } diff --git a/WebServer/IHttpLogger.cs b/WebServer/IHttpLogger.cs index e1f278a..4c0aef9 100644 --- a/WebServer/IHttpLogger.cs +++ b/WebServer/IHttpLogger.cs @@ -7,7 +7,7 @@ using System.Text; namespace WebServer { public interface IHttpLogger { void Log(string message); - void LogRequest(HttpListenerContext context, List methodsUsed); + void LogRequest(HttpListenerContext context, HttpResponse response, List methodsUsed); void LogError(HttpListenerContext context, List methodsUsed, Exception exception); } } diff --git a/WebServer/Models/StatusPageModel.cs b/WebServer/Models/StatusPageModel.cs new file mode 100644 index 0000000..15c529c --- /dev/null +++ b/WebServer/Models/StatusPageModel.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +namespace WebServer.Models { + public class StatusPageModel { + public HttpStatusCode StatusCode; + public ushort StatusCodeAsUShort => (ushort)StatusCode; + public string Header; + public string Details; + public Exception? Exception; + + static readonly Dictionary GenericStatusContent = new Dictionary() { + { HttpStatusCode.OK, ("OK", "The request succeeded") }, + { HttpStatusCode.BadRequest, ("Bad Request", "The server cannot or will not process the request due to something that is perceived to be a client error") }, + { HttpStatusCode.Unauthorized, ("Unauthorized", "Authorization Required") }, + { HttpStatusCode.Forbidden, ("Forbidden", "Access to this resource has been revoked") }, + { HttpStatusCode.NotFound, ("Not Found", "Object or Resource not Found") }, + { HttpStatusCode.PreconditionFailed, ("Precondition Failed!", "The server has indicated preconditions which the client does not meet") }, + { HttpStatusCode.InternalServerError, ("Internal Server Error", "The server has encountered a situation it does not know how to handle") }, + { HttpStatusCode.NotImplemented, ("Not Implemented", "he server has encountered a situation it does not know how to handle.") }, + { HttpStatusCode.ServiceUnavailable, ("Service Unavailable", "The server is not ready to handle the request") } + }; + + public StatusPageModel(string title, string subtitle) : this(HttpStatusCode.OK, title, subtitle) {} + + public StatusPageModel(HttpStatusCode statusCode, string? title = null, string? subtitle = null) { + StatusCode = statusCode; + if (!GenericStatusContent.TryGetValue(statusCode, out var tuple)) + tuple = (statusCode.ToString(), "Great! Something happened, not sure what though"); + Header = title ?? tuple.header; + Details = subtitle ?? tuple.details; + } + } +} diff --git a/WebServer/ResponseTimedOutException.cs b/WebServer/ResponseTimedOutException.cs new file mode 100644 index 0000000..f6fb3b9 --- /dev/null +++ b/WebServer/ResponseTimedOutException.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Text; +using System.Threading; + +namespace WebServer { + public class ResponseTimedOutException : OperationCanceledException { + public string FullPath; + public List MethodsUsed; + public ResponseTimedOutException(string fullPath, List methodsUsed, CancellationToken token) + : base("The request took too long to fulfil", token) { + FullPath = fullPath; + MethodsUsed = methodsUsed; + } + + public override string ToString() + { + return base.ToString() + + $"\nFullPath: '{FullPath}';" + + $"\nMethods Used: [\n{string.Join(",\n", MethodsUsed.Select(m => $" '{m.ReflectedType.FullName}.{m.Name}'"))} \n];"; + } + } +} diff --git a/WebServer/Utils/CachedResponse.cs b/WebServer/Utils/CachedResponse.cs new file mode 100644 index 0000000..d087a0c --- /dev/null +++ b/WebServer/Utils/CachedResponse.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace WebServer.Utils { + public class CachedResponse : HttpResponse { + #region Static Methods, etc + public static bool BypassCache = false; + public static List Instances { get; private set; } = new List(); + public static ushort RaiseAllUpdateFlags() { + ushort flagsRaised = 0; + foreach (CachedResponse resource in Instances) { + if (resource.NeedsUpdate) continue; + flagsRaised++; + resource.RaiseUpdateFlag(); + //Logger.LogDebug($"Raised Update Flag for '{resource.Name}'"); + } + return flagsRaised; + } + + public static CachedResponse? Get(HttpServer server, string path) { + path = path.Trim(); + return Instances.FirstOrDefault(x => x.Server == server && x.Path == path); + } + + public static bool TryGet(HttpServer server, string path, out CachedResponse? resource) + => (resource = Get(server, path)) != null; + #endregion + + public readonly HttpServer Server; + public string Path; + bool UpdateFlagRaised; + Stopwatch TimeSinceLastUpdate; + public HttpServer.Callback? UpdateMethod; // If null, HttpServer will attempt to find one or GetStaticFile + + public bool NeedsUpdate => UpdateFlagRaised || BypassCache || Server.Config.MaxCacheAge < TimeSinceLastUpdate.Elapsed.TotalSeconds; + + public CachedResponse(HttpServer parentServer, HttpServer.Callback? updateMethod) { + Instances.Add(this); + Server = parentServer; + TimeSinceLastUpdate = Stopwatch.StartNew(); + UpdateMethod = updateMethod; + } + + public void RaiseUpdateFlag() => UpdateFlagRaised = true; + public void ClearFlag() => UpdateFlagRaised = false; + } +} diff --git a/WebServer/Utils/MimeTypeMap.cs b/WebServer/Utils/MimeTypeMap.cs index d1b3ad6..831dcee 100644 --- a/WebServer/Utils/MimeTypeMap.cs +++ b/WebServer/Utils/MimeTypeMap.cs @@ -13,7 +13,7 @@ namespace WebServer.Utils { private const string Dot = "."; private const string QuestionMark = "?"; - private const string DefaultMimeType = "application/octet-stream"; + private const string DefaultMimeType = "text/plain"; private static readonly Lazy> _mappings = new Lazy>(BuildMappings); public static string HtmlDocument = GetMimeType(".html"); @@ -218,6 +218,7 @@ namespace WebServer.Utils {".htc", "text/x-component"}, {".htm", "text/html"}, {".html", "text/html"}, + {".cshtml", "text/html"}, {".htt", "text/webviewhtml"}, {".hxa", "application/xml"}, {".hxc", "application/xml"}, diff --git a/WebServer/WebServer.csproj b/WebServer/WebServer.csproj index b4b43f4..e2b21ef 100644 --- a/WebServer/WebServer.csproj +++ b/WebServer/WebServer.csproj @@ -3,6 +3,11 @@ netstandard2.1 enable + true + + + +