initial commit

This commit is contained in:
uwaa 2024-10-25 05:00:22 +01:00
commit 33d2c28712
24 changed files with 1288 additions and 0 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
*.cs eol=crlf
*.txt eol=lf

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.vs
.vscode
bin
obj

37
MiniHTTP/HttpContent.cs Normal file
View file

@ -0,0 +1,37 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
namespace MiniHTTP;
/// <summary>
/// Content served as the body of a HTTP request or response.
/// </summary>
public struct HttpContent
{
public static implicit operator HttpContent?([NotNullWhen(false)] string? value)
{
return value == null ? null : new HttpContent("text/plain", value);
}
/// <summary>
/// The MIME type of the HTTP content.
/// </summary>
public string Type;
/// <summary>
/// The raw HTTP content body, as bytes.
/// </summary>
public byte[] Content;
public HttpContent(string type, string body)
{
Type = type;
Content = Encoding.UTF8.GetBytes(body);
}
public HttpContent(string type, byte[] body)
{
Type = type;
Content = body;
}
}

18
MiniHTTP/HttpMethod.cs Normal file
View file

@ -0,0 +1,18 @@
namespace MiniHTTP;
/// <summary>
/// HTTP method from a HTTP request.
/// </summary>
public enum HttpMethod
{
Any,
GET,
HEAD,
POST,
PUT,
DELETE,
CONNECT,
OPTIONS,
TRACE,
PATCH,
}

264
MiniHTTP/HttpRequest.cs Normal file
View file

