commit a0b2c9f9b1c435478d03caf980467c3bd2d79b8d Author: uwaa Date: Fri Nov 22 06:40:43 2024 +0000 initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..65dad72 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.cs eol=crlf +*.txt eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44e1bbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.vs +.vscode +bin +obj \ No newline at end of file diff --git a/HTTP.Example.sln b/HTTP.Example.sln new file mode 100644 index 0000000..61565f2 --- /dev/null +++ b/HTTP.Example.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35027.167 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HTTP.Example", "HTTP.Example\HTTP.Example.csproj", "{7EAF8960-ABA7-4E12-B9AC-5FE3D0BAB0F6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HTTP", "HTTP\HTTP.csproj", "{67E6FB0A-DB97-4EEE-A371-3BFDFB3F0747}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7EAF8960-ABA7-4E12-B9AC-5FE3D0BAB0F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7EAF8960-ABA7-4E12-B9AC-5FE3D0BAB0F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7EAF8960-ABA7-4E12-B9AC-5FE3D0BAB0F6}.Release|Any CPU.ActiveCfg = Debug|Any CPU + {7EAF8960-ABA7-4E12-B9AC-5FE3D0BAB0F6}.Release|Any CPU.Build.0 = Debug|Any CPU + {67E6FB0A-DB97-4EEE-A371-3BFDFB3F0747}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {67E6FB0A-DB97-4EEE-A371-3BFDFB3F0747}.Debug|Any CPU.Build.0 = Debug|Any CPU + {67E6FB0A-DB97-4EEE-A371-3BFDFB3F0747}.Release|Any CPU.ActiveCfg = Release|Any CPU + {67E6FB0A-DB97-4EEE-A371-3BFDFB3F0747}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {85D6E851-88E0-4C85-9B4F-AF0CB2005BDB} + EndGlobalSection +EndGlobal diff --git a/HTTP.Example/HTTP.Example.csproj b/HTTP.Example/HTTP.Example.csproj new file mode 100644 index 0000000..442fe02 --- /dev/null +++ b/HTTP.Example/HTTP.Example.csproj @@ -0,0 +1,22 @@ + + + Exe + net8.0 + false + enable + Debug + Uwaa.HTTP.Example + HTTPExample + + + + + + + PreserveNewest + + + PreserveNewest + + + diff --git a/HTTP.Example/Program.cs b/HTTP.Example/Program.cs new file mode 100644 index 0000000..2a9336b --- /dev/null +++ b/HTTP.Example/Program.cs @@ -0,0 +1,154 @@ +using System; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using System.Net.Sockets; +using System.Net; +using Uwaa.HTTP.Websockets; +using Uwaa.HTTP.Routing; + +namespace Uwaa.HTTP.Example; + +static class Program +{ + static void Main(string[] args) + { + Console.WriteLine("Loading test certificate"); + X509Certificate cert = X509Certificate.CreateFromCertFile("certs/localhost.pfx"); + + Console.WriteLine("Preparing listeners"); + Router router = CreateRouter(); + HttpServer httpServer = new HttpServer(80, null, router); + HttpServer httpsServer = new HttpServer(443, cert, router); + + httpServer.OnConnectionBegin += LogConnect; + httpServer.OnConnectionEnd += LogDisconnect; + httpsServer.OnConnectionBegin += LogConnect; + httpsServer.OnConnectionEnd += LogDisconnect; + + httpServer.Start(); + httpsServer.Start(); + + Console.WriteLine($"Ready on ports {httpServer.Port} and {httpsServer.Port}"); + + TestClient(); + + Task.Delay(-1).Wait(); + } + + /// + /// Generates the URL router for the HTTP server. + /// + static Router CreateRouter() + { + //The order here matters + Router subpath = new Router(); + subpath.Add("foo", new StaticEndpoint(HttpResponse.OK("A \"foo\" example sub page"))); + subpath.Add("bar", new StaticEndpoint(HttpResponse.OK("A \"bar\" example sub page"))); + + Router router = new Router(); + router.Add(LogRequest); + router.Add(new CORS()); + router.Add("custom", new CustomRoute()); + router.Add("subpath", subpath); + router.Add(new FileEndpoint("www-static")); + router.Add(new FileEndpoint("www-dynamic")); + router.Add("", Root); + router.Add(new StaticEndpoint(HttpResponse.NotFound("File not found"))); + return router; + } + + /// + /// Example HTTP client request. + /// + static async void TestClient() + { + Console.WriteLine("Connecting"); + + //Make request + HttpRequest req = new HttpRequest(HttpMethod.GET, "/test.txt"); + req.Fields.UserAgent = "test (Uwaa.HTTP)"; + req.Fields.Connection = ConnectionType.Close; + + //Send, get response + HttpResponse res = await HttpClient.Request("localhost", true, req); + string resText = res.Content?.AsText ?? "null"; + Console.WriteLine("Got response: " + resText); + } + + /// + /// Logs a new connection to the console. + /// + static void LogConnect(TcpClient client) + { + Console.WriteLine($"{client.Client.RemoteEndPoint} connected"); + } + + /// + /// Logs a disconnection to the console. + /// + static void LogDisconnect(IPEndPoint endpoint) + { + Console.WriteLine($"{endpoint} disconnected"); + } + + /// + /// Logs a request to the console. + /// + static HttpResponse? LogRequest(HttpRequest req, HttpClientInfo info) + { + Console.WriteLine($"{info.Endpoint.Address}: {req.Method} {req.Path}"); + return null; + } + + /// + /// Root endpoint: / + /// + static async Task Root(HttpRequest req, HttpClientInfo info) + { + if (req.IsWebsocket) + return Websocket(req, info); + + byte[] indexFile = await File.ReadAllBytesAsync("www-static/index.htm"); + HttpContent html = new HttpContent(new MIMEType("text", "html"), indexFile); + return HttpResponse.OK(html); + } + + /// + /// Websocket endpoint + /// + static HttpResponse? Websocket(HttpRequest req, HttpClientInfo info) + { + return req.UpgradeToWebsocket(HandleWebsocket, "test"); + } + + static async Task HandleWebsocket(Websocket ws) + { + TimeSpan timeout = TimeSpan.FromMinutes(1); + + DataFrame payload = await ws.Read().WaitAsync(timeout); + if (payload.Opcode != WSOpcode.Close) + { + string result = payload.AsString(); + await ws.Write($"Echoing message: \"{result}\"").WaitAsync(timeout); + } + + return CloseStatus.NormalClosure; + } + + /// + /// Custom route: /custom/{param1}/{param2} + /// + class CustomRoute : RouterBase + { + public override HttpMethod Method => HttpMethod.GET; + public override int Arguments => 2; + + protected override Task GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment path) + { + string param1 = path[0]; + string param2 = path[1]; + return Task.FromResult(HttpResponse.OK($"Variable 1: {param1}\nVariable 2: {param2}")); + } + } +} diff --git a/HTTP.Example/certs/localhost.pfx b/HTTP.Example/certs/localhost.pfx new file mode 100644 index 0000000..bd7ffbe Binary files /dev/null and b/HTTP.Example/certs/localhost.pfx differ diff --git a/HTTP.Example/www-static/favicon.ico b/HTTP.Example/www-static/favicon.ico new file mode 100644 index 0000000..e2688c8 Binary files /dev/null and b/HTTP.Example/www-static/favicon.ico differ diff --git a/HTTP.Example/www-static/index.htm b/HTTP.Example/www-static/index.htm new file mode 100644 index 0000000..40637a5 --- /dev/null +++ b/HTTP.Example/www-static/index.htm @@ -0,0 +1,30 @@ + + + + HTTP Test + + + +

Example website

+ + + \ No newline at end of file diff --git a/HTTP.Example/www-static/test.htm b/HTTP.Example/www-static/test.htm new file mode 100644 index 0000000..f272a14 --- /dev/null +++ b/HTTP.Example/www-static/test.htm @@ -0,0 +1,10 @@ + + + + HTTP Test + + + + Test + + \ No newline at end of file diff --git a/HTTP.Example/www-static/test.txt b/HTTP.Example/www-static/test.txt new file mode 100644 index 0000000..70c379b --- /dev/null +++ b/HTTP.Example/www-static/test.txt @@ -0,0 +1 @@ +Hello world \ No newline at end of file diff --git a/HTTP.Example/www-static/websocket.htm b/HTTP.Example/www-static/websocket.htm new file mode 100644 index 0000000..efd30cd --- /dev/null +++ b/HTTP.Example/www-static/websocket.htm @@ -0,0 +1,28 @@ + + + + HTTP Test + + + +

Websocket test

+
+ + + \ No newline at end of file diff --git a/HTTP.Example/www-static/websocket.js b/HTTP.Example/www-static/websocket.js new file mode 100644 index 0000000..c1e468a --- /dev/null +++ b/HTTP.Example/www-static/websocket.js @@ -0,0 +1,25 @@ +function log(text) { + document.getElementById("log").innerText += text + "\n"; +} + +log("Connecting"); +const ws = new WebSocket(`wss://${location.host}/`, "test"); +function send(text) { + log(">> " + text); + ws.send(text); +} +ws.onerror = (e) => { + log("Error"); + throw e; +}; +ws.onopen = (event) => { + log("Connected"); + send("Hello world"); +}; +ws.onclose = (event) => { + log("Closed"); +}; +ws.onmessage = (event) => { + log("<< " + event.data); + console.log(event.data); +}; \ No newline at end of file diff --git a/HTTP.sln b/HTTP.sln new file mode 100644 index 0000000..ad3d41f --- /dev/null +++ b/HTTP.sln @@ -0,0 +1,29 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35027.167 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HTTP", "HTTP\HTTP.csproj", "{67E6FB0A-DB97-4EEE-A371-3BFDFB3F0747}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7EAF8960-ABA7-4E12-B9AC-5FE3D0BAB0F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7EAF8960-ABA7-4E12-B9AC-5FE3D0BAB0F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7EAF8960-ABA7-4E12-B9AC-5FE3D0BAB0F6}.Release|Any CPU.ActiveCfg = Debug|Any CPU + {7EAF8960-ABA7-4E12-B9AC-5FE3D0BAB0F6}.Release|Any CPU.Build.0 = Debug|Any CPU + {67E6FB0A-DB97-4EEE-A371-3BFDFB3F0747}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {67E6FB0A-DB97-4EEE-A371-3BFDFB3F0747}.Debug|Any CPU.Build.0 = Debug|Any CPU + {67E6FB0A-DB97-4EEE-A371-3BFDFB3F0747}.Release|Any CPU.ActiveCfg = Release|Any CPU + {67E6FB0A-DB97-4EEE-A371-3BFDFB3F0747}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {85D6E851-88E0-4C85-9B4F-AF0CB2005BDB} + EndGlobalSection +EndGlobal diff --git a/HTTP/HTTP.csproj b/HTTP/HTTP.csproj new file mode 100644 index 0000000..3f58778 --- /dev/null +++ b/HTTP/HTTP.csproj @@ -0,0 +1,9 @@ + + + net8.0 + enable + enable + Uwaa.HTTP + Uwaa.HTTP + + diff --git a/HTTP/HttpClient.cs b/HTTP/HttpClient.cs new file mode 100644 index 0000000..19316d0 --- /dev/null +++ b/HTTP/HttpClient.cs @@ -0,0 +1,149 @@ +using System.Net.Security; +using System.Net.Sockets; + +namespace Uwaa.HTTP; + +/// +/// A hypertext transfer protocol client which can fetch content over HTTP, optionally over SSL (HTTPS). +/// +public sealed class HttpClient +{ + /// + /// Performs a single HTTP(S) request and closes. + /// + /// The hostname or IP to send the request to. + /// If true, HTTPS will be used. + /// The request to send. + /// The response from the server. + /// Thrown if the URI is invalid. + public static Task Request(string host, bool secure, HttpRequest req) + { + return Request(host, secure ? 443 : 80, secure, req); + } + + /// + /// Performs a single HTTP(S) request and closes. + /// + /// The hostname or IP to send the request to. + /// The port number to connect to. + /// If true, HTTPS will be used. + /// The request to send. + /// The response from the server. + public static async Task Request(string host, int port, bool secure, HttpRequest request) + { + using TcpClient client = new TcpClient(); + await client.ConnectAsync(host, port); + + Stream innerStream = client.GetStream(); + if (secure) + { + SslStream ssl = new SslStream(client.GetStream()); + await ssl.AuthenticateAsClientAsync(host); + innerStream = ssl; + } + + HttpStream stream = new HttpStream(innerStream); + + //Send request + await request.WriteTo(stream); + + //Read response + return await stream.ReadResponse(); + } + + + /// + /// The host to connect to. + /// + public readonly string Host; + + /// + /// If true, attempt to connect via SSL. + /// + public readonly bool Secure; + + /// + /// The maximum time the socket may be inactive before it is presumed dead and is closed. + /// + public TimeSpan Timeout = TimeSpan.FromSeconds(60); + + TcpClient? client; + HttpStream? stream; + + readonly SemaphoreSlim semaphore; + + public HttpClient(string host, bool secure) + { + Host = host; + Secure = secure; + semaphore = new SemaphoreSlim(1, 1); + } + + /// + /// Queues sending a request to the host and returns the response. + /// + public async Task Fetch(HttpRequest request) + { + await semaphore.WaitAsync(); + try + { + return await FetchInner(request).WaitAsync(Timeout); + } + finally + { + semaphore.Release(); + } + } + + async Task FetchInner(HttpRequest request) + { + bool retried = false; + while (true) + { + if (client == null || !client.Connected || stream == null) + { + client?.Dispose(); + + //New connection + client = new TcpClient(); + await client.ConnectAsync(Host, 443); + + Stream innerStream = client.GetStream(); + if (Secure) + { + SslStream ssl = new SslStream(client.GetStream()); + await ssl.AuthenticateAsClientAsync(Host); + innerStream = ssl; + } + + stream = new HttpStream(innerStream); + } + + try + { + //Send request + await request.WriteTo(stream); + + //Read response + return await stream.ReadResponse(); + } + catch (SocketException e) + { + if (e.SocketErrorCode is SocketError.ConnectionReset or SocketError.ConnectionAborted) + { + if (retried) + throw; + + //Connection down: Dispose stream and retry + stream = null; + retried = true; + continue; + } + else + { + throw; + } + } + } + } +} diff --git a/HTTP/HttpClientInfo.cs b/HTTP/HttpClientInfo.cs new file mode 100644 index 0000000..bde7d49 --- /dev/null +++ b/HTTP/HttpClientInfo.cs @@ -0,0 +1,31 @@ +using System.Net.Sockets; +using System.Net; + +namespace Uwaa.HTTP; + +/// +/// Information about a client which connected to a . +/// +public class HttpClientInfo +{ + /// + /// The TCP client sending this request. + /// + public readonly TcpClient TcpClient; + + /// + /// The IP address and port of the requester. + /// + public readonly IPEndPoint Endpoint; + + /// + /// If true, the request is connected. + /// + public bool Connected => TcpClient.Connected; + + internal HttpClientInfo(TcpClient client, IPEndPoint endpoint) + { + TcpClient = client; + Endpoint = endpoint; + } +} \ No newline at end of file diff --git a/HTTP/HttpContent.cs b/HTTP/HttpContent.cs new file mode 100644 index 0000000..daff290 --- /dev/null +++ b/HTTP/HttpContent.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Uwaa.HTTP; + +/// +/// Content served as the body of a HTTP request or response. +/// +public struct HttpContent +{ + public static implicit operator HttpContent?([NotNullWhen(false)] string? value) + { + return value == null ? null : new HttpContent("text/plain", value); + } + + /// + /// The MIME type of the HTTP content. + /// + public MIMEType Type; + + /// + /// The raw HTTP content body, as bytes. + /// + public byte[] Content; + + /// + /// Converts the contents to a UTF8 string. + /// + public string AsText => Encoding.UTF8.GetString(Content); + + public HttpContent(MIMEType type, string body) + { + Type = type; + Content = Encoding.UTF8.GetBytes(body); + } + + public HttpContent(MIMEType type, byte[] body) + { + Type = type; + Content = body; + } + + public override string ToString() => Type.ToString(); +} diff --git a/HTTP/HttpException.cs b/HTTP/HttpException.cs new file mode 100644 index 0000000..42472a6 --- /dev/null +++ b/HTTP/HttpException.cs @@ -0,0 +1,11 @@ +namespace Uwaa.HTTP; + +/// +/// An exception caused during HTTP reading and parsing. +/// +class HttpException : IOException +{ + public HttpException(string? message, Exception? innerException = null) : base(message, innerException) + { + } +} diff --git a/HTTP/HttpFields.cs b/HTTP/HttpFields.cs new file mode 100644 index 0000000..abd2902 --- /dev/null +++ b/HTTP/HttpFields.cs @@ -0,0 +1,172 @@ +namespace Uwaa.HTTP; + +/// +/// General purpose implementation of . +/// +public class HttpFields +{ + public MIMEType[]? Accept; + + public ConnectionType? Connection; + + public string? Upgrade; + + public string? UserAgent; + + public string? Authorization; + + public string? WebSocketKey; + + public string? WebSocketAccept; + + public string? WebSocketProtocol; + + public string? Location; + + public int? ContentLength; + + public MIMEType? ContentType; + + /// + /// Extra fields to include. + /// + public Dictionary? Misc; + + /// + /// Sets a field. The string will be parsed for non-string fields like Accept. + /// + public virtual string? this[string key] + { + set + { + switch (key.ToLowerInvariant()) + { + case "accept": + Accept = value == null ? null : HttpHelpers.ParseAccept(value); + return; + + case "connection": + { + if (Enum.TryParse(value, true, out ConnectionType conType)) + Connection = conType; + return; + } + + case "upgrade": + Upgrade = value; + return; + + case "user-agent": + UserAgent = value; + return; + + case "authorization": + Authorization = value; + return; + + case "sec-websocket-key": + WebSocketKey = value; + return; + + case "sec-websocket-accept": + WebSocketAccept = value; + return; + + case "sec-websocket-protocol": + WebSocketProtocol = value; + return; + + case "location": + Location = value; + return; + + case "content-length": + { + if (value == null) + { + ContentLength = 0; + } + else + { + if (!int.TryParse(value, out int contentLength)) + throw new HttpException("Invalid Content-Length"); + ContentLength = contentLength; + } + return; + } + + case "content-type": + ContentType = value == null ? (MIMEType?)null: new MIMEType(value); + return; + + default: + if (value == null) + { + Misc?.Remove(key); + } + else + { + Misc ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + Misc[key] = value; + } + return; + } + } + } + + /// + /// Generates strings for the fields and sends them to the provided callback. + /// + public virtual void EmitAll(FieldCallback callback) + { + if (ContentLength.HasValue) + callback("Content-Length", ContentLength.Value.ToString()); + + if (ContentType.HasValue) + callback("Content-Type", ContentType.Value.ToString()); + + if (Accept != null) + callback("Accept", string.Join(", ", Accept)); + + if (Connection.HasValue) + callback("Connection", Connection.Value switch + { + ConnectionType.Close => "close", + ConnectionType.KeepAlive => "keepalive", + ConnectionType.Upgrade => "upgrade", + _ => "close", + }); + + if (UserAgent != null) + callback("User-Agent", UserAgent); + + if (Authorization != null) + callback("Authorization", Authorization); + + if (Upgrade != null) + callback("Upgrade", Upgrade); + + if (Location != null) + callback("Location", Location); + + if (WebSocketKey != null) + callback("Sec-WebSocket-Key", WebSocketKey); + + if (WebSocketAccept != null) + callback("Sec-WebSocket-Accept", WebSocketAccept); + + if (WebSocketProtocol != null) + callback("Sec-WebSocket-Protocol", WebSocketProtocol); + + if (Misc != null) + foreach (var pair in Misc) + callback(pair.Key, pair.Value); + } +} + +public enum ConnectionType +{ + Close, + KeepAlive, + Upgrade, +} \ No newline at end of file diff --git a/HTTP/HttpHelpers.cs b/HTTP/HttpHelpers.cs new file mode 100644 index 0000000..8e35a8a --- /dev/null +++ b/HTTP/HttpHelpers.cs @@ -0,0 +1,66 @@ +namespace Uwaa.HTTP; + +/// +/// Helper methods for HTTP. +/// +public static class HttpHelpers +{ + /// + /// Parses an array of MIME types from an Accept field. + /// + public static MIMEType[] ParseAccept(string accept) + { + int count = 1; + for (int i = 0; i < accept.Length; i++) + if (accept[i] == ',') + count++; + + MIMEType[] result = new MIMEType[count]; + int resultIndex = 0; + int splStart = 0; + int splEnd = 0; + void flush() + { + try + { + result[resultIndex++] = new MIMEType(accept.AsSpan(splStart..(splEnd + 1))); + } + catch (FormatException e) + { + throw new HttpException(e.Message, e); + } + } + for (int i = 0; i < accept.Length; i++) + { + switch (accept[i]) + { + case ';': + flush(); + + //Another stupid idea by the W3C. Not interested in implementing it - skip!!! + while (i < accept.Length && accept[i] != ',') + i++; + splStart = i + 1; + continue; + + case ',': + flush(); + splStart = i + 1; + continue; + + case ' ': + if (splEnd <= splStart) + splStart = i + 1; //Trim start + continue; //Trim end + + default: + splEnd = i; + continue; + } + } + if (splStart < splEnd) + flush(); //Flush remaining + + return result; + } +} diff --git a/HTTP/HttpMethod.cs b/HTTP/HttpMethod.cs new file mode 100644 index 0000000..50bad62 --- /dev/null +++ b/HTTP/HttpMethod.cs @@ -0,0 +1,18 @@ +namespace Uwaa.HTTP; + +/// +/// HTTP method from a HTTP request. +/// +public enum HttpMethod +{ + Any, + GET, + HEAD, + POST, + PUT, + DELETE, + CONNECT, + OPTIONS, + TRACE, + PATCH, +} \ No newline at end of file diff --git a/HTTP/HttpRequest.cs b/HTTP/HttpRequest.cs new file mode 100644 index 0000000..37264f1 --- /dev/null +++ b/HTTP/HttpRequest.cs @@ -0,0 +1,173 @@ +using System.Security.Cryptography; +using System.Text; +using System.Web; +using System.Collections.Specialized; +using Uwaa.HTTP.Websockets; + +namespace Uwaa.HTTP; + +/// +/// Contains the method, path, query, headers, and content of a HTTP request. +/// +public class HttpRequest +{ + /// + /// The HTTP method of the request. + /// + public HttpMethod Method = HttpMethod.Any; + + /// + /// The HTTP path being requested. + /// + public string Path = string.Empty; + + /// + /// Additional paramters in the path of the request. + /// + public NameValueCollection? Query; + + /// + /// HTTP header fields included in the request. + /// + public HttpFields Fields; + + /// + /// The body of the HTTP request, if any. + /// + public HttpContent? Content; + + /// + /// If true, the connection is requesting to be upgrading to a websocket. + /// + public bool IsWebsocket => Fields.Upgrade == "websocket"; + + /// + /// Whether or not the request is valid. + /// + public bool Valid => Method != HttpMethod.Any && Path.StartsWith('/'); + + internal HttpRequest() + { + Fields = new HttpFields(); + } + + public HttpRequest(HttpMethod method, string path, HttpContent? content = null) : this(method, path, new HttpFields(), content) + { + } + + public HttpRequest(HttpMethod method, string path, HttpFields fields, HttpContent? content = null) + { + Method = method; + Fields = fields; + Content = content; + + string[] pathParts = path.Split('?', 2, StringSplitOptions.RemoveEmptyEntries); + Path = pathParts[0]; + Query = HttpUtility.ParseQueryString(pathParts.Length > 1 ? pathParts[1] : string.Empty); + } + + /// + /// Returns true if the client is accepting the provided type, otherwise false. + /// + public bool CanAccept(MIMEType type) + { + if (Fields.Accept == null) + return true; + + for (int i = 0; i < Fields.Accept.Length; i++) + if (Fields.Accept[i].Match(type)) + return true; + + return false; + } + + internal async Task WriteTo(HttpStream stream) + { + StringBuilder sb = new StringBuilder(); + + sb.Append(Method.ToString()); + sb.Append(' '); + sb.Append(Path); + if (Query != null && Query.Count > 0) + { + sb.Append('?'); + for (int i = 0; i < Query.Count; i++) + { + if (i > 0) + sb.Append('&'); + sb.Append(HttpUtility.UrlEncode(Query.GetKey(i))); + sb.Append('='); + sb.Append(HttpUtility.UrlEncode(Query[i])); + } + + } + sb.Append(" HTTP/1.1\r\n"); + + void writeField(string key, string value) + { + sb.Append(key); + sb.Append(": "); + sb.Append(value); + sb.Append("\r\n"); + } + + if (Content.HasValue) + { + Fields.ContentLength = Content.Value.Content.Length; + Fields.ContentType = Content.Value.Type.ToString(); + } + else + { + Fields.ContentLength = null; + Fields.ContentType = null; + } + + Fields.EmitAll(writeField); + sb.Append("\r\n"); + + await stream.Write(Encoding.ASCII.GetBytes(sb.ToString())); + + if (Content.HasValue) + await stream.Write(Content.Value.Content); + + await stream.Flush(); + } + + /// + /// Generates a response which upgrades the connection to a websocket. + /// + /// The websocket execution function to call. + /// Subprotocols which can be accepted. If null or empty, any protocol will be accepted. + /// Returns a response. + /// + /// If an upgrade has not been requested or no subprotocol can be negotiated, this will return null. + /// + public SwitchingProtocols? UpgradeToWebsocket(WebsocketHandler callback, params string[]? protocols) + { + if (Fields.WebSocketKey == null) + return null; + + //Subprotocol negotiation + string? chosenProtocol = null; + string? requestedProtocols = Fields.WebSocketProtocol; + if (requestedProtocols != null && protocols != null && protocols.Length > 0) + { + foreach (string requested in requestedProtocols.ToLower().Split(',', StringSplitOptions.TrimEntries)) + { + foreach (string supported in protocols) + { + if (requested.Equals(supported, StringComparison.InvariantCultureIgnoreCase)) + { + chosenProtocol = supported; + goto a; + } + } + } + return null; + } + + a: + string acceptKey = Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(Fields.WebSocketKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))); + return new SwitchingProtocols(acceptKey, chosenProtocol, callback); + } +} diff --git a/HTTP/HttpResponse.cs b/HTTP/HttpResponse.cs new file mode 100644 index 0000000..a39e71b --- /dev/null +++ b/HTTP/HttpResponse.cs @@ -0,0 +1,120 @@ +using System.Text; +using Uwaa.HTTP.Websockets; + +namespace Uwaa.HTTP; + +public class SwitchingProtocols : HttpResponse +{ + /// + /// Called once a HTTP request is upgrade to a websocket. + /// + public readonly WebsocketHandler Callback; + + internal SwitchingProtocols(string acceptKey, string? chosenProtocol, WebsocketHandler callback) : base(101, "Switching Protocols", new HttpFields()) + { + Fields.WebSocketAccept = acceptKey; + Fields.WebSocketProtocol = chosenProtocol; + Fields.Upgrade = "websocket"; + Fields.Connection = ConnectionType.Upgrade; + Callback = callback; + } +} + +/// +/// Contains the status code, status message, fields, and content of a HTTP response. +/// +public class HttpResponse +{ + public static HttpResponse OK(HttpContent? content) => new HttpResponse(200, "OK", content); + + public static HttpResponse Redirect(string location) => new HttpResponse(301, "Redirect", new HttpFields() { Location = location }); + + public static HttpResponse BadRequest(HttpContent? content) => new HttpResponse(400, "Bad request", content); + + public static HttpResponse NotFound(HttpContent? content) => new HttpResponse(404, "Not found", content); + + public static HttpResponse NotAcceptable(HttpContent? content) => new HttpResponse(406, "Not acceptable", content); + + public static HttpResponse InternalServerError(string location) => new HttpResponse(500, "Internal server error"); + + public static implicit operator HttpResponse(HttpContent value) + { + return OK(value); + } + + public static implicit operator HttpResponse(HttpContent? value) + { + return value == null ? NotFound(value) : OK(value); + } + + public readonly int StatusCode; + + public readonly string StatusMessage; + + /// + /// HTTP header fields included in the response. + /// + public readonly HttpFields Fields; + + /// + /// The body of the HTTP body, if any. + /// + public readonly HttpContent? Content; + + public HttpResponse(int statusCode, string statusMessage, HttpContent? body = null) : this(statusCode, statusMessage, new HttpFields(), body) + { + } + + public HttpResponse(int statusCode, string statusMessage, HttpFields fields, HttpContent? body = null) + { + StatusCode = statusCode; + StatusMessage = statusMessage; + Fields = fields; + Content = body; + } + + internal async Task WriteTo(HttpStream stream) + { + if (StatusCode == 0) + return; + + StringBuilder sb = new StringBuilder(); + + void writeField(string name, string value) + { + sb.Append(name); + sb.Append(": "); + sb.Append(value); + sb.Append("\r\n"); + } + + sb.Append("HTTP/1.1 "); + sb.Append(StatusCode); + sb.Append(' '); + sb.Append(StatusMessage); + sb.Append("\r\n"); + + if (Content.HasValue) + { + Fields.ContentLength = Content.Value.Content.Length; + Fields.ContentType = Content.Value.Type.ToString(); + } + else + { + Fields.ContentLength = null; + Fields.ContentType = null; + } + + Fields.EmitAll(writeField); + sb.Append("\r\n"); + + await stream.Write(Encoding.ASCII.GetBytes(sb.ToString())); + + if (Content.HasValue) + await stream.Write(Content.Value.Content); + + await stream.Flush(); + } +} + +public delegate void FieldCallback(string name, string value); \ No newline at end of file diff --git a/HTTP/HttpServer.cs b/HTTP/HttpServer.cs new file mode 100644 index 0000000..3b21370 --- /dev/null +++ b/HTTP/HttpServer.cs @@ -0,0 +1,234 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using Uwaa.HTTP.Routing; +using Uwaa.HTTP.Websockets; + +namespace Uwaa.HTTP; + +/// +/// A hypertext transfer protocol server which can listen on a port and deliver content over HTTP, optionally over SSL (HTTPS). +/// +public sealed class HttpServer +{ + /// + /// The router to be used to find and serve content to clients. + /// + public readonly RouterBase Router; + + /// + /// If non-null, all connections will be secured via SSL using this certificate. + /// + public readonly X509Certificate? Certificate; + + /// + /// Maximum number of connections per IP before new connections are rejected. + /// + /// + /// Prevents Slow Loris attacks. + /// + public int MaxConnections = 20; + + /// + /// The port the server will listen on. + /// + public int Port => ((IPEndPoint)listener.LocalEndpoint).Port; + + /// + /// Called when a client establishes a connection with the server. + /// + public event Action? OnConnectionBegin; + + /// + /// Called when a connection has terminated. + /// + public event Action? OnConnectionEnd; + + /// + /// Called when a request has been served a response. + /// + public event Action? OnResponse; + + /// + /// The maximum time the socket may be inactive before it is presumed dead and closed. + /// + public TimeSpan Timeout = TimeSpan.FromSeconds(20); + + readonly Dictionary IPCounts = new Dictionary(); + + readonly SemaphoreSlim IPCountsLock = new SemaphoreSlim(1, 1); + + readonly TcpListener listener; + + public HttpServer(int port, X509Certificate? certificate, RouterBase router) + { + if (OperatingSystem.IsWindows() && certificate is X509Certificate2 cert) + { + //Hack because SslStream is stupid on windows + certificate = new X509Certificate2(cert.Export(X509ContentType.Pfx)); + } + + listener = new TcpListener(IPAddress.Any, port); + Certificate = certificate; + Router = router; + } + + /// + /// Begins listening for new connections. + /// + public async void Start() + { + listener.Start(); + + while (true) + HandleClient(await listener.AcceptTcpClientAsync()); + } + + async void HandleClient(TcpClient client) + { + OnConnectionBegin?.Invoke(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 + Stream stream = client.GetStream(); + client.LingerState = new LingerOption(true, 5); + + if (Certificate != null) + { + //Pass through SSL stream + SslStream ssl = new SslStream(stream); + await ssl.AuthenticateAsServerAsync(Certificate).WaitAsync(Timeout); + stream = ssl; + } + + //HTTP request-response loop + while (client.Connected) + { + HttpStream httpStream = new HttpStream(stream); + try + { + HttpClientInfo clientInfo = new HttpClientInfo(client, endpoint); + HttpRequest req = await httpStream.ReadRequest().WaitAsync(Timeout); + + //Parse path + ArraySegment pathSpl = req.Path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (pathSpl.Count == 0) + { + pathSpl = new string[] { string.Empty }; + } + else + { + for (int i = 0; i < pathSpl.Count; i++) + pathSpl[i] = WebUtility.UrlDecode(pathSpl[i]); + } + + //Execute + HttpResponse? response = (await Router.GetResponse(req, clientInfo, pathSpl)) ?? HttpResponse.NotFound("Router produced no response"); + + OnResponse?.Invoke(req, response); + await response.WriteTo(httpStream).WaitAsync(Timeout); + + if (response is SwitchingProtocols swp) + { + //Create and run websocket + WebsocketRemote ws = new WebsocketRemote(req, clientInfo, httpStream, swp.Fields.WebSocketProtocol); + CloseStatus closeStatus = await swp.Callback(ws); + ws.Close(closeStatus); + break; //Close + } + else + { + if (response.StatusCode is not >= 200 or not < 300 || (req.Fields.Connection == ConnectionType.Close)) + break; //Close + } + } + catch (HttpException e) + { + try + { + await new HttpResponse(400, e.Message).WriteTo(httpStream).WaitAsync(Timeout); + } + catch { } + break; + } + catch (TimeoutException) + { + //Timeout + break; + } + catch (IOException) + { + //Disconnect + throw; + } + catch (SocketException) + { + //Disconnect + throw; + } + catch (Exception) + { + await new HttpResponse(500, "Internal server error").WriteTo(httpStream).WaitAsync(Timeout); + throw; + } + } + } + catch (Exception) + { + //Swallow exceptions to prevent the server from crashing. + //When debugging, use a debugger to break on exceptions. + } + finally + { + client.Close(); + try + { + await IPCountsLock.WaitAsync(); + if (IPCounts[endpoint.Address] <= 1) + { + IPCounts.Remove(endpoint.Address); + } + else + { + IPCounts[endpoint.Address]--; + } + } + finally + { + IPCountsLock.Release(); + } + + OnConnectionEnd?.Invoke(endpoint); + } + } +} diff --git a/HTTP/HttpStream.cs b/HTTP/HttpStream.cs new file mode 100644 index 0000000..2d39af7 --- /dev/null +++ b/HTTP/HttpStream.cs @@ -0,0 +1,256 @@ +using System.Buffers; +using System.Collections.Specialized; +using System.Net.Sockets; +using System.Text; +using System.Web; + +namespace Uwaa.HTTP; + +class HttpStream : IDisposable +{ + /// + /// The underlying TCP stream. + /// + readonly Stream Stream; + + /// + /// The read/write buffer. + /// + readonly BufferedStream Buffer; + + /// + /// Text decoder. + /// + readonly Decoder Decoder; + + public HttpStream(Stream stream) : base() + { + Stream = stream; + Buffer = new BufferedStream(stream); + Decoder = Encoding.ASCII.GetDecoder(); + } + + public async ValueTask ReadLine() + { + const int maxChars = 4096; + byte[] dataBuffer = ArrayPool.Shared.Rent(1); + char[] charBuffer = ArrayPool.Shared.Rent(maxChars); + try + { + int charBufferIndex = 0; + while (true) + { + if (await Buffer.ReadAsync(dataBuffer.AsMemory(0, 1)) == 0) + if (charBufferIndex == 0) + throw new SocketException((int)SocketError.ConnectionReset); + else + break; + + if (charBufferIndex >= maxChars) + throw new HttpException("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); + } + catch (IOException e) + { + if (e.InnerException is SocketException se) + throw se; + else + throw; + } + finally + { + //Clearing the array is unnecessary but it is good security just in case. + ArrayPool.Shared.Return(charBuffer, true); + ArrayPool.Shared.Return(dataBuffer); + } + } + + public ValueTask Read(Memory buffer) + { + try + { + return Buffer.ReadAsync(buffer); + } + catch (IOException e) + { + if (e.InnerException is SocketException se) + throw se; + else + throw; + } + } + + + public ValueTask Write(string text) + { + byte[] data = Encoding.ASCII.GetBytes(text); + return Buffer.WriteAsync(data); + } + + public ValueTask WriteLine(string text) + { + return Write(text + "\r\n"); + } + + public ValueTask WriteLine() + { + return Write("\r\n"); + } + + public ValueTask Write(ReadOnlyMemory bytes) + { + try + { + return Buffer.WriteAsync(bytes); + } + catch (IOException e) + { + if (e.InnerException is SocketException se) + throw se; + else + throw; + } + } + + public async Task Flush() + { + try + { + await Buffer.FlushAsync(); + await Stream.FlushAsync(); + } + catch (IOException e) + { + if (e.InnerException is SocketException se) + throw se; + else + throw; + } + } + + + public async ValueTask ReadFields() + { + HttpFields fields = new HttpFields(); + while (true) + { + string? headerStr = await ReadLine(); + + if (string.IsNullOrWhiteSpace(headerStr)) + break; //End of headers + + if (fields.Misc != null && fields.Misc.Count >= 30) + throw new HttpException("Too many headers"); + + int splitPoint = headerStr.IndexOf(':'); + if (splitPoint == -1) + throw new HttpException("A header is invalid"); + + string name = headerStr.Remove(splitPoint).Trim(); + string value = headerStr.Substring(splitPoint + 1).Trim(); + fields[name] = value; + } + return fields; + } + + public async ValueTask ReadContent(HttpFields headers) + { + if (!headers.ContentLength.HasValue) + return null; + + if (!headers.ContentType.HasValue) + throw new HttpException("Content length was sent but no content type"); + + if (headers.ContentLength.Value > 10_000_000) + throw new HttpException("Too much content (max: 10 MB)"); + + byte[] data = new byte[headers.ContentLength.Value]; + await Read(data); + return new HttpContent(headers.ContentType.Value, data); + } + + public async ValueTask<(HttpMethod Method, string Path, NameValueCollection Query)> ReadRequestHeader() + { + //Read initial header + string header = await ReadLine(); + + string[] parts = header.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) //breaks specification, must require 3, but impl genuinely only needs 2 + throw new HttpException("Invalid initial header"); + + //Method + if (!Enum.TryParse(parts[0], true, out HttpMethod method)) + throw new HttpException("Unknown HTTP method"); + + //Path and query + string[] pathParts = parts[1].Split('?', 2, StringSplitOptions.RemoveEmptyEntries); + string path = pathParts[0].Replace("\\", "/"); + NameValueCollection query = HttpUtility.ParseQueryString(pathParts.Length > 1 ? pathParts[1] : string.Empty); + + return (method, path, query); + } + + public async ValueTask<(int Code, string Message)> ReadResponseHeader() + { + string responseHeader = await ReadLine(); + string[] parts = responseHeader.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 3) + throw new HttpException("Invalid initial header"); + + if (!parts[0].Equals("HTTP/1.0", StringComparison.OrdinalIgnoreCase) && !parts[0].Equals("HTTP/1.1", StringComparison.OrdinalIgnoreCase)) + throw new HttpException("Unsupported HTTP version"); + + if (!int.TryParse(parts[1], out int statusCode)) + throw new HttpException("Invalid status code"); + + return (statusCode, parts[2]); + } + + public async Task ReadRequest() + { + try + { + (HttpMethod method, string path, NameValueCollection query) = await ReadRequestHeader(); + HttpFields fields = await ReadFields(); + HttpContent? content = await ReadContent(fields); + return new HttpRequest(method, path, fields, content); + } + catch (FormatException e) + { + throw new HttpException(e.Message, e); + } + } + + public async Task ReadResponse() + { + try + { + (int statusCode, string statusMessage) = await ReadResponseHeader(); + HttpFields fields = await ReadFields(); + HttpContent? content = await ReadContent(fields); + return new HttpResponse(statusCode, statusMessage, fields, content); + } + catch (FormatException e) + { + throw new HttpException(e.Message, e); + } + } + + /// + /// Disposes the underlying stream. + /// + public void Dispose() + { + ((IDisposable)Stream).Dispose(); + } +} diff --git a/HTTP/MIMEType.cs b/HTTP/MIMEType.cs new file mode 100644 index 0000000..415b1a0 --- /dev/null +++ b/HTTP/MIMEType.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Uwaa.HTTP; + +/// +/// Represents a Multipurpose Internet Mail Extensions type, which indicates the nature and format of a document/file/chunk of data. +/// +[JsonConverter(typeof(MIMETypeConverter))] +public readonly record struct MIMEType +{ + public static explicit operator string(MIMEType type) => type.ToString(); + + public static implicit operator MIMEType(string type) => new MIMEType(type); + + /// + /// The first part of the MIME type, representing the general category into which the data type falls. + /// + public readonly string? Type { get; init; } + + /// + /// The second part of the MIME type, identifying the exact kind of data the MIME type represents. + /// + public readonly string? Subtype { get; init; } + + /// + /// Parses a MIME type string. + /// + /// The string to parse. + /// Thrown if MIME type is missing a subtype. + public MIMEType(ReadOnlySpan text) + { + if (text == "*/*") + { + Type = null; + Subtype = null; + return; + } + + int spl = text.IndexOf('/'); + if (spl == -1) + { + //MIME types need a subtype to be valid. + throw new FormatException("The provided MIME type is missing a subtype."); + } + + if (spl == 1 && text[0] == '*') + Type = null; + else + Type = new string(text[..spl]); + + if (spl == text.Length - 2 && text[^1] == '*') + Subtype = null; + else + Subtype = new string(text[(spl + 1)..]); + } + + /// + /// Constructs a MIME type from its two components. + /// + public MIMEType(string? type, string? subType) + { + Type = type; + Subtype = subType; + } + + /// + /// Determines if the given MIME type matches the pattern specified by this MIME type. + /// + public readonly bool Match(MIMEType type) + { + return (Type == null || Type.Equals(type.Type, StringComparison.OrdinalIgnoreCase)) && (Subtype == null || Subtype.Equals(type.Subtype, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Generates a string for the MIME type. + /// + public override readonly string ToString() => $"{Type ?? "*"}/{Subtype ?? "*"}"; +} + +class MIMETypeConverter : JsonConverter +{ + public sealed override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(MIMEType); + + public override MIMEType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? str = reader.GetString(); + if (str == null) + throw new JsonException("Cannot read MIME type"); + + return new MIMEType(str); + } + + public override void Write(Utf8JsonWriter writer, MIMEType status, JsonSerializerOptions options) + { + writer.WriteStringValue(status.ToString()); + } +} \ No newline at end of file diff --git a/HTTP/README.md b/HTTP/README.md new file mode 100644 index 0000000..7865246 --- /dev/null +++ b/HTTP/README.md @@ -0,0 +1 @@ +Minimal HTTPS 1.1 server and client library for C#, featuring routing. Does not depend on IIS. \ No newline at end of file diff --git a/HTTP/Routing/CORS.cs b/HTTP/Routing/CORS.cs new file mode 100644 index 0000000..a1290e5 --- /dev/null +++ b/HTTP/Routing/CORS.cs @@ -0,0 +1,33 @@ +namespace Uwaa.HTTP.Routing; + +/// +/// Handles CORS preflight requests. +/// +public class CORS : RouterBase +{ + public override HttpMethod Method => HttpMethod.OPTIONS; + + public override int Arguments => 0; + + protected override Task GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment path) + { + return Task.FromResult(new HttpResponse(200, "OK", PreflightResponseFields.Singleton)); + } +} + +class PreflightResponseFields : HttpFields +{ + public static readonly PreflightResponseFields Singleton = new PreflightResponseFields(); + + PreflightResponseFields() + { + } + + public override void EmitAll(FieldCallback callback) + { + base.EmitAll(callback); + callback("Access-Control-Allow-Origin", "*"); + callback("Methods", "GET,HEAD,POST,PUT,DELETE,CONNECT,OPTIONS,TRACE,PATCH"); + callback("Vary", "Origin"); + } +} \ No newline at end of file diff --git a/HTTP/Routing/FileEndpoint.cs b/HTTP/Routing/FileEndpoint.cs new file mode 100644 index 0000000..070d4de --- /dev/null +++ b/HTTP/Routing/FileEndpoint.cs @@ -0,0 +1,107 @@ +using System.Text.RegularExpressions; + +namespace Uwaa.HTTP.Routing; + +/// +/// Special router which serves static files from the filesystem. +/// +public partial class FileEndpoint : RouterBase +{ + static MIMEType GuessMIMEType(string extension) + { + return extension switch + { + ".txt" => new("text", "plain"), + ".htm" or "html" => new("text", "html"), + ".js" => new("text", "javascript"), + ".css" => new("text", "css"), + ".csv" => new("text", "csv"), + + ".bin" => new("application", "octet-stream"), + ".zip" => new("application", "zip"), + ".7z" => new("application", "x-7z-compressed"), + ".gz" => new("application", "gzip"), + ".xml" => new("application", "xml"), + ".pdf" => new("application", "pdf"), + ".json" => new("application", "json"), + + ".bmp" => new("image", "bmp"), + ".png" => new("image", "png"), + ".jpg" or "jpeg" => new("image", "jpeg"), + ".gif" => new("image", "gif"), + ".webp" => new("image", "webp"), + ".svg" => new("image", "svg+xml"), + ".ico" => new("image", "vnd.microsoft.icon"), + + ".mid" or ".midi" => new("audio", "midi"), + ".mp3" => new("audio", "mpeg"), + ".ogg" or ".oga" or ".opus" => new("audio", "ogg"), + ".wav" => new("audio", "wav"), + ".weba" => new("audio", "webm"), + + ".webm" => new("video", "webm"), + ".mp4" => new("video", "mp4"), + ".mpeg" => new("video", "mpeg"), + ".ogv" => new("video", "ogg"), + + ".otf" => new("font", "otf"), + ".ttf" => new("font", "ttf"), + ".woff" => new("font", "woff"), + ".woff2" => new("font", "woff2"), + + _ => new("application", "octet-stream"), //Unknown + }; + } + + /// + /// The source directory from which assets should be served. + /// + public string Directory; + + public override HttpMethod Method => HttpMethod.GET; + + public override int Arguments => 1; + + public FileEndpoint(string directory) + { + Directory = directory; + } + + protected override async Task GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment path) + { + string? asset = path[0]; + if (string.IsNullOrWhiteSpace(asset)) + return null; + + if (FilenameChecker().IsMatch(asset)) + return HttpResponse.BadRequest("Illegal chars in asset path"); + + string assetPath = $"{Directory}/{asset}"; + FileInfo fileInfo = new FileInfo(assetPath); + MIMEType mime; + if (string.IsNullOrEmpty(fileInfo.Extension)) + { + mime = new MIMEType("text", "html"); + assetPath += ".htm"; + } + else + { + mime = GuessMIMEType(fileInfo.Extension); + } + + if (!File.Exists(assetPath)) + return null; + + return new HttpContent(mime, await File.ReadAllBytesAsync(assetPath)); + } + + /// + /// Ensures filenames are legal. + /// + /// + /// Enforcing a set of legal characters in filenames reduces the potential attack surface against the server. + /// + /// Returns a regular expression which checks for invalid characters. + [GeneratedRegex(@"[^a-zA-Z0-9_\-.()\[\] ]")] + private static partial Regex FilenameChecker(); +} diff --git a/HTTP/Routing/FuncEndpoint.cs b/HTTP/Routing/FuncEndpoint.cs new file mode 100644 index 0000000..9dc4489 --- /dev/null +++ b/HTTP/Routing/FuncEndpoint.cs @@ -0,0 +1,24 @@ +namespace Uwaa.HTTP.Routing; + +public delegate Task FuncEndpointDelegateAsync(HttpRequest req, HttpClientInfo info); + +public delegate HttpResponse? FuncEndpointDelegate(HttpRequest req, HttpClientInfo info); + +public class FuncEndpoint : RouterBase +{ + public FuncEndpointDelegateAsync Function; + + public FuncEndpoint(FuncEndpointDelegateAsync function) + { + Function = function; + } + + public override HttpMethod Method => HttpMethod.Any; + + public override int Arguments => 0; + + protected override Task GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment path) + { + return Function(req, info); + } +} diff --git a/HTTP/Routing/Router.cs b/HTTP/Routing/Router.cs new file mode 100644 index 0000000..1e7dd10 --- /dev/null +++ b/HTTP/Routing/Router.cs @@ -0,0 +1,70 @@ +namespace Uwaa.HTTP.Routing; + +/// +/// Selects one of multiple possible routers. +/// +public class Router : RouterBase +{ + public record Route(string? Key, RouterBase Router); + + public readonly List Routes = new List(); + + public override HttpMethod Method => HttpMethod.Any; + + public override int Arguments => 0; + + protected override async Task GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment path) + { + string next = path.Count == 0 ? string.Empty : path[0]; + foreach (Route route in Routes) + { + if (route.Key == next || route.Key == null) + { + HttpResponse? resp = await route.Router.GetResponse(req, info, route.Key == null || path.Count == 0 ? path : path[1..]); + if (resp != null) + return resp; + } + } + return null; + } + + /// + /// Adds a route to the router on the given sub path. + /// + public void Add(string? key, RouterBase router) => Routes.Add(new Route(key, router)); + + /// + /// Adds a route to the router. + /// + public void Add(RouterBase router) => Add(null, router); + + /// + /// Adds an endpoint to the router on the given sub path. + /// + public void Add(string? key, FuncEndpointDelegateAsync func) => Routes.Add(new Route(key, new FuncEndpoint(func))); + + /// + /// Adds a wildcard endpoint to the router. + /// + public void Add(FuncEndpointDelegateAsync func) => Add(null, func); + + /// + /// Adds an endpoint to the router on the given sub path. + /// + public void Add(string? key, FuncEndpointDelegate func) => Add(key, (req, info) => Task.FromResult(func(req, info))); + + /// + /// Adds a wildcard endpoint to the router. + /// + public void Add(FuncEndpointDelegate func) => Add(null, func); + + /// + /// Adds a static response to the router on the given sub path. + /// + public void Add(string? key, HttpResponse response) => Routes.Add(new Route(key, new StaticEndpoint(response))); + + /// + /// Adds a wildcard static response to the router. + /// + public void Add(HttpResponse response) => Add(null, response); +} diff --git a/HTTP/Routing/RouterBase.cs b/HTTP/Routing/RouterBase.cs new file mode 100644 index 0000000..5417b20 --- /dev/null +++ b/HTTP/Routing/RouterBase.cs @@ -0,0 +1,38 @@ +namespace Uwaa.HTTP.Routing; + +/// +/// Parses a path and generates or acquires a response. +/// +public abstract class RouterBase +{ + /// + /// Which HTTP method the router will respond to. + /// + public abstract HttpMethod Method { get; } + + /// + /// The minimum number of path segments required to produce a response. + /// + public abstract int Arguments { get; } + + public Task GetResponse(HttpRequest req, HttpClientInfo info, ArraySegment path) + { + HttpMethod method = Method; + if (method != HttpMethod.Any && req.Method != method) + return Task.FromResult(null); + + int arguments = Arguments; + if (path.Count < arguments) + return Task.FromResult(null); + + return GetResponseInner(req, info, path); + } + + /// + /// Inline method for routing. + /// + /// The request for which the response is being generated. + /// Information about the client. + /// The path segments relevant to the router. + protected abstract Task GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment path); +} diff --git a/HTTP/Routing/StaticEndpoint.cs b/HTTP/Routing/StaticEndpoint.cs new file mode 100644 index 0000000..f388edc --- /dev/null +++ b/HTTP/Routing/StaticEndpoint.cs @@ -0,0 +1,20 @@ +namespace Uwaa.HTTP.Routing; + +public class StaticEndpoint : RouterBase +{ + public HttpResponse Response; + + public StaticEndpoint(HttpResponse response) + { + Response = response; + } + + public override HttpMethod Method => HttpMethod.GET; + + public override int Arguments => 0; + + protected override Task GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment path) + { + return Task.FromResult(Response)!; + } +} diff --git a/HTTP/Websockets/CloseStatus.cs b/HTTP/Websockets/CloseStatus.cs new file mode 100644 index 0000000..268fca2 --- /dev/null +++ b/HTTP/Websockets/CloseStatus.cs @@ -0,0 +1,16 @@ +namespace Uwaa.HTTP.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 +} \ No newline at end of file diff --git a/HTTP/Websockets/DataFrame.cs b/HTTP/Websockets/DataFrame.cs new file mode 100644 index 0000000..952ee5c --- /dev/null +++ b/HTTP/Websockets/DataFrame.cs @@ -0,0 +1,24 @@ +using System.Text; + +namespace Uwaa.HTTP.Websockets; + +public readonly struct DataFrame +{ + public readonly WSOpcode Opcode; + + public readonly bool EndOfMessage; + + public readonly ArraySegment Payload; + + public DataFrame(WSOpcode opcode, bool endOfMessage, ArraySegment payload) + { + Opcode = opcode; + EndOfMessage = endOfMessage; + Payload = payload; + } + + public string AsString() + { + return Encoding.UTF8.GetString(Payload); + } +} diff --git a/HTTP/Websockets/WSOpcode.cs b/HTTP/Websockets/WSOpcode.cs new file mode 100644 index 0000000..7103e65 --- /dev/null +++ b/HTTP/Websockets/WSOpcode.cs @@ -0,0 +1,12 @@ +namespace Uwaa.HTTP.Websockets; + +public enum WSOpcode : byte +{ + Continuation = 0x0, + Text = 0x1, + Binary = 0x2, + + Close = 0x8, + Ping = 0x9, + Pong = 0xA, +} diff --git a/HTTP/Websockets/Websocket.cs b/HTTP/Websockets/Websocket.cs new file mode 100644 index 0000000..b54db43 --- /dev/null +++ b/HTTP/Websockets/Websocket.cs @@ -0,0 +1,289 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Text; + +namespace Uwaa.HTTP.Websockets; + +/// +/// A websocket wrapper over a HTTP stream. +/// +public class Websocket +{ + /// + /// The chosen sub-protocol negotiated with the remote endpoint. + /// + public readonly string? SubProtocol; + + internal readonly HttpStream Stream; + + readonly List finalPayload = new List(); + WSOpcode currentOpcode; + + internal Websocket(HttpStream stream, string? subProtocol) + { + Stream = stream; + SubProtocol = subProtocol; + } + + /// + /// Reads a data frame from the websocket. + /// + /// + /// Thrown when the stream stops unexpectedly. + /// Thrown when the client declares a payload which is too large. + public async Task Read() + { + var pool = ArrayPool.Shared; + byte[] recvBuffer = pool.Rent(10); + try + { + while (true) + { + //First byte + if (await Stream.Read(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 Stream.Read(recvBuffer.AsMemory(0, 2)) < 2) + throw new EndOfStreamException(); + + payloadLength = BinaryPrimitives.ReadUInt16BigEndian(recvBuffer); + } + else if (secondByte == 127) + { + if (await Stream.Read(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 Stream.Read(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 payload = new ArraySegment(payloadBuffer, 0, (int)payloadLength); + if (await Stream.Read(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++; + } + } + + switch (opcode) + { + case WSOpcode.Close: + await Write(new DataFrame(WSOpcode.Close, true, Array.Empty())); + return new DataFrame(WSOpcode.Close, true, FlushPayload()); + + case WSOpcode.Continuation: + case WSOpcode.Text: + case WSOpcode.Binary: + { + if (opcode is WSOpcode.Text or WSOpcode.Binary) + currentOpcode = opcode; + + finalPayload.AddRange(payload); + + if (fin) + return new DataFrame(currentOpcode, fin, FlushPayload()); + else + break; + } + + case WSOpcode.Ping: + await Write(new DataFrame(WSOpcode.Pong, true, payload)); + break; + } + } + finally + { + pool.Return(payloadBuffer); + } + } + } + finally + { + pool.Return(recvBuffer); + } + } + + byte[] FlushPayload() + { + byte[] final = finalPayload.ToArray(); + finalPayload.Clear(); + return final; + } + + public Task Write(byte[] payload) + { + return Write(new DataFrame(WSOpcode.Binary, true, payload)); + } + + public Task Write(string payload) + { + return Write(new DataFrame(WSOpcode.Text, true, Encoding.UTF8.GetBytes(payload))); + } + + public async Task Write(DataFrame frame) + { + var pool = ArrayPool.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 Stream.Write(writeBuf.AsMemory(0, 1)); + + if (frame.Payload.Count < 126) + { + writeBuf[0] = (byte)frame.Payload.Count; + await Stream.Write(writeBuf.AsMemory(0, 1)); + } + else + { + if (frame.Payload.Count < ushort.MaxValue) + { + writeBuf[0] = 126; + BinaryPrimitives.WriteUInt16BigEndian(writeBuf.AsSpan(1), (ushort)frame.Payload.Count); + await Stream.Write(writeBuf.AsMemory(0, 3)); + } + else + { + writeBuf[0] = 127; + BinaryPrimitives.WriteUInt64BigEndian(writeBuf.AsSpan(1), (ulong)frame.Payload.Count); + await Stream.Write(writeBuf.AsMemory(0, 9)); + } + } + + await Stream.Write(frame.Payload); + await Stream.Flush(); + } + finally + { + pool.Return(writeBuf); + } + } + + internal async void Close(CloseStatus status = CloseStatus.NormalClosure) + { + var pool = ArrayPool.Shared; + byte[] closeBuf = pool.Rent(2); + try + { + BinaryPrimitives.WriteUInt16BigEndian(closeBuf, (ushort)status); + await Write(new DataFrame(WSOpcode.Close, true, new ArraySegment(closeBuf, 0, 2))); + } + finally + { + pool.Return(closeBuf); + } + } +} + +/// +/// A remote websocket connected to a local HTTP server. +/// +public class WebsocketRemote : Websocket +{ + /// + /// The HTTP request encompassing the websocket. + /// + public readonly HttpRequest Request; + + /// + /// The HTTP request encompassing the websocket. + /// + public readonly HttpClientInfo ClientInfo; + + internal WebsocketRemote(HttpRequest request, HttpClientInfo clientInfo, HttpStream stream, string? subProtocol) : base(stream, subProtocol) + { + Request = request; + ClientInfo = clientInfo; + } +} \ No newline at end of file diff --git a/HTTP/Websockets/WebsocketHandler.cs b/HTTP/Websockets/WebsocketHandler.cs new file mode 100644 index 0000000..f8ba918 --- /dev/null +++ b/HTTP/Websockets/WebsocketHandler.cs @@ -0,0 +1,8 @@ +namespace Uwaa.HTTP.Websockets; + +/// +/// A delegate called once a HTTP request is upgrade to a websocket. +/// +/// The websocket to the remote endpoint. +/// The status to send to the client when closing the websocket. +public delegate Task WebsocketHandler(WebsocketRemote ws); diff --git a/PNG.sln b/PNG.sln new file mode 100644 index 0000000..11fdd78 --- /dev/null +++ b/PNG.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35027.167 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PNG", "PNG\PNG.csproj", "{6ED5CEE9-F934-4035-B5C5-193BECBEB505}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6ED5CEE9-F934-4035-B5C5-193BECBEB505}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6ED5CEE9-F934-4035-B5C5-193BECBEB505}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6ED5CEE9-F934-4035-B5C5-193BECBEB505}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6ED5CEE9-F934-4035-B5C5-193BECBEB505}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {71C0A381-2076-44A1-BE59-F5ABCBA6AD7B} + EndGlobalSection +EndGlobal diff --git a/PNG/Adam7.cs b/PNG/Adam7.cs new file mode 100644 index 0000000..8685bc2 --- /dev/null +++ b/PNG/Adam7.cs @@ -0,0 +1,95 @@ +namespace Uwaa.PNG; + +internal static class Adam7 +{ + /// + /// For a given pass number (1 indexed) the scanline indexes of the lines included in that pass in the 8x8 grid. + /// + static readonly IReadOnlyDictionary PassToScanlineGridIndex = new Dictionary + { + { 1, [ 0 ] }, + { 2, [ 0 ] }, + { 3, [ 4 ] }, + { 4, [ 0, 4 ] }, + { 5, [ 2, 6 ] }, + { 6, [ 0, 2, 4, 6 ] }, + { 7, [ 1, 3, 5, 7 ] } + }; + + static readonly IReadOnlyDictionary PassToScanlineColumnIndex = new Dictionary + { + { 1, [ 0 ] }, + { 2, [ 4 ] }, + { 3, [ 0, 4 ] }, + { 4, [ 2, 6 ] }, + { 5, [ 0, 2, 4, 6 ] }, + { 6, [ 1, 3, 5, 7 ] }, + { 7, [ 0, 1, 2, 3, 4, 5, 6, 7 ] } + }; + + /* + * To go from raw image data to interlaced: + * + * An 8x8 grid is repeated over the image. There are 7 passes and the indexes in this grid correspond to the + * pass number including that pixel. Each row in the grid corresponds to a scanline. + * + * 1 6 4 6 2 6 4 6 - Scanline 0: pass 1 has pixel 0, 8, 16, etc. pass 2 has pixel 4, 12, 20, etc. + * 7 7 7 7 7 7 7 7 + * 5 6 5 6 5 6 5 6 + * 7 7 7 7 7 7 7 7 + * 3 6 4 6 3 6 4 6 + * 7 7 7 7 7 7 7 7 + * 5 6 5 6 5 6 5 6 + * 7 7 7 7 7 7 7 7 + * + * + * + */ + + public static int GetNumberOfScanlinesInPass(ImageHeader header, int pass) + { + int[] indices = PassToScanlineGridIndex[pass + 1]; + + int mod = header.Height % 8; + + if (mod == 0) //fits exactly + return indices.Length * (header.Height / 8); + + int additionalLines = 0; + for (int i = 0; i < indices.Length; i++) + if (indices[i] < mod) + additionalLines++; + + return (indices.Length * (header.Height / 8)) + additionalLines; + } + + public static int GetPixelsPerScanlineInPass(ImageHeader header, int pass) + { + int[] indices = PassToScanlineColumnIndex[pass + 1]; + + int mod = header.Width % 8; + + if (mod == 0) //fits exactly + return indices.Length * (header.Width / 8); + + int additionalColumns = 0; + for (int i = 0; i < indices.Length; i++) + if (indices[i] < mod) + additionalColumns++; + + return (indices.Length * (header.Width / 8)) + additionalColumns; + } + + public static (int x, int y) GetPixelIndexForScanlineInPass(int pass, int scanlineIndex, int indexInScanline) + { + int[] columnIndices = PassToScanlineColumnIndex[pass + 1]; + int[] rows = PassToScanlineGridIndex[pass + 1]; + + int actualRow = scanlineIndex % rows.Length; + int actualCol = indexInScanline % columnIndices.Length; + int precedingRows = 8 * (scanlineIndex / rows.Length); + int precedingCols = 8 * (indexInScanline / columnIndices.Length); + + return (precedingCols + columnIndices[actualCol], precedingRows + rows[actualRow]); + } +} \ No newline at end of file diff --git a/PNG/ChunkHeader.cs b/PNG/ChunkHeader.cs new file mode 100644 index 0000000..7b1002b --- /dev/null +++ b/PNG/ChunkHeader.cs @@ -0,0 +1,58 @@ +namespace Uwaa.PNG; + +/// +/// The header for a data chunk in a PNG file. +/// +public readonly struct ChunkHeader +{ + /// + /// The position/start of the chunk header within the stream. + /// + public long Position { get; } + + /// + /// The length of the chunk in bytes. + /// + public int Length { get; } + + /// + /// The name of the chunk, uppercase first letter means the chunk is critical (vs. ancillary). + /// + public string Name { get; } + + /// + /// Whether the chunk is critical (must be read by all readers) or ancillary (may be ignored). + /// + public bool IsCritical => char.IsUpper(Name[0]); + + /// + /// A public chunk is one that is defined in the International Standard or is registered in the list of public chunk types maintained by the Registration Authority. + /// Applications can also define private (unregistered) chunk types for their own purposes. + /// + public bool IsPublic => char.IsUpper(Name[1]); + + /// + /// Whether the (if unrecognized) chunk is safe to copy. + /// + public bool IsSafeToCopy => char.IsUpper(Name[3]); + + /// + /// Create a new . + /// + public ChunkHeader(long position, int length, string name) + { + if (length < 0) + { + throw new ArgumentException($"Length less than zero ({length}) encountered when reading chunk at position {position}."); + } + + Position = position; + Length = length; + Name = name; + } + + public override string ToString() + { + return $"{Name} at {Position} (length: {Length})."; + } +} \ No newline at end of file diff --git a/PNG/ColorType.cs b/PNG/ColorType.cs new file mode 100644 index 0000000..15d9a90 --- /dev/null +++ b/PNG/ColorType.cs @@ -0,0 +1,28 @@ +namespace Uwaa.PNG; + +/// +/// Describes the interpretation of the image data. +/// +[Flags] +public enum ColorType : byte +{ + /// + /// Grayscale. + /// + None = 0, + + /// + /// Colors are stored in a palette rather than directly in the data. + /// + PaletteUsed = 1, + + /// + /// The image uses color. + /// + ColorUsed = 2, + + /// + /// The image has an alpha channel. + /// + AlphaChannelUsed = 4 +} \ No newline at end of file diff --git a/PNG/Crc32.cs b/PNG/Crc32.cs new file mode 100644 index 0000000..e3a7599 --- /dev/null +++ b/PNG/Crc32.cs @@ -0,0 +1,84 @@ +namespace Uwaa.PNG; + +/// +/// 32-bit Cyclic Redundancy Code used by the PNG for checking the data is intact. +/// +public static class Crc32 +{ + const uint Polynomial = 0xEDB88320; + + static readonly uint[] Lookup; + + static Crc32() + { + Lookup = new uint[256]; + for (uint i = 0; i < 256; i++) + { + var value = i; + for (var j = 0; j < 8; ++j) + { + if ((value & 1) != 0) + { + value = (value >> 1) ^ Polynomial; + } + else + { + value >>= 1; + } + } + + Lookup[i] = value; + } + } + + /// + /// Calculate the CRC32 for data. + /// + public static uint Calculate(byte[] data) + { + var crc32 = uint.MaxValue; + for (var i = 0; i < data.Length; i++) + { + var index = (crc32 ^ data[i]) & 0xFF; + crc32 = (crc32 >> 8) ^ Lookup[index]; + } + + return crc32 ^ uint.MaxValue; + } + + /// + /// Calculate the CRC32 for data. + /// + public static uint Calculate(List data) + { + var crc32 = uint.MaxValue; + for (var i = 0; i < data.Count; i++) + { + var index = (crc32 ^ data[i]) & 0xFF; + crc32 = (crc32 >> 8) ^ Lookup[index]; + } + + return crc32 ^ uint.MaxValue; + } + + /// + /// Calculate the combined CRC32 for data. + /// + public static uint Calculate(byte[] data, byte[] data2) + { + var crc32 = uint.MaxValue; + for (var i = 0; i < data.Length; i++) + { + var index = (crc32 ^ data[i]) & 0xFF; + crc32 = (crc32 >> 8) ^ Lookup[index]; + } + + for (var i = 0; i < data2.Length; i++) + { + var index = (crc32 ^ data2[i]) & 0xFF; + crc32 = (crc32 >> 8) ^ Lookup[index]; + } + + return crc32 ^ uint.MaxValue; + } +} diff --git a/PNG/Decoder.cs b/PNG/Decoder.cs new file mode 100644 index 0000000..de7d902 --- /dev/null +++ b/PNG/Decoder.cs @@ -0,0 +1,189 @@ +namespace Uwaa.PNG; + +internal static class Decoder +{ + public static (byte bytesPerPixel, byte samplesPerPixel) GetBytesAndSamplesPerPixel(ImageHeader header) + { + var bitDepthCorrected = (header.BitDepth + 7) / 8; + var samplesPerPixel = SamplesPerPixel(header); + return ((byte)(samplesPerPixel * bitDepthCorrected), samplesPerPixel); + } + + public static byte[] Decode(byte[] decompressedData, ImageHeader header, byte bytesPerPixel, byte samplesPerPixel) + { + switch (header.InterlaceMethod) + { + case InterlaceMethod.None: + { + var bytesPerScanline = BytesPerScanline(header, samplesPerPixel); + + var currentRowStartByteAbsolute = 1; + for (var rowIndex = 0; rowIndex < header.Height; rowIndex++) + { + var filterType = (FilterType)decompressedData[currentRowStartByteAbsolute - 1]; + + var previousRowStartByteAbsolute = rowIndex + (bytesPerScanline * (rowIndex - 1)); + + var end = currentRowStartByteAbsolute + bytesPerScanline; + for (var currentByteAbsolute = currentRowStartByteAbsolute; currentByteAbsolute < end; currentByteAbsolute++) + { + ReverseFilter(decompressedData, filterType, previousRowStartByteAbsolute, currentRowStartByteAbsolute, currentByteAbsolute, currentByteAbsolute - currentRowStartByteAbsolute, bytesPerPixel); + } + + currentRowStartByteAbsolute += bytesPerScanline + 1; + } + + return decompressedData; + } + case InterlaceMethod.Adam7: + { + int byteHack = bytesPerPixel == 1 ? 1 : 0; // TODO: Further investigation required. + int pixelsPerRow = (header.Width * bytesPerPixel) + byteHack; // Add an extra byte per line. + byte[] newBytes = new byte[header.Height * pixelsPerRow]; + int i = 0; + int previousStartRowByteAbsolute = -1; + // 7 passes + for (int pass = 0; pass < 7; pass++) + { + int numberOfScanlines = Adam7.GetNumberOfScanlinesInPass(header, pass); + int numberOfPixelsPerScanline = Adam7.GetPixelsPerScanlineInPass(header, pass); + + if (numberOfScanlines <= 0 || numberOfPixelsPerScanline <= 0) + { + continue; + } + + for (int scanlineIndex = 0; scanlineIndex < numberOfScanlines; scanlineIndex++) + { + FilterType filterType = (FilterType)decompressedData[i++]; + int rowStartByte = i; + + for (int j = 0; j < numberOfPixelsPerScanline; j++) + { + (int x, int y) pixelIndex = Adam7.GetPixelIndexForScanlineInPass(pass, scanlineIndex, j); + for (int k = 0; k < bytesPerPixel; k++) + { + int byteLineNumber = (j * bytesPerPixel) + k; + ReverseFilter(decompressedData, filterType, previousStartRowByteAbsolute, rowStartByte, i, byteLineNumber, bytesPerPixel); + i++; + } + + int start = byteHack + (pixelsPerRow * pixelIndex.y) + (pixelIndex.x * bytesPerPixel); + Array.ConstrainedCopy(decompressedData, rowStartByte + (j * bytesPerPixel), newBytes, start, bytesPerPixel); + } + + previousStartRowByteAbsolute = rowStartByte; + } + } + + return newBytes; + } + default: + throw new ArgumentOutOfRangeException($"Invalid interlace method: {header.InterlaceMethod}."); + } + } + + static byte SamplesPerPixel(ImageHeader header) + { + return header.ColorType switch + { + ColorType.None => 1, + ColorType.PaletteUsed or ColorType.PaletteUsed | ColorType.ColorUsed => 1, + ColorType.ColorUsed => 3, + ColorType.AlphaChannelUsed => 2, + ColorType.ColorUsed | ColorType.AlphaChannelUsed => 4, + _ => 0, + }; + } + + static int BytesPerScanline(ImageHeader header, byte samplesPerPixel) + { + int width = header.Width; + + return header.BitDepth switch + { + 1 => (width + 7) / 8, + 2 => (width + 3) / 4, + 4 => (width + 1) / 2, + 8 or 16 => width * samplesPerPixel * (header.BitDepth / 8), + _ => 0, + }; + } + + static void ReverseFilter(byte[] data, FilterType type, int previousRowStartByteAbsolute, int rowStartByteAbsolute, int byteAbsolute, int rowByteIndex, int bytesPerPixel) + { + byte GetLeftByteValue() + { + int leftIndex = rowByteIndex - bytesPerPixel; + byte leftValue = leftIndex >= 0 ? data[rowStartByteAbsolute + leftIndex] : (byte)0; + return leftValue; + } + + byte GetAboveByteValue() + { + int upIndex = previousRowStartByteAbsolute + rowByteIndex; + return upIndex >= 0 ? data[upIndex] : (byte)0; + } + + byte GetAboveLeftByteValue() + { + int index = previousRowStartByteAbsolute + rowByteIndex - bytesPerPixel; + return index < previousRowStartByteAbsolute || previousRowStartByteAbsolute < 0 ? (byte)0 : data[index]; + } + + // Moved out of the switch for performance. + if (type == FilterType.Up) + { + int above = previousRowStartByteAbsolute + rowByteIndex; + if (above < 0) + return; + + data[byteAbsolute] += data[above]; + return; + } + + if (type == FilterType.Sub) + { + int leftIndex = rowByteIndex - bytesPerPixel; + if (leftIndex < 0) + return; + + data[byteAbsolute] += data[rowStartByteAbsolute + leftIndex]; + return; + } + + switch (type) + { + case FilterType.None: + return; + case FilterType.Average: + data[byteAbsolute] += (byte)((GetLeftByteValue() + GetAboveByteValue()) / 2); + break; + case FilterType.Paeth: + byte a = GetLeftByteValue(); + byte b = GetAboveByteValue(); + byte c = GetAboveLeftByteValue(); + data[byteAbsolute] += GetPaethValue(a, b, c); + break; + default: + throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + } + + /// + /// Computes a simple linear function of the three neighboring pixels (left, above, upper left), + /// then chooses as predictor the neighboring pixel closest to the computed value. + /// + static byte GetPaethValue(byte a, byte b, byte c) + { + int p = a + b - c; + int pa = Math.Abs(p - a); + int pb = Math.Abs(p - b); + int pc = Math.Abs(p - c); + + if (pa <= pb && pa <= pc) + return a; + else + return pb <= pc ? b : c; + } +} diff --git a/PNG/FilterType.cs b/PNG/FilterType.cs new file mode 100644 index 0000000..9b6f586 --- /dev/null +++ b/PNG/FilterType.cs @@ -0,0 +1,25 @@ +namespace Uwaa.PNG; + +internal enum FilterType +{ + /// + /// The raw byte is unaltered. + /// + None = 0, + /// + /// The byte to the left. + /// + Sub = 1, + /// + /// The byte above. + /// + Up = 2, + /// + /// The mean of bytes left and above, rounded down. + /// + Average = 3, + /// + /// Byte to the left, above or top-left based on Paeth's algorithm. + /// + Paeth = 4 +} \ No newline at end of file diff --git a/PNG/ImageHeader.cs b/PNG/ImageHeader.cs new file mode 100644 index 0000000..6f42e24 --- /dev/null +++ b/PNG/ImageHeader.cs @@ -0,0 +1,71 @@ +namespace Uwaa.PNG; + +/// +/// The high level information about the image. +/// +public readonly struct ImageHeader +{ + internal static readonly byte[] HeaderBytes = { 73, 72, 68, 82 }; + + internal static readonly byte[] ValidationHeader = { 137, 80, 78, 71, 13, 10, 26, 10 }; + + static readonly Dictionary> PermittedBitDepths = new Dictionary> + { + {ColorType.None, new HashSet {1, 2, 4, 8, 16}}, + {ColorType.ColorUsed, new HashSet {8, 16}}, + {ColorType.PaletteUsed | ColorType.ColorUsed, new HashSet {1, 2, 4, 8}}, + {ColorType.AlphaChannelUsed, new HashSet {8, 16}}, + {ColorType.AlphaChannelUsed | ColorType.ColorUsed, new HashSet {8, 16}}, + }; + + /// + /// The width of the image in pixels. + /// + public int Width { get; } + + /// + /// The height of the image in pixels. + /// + public int Height { get; } + + /// + /// The bit depth of the image. + /// + public byte BitDepth { get; } + + /// + /// The color type of the image. + /// + public ColorType ColorType { get; } + + /// + /// The interlace method used by the image.. + /// + public InterlaceMethod InterlaceMethod { get; } + + /// + /// Create a new . + /// + public ImageHeader(int width, int height, byte bitDepth, ColorType colorType, InterlaceMethod interlaceMethod) + { + if (width == 0) + throw new ArgumentOutOfRangeException(nameof(width), "Invalid width (0) for image."); + + if (height == 0) + throw new ArgumentOutOfRangeException(nameof(height), "Invalid height (0) for image."); + + if (!PermittedBitDepths.TryGetValue(colorType, out var permitted) || !permitted.Contains(bitDepth)) + throw new ArgumentException($"The bit depth {bitDepth} is not permitted for color type {colorType}."); + + Width = width; + Height = height; + BitDepth = bitDepth; + ColorType = colorType; + InterlaceMethod = interlaceMethod; + } + + public override string ToString() + { + return $"w: {Width}, h: {Height}, bitDepth: {BitDepth}, colorType: {ColorType}, interlace: {InterlaceMethod}."; + } +} \ No newline at end of file diff --git a/PNG/InterlaceMethod.cs b/PNG/InterlaceMethod.cs new file mode 100644 index 0000000..65dc6f3 --- /dev/null +++ b/PNG/InterlaceMethod.cs @@ -0,0 +1,17 @@ +namespace Uwaa.PNG; + +/// +/// Indicates the transmission order of the image data. +/// +public enum InterlaceMethod : byte +{ + /// + /// No interlace. + /// + None = 0, + + /// + /// Adam7 interlace. + /// + Adam7 = 1 +} \ No newline at end of file diff --git a/PNG/PNG.csproj b/PNG/PNG.csproj new file mode 100644 index 0000000..e253d22 --- /dev/null +++ b/PNG/PNG.csproj @@ -0,0 +1,8 @@ + + + net8.0 + enable + enable + Uwaa.PNG + + diff --git a/PNG/Palette.cs b/PNG/Palette.cs new file mode 100644 index 0000000..48ba57c --- /dev/null +++ b/PNG/Palette.cs @@ -0,0 +1,42 @@ +using System.Runtime.CompilerServices; + +namespace Uwaa.PNG; + +internal class Palette +{ + public bool HasAlphaValues { get; private set; } + + public byte[] Data { get; } + + /// + /// Creates a palette object. Input palette data length from PLTE chunk must be a multiple of 3. + /// + public Palette(byte[] data) + { + Data = new byte[data.Length * 4 / 3]; + var dataIndex = 0; + for (var i = 0; i < data.Length; i += 3) + { + Data[dataIndex++] = data[i]; + Data[dataIndex++] = data[i + 1]; + Data[dataIndex++] = data[i + 2]; + Data[dataIndex++] = 255; + } + } + + /// + /// Adds transparency values from tRNS chunk. + /// + public void SetAlphaValues(byte[] bytes) + { + HasAlphaValues = true; + + for (var i = 0; i < bytes.Length; i++) + Data[(i * 4) + 3] = bytes[i]; + } + + public Pixel GetPixel(int index) + { + return Unsafe.As(ref Data[index * 4]); + } +} \ No newline at end of file diff --git a/PNG/Pixel.cs b/PNG/Pixel.cs new file mode 100644 index 0000000..3ccd6c3 --- /dev/null +++ b/PNG/Pixel.cs @@ -0,0 +1,93 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Uwaa.PNG; + +/// +/// A 32-bit RGBA pixel in a image. +/// +[StructLayout(LayoutKind.Sequential)] +public readonly struct Pixel : IEquatable +{ + /// + /// The red value for the pixel. + /// + public byte R { get; } + + /// + /// The green value for the pixel. + /// + public byte G { get; } + + /// + /// The blue value for the pixel. + /// + public byte B { get; } + + /// + /// The alpha transparency value for the pixel. + /// + public byte A { get; } + + /// + /// Create a new . + /// + /// The red value for the pixel. + /// The green value for the pixel. + /// The blue value for the pixel. + /// The alpha transparency value for the pixel. + public Pixel(byte r, byte g, byte b, byte a) + { + R = r; + G = g; + B = b; + A = a; + } + + /// + /// Create a new which is fully opaque. + /// + /// The red value for the pixel. + /// The green value for the pixel. + /// The blue value for the pixel. + public Pixel(byte r, byte g, byte b) + { + R = r; + G = g; + B = b; + A = 255; + } + + /// + /// Create a new grayscale . + /// + /// The grayscale value. + public Pixel(byte grayscale) + { + R = grayscale; + G = grayscale; + B = grayscale; + A = 255; + } + + public override string ToString() => $"({R}, {G}, {B}, {A})"; + + public override bool Equals(object? obj) => obj is Pixel pixel && Equals(pixel); + + /// + /// Whether the pixel values are equal. + /// + /// The other pixel. + /// if all pixel values are equal otherwise . + public bool Equals(Pixel other) + { + Pixel this_ = this; + return Unsafe.As(ref this_) == Unsafe.As(ref other); + } + + public override int GetHashCode() => HashCode.Combine(R, G, B, A); + + public static bool operator ==(Pixel left, Pixel right) => left.Equals(right); + + public static bool operator !=(Pixel left, Pixel right) => !(left == right); +} \ No newline at end of file diff --git a/PNG/Png.cs b/PNG/Png.cs new file mode 100644 index 0000000..f37fe41 --- /dev/null +++ b/PNG/Png.cs @@ -0,0 +1,80 @@ +namespace Uwaa.PNG; + +/// +/// A PNG image. Call to open from file or bytes. +/// +public class Png +{ + readonly RawPngData data; + readonly bool hasTransparencyChunk; + + /// + /// The header data from the PNG image. + /// + public ImageHeader Header { get; } + + /// + /// The width of the image in pixels. + /// + public int Width => Header.Width; + + /// + /// The height of the image in pixels. + /// + public int Height => Header.Height; + + /// + /// Whether the image has an alpha (transparency) layer. + /// + public bool HasAlphaChannel => (Header.ColorType & ColorType.AlphaChannelUsed) != 0 || hasTransparencyChunk; + + internal Png(ImageHeader header, RawPngData data, bool hasTransparencyChunk) + { + Header = header; + this.data = data ?? throw new ArgumentNullException(nameof(data)); + this.hasTransparencyChunk = hasTransparencyChunk; + } + + /// + /// Get the pixel at the given column and row (x, y). + /// + /// + /// Pixel values are generated on demand from the underlying data to prevent holding many items in memory at once, so consumers + /// should cache values if they're going to be looped over many time. + /// + /// The x coordinate (column). + /// The y coordinate (row). + /// The pixel at the coordinate. + public Pixel GetPixel(int x, int y) => data.GetPixel(x, y); + + /// + /// Read the PNG image from the stream. + /// + /// The stream containing PNG data to be read. + /// The data from the stream. + public static Png Open(Stream stream) + => PngOpener.Open(stream); + + /// + /// Read the PNG image from the bytes. + /// + /// The bytes of the PNG data to be read. + /// The data from the bytes. + public static Png Open(byte[] bytes) + { + using var memoryStream = new MemoryStream(bytes); + return PngOpener.Open(memoryStream); + } + + /// + /// Read the PNG from the file path. + /// + /// The path to the PNG file to open. + /// This will open the file to obtain a so will lock the file during reading. + /// The data from the file. + public static Png Open(string path) + { + using var fileStream = File.OpenRead(path); + return Open(fileStream); + } +} diff --git a/PNG/PngBuilder.cs b/PNG/PngBuilder.cs new file mode 100644 index 0000000..946d248 --- /dev/null +++ b/PNG/PngBuilder.cs @@ -0,0 +1,514 @@ +using System.IO.Compression; +using System.Text; + +namespace Uwaa.PNG; + +/// +/// Used to construct PNG images. Call to make a new builder. +/// +public class PngBuilder +{ + const byte Deflate32KbWindow = 120; + const byte ChecksumBits = 1; + + readonly byte[] rawData; + readonly bool hasAlphaChannel; + readonly int width; + readonly int height; + readonly int bytesPerPixel; + + bool hasTooManyColorsForPalette; + + readonly int backgroundColorInt; + readonly Dictionary colorCounts; + + readonly List<(string keyword, byte[] data)> storedStrings = new List<(string keyword, byte[] data)>(); + + /// + /// Create a builder for a PNG with the given width and size. + /// + public static PngBuilder Create(int width, int height, bool hasAlphaChannel) + { + int bpp = hasAlphaChannel ? 4 : 3; + + int length = (height * width * bpp) + height; + + return new PngBuilder(new byte[length], hasAlphaChannel, width, height, bpp); + } + + /// + /// Create a builder from a . + /// + public static PngBuilder FromPng(Png png) + { + var result = Create(png.Width, png.Height, png.HasAlphaChannel); + + for (int y = 0; y < png.Height; y++) + for (int x = 0; x < png.Width; x++) + result.SetPixel(png.GetPixel(x, y), x, y); + + return result; + } + + /// + /// Create a builder from the bytes of the specified PNG image. + /// + public static PngBuilder FromPngBytes(byte[] png) + { + var pngActual = Png.Open(png); + return FromPng(pngActual); + } + + /// + /// Create a builder from the bytes in the BGRA32 pixel format. + /// https://docs.microsoft.com/en-us/dotnet/api/system.windows.media.pixelformats.bgra32 + /// + /// The pixels in BGRA32 format. + /// The width in pixels. + /// The height in pixels. + /// Whether to include an alpha channel in the output. + public static PngBuilder FromBgra32Pixels(byte[] data, int width, int height, bool useAlphaChannel = true) + { + using var memoryStream = new MemoryStream(data); + return FromBgra32Pixels(memoryStream, width, height, useAlphaChannel); + } + + /// + /// Create a builder from the bytes in the BGRA32 pixel format. + /// https://docs.microsoft.com/en-us/dotnet/api/system.windows.media.pixelformats.bgra32 + /// + /// The pixels in BGRA32 format. + /// The width in pixels. + /// The height in pixels. + /// Whether to include an alpha channel in the output. + public static PngBuilder FromBgra32Pixels(Stream data, int width, int height, bool useAlphaChannel = true) + { + int bpp = useAlphaChannel ? 4 : 3; + int length = (height * width * bpp) + height; + PngBuilder builder = new PngBuilder(new byte[length], useAlphaChannel, width, height, bpp); + byte[] buffer = new byte[4]; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + int read = data.Read(buffer, 0, buffer.Length); + + if (read != 4) + { + throw new InvalidOperationException($"Unexpected end of stream, expected to read 4 bytes at offset {data.Position - read} for (x: {x}, y: {y}), instead got {read}."); + } + + if (useAlphaChannel) + { + builder.SetPixel(new Pixel(buffer[0], buffer[1], buffer[2], buffer[3]), x, y); + } + else + { + builder.SetPixel(buffer[0], buffer[1], buffer[2], x, y); + } + } + } + + return builder; + } + + PngBuilder(byte[] rawData, bool hasAlphaChannel, int width, int height, int bytesPerPixel) + { + this.rawData = rawData; + this.hasAlphaChannel = hasAlphaChannel; + this.width = width; + this.height = height; + this.bytesPerPixel = bytesPerPixel; + + backgroundColorInt = PixelToColorInt(0, 0, 0, hasAlphaChannel ? (byte)0 : byte.MaxValue); + + colorCounts = new Dictionary() + { + { backgroundColorInt, width * height} + }; + } + + /// + /// Sets the RGB pixel value for the given column (x) and row (y). + /// + public void SetPixel(byte r, byte g, byte b, int x, int y) => SetPixel(new Pixel(r, g, b), x, y); + + /// + /// Set the pixel value for the given column (x) and row (y). + /// + public void SetPixel(Pixel pixel, int x, int y) + { + if (!hasTooManyColorsForPalette) + { + int val = PixelToColorInt(pixel); + if (val != backgroundColorInt) + { + if (!colorCounts.TryGetValue(val, out int value)) + { + colorCounts[val] = 1; + } + else + { + colorCounts[val] = value + 1; + } + + colorCounts[backgroundColorInt]--; + if (colorCounts[backgroundColorInt] == 0) + { + colorCounts.Remove(backgroundColorInt); + } + } + + if (colorCounts.Count > 256) + { + hasTooManyColorsForPalette = true; + } + } + + int start = (y * ((width * bytesPerPixel) + 1)) + 1 + (x * bytesPerPixel); + + rawData[start++] = pixel.R; + rawData[start++] = pixel.G; + rawData[start++] = pixel.B; + + if (hasAlphaChannel) + { + rawData[start] = pixel.A; + } + } + + /// + /// Allows you to store arbitrary text data in the "iTXt" international textual data + /// chunks of the generated PNG image. + /// + /// + /// A keyword identifying the text data between 1-79 characters in length. + /// Must not start with, end with or contain consecutive whitespace characters. + /// Only characters in the range 32 - 126 and 161 - 255 are permitted. + /// + /// + /// The text data to store. Encoded as UTF-8 that may not contain zero (0) bytes but can be zero-length. + /// + public PngBuilder StoreText(string keyword, string text) + { + if (keyword == null) + throw new ArgumentNullException(nameof(keyword), "Keyword may not be null."); + + if (text == null) + throw new ArgumentNullException(nameof(text), "Text may not be null."); + + if (keyword == string.Empty) + throw new ArgumentException("Keyword may not be empty.", nameof(keyword)); + + if (keyword.Length > 79) + throw new ArgumentException($"Keyword must be between 1 - 79 characters, provided keyword '{keyword}' has length of {keyword.Length} characters.", nameof(keyword)); + + for (int i = 0; i < keyword.Length; i++) + { + char c = keyword[i]; + bool isValid = (c >= 32 && c <= 126) || (c >= 161 && c <= 255); + if (!isValid) + { + throw new ArgumentException($"The keyword can only contain printable Latin 1 characters and spaces in the ranges 32 - 126 or 161 -255. The provided keyword '{keyword}' contained an invalid character ({c}) at index {i}.", nameof(keyword)); + } + + // TODO: trailing, leading and consecutive whitespaces are also prohibited. + } + + var bytes = Encoding.UTF8.GetBytes(text); + + for (int i = 0; i < bytes.Length; i++) + { + byte b = bytes[i]; + if (b == 0) + throw new ArgumentOutOfRangeException(nameof(text), $"The provided text contained a null (0) byte when converted to UTF-8. Null bytes are not permitted. Text was: '{text}'"); + } + + storedStrings.Add((keyword, bytes)); + + return this; + } + + /// + /// Get the bytes of the PNG file for this builder. + /// + public byte[] Save(SaveOptions? options = null) + { + using var memoryStream = new MemoryStream(); + Save(memoryStream, options); + return memoryStream.ToArray(); + } + + /// + /// Write the PNG file bytes to the provided stream. + /// + public void Save(Stream outputStream, SaveOptions? options = null) + { + options = options ?? new SaveOptions(); + + byte[]? palette = null; + int dataLength = rawData.Length; + int bitDepth = 8; + + if (!hasTooManyColorsForPalette && !hasAlphaChannel) + { + var paletteColors = colorCounts.OrderByDescending(x => x.Value).Select(x => x.Key).ToList(); + bitDepth = paletteColors.Count > 16 ? 8 : 4; + int samplesPerByte = bitDepth == 8 ? 1 : 2; + bool applyShift = samplesPerByte == 2; + + palette = new byte[3 * paletteColors.Count]; + + for (int i = 0; i < paletteColors.Count; i++) + { + (byte r, byte g, byte b, byte a) = ColorIntToPixel(paletteColors[i]); + int startIndex = i * 3; + palette[startIndex++] = r; + palette[startIndex++] = g; + palette[startIndex] = b; + } + + int rawDataIndex = 0; + + for (int y = 0; y < height; y++) + { + // None filter - we don't use filtering for palette images. + rawData[rawDataIndex++] = 0; + + for (int x = 0; x < width; x++) + { + int index = (y * width * bytesPerPixel) + y + 1 + (x * bytesPerPixel); + + byte r = rawData[index++]; + byte g = rawData[index++]; + byte b = rawData[index]; + + int colorInt = PixelToColorInt(r, g, b); + + byte value = (byte)paletteColors.IndexOf(colorInt); + + if (applyShift) + { + // apply mask and shift + int withinByteIndex = x % 2; + + if (withinByteIndex == 1) + { + rawData[rawDataIndex] = (byte)(rawData[rawDataIndex] + value); + rawDataIndex++; + } + else + { + rawData[rawDataIndex] = (byte)(value << 4); + } + } + else + { + rawData[rawDataIndex++] = value; + } + + } + } + + dataLength = rawDataIndex; + } + else + { + AttemptCompressionOfRawData(rawData, options); + } + + outputStream.Write(ImageHeader.ValidationHeader); + + PngStreamWriteHelper stream = new PngStreamWriteHelper(outputStream); + + stream.WriteChunkLength(13); + stream.WriteChunkHeader(ImageHeader.HeaderBytes); + + StreamHelper.WriteBigEndianInt32(stream, width); + StreamHelper.WriteBigEndianInt32(stream, height); + stream.WriteByte((byte)bitDepth); + + var colorType = ColorType.ColorUsed; + if (hasAlphaChannel) + colorType |= ColorType.AlphaChannelUsed; + + if (palette != null) + colorType |= ColorType.PaletteUsed; + + stream.WriteByte((byte)colorType); + stream.WriteByte(0); + stream.WriteByte(0); + stream.WriteByte((byte)InterlaceMethod.None); + + stream.WriteCrc(); + + if (palette != null) + { + stream.WriteChunkLength(palette.Length); + stream.WriteChunkHeader(Encoding.ASCII.GetBytes("PLTE")); + stream.Write(palette, 0, palette.Length); + stream.WriteCrc(); + } + + byte[] imageData = Compress(rawData, dataLength, options); + stream.WriteChunkLength(imageData.Length); + stream.WriteChunkHeader(Encoding.ASCII.GetBytes("IDAT")); + stream.Write(imageData, 0, imageData.Length); + stream.WriteCrc(); + + foreach (var storedString in storedStrings) + { + byte[] keyword = Encoding.GetEncoding("iso-8859-1").GetBytes(storedString.keyword); + int length = keyword.Length + + 1 // Null separator + + 1 // Compression flag + + 1 // Compression method + + 1 // Null separator + + 1 // Null separator + + storedString.data.Length; + + stream.WriteChunkLength(length); + stream.WriteChunkHeader(Encoding.ASCII.GetBytes("iTXt")); + stream.Write(keyword, 0, keyword.Length); + + stream.WriteByte(0); // Null separator + stream.WriteByte(0); // Compression flag (0 for uncompressed) + stream.WriteByte(0); // Compression method (0, ignored since flag is zero) + stream.WriteByte(0); // Null separator + stream.WriteByte(0); // Null separator + + stream.Write(storedString.data, 0, storedString.data.Length); + stream.WriteCrc(); + } + + stream.WriteChunkLength(0); + stream.WriteChunkHeader(Encoding.ASCII.GetBytes("IEND")); + stream.WriteCrc(); + } + + static byte[] Compress(byte[] data, int dataLength, SaveOptions options) + { + const int headerLength = 2; + const int checksumLength = 4; + + var compressionLevel = options?.AttemptCompression == true ? CompressionLevel.Optimal : CompressionLevel.Fastest; + + using (var compressStream = new MemoryStream()) + using (var compressor = new DeflateStream(compressStream, compressionLevel, true)) + { + compressor.Write(data, 0, dataLength); + compressor.Close(); + + compressStream.Seek(0, SeekOrigin.Begin); + + var result = new byte[headerLength + compressStream.Length + checksumLength]; + + // Write the ZLib header. + result[0] = Deflate32KbWindow; + result[1] = ChecksumBits; + + // Write the compressed data. + int streamValue; + int i = 0; + while ((streamValue = compressStream.ReadByte()) != -1) + { + result[headerLength + i] = (byte)streamValue; + i++; + } + + // Write Checksum of raw data. + int checksum = Adler32Checksum(data, dataLength); + + long offset = headerLength + compressStream.Length; + + result[offset++] = (byte)(checksum >> 24); + result[offset++] = (byte)(checksum >> 16); + result[offset++] = (byte)(checksum >> 8); + result[offset] = (byte)(checksum >> 0); + + return result; + } + } + + /// + /// Calculate the Adler-32 checksum for some data. + /// + /// + /// Complies with the RFC 1950: ZLIB Compressed Data Format Specification. + /// + public static int Adler32Checksum(IEnumerable data, int length = -1) + { + // Both sums (s1 and s2) are done modulo 65521. + const int AdlerModulus = 65521; + + // s1 is the sum of all bytes. + int s1 = 1; + + // s2 is the sum of all s1 values. + int s2 = 0; + + int count = 0; + foreach (byte b in data) + { + if (length > 0 && count == length) + break; + + s1 = (s1 + b) % AdlerModulus; + s2 = (s1 + s2) % AdlerModulus; + count++; + } + + // The Adler-32 checksum is stored as s2*65536 + s1. + return (s2 * 65536) + s1; + } + + /// + /// Attempt to improve compressability of the raw data by using adaptive filtering. + /// + void AttemptCompressionOfRawData(byte[] rawData, SaveOptions options) + { + if (!options.AttemptCompression) + return; + + int bytesPerScanline = 1 + (bytesPerPixel * width); + int scanlineCount = rawData.Length / bytesPerScanline; + + byte[] scanData = new byte[bytesPerScanline - 1]; + + for (int scanlineRowIndex = 0; scanlineRowIndex < scanlineCount; scanlineRowIndex++) + { + int sourceIndex = (scanlineRowIndex * bytesPerScanline) + 1; + + Array.Copy(rawData, sourceIndex, scanData, 0, bytesPerScanline - 1); + + //Incomplete: the original source had unfinished junk code here which did nothing + } + } + + static int PixelToColorInt(Pixel p) => PixelToColorInt(p.R, p.G, p.B, p.A); + static int PixelToColorInt(byte r, byte g, byte b, byte a = 255) + { + return (a << 24) + (r << 16) + (g << 8) + b; + } + + static (byte r, byte g, byte b, byte a) ColorIntToPixel(int i) => ((byte)(i >> 16), (byte)(i >> 8), (byte)i, (byte)(i >> 24)); + + /// + /// Options for configuring generation of PNGs from a . + /// + public class SaveOptions + { + /// + /// Whether the library should try to reduce the resulting image size. + /// This process does not affect the original image data (it is lossless) but may + /// result in longer save times. + /// + public bool AttemptCompression { get; set; } + + /// + /// The number of parallel tasks allowed during compression. + /// + public int MaxDegreeOfParallelism { get; set; } = 1; + } +} \ No newline at end of file diff --git a/PNG/PngOpener.cs b/PNG/PngOpener.cs new file mode 100644 index 0000000..1b18d51 --- /dev/null +++ b/PNG/PngOpener.cs @@ -0,0 +1,158 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text; + +namespace Uwaa.PNG; + +internal static class PngOpener +{ + public static Png Open(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream, nameof(stream)); + + if (!stream.CanRead) + throw new ArgumentException($"The provided stream of type {stream.GetType().FullName} was not readable."); + + if (!HasValidHeader(stream)) + throw new ArgumentException($"The provided stream did not start with the PNG header."); + + Span crc = stackalloc byte[4]; + ImageHeader imageHeader = ReadImageHeader(stream, crc); + + bool hasEncounteredImageEnd = false; + + Palette? palette = null; + + using MemoryStream output = new MemoryStream(); + using MemoryStream memoryStream = new MemoryStream(); + + while (TryReadChunkHeader(stream, out var header)) + { + if (hasEncounteredImageEnd) + break; + + byte[] bytes = new byte[header.Length]; + int read = stream.Read(bytes, 0, bytes.Length); + if (read != bytes.Length) + throw new InvalidOperationException($"Did not read {header.Length} bytes for the {header} header, only found: {read}."); + + if (header.IsCritical) + { + switch (header.Name) + { + case "PLTE": + if (header.Length % 3 != 0) + throw new InvalidOperationException($"Palette data must be multiple of 3, got {header.Length}."); + + // Ignore palette data unless the header.ColorType indicates that the image is paletted. + if (imageHeader.ColorType.HasFlag(ColorType.PaletteUsed)) + palette = new Palette(bytes); + + break; + + case "IDAT": + memoryStream.Write(bytes, 0, bytes.Length); + break; + + case "IEND": + hasEncounteredImageEnd = true; + break; + + default: + throw new NotSupportedException($"Encountered critical header {header} which was not recognised."); + } + } + else + { + switch (header.Name) + { + case "tRNS": + // Add transparency to palette, if the PLTE chunk has been read. + palette?.SetAlphaValues(bytes); + break; + } + } + + read = stream.Read(crc); + if (read != 4) + throw new InvalidOperationException($"Did not read 4 bytes for the CRC, only found: {read}."); + + int result = (int)Crc32.Calculate(Encoding.ASCII.GetBytes(header.Name), bytes); + int crcActual = (crc[0] << 24) + (crc[1] << 16) + (crc[2] << 8) + crc[3]; + + if (result != crcActual) + throw new InvalidOperationException($"CRC calculated {result} did not match file {crcActual} for chunk: {header.Name}."); + } + + memoryStream.Flush(); + memoryStream.Seek(2, SeekOrigin.Begin); + + using (DeflateStream deflateStream = new DeflateStream(memoryStream, CompressionMode.Decompress)) + { + deflateStream.CopyTo(output); + deflateStream.Close(); + } + + byte[] bytesOut = output.ToArray(); + + (byte bytesPerPixel, byte samplesPerPixel) = Decoder.GetBytesAndSamplesPerPixel(imageHeader); + + bytesOut = Decoder.Decode(bytesOut, imageHeader, bytesPerPixel, samplesPerPixel); + + return new Png(imageHeader, new RawPngData(bytesOut, bytesPerPixel, palette, imageHeader), palette?.HasAlphaValues ?? false); + } + + static bool HasValidHeader(Stream stream) + { + for (int i = 0; i < ImageHeader.ValidationHeader.Length; i++) + if (stream.ReadByte() != ImageHeader.ValidationHeader[i]) + return false; + + return true; + } + + static bool TryReadChunkHeader(Stream stream, out ChunkHeader chunkHeader) + { + chunkHeader = default; + + long position = stream.Position; + if (!StreamHelper.TryReadHeaderBytes(stream, out var headerBytes)) + return false; + + int length = BinaryPrimitives.ReadInt32BigEndian(headerBytes); + string name = Encoding.ASCII.GetString(headerBytes, 4, 4); + chunkHeader = new ChunkHeader(position, length, name); + + return true; + } + + static ImageHeader ReadImageHeader(Stream stream, Span crc) + { + if (!TryReadChunkHeader(stream, out var header)) + throw new ArgumentException("The provided stream did not contain a single chunk."); + + if (header.Name != "IHDR") + throw new ArgumentException($"The first chunk was not the IHDR chunk: {header}."); + + if (header.Length != 13) + throw new ArgumentException($"The first chunk did not have a length of 13 bytes: {header}."); + + byte[] ihdrBytes = new byte[13]; + int read = stream.Read(ihdrBytes, 0, ihdrBytes.Length); + + if (read != 13) + throw new InvalidOperationException($"Did not read 13 bytes for the IHDR, only found: {read}."); + + read = stream.Read(crc); + if (read != 4) + throw new InvalidOperationException($"Did not read 4 bytes for the CRC, only found: {read}."); + + int width = BinaryPrimitives.ReadInt32BigEndian(ihdrBytes); + int height = BinaryPrimitives.ReadInt32BigEndian(ihdrBytes[4..]); + byte bitDepth = ihdrBytes[8]; + byte colorType = ihdrBytes[9]; + byte interlaceMethod = ihdrBytes[12]; + + return new ImageHeader(width, height, bitDepth, (ColorType)colorType, (InterlaceMethod)interlaceMethod); + } +} diff --git a/PNG/PngStreamWriteHelper.cs b/PNG/PngStreamWriteHelper.cs new file mode 100644 index 0000000..95f9aa1 --- /dev/null +++ b/PNG/PngStreamWriteHelper.cs @@ -0,0 +1,57 @@ +namespace Uwaa.PNG; + +internal class PngStreamWriteHelper : Stream +{ + readonly Stream inner; + readonly List written = new List(); + + public override bool CanRead => inner.CanRead; + + public override bool CanSeek => inner.CanSeek; + + public override bool CanWrite => inner.CanWrite; + + public override long Length => inner.Length; + + public override long Position + { + get => inner.Position; + set => inner.Position = value; + } + + public PngStreamWriteHelper(Stream inner) + { + this.inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + public override void Flush() => inner.Flush(); + + public void WriteChunkHeader(byte[] header) + { + written.Clear(); + Write(header, 0, header.Length); + } + + public void WriteChunkLength(int length) + { + StreamHelper.WriteBigEndianInt32(inner, length); + } + + public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + + public override void SetLength(long value) => inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) + { + written.AddRange(buffer.Skip(offset).Take(count)); + inner.Write(buffer, offset, count); + } + + public void WriteCrc() + { + var result = (int)Crc32.Calculate(written); + StreamHelper.WriteBigEndianInt32(inner, result); + } +} \ No newline at end of file diff --git a/PNG/README.md b/PNG/README.md new file mode 100644 index 0000000..a416e6d --- /dev/null +++ b/PNG/README.md @@ -0,0 +1 @@ +Minimal PNG encoder and decoder based on biggustav. \ No newline at end of file diff --git a/PNG/RawPngData.cs b/PNG/RawPngData.cs new file mode 100644 index 0000000..0769cf0 --- /dev/null +++ b/PNG/RawPngData.cs @@ -0,0 +1,120 @@ +namespace Uwaa.PNG; + +/// +/// Provides convenience methods for indexing into a raw byte array to extract pixel values. +/// +internal class RawPngData +{ + readonly byte[] data; + readonly int bytesPerPixel; + readonly int width; + readonly Palette? palette; + readonly ColorType colorType; + readonly int rowOffset; + readonly int bitDepth; + + /// + /// Create a new . + /// + /// The decoded pixel data as bytes. + /// The number of bytes in each pixel. + /// The palette for the image. + /// The image header. + public RawPngData(byte[] data, int bytesPerPixel, Palette? palette, ImageHeader imageHeader) + { + if (width < 0) + { + throw new ArgumentOutOfRangeException($"Width must be greater than or equal to 0, got {width}."); + } + + this.data = data ?? throw new ArgumentNullException(nameof(data)); + this.bytesPerPixel = bytesPerPixel; + this.palette = palette; + + width = imageHeader.Width; + colorType = imageHeader.ColorType; + rowOffset = imageHeader.InterlaceMethod == InterlaceMethod.Adam7 ? 0 : 1; + bitDepth = imageHeader.BitDepth; + } + + public Pixel GetPixel(int x, int y) + { + if (palette != null) + { + int pixelsPerByte = 8 / bitDepth; + int bytesInRow = 1 + (width / pixelsPerByte); + int byteIndexInRow = x / pixelsPerByte; + int paletteIndex = 1 + (y * bytesInRow) + byteIndexInRow; + + byte b = data[paletteIndex]; + + if (bitDepth == 8) + return palette.GetPixel(b); + + int withinByteIndex = x % pixelsPerByte; + int rightShift = 8 - ((withinByteIndex + 1) * bitDepth); + int indexActual = (b >> rightShift) & ((1 << bitDepth) - 1); + + return palette.GetPixel(indexActual); + } + + int rowStartPixel = rowOffset + (rowOffset * y) + (bytesPerPixel * width * y); + int pixelStartIndex = rowStartPixel + (bytesPerPixel * x); + byte first = data[pixelStartIndex]; + + switch (bytesPerPixel) + { + case 1: + return new Pixel(first); + + case 2: + switch (colorType) + { + case ColorType.None: + { + byte second = data[pixelStartIndex + 1]; + byte value = ToSingleByte(first, second); + return new Pixel(value, value, value, 255); + + } + default: + return new Pixel(first, first, first, data[pixelStartIndex + 1]); + } + + case 3: + return new Pixel(first, data[pixelStartIndex + 1], data[pixelStartIndex + 2], 255); + + case 4: + switch (colorType) + { + case ColorType.None | ColorType.AlphaChannelUsed: + { + byte second = data[pixelStartIndex + 1]; + byte firstAlpha = data[pixelStartIndex + 2]; + byte secondAlpha = data[pixelStartIndex + 3]; + byte gray = ToSingleByte(first, second); + byte alpha = ToSingleByte(firstAlpha, secondAlpha); + return new Pixel(gray, gray, gray, alpha); + } + default: + return new Pixel(first, data[pixelStartIndex + 1], data[pixelStartIndex + 2], data[pixelStartIndex + 3]); + } + + case 6: + return new Pixel(first, data[pixelStartIndex + 2], data[pixelStartIndex + 4], 255); + + case 8: + return new Pixel(first, data[pixelStartIndex + 2], data[pixelStartIndex + 4], data[pixelStartIndex + 6]); + + default: + throw new InvalidOperationException($"Unreconized number of bytes per pixel: {bytesPerPixel}."); + } + } + + static byte ToSingleByte(byte first, byte second) + { + int us = (first << 8) + second; + byte result = (byte)Math.Round(255 * us / (double)ushort.MaxValue); + return result; + } +} \ No newline at end of file diff --git a/PNG/StreamHelper.cs b/PNG/StreamHelper.cs new file mode 100644 index 0000000..650387d --- /dev/null +++ b/PNG/StreamHelper.cs @@ -0,0 +1,26 @@ +using System.Buffers.Binary; + +namespace Uwaa.PNG; + +internal static class StreamHelper +{ + public static int ReadBigEndianInt32(Stream stream) + { + Span buffer = stackalloc byte[4]; + stream.Read(buffer); + return BinaryPrimitives.ReadInt32BigEndian(buffer); + } + + public static void WriteBigEndianInt32(Stream stream, int value) + { + Span buffer = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(buffer, value); + stream.Write(buffer); + } + + public static bool TryReadHeaderBytes(Stream stream, out byte[] bytes) + { + bytes = new byte[8]; + return stream.Read(bytes, 0, 8) == 8; + } +} \ No newline at end of file