initial commit
This commit is contained in:
commit
33d2c28712
24 changed files with 1288 additions and 0 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*.cs eol=crlf
|
||||||
|
*.txt eol=lf
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.vs
|
||||||
|
.vscode
|
||||||
|
bin
|
||||||
|
obj
|
37
MiniHTTP/HttpContent.cs
Normal file
37
MiniHTTP/HttpContent.cs
Normal 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
18
MiniHTTP/HttpMethod.cs
Normal 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
264
MiniHTTP/HttpRequest.cs
Normal 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
37
MiniHTTP/HttpResponse.cs
Normal 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
187
MiniHTTP/HttpServer.cs
Normal 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
8
MiniHTTP/MiniHTTP.csproj
Normal 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>
|
11
MiniHTTP/Responses/BadRequest.cs
Normal file
11
MiniHTTP/Responses/BadRequest.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
11
MiniHTTP/Responses/Empty.cs
Normal file
11
MiniHTTP/Responses/Empty.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
11
MiniHTTP/Responses/NotFound.cs
Normal file
11
MiniHTTP/Responses/NotFound.cs
Normal 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
11
MiniHTTP/Responses/OK.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
19
MiniHTTP/Responses/Redirect.cs
Normal file
19
MiniHTTP/Responses/Redirect.cs
Normal 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
35
MiniHTTP/Routing/CORS.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
19
MiniHTTP/Routing/EndpointHandler.cs
Normal file
19
MiniHTTP/Routing/EndpointHandler.cs
Normal 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);
|
11
MiniHTTP/Routing/IRouter.cs
Normal file
11
MiniHTTP/Routing/IRouter.cs
Normal 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);
|
||||||
|
}
|
58
MiniHTTP/Routing/ParameterCollection.cs
Normal file
58
MiniHTTP/Routing/ParameterCollection.cs
Normal 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
126
MiniHTTP/Routing/Router.cs
Normal 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
109
MiniHTTP/Routing/Static.cs
Normal 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();
|
||||||
|
}
|
16
MiniHTTP/Websockets/CloseStatus.cs
Normal file
16
MiniHTTP/Websockets/CloseStatus.cs
Normal 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
|
||||||
|
}
|
24
MiniHTTP/Websockets/DataFrame.cs
Normal file
24
MiniHTTP/Websockets/DataFrame.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
12
MiniHTTP/Websockets/WSOpcode.cs
Normal file
12
MiniHTTP/Websockets/WSOpcode.cs
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
namespace MiniHTTP.Websockets;
|
||||||
|
|
||||||
|
public enum WSOpcode : byte
|
||||||
|
{
|
||||||
|
Continuation = 0x0,
|
||||||
|
Text = 0x1,
|
||||||
|
Binary = 0x2,
|
||||||
|
|
||||||
|
Close = 0x8,
|
||||||
|
Ping = 0x9,
|
||||||
|
Pong = 0xA,
|
||||||
|
}
|
257
MiniHTTP/Websockets/Websocket.cs
Normal file
257
MiniHTTP/Websockets/Websocket.cs
Normal 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
1
README.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Minimal HTTPS 1.1 server library for C#, featuring routing. Does not depend on IIS.
|
Loading…
Reference in a new issue