@ -0,0 +1,264 @@
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Web;
using System.Collections.Specialized;
using MiniHTTP.Websockets;
using MiniHTTP.Responses;
namespace MiniHTTP;
/// <summary>
/// A HTTP request currently being made to a <see cref="HttpServer"/>.
/// </summary>
public sealed class HttpRequest
{
/// <summary>
/// The server the request is being made to.
/// </summary>
public readonly HttpServer Server;
/// <summary>
/// The TCP client serving this request.
/// </summary>
public readonly TcpClient Client;
/// <summary>
/// The underlying TCP stream.
/// </summary>
public readonly Stream Stream;
/// <summary>
/// The HTTP method of the request.
/// </summary>
public HttpMethod Method { get; private set; } = HttpMethod.Any;
/// <summary>
/// The HTTP path being requested.
/// </summary>
public string Path { get; private set; } = string.Empty;
/// <summary>
/// Additional paramters in the path of the request.
/// </summary>
public NameValueCollection? Query { get; private set; }
/// <summary>
/// HTTP headers included in the request.
/// </summary>
public readonly Dictionary<string, string> Headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
internal readonly BufferedStream Buffer;
readonly Decoder Decoder;
readonly StreamWriter Writer;
/// <summary>
/// If true, the request is connected and waiting for a reply.
/// </summary>
public bool Connected => Client.Connected;
internal HttpRequest(HttpServer server, TcpClient client, Stream stream)
{
Server = server;
Client = client;
Stream = stream;
Buffer = new BufferedStream(stream);
Decoder = Encoding.UTF8.GetDecoder();
Writer = new StreamWriter(Buffer, Encoding.ASCII);
Writer.AutoFlush = true;
}
public async Task ReadAll()
{
//Read initial header
string? header = await ReadLine();
if (header == null)
throw new RequestParseException("Connection closed unexpectedly");
if (header.Length > 1000)
throw new RequestParseException("Initial header is too long");
{
string[] parts = header.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3)
throw new RequestParseException("Invalid initial header");
HttpMethod method;
if (!Enum.TryParse(parts[0], true, out method))
throw new RequestParseException("Unknown HTTP method");
Method = method;
string[] pathParts = parts[1].Replace("\\", "/").Split('?', 2, StringSplitOptions.RemoveEmptyEntries);
Path = pathParts[0];
Query = HttpUtility.ParseQueryString(pathParts.Length > 1 ? pathParts[1] : string.Empty);
}
//Read headers
while (true)
{
string? headerStr = await ReadLine();
if (headerStr == null)
throw new RequestParseException("Connection closed unexpectedly");
if (string.IsNullOrWhiteSpace(headerStr))
break; //End of headers
if (Headers.Count >= 20)
throw new RequestParseException("Too many headers");
if (headerStr.Length > 500)
throw new RequestParseException("A request header is too long");
int splitPoint = headerStr.IndexOf(':');
if (splitPoint == -1)
throw new RequestParseException("A header is invalid");
string name = headerStr.Remove(splitPoint).Trim();
string value = headerStr.Substring(splitPoint + 1).Trim();
if (Headers.ContainsKey(name))
throw new RequestParseException("Duplicate header");
Headers.Add(name, value);
}
}
public async Task<string> ReadLine()
{
byte[] dataBuffer = new byte[1];
char[] charBuffer = new char[4096];
int charBufferIndex = 0;
while (true)
{
if (await Buffer.ReadAsync(dataBuffer) == 0)
break;
if (charBufferIndex >= charBuffer.Length)
throw new RequestParseException("Header is too large");
charBufferIndex += Decoder.GetChars(dataBuffer, 0, 1, charBuffer, charBufferIndex, false);
if (charBufferIndex >= 2 && charBuffer[charBufferIndex - 1] == '\n' && charBuffer[charBufferIndex - 2] == '\r')
{
charBufferIndex -= 2;
break;
}
}
Decoder.Reset();
return new string(charBuffer, 0, charBufferIndex);
}
public async Task WriteStatus(int code, string message)
{
await Writer.WriteAsync("HTTP/1.1 ");
await Writer.WriteAsync(code.ToString());
await Writer.WriteAsync(' ');
await Writer.WriteAsync(message);
await WriteLine();
}
public async Task WriteHeader(string name, string value)
{
await Writer.WriteAsync(name);
await Writer.WriteAsync(": ");
await Writer.WriteAsync(value);
await WriteLine();
}
public async Task WriteLine()
{
await Writer.WriteAsync("\r\n");
}
public async Task WriteBytes(byte[] bytes)
{
await Buffer.WriteAsync(bytes);
}
public async Task Flush()
{
await Buffer.FlushAsync();
}
/// <summary>
/// Writes a response without closing the socket.
/// </summary>
public async Task Write(HttpResponse response)
{
if (response.StatusCode == 0)
return;
await WriteStatus(response.StatusCode, response.StatusMessage);
foreach (var header in response.GetHeaders())
await WriteHeader(header.Item1, header.Item2);
await WriteHeader("Access-Control-Allow-Origin", "*");
await WriteLine();
if (response.Body.HasValue)
await WriteBytes(response.Body.Value.Content);
await Flush();
}
/// <summary>
/// Attempts to destroy the connection, ignoring all errors.
/// </summary>
public async void Close(HttpResponse? response)
{
if (response != null && Client.Connected)
{
try
{
await Write(response);
}
catch
{
//Who cares if it does not arrive. Fuck off remote endpoint.
}
}
Client.Close();
Client.Dispose();
}
/// <summary>
/// Sends a "switching protocol" header for a websocket.
/// </summary>
public async Task<Websocket?> UpgradeToWebsocket()
{
if (!Headers.TryGetValue("Sec-WebSocket-Key", out string? wsKey))
{
await Write(new BadRequest("Missing Sec-WebSocket-Key header"));
return null;
}
//Increase timeouts
Client.SendTimeout = 120_000;
Client.ReceiveTimeout = 120_000;
string acceptKey = Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(wsKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")));
await WriteStatus(101, "Switching Protocols");
await WriteHeader("Upgrade", "websocket");
await WriteHeader("Connection", "Upgrade");
await WriteHeader("Sec-WebSocket-Accept", acceptKey);
await WriteHeader("Access-Control-Allow-Origin", "*");
await WriteLine();
await Flush();
return new Websocket(this);
}
}
/// <summary>
/// An exception caused during HTTP reading and parsing.
/// </summary>
class RequestParseException : IOException
{
public RequestParseException(string? message) : base(message)
{
}
}

37
MiniHTTP/HttpResponse.cs Normal file
View file

