Test Project Created
+ Implemented Request Processing (Cache => Regex Events => Razor Pages => Static Content) + Rendering based on domain works - Areas are not implemented + Content Type Map now defines `.cshtml` files and default mime type was changed to "text/plain" + GetStaticFile no longer returns private templates/layouts (used to be able to view those in plain text by requesting with a fully qualified path) I will make more concise tests in a bit (I was not pushing to git properly, so have to make big commits for the time being)
This commit is contained in:
@@ -1,9 +1,30 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
|
using System.Reflection;
|
||||||
|
using WebServer;
|
||||||
|
|
||||||
namespace WebServer.Test {
|
namespace WebServer.Test {
|
||||||
internal class Program {
|
internal class Program {
|
||||||
internal static void Main(string[] args) {
|
internal static void Main(string[] args) {
|
||||||
Console.WriteLine("Hello, World!");
|
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<MethodBase> methodsUsed, Exception exception)
|
||||||
|
=> Console.WriteLine(exception);
|
||||||
|
|
||||||
|
public void LogRequest(HttpListenerContext context, HttpResponse response, List<MethodBase> methodsUsed)
|
||||||
|
=> Console.WriteLine($"[{(int)response.StatusCode}] {context.Request.Url?.PathAndQuery}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
31
WebServer.Test/Views/localhost/_Layout.cshtml
Normal file
31
WebServer.Test/Views/localhost/_Layout.cshtml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>@ViewBag.Title</title>
|
||||||
|
<link href="https://kavemans.dev/src/styles/framework.css" type="text/css" rel="stylesheet" />
|
||||||
|
<link href="https://kavemans.dev/src/styles/common.css" type="text/css" rel="stylesheet" />
|
||||||
|
<link href="https://kavemans.dev/src/styles/main.css" type="text/css" rel="stylesheet" />
|
||||||
|
@RenderSection("Styles", required: false)
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-image: url('https://kavemans.dev/src/images/Chalkboard.webp');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ice {
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="centerize stickyFooter">
|
||||||
|
@RenderBody()
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
WebServer.Test/Views/localhost/_StatusPage.cshtml
Normal file
16
WebServer.Test/Views/localhost/_StatusPage.cshtml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
@using RazorLight
|
||||||
|
@inherits TemplatePage<WebServer.Models.StatusPageModel>
|
||||||
|
@model WebServer.Models.StatusPageModel
|
||||||
|
@{
|
||||||
|
Layout = @"_Layout.cshtml";
|
||||||
|
ViewBag.Title = $"{Model.StatusCodeAsUShort} - {Model.Header}";
|
||||||
|
}
|
||||||
|
<div class="ice centerize">
|
||||||
|
<div style="font-size: 2em;">@Model.Header</div>
|
||||||
|
<span>@Model.Details</span>
|
||||||
|
@if (Model.Exception != null) {
|
||||||
|
<div style="padding: 5px; border-radius: 5px; background: rgba(0, 0, 0, 0.5); box-shadow: inset rgb(255 255 255 / 20%) 0 0 0 2px; width: auto; max-width: 95%; text-align: left;">
|
||||||
|
<pre style="margin: 0;">@Model.Exception</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
8
WebServer.Test/Views/localhost/test123/index.cshtml
Normal file
8
WebServer.Test/Views/localhost/test123/index.cshtml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
@using RazorLight
|
||||||
|
@inherits TemplatePage<object?>
|
||||||
|
@{
|
||||||
|
Layout = "/_Layout.cshtml";
|
||||||
|
ViewBag.Title = "Hello World!";
|
||||||
|
}
|
||||||
|
|
||||||
|
<u>Hello from test123</u>
|
||||||
@@ -7,25 +7,23 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<PublishAot>true</PublishAot>
|
<PublishAot>true</PublishAot>
|
||||||
<InvariantGlobalization>true</InvariantGlobalization>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
<PreserveCompilationContext>true</PreserveCompilationContext>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Not entirely sure what this does -->
|
<!-- Remove all site contents and Razor pages from being compiled -->
|
||||||
<None Remove="Views\**" />
|
<None Remove="Views\**" />
|
||||||
<!-- Remove all Razor pages from being compiled in project -->
|
|
||||||
<Content Remove="Views/**.cshtml" />
|
<Content Remove="Views/**.cshtml" />
|
||||||
|
<!-- But, inlude in Editor/Solution Explorer -->
|
||||||
|
<Compile Remove="Views\**" />
|
||||||
|
<Content Include="Views\**" />
|
||||||
<!-- Copy everything from views into output directory-->
|
<!-- Copy everything from views into output directory-->
|
||||||
<None Include="Views/**">
|
<None Include="Views/**">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
<!-- Do not compile contents -->
|
|
||||||
<Compile Remove="Views\**" />
|
|
||||||
<!-- But, inlude in Editor -->
|
|
||||||
<Content Include="Views\**" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<!-- Web Server assembly reference -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\WebServer\SimpleWebServer.csproj" />
|
<ProjectReference Include="..\WebServer\WebServer.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
@@ -3,9 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
|||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.10.35122.118
|
VisualStudioVersion = 17.10.35122.118
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
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
|
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
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
|||||||
@@ -9,5 +9,27 @@ namespace WebServer {
|
|||||||
public string DefaultDomain = "localhost"; // The domain that the server will fallback on if the
|
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 bool ShowExceptionOnErrorPages = true; // On InternalServerError(500), should it show the exception?
|
||||||
public ushort MaxConcurrentRequests = 100;
|
public ushort MaxConcurrentRequests = 100;
|
||||||
|
public List<string> UriFillers = new List<string>() { // Todo: Needs revision,
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
".txt",
|
||||||
|
"./index.html",
|
||||||
|
"./index.htm",
|
||||||
|
"./index.txt",
|
||||||
|
"./default.webp",
|
||||||
|
"./default.png",
|
||||||
|
"../default.webp",
|
||||||
|
"../default.png"
|
||||||
|
};
|
||||||
|
public Dictionary<string, string?> GenericHeaders = new Dictionary<string, string?>() {
|
||||||
|
{ "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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ namespace WebServer {
|
|||||||
get => Encoding.UTF8.GetString(Content);
|
get => Encoding.UTF8.GetString(Content);
|
||||||
set => Content = Encoding.UTF8.GetBytes(value);
|
set => Content = Encoding.UTF8.GetBytes(value);
|
||||||
}
|
}
|
||||||
|
public Dictionary<string, string?> Headers = new Dictionary<string, string?>();
|
||||||
|
|
||||||
public HttpResponse() { }
|
public HttpResponse() { }
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Dynamic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Reflection;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using RazorLight;
|
||||||
|
using RazorLight.Razor;
|
||||||
|
using WebServer.Models;
|
||||||
|
using WebServer.Utils;
|
||||||
|
|
||||||
namespace WebServer {
|
namespace WebServer {
|
||||||
public class HttpServer {
|
public class HttpServer {
|
||||||
@@ -13,9 +20,12 @@ namespace WebServer {
|
|||||||
public readonly DirectoryInfo ViewsDirectory;
|
public readonly DirectoryInfo ViewsDirectory;
|
||||||
public ushort ActivePort { get; protected set; }
|
public ushort ActivePort { get; protected set; }
|
||||||
public HttpListener HttpListener { get; protected set; }
|
public HttpListener HttpListener { get; protected set; }
|
||||||
public IHttpLogger Logger;
|
public IHttpLogger? Logger;
|
||||||
|
public delegate Task<HttpResponse?> Callback(HttpListenerContext context, CachedResponse? cache);
|
||||||
|
|
||||||
Dictionary<string, Dictionary<Regex, Func<HttpListenerRequest, Task<HttpResponse?>>>> HttpCallbacks = new Dictionary<string, Dictionary<Regex, Func<HttpListenerRequest, Task<HttpResponse?>>>>();
|
Dictionary<string, Dictionary<Regex, Callback>> HttpCallbacks = new Dictionary<string, Dictionary<Regex, Callback>>();
|
||||||
|
Dictionary<string, RazorLightEngine> RazorEngines = new Dictionary<string, RazorLightEngine>();
|
||||||
|
public readonly RazorLightEngine DefaultRazorEngine;
|
||||||
/* Instead of three directories (from previous render engine)
|
/* Instead of three directories (from previous render engine)
|
||||||
* - Public/PublicTemplates (was public static stuff, also allowed views)
|
* - Public/PublicTemplates (was public static stuff, also allowed views)
|
||||||
* - Static (was used for builds)
|
* - Static (was used for builds)
|
||||||
@@ -25,7 +35,7 @@ namespace WebServer {
|
|||||||
* - Views - Site source, including public/private views (views are able to be processed)
|
* - 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;
|
Config = config;
|
||||||
Logger = logger;
|
Logger = logger;
|
||||||
ViewsDirectory = new DirectoryInfo(Path.Combine(Environment.CurrentDirectory, "Views"));
|
ViewsDirectory = new DirectoryInfo(Path.Combine(Environment.CurrentDirectory, "Views"));
|
||||||
@@ -35,10 +45,26 @@ namespace WebServer {
|
|||||||
ViewsDirectory.CreateSubdirectory(config.DefaultDomain);
|
ViewsDirectory.CreateSubdirectory(config.DefaultDomain);
|
||||||
if (config.AutoStart)
|
if (config.AutoStart)
|
||||||
_ = StartAsync();
|
_ = 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() {
|
public async Task StartAsync() {
|
||||||
if (HttpListener?.IsListening == true)
|
if (HttpListener?.IsListening ?? false)
|
||||||
await StopAsync();
|
await StopAsync();
|
||||||
HttpListener = new HttpListener() {
|
HttpListener = new HttpListener() {
|
||||||
IgnoreWriteExceptions = true // Used to crash the server, don't think it is needed anymore
|
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;
|
public int TasksCount => Tasks.Count;
|
||||||
HashSet<Task> Tasks = new HashSet<Task>();
|
HashSet<Task> Tasks = new HashSet<Task>();
|
||||||
async Task ListenAsync(CancellationToken token) {
|
async Task ListenAsync(CancellationToken token) {
|
||||||
Tasks = new HashSet<Task>(64);
|
Tasks = new HashSet<Task>(128);
|
||||||
for (int i = 0; i < Tasks.Count; i++) // Create 64 tasks
|
for (int i = 0; i < 64; i++) // Create 64 tasks
|
||||||
Tasks.Add(HttpListener.GetContextAsync());
|
Tasks.Add(HttpListener.GetContextAsync());
|
||||||
Logger.Log($"Listening with {Tasks.Count} worker(s)");
|
Logger?.Log($"Listening with {Tasks.Count} worker(s)");
|
||||||
while (!token.IsCancellationRequested) {
|
while (!token.IsCancellationRequested) {
|
||||||
Task t = await Task.WhenAny(Tasks);
|
Task t = await Task.WhenAny(Tasks);
|
||||||
Tasks.Remove(t);
|
Tasks.Remove(t);
|
||||||
@@ -68,9 +94,10 @@ namespace WebServer {
|
|||||||
if (t is Task<HttpListenerContext> context) {
|
if (t is Task<HttpListenerContext> context) {
|
||||||
if (Tasks.Count < Config.MaxConcurrentRequests)
|
if (Tasks.Count < Config.MaxConcurrentRequests)
|
||||||
Tasks.Add(HttpListener.GetContextAsync());
|
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();
|
ListenToken.Cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ProcessRequestAsync(HttpListenerContext context, CancellationToken token) { }
|
public async Task ProcessRequestAsync(HttpListenerContext context, CancellationToken token) {
|
||||||
|
List<MethodBase> methodsUsed = new List<MethodBase>() { 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<object?>(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<HttpResponse> GetStaticFile(HttpListenerContext context, CachedResponse? cache) => await GetStaticFile(context.Request.Url?.Host, context.Request.Url?.LocalPath, cache);
|
||||||
|
|
||||||
|
public async Task<HttpResponse> 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<HttpResponse> 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<HttpResponse?> 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<Regex, Callback>());
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using System.Text;
|
|||||||
namespace WebServer {
|
namespace WebServer {
|
||||||
public interface IHttpLogger {
|
public interface IHttpLogger {
|
||||||
void Log(string message);
|
void Log(string message);
|
||||||
void LogRequest(HttpListenerContext context, List<MethodBase> methodsUsed);
|
void LogRequest(HttpListenerContext context, HttpResponse response, List<MethodBase> methodsUsed);
|
||||||
void LogError(HttpListenerContext context, List<MethodBase> methodsUsed, Exception exception);
|
void LogError(HttpListenerContext context, List<MethodBase> methodsUsed, Exception exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
WebServer/Models/StatusPageModel.cs
Normal file
38
WebServer/Models/StatusPageModel.cs
Normal file
@@ -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<HttpStatusCode, (string header, string details)> GenericStatusContent = new Dictionary<HttpStatusCode, (string header, string details)>() {
|
||||||
|
{ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
WebServer/ResponseTimedOutException.cs
Normal file
26
WebServer/ResponseTimedOutException.cs
Normal file
@@ -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<MethodBase> MethodsUsed;
|
||||||
|
public ResponseTimedOutException(string fullPath, List<MethodBase> 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];";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
WebServer/Utils/CachedResponse.cs
Normal file
48
WebServer/Utils/CachedResponse.cs
Normal file
@@ -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<CachedResponse> Instances { get; private set; } = new List<CachedResponse>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ namespace WebServer.Utils
|
|||||||
{
|
{
|
||||||
private const string Dot = ".";
|
private const string Dot = ".";
|
||||||
private const string QuestionMark = "?";
|
private const string QuestionMark = "?";
|
||||||
private const string DefaultMimeType = "application/octet-stream";
|
private const string DefaultMimeType = "text/plain";
|
||||||
private static readonly Lazy<IDictionary<string, string>> _mappings = new Lazy<IDictionary<string, string>>(BuildMappings);
|
private static readonly Lazy<IDictionary<string, string>> _mappings = new Lazy<IDictionary<string, string>>(BuildMappings);
|
||||||
public static string HtmlDocument = GetMimeType(".html");
|
public static string HtmlDocument = GetMimeType(".html");
|
||||||
|
|
||||||
@@ -218,6 +218,7 @@ namespace WebServer.Utils
|
|||||||
{".htc", "text/x-component"},
|
{".htc", "text/x-component"},
|
||||||
{".htm", "text/html"},
|
{".htm", "text/html"},
|
||||||
{".html", "text/html"},
|
{".html", "text/html"},
|
||||||
|
{".cshtml", "text/html"},
|
||||||
{".htt", "text/webviewhtml"},
|
{".htt", "text/webviewhtml"},
|
||||||
{".hxa", "application/xml"},
|
{".hxa", "application/xml"},
|
||||||
{".hxc", "application/xml"},
|
{".hxc", "application/xml"},
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netstandard2.1</TargetFramework>
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<PreserveCompilationContext>true</PreserveCompilationContext>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="RazorLight" Version="2.3.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user