@ -0,0 +1,37 @@
namespace MiniHTTP.Responses;
public class HttpResponse
{
public static implicit operator HttpResponse(HttpContent value)
{
return new OK(value);
}
public static implicit operator HttpResponse(HttpContent? value)
{
return value == null ? new NotFound() : new OK(value);
}
public readonly int StatusCode;
public readonly string StatusMessage;
public readonly HttpContent? Body;
public HttpResponse(int statusCode, string statusMessage, HttpContent? body = null)
{
StatusCode = statusCode;
StatusMessage = statusMessage;
Body = body;
}
public virtual IEnumerable<(string, string)> GetHeaders()
{
if (Body.HasValue)
{
int contentLength = Body.Value.Content.Length;
yield return ("Content-Length", contentLength.ToString());
yield return ("Content-Type", Body.Value.Type);
}
}
}

187
MiniHTTP/HttpServer.cs Normal file
View file

@ -0,0 +1,187 @@
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using MiniHTTP.Responses;
using MiniHTTP.Routing;
namespace MiniHTTP;
/// <summary>
/// A hypertext transfer protocol server which can listen on a port and deliver content over HTTP, optionally over SSL (HTTPS).
/// </summary>
public sealed class HttpServer
{
/// <summary>
/// The router to be used to find and serve content to clients.
/// </summary>
public readonly IRouter Router;
/// <summary>
/// If non-null, all connections will be secured via SSL using this certificate.
/// </summary>
public readonly X509Certificate? Certificate;
/// <summary>
/// Maximum number of connections per IP before new connections are rejected.
/// </summary>
/// <remarks>
/// Prevents Slow Loris attacks.
/// </remarks>
public int MaxConnections = 5;
/// <summary>
/// The port the server will listen on.
/// </summary>
public int Port => ((IPEndPoint)listener.LocalEndpoint).Port;
readonly Dictionary<IPAddress, int> IPCounts = new Dictionary<IPAddress, int>();
readonly SemaphoreSlim IPCountsLock = new SemaphoreSlim(1, 1);
readonly TcpListener listener;
public HttpServer(int port, X509Certificate? certificate, IRouter router)
{
listener = new TcpListener(IPAddress.Any, port);
Certificate = certificate;
Router = router;
}
/// <summary>
/// Begins listening for new connections.
/// </summary>
public async Task Start()
{
listener.Start();
while (true)
HandleClient(await listener.AcceptTcpClientAsync());
}
async void HandleClient(TcpClient client)
{
if (client.Client.RemoteEndPoint is not IPEndPoint endpoint)
return;
//Count IPs to prevent Slow Loris attacks
try
{
await IPCountsLock.WaitAsync();
if (IPCounts.TryGetValue(endpoint.Address, out int count))
{
if (count >= MaxConnections)
{
//Too many connections
client.Close();
return;
}
IPCounts[endpoint.Address] = count + 1;
}
else
{
IPCounts[endpoint.Address] = 1;
}
}
finally
{
IPCountsLock.Release();
}
try
{
//Setup client
client.Client.LingerState = new LingerOption(true, 5);
client.Client.SendTimeout = 20_000;
client.Client.ReceiveTimeout = 20_000;
Stream stream;
try
{
stream = client.GetStream();
if (Certificate != null)
{
//Pass through SSL stream
SslStream ssl = new SslStream(stream);
await ssl.AuthenticateAsServerAsync(Certificate);
stream = ssl;
}
while (client.Connected)
{
HttpRequest req = new HttpRequest(this, client, stream);
try
{
await req.ReadAll();
//Parse path
ArraySegment<string> pathSpl = req.Path.Split('/');
for (int i = 0; i < pathSpl.Count; i++)
pathSpl[i] = WebUtility.UrlDecode(pathSpl[i]);
if (pathSpl.Count > 0 && pathSpl[0].Length == 0)
pathSpl = pathSpl.Slice(1);
//Execute
HttpResponse? response = await Router.Handle(req, pathSpl, null);
if (response != null)
await req.Write(response);
else
await req.Write(new HttpResponse(500, "Server produced no response"));
}
catch (RequestParseException e)
{
req.Close(new HttpResponse(400, e.Message));
break;
}
catch (IOException)
{
throw;
}
catch (Exception)
{
await req.Write(new HttpResponse(500, "Internal Server Error"));
throw;
}
if (req.Headers.TryGetValue("connection", out string? connectionValue) && connectionValue == "close")
{
client.Close();
break;
}
}
}
catch (IOException)
{
//Client likely disconnected unexpectedly
}
catch (Exception)
{
//Error
}
}
finally
{
client.Close();
try
{
await IPCountsLock.WaitAsync();
if (IPCounts[endpoint.Address] <= 1)
{
IPCounts.Remove(endpoint.Address);
}
else
{
IPCounts[endpoint.Address]--;
}
}
finally
{
IPCountsLock.Release();
}
}
}
}

8
MiniHTTP/MiniHTTP.csproj Normal file
View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,11 @@
namespace MiniHTTP.Responses;
/// <summary>
/// An error caused by an invalid request.
/// </summary>
public class BadRequest : HttpResponse
{
public BadRequest(string? body = null) : base(400, "Bad Request", body)
{
}
}

View file

@ -0,0 +1,11 @@
namespace MiniHTTP.Responses;
/// <summary>
/// Special response which closes the request without sending a response. Used when an endpoint handles its own response, such as websocket upgrades.
/// </summary>
public class Empty : HttpResponse
{
public Empty() : base(0, string.Empty)
{
}
}

View file

@ -0,0 +1,11 @@
namespace MiniHTTP.Responses;
/// <summary>
/// An error caused by a request to non-existent data.
/// </summary>
public class NotFound : HttpResponse
{
public NotFound(HttpContent? body = null) : base(404, "Not Found", body)
{
}
}

11
MiniHTTP/Responses/OK.cs Normal file
View file

@ -0,0 +1,11 @@
namespace MiniHTTP.Responses;
/// <summary>
/// An simple reply to a request.
/// </summary>
public class OK : HttpResponse
{
public OK(HttpContent? body = null) : base(body == null ? 204 : 200, body == null ? "No Content" : "OK", body)
{
}
}

View file

@ -0,0 +1,19 @@
namespace MiniHTTP.Responses;
/// <summary>
/// A response redirecting the request to another location.
/// </summary>
public class Redirect : HttpResponse
{
public string Location;
public Redirect(string location) : base(301, "Redirect")
{
Location = location;
}
public override IEnumerable<(string, string)> GetHeaders()
{
yield return ("Location", Location);
}
}

35
MiniHTTP/Routing/CORS.cs Normal file
View file

@ -0,0 +1,35 @@
using MiniHTTP.Responses;
namespace MiniHTTP.Routing;
/// <summary>
/// Handles CORS preflight requests.
/// </summary>
public class CORS : IRouter
{
public CORS()
{
}
public Task<HttpResponse?> Handle(HttpRequest client, ArraySegment<string> path, ParameterCollection? baseParameters)
{
if (client.Method != HttpMethod.OPTIONS)
return Task.FromResult<HttpResponse?>(null);
return Task.FromResult<HttpResponse?>(new PreflightResponse());
}
}
class PreflightResponse : OK
{
public PreflightResponse() : base()
{
}
public override IEnumerable<(string, string)> GetHeaders()
{
yield return ("Access-Control-Allow-Origin", "*");
yield return ("Methods", "GET,HEAD,POST,PUT,DELETE,CONNECT,OPTIONS,TRACE,PATCH");
yield return ("Vary", "Origin");
}
}

View file

@ -0,0 +1,19 @@
using MiniHTTP.Responses;
namespace MiniHTTP.Routing;
/// <summary>
/// An asynchronous endpoint handler.
/// </summary>
/// <param name="request">The request being served.</param>
/// <param name="parameters">Parameters in the path.</param>
/// <returns>Returns a HTTP response if the request successfully hit. Returns null if the request misses the handler.</returns>
public delegate Task<HttpResponse?> EndpointHandlerAsync(HttpRequest request, ParameterCollection parameters);
/// <summary>
/// An synchronous endpoint handler.
/// </summary>
/// <param name="request">The request being served.</param>
/// <param name="parameters">Parameters in the path.</param>
/// <returns>Returns a HTTP response if the request successfully hit. Returns null if the request misses the handler.</returns>
public delegate HttpResponse? EndpointHandler(HttpRequest request, ParameterCollection parameters);

View file

@ -0,0 +1,11 @@
using MiniHTTP.Responses;
namespace MiniHTTP.Routing;
/// <summary>
/// Parses a path and serves content to clients.
/// </summary>
public interface IRouter
{
Task<HttpResponse?> Handle(HttpRequest client, ArraySegment<string> path, ParameterCollection? baseParameters);
}

View file

@ -0,0 +1,58 @@
namespace MiniHTTP.Routing;
/// <summary>
/// A map of arguments passed as parameters in a HTTP path.
/// </summary>
/// <remarks>
/// <para>
/// Routes may contain parameters, denoted by a colon at the beginning. When a request is made, whatever is provided in that space becomes available in the parameter collection as an argument.
/// If you have a route such as <c>/far/:example/bar</c>, and a client requests the path <c>/foo/quux/bar</c>, then the argument passed to the "example" parameter will be "quux".
/// </para>
/// <para>
/// Arguments can be obtained from the collection like a <see cref="Dictionary{TKey, TValue}"/>. For example:
/// <code>
/// string id = parameters["id"];
/// </code>
/// </para>
/// </remarks>
public class ParameterCollection
{
public static readonly ParameterCollection Empty = new ParameterCollection();
/// <summary>
/// A parameter collection to read from if this instance fails to fulfill a lookup.
/// </summary>
public readonly ParameterCollection? Fallback;
readonly Dictionary<string, string> Parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public string? this[string key]
{
get
{
if (Parameters.TryGetValue(key, out var value))
return value;
else if (Fallback == null)
return null;
else
return Fallback[key];
}
set
{
if (value == null)
Parameters.Remove(key);
else
Parameters[key] = value;
}
}
public ParameterCollection(ParameterCollection? fallback = null)
{
Fallback = fallback;
}
public void Clear()
{
Parameters.Clear();
}
}

126
MiniHTTP/Routing/Router.cs Normal file
View file

@ -0,0 +1,126 @@
using MiniHTTP.Responses;
namespace MiniHTTP.Routing;
/// <summary>
/// Standard implementation for <see cref="IRouter"/>.
/// </summary>
public class Router : IRouter
{
struct Route
{
public string[] Paths;
public IRouter Router;
public Route(string[] paths, IRouter router)
{
Paths = paths;
Router = router;
}
}
/// <summary>
/// The default handler to use if no other handlers are hit.
/// </summary>
public EndpointHandlerAsync? Endpoint;
/// <summary>
/// Which method this router serves.
/// </summary>
public HttpMethod Method = HttpMethod.Any;
readonly List<Route> Routes = new List<Route>();
public Router(EndpointHandlerAsync endpoint, HttpMethod method = HttpMethod.Any)
{
Endpoint = endpoint;
}
public Router(EndpointHandler endpoint, HttpMethod method = HttpMethod.Any)
{
Endpoint = (client, parameters) => Task.FromResult(endpoint(client, parameters));
}
internal async Task<HttpResponse?> Handle(HttpRequest client, ArraySegment<string> path, ParameterCollection? baseParameters)
{
if (Method != HttpMethod.Any && client.Method != Method)
return null;
ParameterCollection parameters = new ParameterCollection(baseParameters);
foreach (var route in Routes)
{
if (route.Paths.Length != 1 || route.Paths[0] != "*")
{
if (route.Paths.Length > path.Count)
continue;
for (int i = 0; i < route.Paths.Length; i++)
{
string key = route.Paths[i];
if (key.Length == 0)
{
if (path[i].Length == 0)
continue;
else
goto skip;
}
if (key[0] == ':')
{
parameters[route.Paths[i][1..]] = path[i];
continue;
}
if (!key.Equals(path[i], StringComparison.OrdinalIgnoreCase))
goto skip;
}
}
HttpResponse? resp = await route.Router.Handle(client, path.Slice(route.Paths.Length), parameters);
if (resp != null)
return resp;
skip:
parameters.Clear();
}
if (Endpoint == null)
return null;
else
return await Endpoint(client, baseParameters ?? ParameterCollection.Empty);
}
Task<HttpResponse?> IRouter.Handle(HttpRequest client, ArraySegment<string> path, ParameterCollection? baseParameters)
=> Handle(client, path, baseParameters);
/// <summary>
/// Adds a new route to the router.
/// </summary>
/// <param name="path">The subpath to the route.</param>
/// <param name="route">The router to use.</param>
public void Use(string path, IRouter route)
{
string[] pathParams = path.Split('/', StringSplitOptions.TrimEntries);
Routes.Add(new Route(pathParams, route));
}
/// <summary>
/// Adds an endpoint to the router.
/// </summary>
/// <param name="method">The HTTP method to respond to.</param>
/// <param name="path">The subpath to the endpoint.</param>
/// <param name="endpoint">The endpoint handler which serves the request.</param>
public void Add(HttpMethod method, string path, EndpointHandlerAsync endpoint) => Use(path, new Router(endpoint, method));
/// <inheritdoc cref="Add(string, EndpointHandlerAsync)"/>
public void Add(HttpMethod method, string path, EndpointHandler endpoint) => Use(path, new Router(endpoint, method));
/// <summary>
/// Adds a static file route to the router.
/// </summary>
public void Static(string directory) => Routes.Add(new Route([":asset"], new Static(directory, "asset")));
/// <inheritdoc cref="Static(string)"/>
public void Static(string directory, string path, string assetParam) => Use(path, new Static(directory, assetParam));
}

109
MiniHTTP/Routing/Static.cs Normal file
View file

@ -0,0 +1,109 @@
using MiniHTTP.Responses;
using System.Text.RegularExpressions;
namespace MiniHTTP.Routing;
/// <summary>
/// Special router which serves static files from the filesystem.
/// </summary>
public partial class Static : IRouter
{
static string GuessMIMEType(string extension)
{
return extension switch
{
".txt" => "text/plain",
".htm" or "html" => "text/html",
".js" => "text/javascript",
".css" => "text/css",
".csv" => "text/csv",
".bin" => "application/octet-stream",
".zip" => "application/zip",
".7z" => "application/x-7z-compressed",
".gz" => "application/gzip",
".xml" => "application/xml",
".pdf" => "application/pdf",
".json" => "application/json",
".bmp" => "image/bmp",
".png" => "image/png",
".jpg" or "jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
".ico" => "image/vnd.microsoft.icon",
".mid" or ".midi" => "audio/midi",
".mp3" => "audio/mpeg",
".ogg" or ".oga" or ".opus" => "audio/ogg",
".wav" => "audio/wav",
".weba" => "audio/webm",
".webm" => "video/webm",
".mp4" => "video/mp4",
".mpeg" => "video/mpeg",
".ogv" => "video/ogg",
".otf" => "font/otf",
".ttf" => "font/ttf",
".woff" => "font/woff",
".woff2" => "font/woff2",
_ => "application/octet-stream", //Unknown
};
}
/// <summary>
/// The source directory from which assets should be served.
/// </summary>
public string Directory;
/// <summary>
/// The name of the asset parameter in the route.
/// </summary>
public string AssetParameter;
public Static(string directory, string assetParameter)
{
Directory = directory;
AssetParameter = assetParameter;
}
async Task<HttpResponse?> IRouter.Handle(HttpRequest client, ArraySegment<string> path, ParameterCollection? baseParameters)
{
if (baseParameters == null)
return null;
string? asset = baseParameters[AssetParameter];
if (string.IsNullOrWhiteSpace(asset))
return null;
if (FilenameChecker().IsMatch(asset))
return new BadRequest("Illegal chars in asset path");
string assetPath = $"{Directory}/{asset}";
FileInfo fileInfo = new FileInfo(assetPath);
string mime;
if (string.IsNullOrEmpty(fileInfo.Extension))
{
mime = "text/html";
assetPath += ".htm";
}
else
{
mime = GuessMIMEType(fileInfo.Extension);
}
return !File.Exists(assetPath) ? null : (HttpResponse)new HttpContent(mime, await File.ReadAllBytesAsync(assetPath));
}
/// <summary>
/// Ensures filenames are legal.
/// </summary>
/// <remarks>
/// Enforcing a set of legal characters in filenames reduces the potential attack surface against the server.
/// </remarks>
/// <returns>Returns a regular expression which checks for invalid characters.</returns>
[GeneratedRegex(@"[^a-zA-Z0-9_\-.()\[\] ]")]
private static partial Regex FilenameChecker();
}

View file

@ -0,0 +1,16 @@
namespace MiniHTTP.Websockets;
public enum CloseStatus : ushort
{
NormalClosure = 1000,
EndpointUnavailable = 1001,
ProtocolError = 1002,
InvalidMessageType = 1003,
Empty = 1005,
AbnormalClosure = 1006,
InvalidPayloadData = 1007,
PolicyViolation = 1008,
MessageTooBig = 1009,
MandatoryExtension = 1010,
InternalServerError = 1011
}

View file

@ -0,0 +1,24 @@
using System.Text;
namespace MiniHTTP.Websockets;
public readonly struct DataFrame
{
public readonly WSOpcode Opcode;
public readonly bool EndOfMessage;
public readonly ArraySegment<byte> Payload;
public DataFrame(WSOpcode opcode, bool endOfMessage, ArraySegment<byte> payload)
{
Opcode = opcode;
EndOfMessage = endOfMessage;
Payload = payload;
}
public string AsString()
{
return Encoding.UTF8.GetString(Payload);
}
}

View file

@ -0,0 +1,12 @@
namespace MiniHTTP.Websockets;
public enum WSOpcode : byte
{
Continuation = 0x0,
Text = 0x1,
Binary = 0x2,
Close = 0x8,
Ping = 0x9,
Pong = 0xA,
}

View file

@ -0,0 +1,257 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Text;
namespace MiniHTTP.Websockets;
/// <summary>
/// A websocket client currently connected to a HTTP server.
/// </summary>
public sealed class Websocket
{
/// <summary>
/// The HTTP request encompassing the websocket.
/// </summary>
public readonly HttpRequest Request;
readonly List<byte> finalPayload = new List<byte>();
internal Websocket(HttpRequest client)
{
Request = client;
}
/// <summary>
/// Reads a data frame from the websocket.
/// </summary>
/// <returns></returns>
/// <exception cref="EndOfStreamException">Thrown when the stream stops unexpectedly.</exception>
/// <exception cref="IOException">Thrown when the client declares a payload which is too large.</exception>
public async Task<DataFrame> Read()
{
var pool = ArrayPool<byte>.Shared;
byte[] recvBuffer = pool.Rent(10);
try
{
while (true)
{
//First byte
if (await Request.Buffer.ReadAsync(recvBuffer.AsMemory(0, 2)) < 2)
throw new EndOfStreamException();
byte firstByte = recvBuffer[0];
bool fin = (firstByte & 1) != 0;
//bool rsv1 = (firstByte & 2) != 0;
//bool rsv2 = (firstByte & 4) != 0;
//bool rsv3 = (firstByte & 8) != 0;
WSOpcode opcode = (WSOpcode)(firstByte & 0b00001111);
//Second byte
byte secondByte = recvBuffer[1];
bool maskEnabled = (secondByte & 0b10000000) != 0;
if (maskEnabled)
secondByte &= 0b01111111;
//Payload length
uint payloadLength;
if (secondByte < 126)
{
payloadLength = secondByte;
}
else
{
if (secondByte == 126)
{
if (await Request.Buffer.ReadAsync(recvBuffer.AsMemory(0, 2)) < 2)
throw new EndOfStreamException();
payloadLength = BinaryPrimitives.ReadUInt16BigEndian(recvBuffer);
}
else if (secondByte == 127)
{
if (await Request.Buffer.ReadAsync(recvBuffer.AsMemory(0, 8)) < 8)
throw new EndOfStreamException();
payloadLength = BinaryPrimitives.ReadUInt32BigEndian(recvBuffer);
}
else
{
throw new Exception("This shouldn't happen");
}
}
if (finalPayload.Count + payloadLength > 100_000)
throw new IOException("Payload too large");
//Mask
byte maskKey1, maskKey2, maskKey3, maskKey4;
if (maskEnabled)
{
if (await Request.Buffer.ReadAsync(recvBuffer.AsMemory(0, 4)) < 4)
throw new EndOfStreamException();
maskKey1 = recvBuffer[0];
maskKey2 = recvBuffer[1];
maskKey3 = recvBuffer[2];
maskKey4 = recvBuffer[3];
}
else
{
maskKey1 = 0;
maskKey2 = 0;
maskKey3 = 0;
maskKey4 = 0;
}
//Payload
byte[] payloadBuffer = pool.Rent((int)payloadLength);
try
{
ArraySegment<byte> payload = new ArraySegment<byte>(payloadBuffer, 0, (int)payloadLength);
if (await Request.Buffer.ReadAsync(payload) < payloadLength)
throw new EndOfStreamException();
//Unmask payload
//TODO: Optimize using unsafe
if (maskEnabled)
{
int index = 0;
while (true)
{
if (index >= payloadLength)
break;
payload[index] = (byte)(payload[index] ^ maskKey1);
index++;
if (index >= payloadLength)
break;
payload[index] = (byte)(payload[index] ^ maskKey2);
index++;
if (index >= payloadLength)
break;
payload[index] = (byte)(payload[index] ^ maskKey3);
index++;
if (index >= payloadLength)
break;
payload[index] = (byte)(payload[index] ^ maskKey4);
index++;
}
}
if (opcode is WSOpcode.Close)
{
await Write(new DataFrame(WSOpcode.Close, true, Array.Empty<byte>()));
Request.Client.Close();
return new DataFrame(WSOpcode.Close, true, FlushPayload());
}
if (opcode is WSOpcode.Text or WSOpcode.Binary)
{
finalPayload.AddRange(payload);
if (fin)
{
return new DataFrame(opcode, fin, FlushPayload());
}
}
if (opcode is WSOpcode.Ping)
{
await Write(new DataFrame(WSOpcode.Pong, true, payload));
}
}
finally
{
pool.Return(payloadBuffer);
}
}
}
finally
{
pool.Return(recvBuffer);
}
}
byte[] FlushPayload()
{
byte[] final = finalPayload.ToArray();
finalPayload.Clear();
return final;
}
public Task Write(bool endOfMessage, byte[] payload)
{
return Write(new DataFrame(WSOpcode.Binary, endOfMessage, payload));
}
public Task Write(bool endOfMessage, string payload)
{
return Write(new DataFrame(WSOpcode.Text, endOfMessage, Encoding.UTF8.GetBytes(payload)));
}
public async Task Write(DataFrame frame)
{
if (!Request.Connected)
return;
var pool = ArrayPool<byte>.Shared;
byte[] writeBuf = pool.Rent(10);
try
{
byte firstByte = 0;
if (frame.EndOfMessage)
firstByte |= 0b10000000;
firstByte |= (byte)((int)frame.Opcode & 0b00001111);
writeBuf[0] = firstByte;
await Request.Buffer.WriteAsync(writeBuf.AsMemory(0, 1));
if (frame.Payload.Count < 126)
{
writeBuf[0] = (byte)frame.Payload.Count;
await Request.Buffer.WriteAsync(writeBuf.AsMemory(0, 1));
}
else
{
if (frame.Payload.Count < ushort.MaxValue)
{
writeBuf[0] = 126;
BinaryPrimitives.WriteUInt16BigEndian(writeBuf.AsSpan(1), (ushort)frame.Payload.Count);
await Request.Buffer.WriteAsync(writeBuf.AsMemory(0, 3));
}
else
{
writeBuf[0] = 127;
BinaryPrimitives.WriteUInt64BigEndian(writeBuf.AsSpan(1), (ulong)frame.Payload.Count);
await Request.Buffer.WriteAsync(writeBuf.AsMemory(0, 9));
}
}
await Request.Buffer.WriteAsync(frame.Payload);
await Request.Flush();
}
finally
{
pool.Return(writeBuf);
}
}
public async void Close(CloseStatus status = CloseStatus.NormalClosure)
{
byte[] closeBuf = new byte[2];
BinaryPrimitives.WriteUInt16BigEndian(closeBuf, (ushort)status);
await Write(new DataFrame(WSOpcode.Close, true, closeBuf));
Request.Client.Close();
}
}

1
README.md Normal file
View file

@ -0,0 +1 @@
Minimal HTTPS 1.1 server library for C#, featuring routing. Does not depend on IIS.