initial commit

This commit is contained in:
uwaa 2024-11-22 06:40:43 +00:00
commit a0b2c9f9b1
57 changed files with 4050 additions and 0 deletions

2
.gitattributes vendored Normal file
View file

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

4
.gitignore vendored Normal file
View file

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

31
HTTP.Example.sln Normal file
View file

@ -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

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>false</ImplicitUsings>
<Nullable>enable</Nullable>
<Configurations>Debug</Configurations>
<RootNamespace>Uwaa.HTTP.Example</RootNamespace>
<AssemblyName>HTTPExample</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\HTTP\HTTP.csproj" />
<None Update="certs\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="www-static\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

154
HTTP.Example/Program.cs Normal file
View file

@ -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();
}
/// <summary>
/// Generates the URL router for the HTTP server.
/// </summary>
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;
}
/// <summary>
/// Example HTTP client request.
/// </summary>
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);
}
/// <summary>
/// Logs a new connection to the console.
/// </summary>
static void LogConnect(TcpClient client)
{
Console.WriteLine($"{client.Client.RemoteEndPoint} connected");
}
/// <summary>
/// Logs a disconnection to the console.
/// </summary>
static void LogDisconnect(IPEndPoint endpoint)
{
Console.WriteLine($"{endpoint} disconnected");
}
/// <summary>
/// Logs a request to the console.
/// </summary>
static HttpResponse? LogRequest(HttpRequest req, HttpClientInfo info)
{
Console.WriteLine($"{info.Endpoint.Address}: {req.Method} {req.Path}");
return null;
}
/// <summary>
/// Root endpoint: /
/// </summary>
static async Task<HttpResponse?> 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);
}
/// <summary>
/// Websocket endpoint
/// </summary>
static HttpResponse? Websocket(HttpRequest req, HttpClientInfo info)
{
return req.UpgradeToWebsocket(HandleWebsocket, "test");
}
static async Task<CloseStatus> 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;
}
/// <summary>
/// Custom route: /custom/{param1}/{param2}
/// </summary>
class CustomRoute : RouterBase
{
public override HttpMethod Method => HttpMethod.GET;
public override int Arguments => 2;
protected override Task<HttpResponse?> GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment<string> path)
{
string param1 = path[0];
string param2 = path[1];
return Task.FromResult<HttpResponse?>(HttpResponse.OK($"Variable 1: {param1}\nVariable 2: {param2}"));
}
}
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<title>HTTP Test</title>
<style>
body {
color: white;
background-color: #151515;
font-family: sans-serif;
}
a {
color: #79d3ff;
}
a:visited {
color: #719bff;
}
</style>
</head>
<body>
<h1>Example website</h1>
<div style="padding-left:20px;">
<a href="./websocket">Websocket test</a><br>
<a href="./custom/foo/bar">Custom route</a><br>
<a href="./test">test.htm</a><br>
<br>
<a href="./subpath/foo">Foo</a><br>
<a href="./subpath/bar">Bar</a><br>
</div>
</body>
</html>

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>HTTP Test</title>
<link rel="icon" id="icon" href="data:;base64,iVBORw0KGgo=">
</head>
<body>
Test
</body>
</html>

View file

@ -0,0 +1 @@
Hello world

View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<title>HTTP Test</title>
<style>
body {
color: white;
background-color: #151515;
font-family: sans-serif;
}
#log {
color: #afc3ff;
background-color: #2a2d35;
font-family: monospace,monospace;
padding: 10px;
border-radius: 5px;
border: solid white 1px;
width: fit-content;
min-width: 300px;
}
</style>
</head>
<body>
<h1>Websocket test</h1>
<div id="log"></div>
<script src="./websocket.js"></script>
</body>
</html>

View file

@ -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);
};

29
HTTP.sln Normal file
View file

@ -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

9
HTTP/HTTP.csproj Normal file
View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Uwaa.HTTP</RootNamespace>
<AssemblyName>Uwaa.HTTP</AssemblyName>
</PropertyGroup>
</Project>

149
HTTP/HttpClient.cs Normal file
View file

@ -0,0 +1,149 @@
using System.Net.Security;
using System.Net.Sockets;
namespace Uwaa.HTTP;
/// <summary>
/// A hypertext transfer protocol client which can fetch content over HTTP, optionally over SSL (HTTPS).
/// </summary>
public sealed class HttpClient
{
/// <summary>
/// Performs a single HTTP(S) request and closes.
/// </summary>
/// <param name="host">The hostname or IP to send the request to.</param>
/// <param name="secure">If true, HTTPS will be used.</param>
/// <param name="req">The request to send.</param>
/// <returns>The response from the server.</returns>
/// <exception cref="InvalidOperationException">Thrown if the URI is invalid.</exception>
public static Task<HttpResponse> Request(string host, bool secure, HttpRequest req)
{
return Request(host, secure ? 443 : 80, secure, req);
}
/// <summary>
/// Performs a single HTTP(S) request and closes.
/// </summary>
/// <param name="host">The hostname or IP to send the request to.</param>
/// <param name="port">The port number to connect to.</param>
/// <param name="secure">If true, HTTPS will be used.</param>
/// <param name="request">The request to send.</param>
/// <returns>The response from the server.</returns>
public static async Task<HttpResponse> 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();
}
/// <summary>
/// The host to connect to.
/// </summary>
public readonly string Host;
/// <summary>
/// If true, attempt to connect via SSL.
/// </summary>
public readonly bool Secure;
/// <summary>
/// The maximum time the socket may be inactive before it is presumed dead and is closed.
/// </summary>
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);
}
/// <summary>
/// Queues sending a request to the host and returns the response.
/// </summary>
public async Task<HttpResponse> Fetch(HttpRequest request)
{
await semaphore.WaitAsync();
try
{
return await FetchInner(request).WaitAsync(Timeout);
}
finally
{
semaphore.Release();
}
}
async Task<HttpResponse> 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;
}
}
}
}
}

31
HTTP/HttpClientInfo.cs Normal file
View file

@ -0,0 +1,31 @@
using System.Net.Sockets;
using System.Net;
namespace Uwaa.HTTP;
/// <summary>
/// Information about a client which connected to a <see cref="HttpServer"/>.
/// </summary>
public class HttpClientInfo
{
/// <summary>
/// The TCP client sending this request.
/// </summary>
public readonly TcpClient TcpClient;
/// <summary>
/// The IP address and port of the requester.
/// </summary>
public readonly IPEndPoint Endpoint;
/// <summary>
/// If true, the request is connected.
/// </summary>
public bool Connected => TcpClient.Connected;
internal HttpClientInfo(TcpClient client, IPEndPoint endpoint)
{
TcpClient = client;
Endpoint = endpoint;
}
}

44
HTTP/HttpContent.cs Normal file
View file

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

11
HTTP/HttpException.cs Normal file
View file

@ -0,0 +1,11 @@
namespace Uwaa.HTTP;
/// <summary>
/// An exception caused during HTTP reading and parsing.
/// </summary>
class HttpException : IOException
{
public HttpException(string? message, Exception? innerException = null) : base(message, innerException)
{
}
}

172
HTTP/HttpFields.cs Normal file
View file

@ -0,0 +1,172 @@
namespace Uwaa.HTTP;
/// <summary>
/// General purpose implementation of <see cref="IHttpFields"/>.
/// </summary>
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;
/// <summary>
/// Extra fields to include.
/// </summary>
public Dictionary<string, string>? Misc;
/// <summary>
/// Sets a field. The string will be parsed for non-string fields like Accept.
/// </summary>
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<string, string>(StringComparer.OrdinalIgnoreCase);
Misc[key] = value;
}
return;
}
}
}
/// <summary>
/// Generates strings for the fields and sends them to the provided callback.
/// </summary>
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,
}

66
HTTP/HttpHelpers.cs Normal file
View file

@ -0,0 +1,66 @@
namespace Uwaa.HTTP;
/// <summary>
/// Helper methods for HTTP.
/// </summary>
public static class HttpHelpers
{
/// <summary>
/// Parses an array of MIME types from an Accept field.
/// </summary>
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;
}
}

18
HTTP/HttpMethod.cs Normal file
View file

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

173
HTTP/HttpRequest.cs Normal file
View file

@ -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;
/// <summary>
/// Contains the method, path, query, headers, and content of a HTTP request.
/// </summary>
public class HttpRequest
{
/// <summary>
/// The HTTP method of the request.
/// </summary>
public HttpMethod Method = HttpMethod.Any;
/// <summary>
/// The HTTP path being requested.
/// </summary>
public string Path = string.Empty;
/// <summary>
/// Additional paramters in the path of the request.
/// </summary>
public NameValueCollection? Query;
/// <summary>
/// HTTP header fields included in the request.
/// </summary>
public HttpFields Fields;
/// <summary>
/// The body of the HTTP request, if any.
/// </summary>
public HttpContent? Content;
/// <summary>
/// If true, the connection is requesting to be upgrading to a websocket.
/// </summary>
public bool IsWebsocket => Fields.Upgrade == "websocket";
/// <summary>
/// Whether or not the request is valid.
/// </summary>
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);
}
/// <summary>
/// Returns true if the client is accepting the provided type, otherwise false.
/// </summary>
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();
}
/// <summary>
/// Generates a response which upgrades the connection to a websocket.
/// </summary>
/// <param name="callback">The websocket execution function to call.</param>
/// <param name="protocols">Subprotocols which can be accepted. If null or empty, any protocol will be accepted.</param>
/// <returns>Returns a <seealso cref="SwitchingProtocols"/> response.</returns>
/// <remarks>
/// If an upgrade has not been requested or no subprotocol can be negotiated, this will return null.
/// </remarks>
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);
}
}

120
HTTP/HttpResponse.cs Normal file
View file

@ -0,0 +1,120 @@
using System.Text;
using Uwaa.HTTP.Websockets;
namespace Uwaa.HTTP;
public class SwitchingProtocols : HttpResponse
{
/// <summary>
/// Called once a HTTP request is upgrade to a websocket.
/// </summary>
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;
}
}
/// <summary>
/// Contains the status code, status message, fields, and content of a HTTP response.
/// </summary>
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;
/// <summary>
/// HTTP header fields included in the response.
/// </summary>
public readonly HttpFields Fields;
/// <summary>
/// The body of the HTTP body, if any.
/// </summary>
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);

234
HTTP/HttpServer.cs Normal file
View file

@ -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;
/// <summary>
/// A hypertext transfer protocol server which can listen on a port and deliver content over HTTP, optionally over SSL (HTTPS).
/// </summary>
public sealed class HttpServer
{
/// <summary>
/// The router to be used to find and serve content to clients.
/// </summary>
public readonly RouterBase Router;
/// <summary>
/// If non-null, all connections will be secured via SSL using this certificate.
/// </summary>
public readonly X509Certificate? Certificate;
/// <summary>
/// Maximum number of connections per IP before new connections are rejected.
/// </summary>
/// <remarks>
/// Prevents Slow Loris attacks.
/// </remarks>
public int MaxConnections = 20;
/// <summary>
/// The port the server will listen on.
/// </summary>
public int Port => ((IPEndPoint)listener.LocalEndpoint).Port;
/// <summary>
/// Called when a client establishes a connection with the server.
/// </summary>
public event Action<TcpClient>? OnConnectionBegin;
/// <summary>
/// Called when a connection has terminated.
/// </summary>
public event Action<IPEndPoint>? OnConnectionEnd;
/// <summary>
/// Called when a request has been served a response.
/// </summary>
public event Action<HttpRequest, HttpResponse>? OnResponse;
/// <summary>
/// The maximum time the socket may be inactive before it is presumed dead and closed.
/// </summary>
public TimeSpan Timeout = TimeSpan.FromSeconds(20);
readonly Dictionary<IPAddress, int> IPCounts = new Dictionary<IPAddress, int>();
readonly SemaphoreSlim IPCountsLock = new SemaphoreSlim(1, 1);
readonly TcpListener listener;
public HttpServer(int port, X509Certificate? certificate, 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;
}
/// <summary>
/// Begins listening for new connections.
/// </summary>
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<string> 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);
}
}
}

256
HTTP/HttpStream.cs Normal file
View file

@ -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
{
/// <summary>
/// The underlying TCP stream.
/// </summary>
readonly Stream Stream;
/// <summary>
/// The read/write buffer.
/// </summary>
readonly BufferedStream Buffer;
/// <summary>
/// Text decoder.
/// </summary>
readonly Decoder Decoder;
public HttpStream(Stream stream) : base()
{
Stream = stream;
Buffer = new BufferedStream(stream);
Decoder = Encoding.ASCII.GetDecoder();
}
public async ValueTask<string> ReadLine()
{
const int maxChars = 4096;
byte[] dataBuffer = ArrayPool<byte>.Shared.Rent(1);
char[] charBuffer = ArrayPool<char>.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<char>.Shared.Return(charBuffer, true);
ArrayPool<byte>.Shared.Return(dataBuffer);
}
}
public ValueTask<int> Read(Memory<byte> 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<byte> 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<HttpFields> 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<HttpContent?> 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<HttpRequest> 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<HttpResponse> 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);
}
}
/// <summary>
/// Disposes the underlying stream.
/// </summary>
public void Dispose()
{
((IDisposable)Stream).Dispose();
}
}

98
HTTP/MIMEType.cs Normal file
View file

@ -0,0 +1,98 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Uwaa.HTTP;
/// <summary>
/// Represents a Multipurpose Internet Mail Extensions type, which indicates the nature and format of a document/file/chunk of data.
/// </summary>
[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);
/// <summary>
/// The first part of the MIME type, representing the general category into which the data type falls.
/// </summary>
public readonly string? Type { get; init; }
/// <summary>
/// The second part of the MIME type, identifying the exact kind of data the MIME type represents.
/// </summary>
public readonly string? Subtype { get; init; }
/// <summary>
/// Parses a MIME type string.
/// </summary>
/// <param name="text">The string to parse.</param>
/// <exception cref="FormatException">Thrown if MIME type is missing a subtype.</exception>
public MIMEType(ReadOnlySpan<char> 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)..]);
}
/// <summary>
/// Constructs a MIME type from its two components.
/// </summary>
public MIMEType(string? type, string? subType)
{
Type = type;
Subtype = subType;
}
/// <summary>
/// Determines if the given MIME type matches the pattern specified by this MIME type.
/// </summary>
public readonly bool Match(MIMEType type)
{
return (Type == null || Type.Equals(type.Type, StringComparison.OrdinalIgnoreCase)) && (Subtype == null || Subtype.Equals(type.Subtype, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Generates a string for the MIME type.
/// </summary>
public override readonly string ToString() => $"{Type ?? "*"}/{Subtype ?? "*"}";
}
class MIMETypeConverter : JsonConverter<MIMEType>
{
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());
}
}

1
HTTP/README.md Normal file
View file

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

33
HTTP/Routing/CORS.cs Normal file
View file

@ -0,0 +1,33 @@
namespace Uwaa.HTTP.Routing;
/// <summary>
/// Handles CORS preflight requests.
/// </summary>
public class CORS : RouterBase
{
public override HttpMethod Method => HttpMethod.OPTIONS;
public override int Arguments => 0;
protected override Task<HttpResponse?> GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment<string> path)
{
return Task.FromResult<HttpResponse?>(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");
}
}

View file

@ -0,0 +1,107 @@
using System.Text.RegularExpressions;
namespace Uwaa.HTTP.Routing;
/// <summary>
/// Special router which serves static files from the filesystem.
/// </summary>
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
};
}
/// <summary>
/// The source directory from which assets should be served.
/// </summary>
public string Directory;
public override HttpMethod Method => HttpMethod.GET;
public override int Arguments => 1;
public FileEndpoint(string directory)
{
Directory = directory;
}
protected override async Task<HttpResponse?> GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment<string> 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));
}
/// <summary>
/// Ensures filenames are legal.
/// </summary>
/// <remarks>
/// Enforcing a set of legal characters in filenames reduces the potential attack surface against the server.
/// </remarks>
/// <returns>Returns a regular expression which checks for invalid characters.</returns>
[GeneratedRegex(@"[^a-zA-Z0-9_\-.()\[\] ]")]
private static partial Regex FilenameChecker();
}

View file

@ -0,0 +1,24 @@
namespace Uwaa.HTTP.Routing;
public delegate Task<HttpResponse?> 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<HttpResponse?> GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment<string> path)
{
return Function(req, info);
}
}

70
HTTP/Routing/Router.cs Normal file
View file

@ -0,0 +1,70 @@
namespace Uwaa.HTTP.Routing;
/// <summary>
/// Selects one of multiple possible routers.
/// </summary>
public class Router : RouterBase
{
public record Route(string? Key, RouterBase Router);
public readonly List<Route> Routes = new List<Route>();
public override HttpMethod Method => HttpMethod.Any;
public override int Arguments => 0;
protected override async Task<HttpResponse?> GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment<string> 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;
}
/// <summary>
/// Adds a route to the router on the given sub path.
/// </summary>
public void Add(string? key, RouterBase router) => Routes.Add(new Route(key, router));
/// <summary>
/// Adds a route to the router.
/// </summary>
public void Add(RouterBase router) => Add(null, router);
/// <summary>
/// Adds an endpoint to the router on the given sub path.
/// </summary>
public void Add(string? key, FuncEndpointDelegateAsync func) => Routes.Add(new Route(key, new FuncEndpoint(func)));
/// <summary>
/// Adds a wildcard endpoint to the router.
/// </summary>
public void Add(FuncEndpointDelegateAsync func) => Add(null, func);
/// <summary>
/// Adds an endpoint to the router on the given sub path.
/// </summary>
public void Add(string? key, FuncEndpointDelegate func) => Add(key, (req, info) => Task.FromResult(func(req, info)));
/// <summary>
/// Adds a wildcard endpoint to the router.
/// </summary>
public void Add(FuncEndpointDelegate func) => Add(null, func);
/// <summary>
/// Adds a static response to the router on the given sub path.
/// </summary>
public void Add(string? key, HttpResponse response) => Routes.Add(new Route(key, new StaticEndpoint(response)));
/// <summary>
/// Adds a wildcard static response to the router.
/// </summary>
public void Add(HttpResponse response) => Add(null, response);
}

View file

@ -0,0 +1,38 @@
namespace Uwaa.HTTP.Routing;
/// <summary>
/// Parses a path and generates or acquires a response.
/// </summary>
public abstract class RouterBase
{
/// <summary>
/// Which HTTP method the router will respond to.
/// </summary>
public abstract HttpMethod Method { get; }
/// <summary>
/// The minimum number of path segments required to produce a response.
/// </summary>
public abstract int Arguments { get; }
public Task<HttpResponse?> GetResponse(HttpRequest req, HttpClientInfo info, ArraySegment<string> path)
{
HttpMethod method = Method;
if (method != HttpMethod.Any && req.Method != method)
return Task.FromResult<HttpResponse?>(null);
int arguments = Arguments;
if (path.Count < arguments)
return Task.FromResult<HttpResponse?>(null);
return GetResponseInner(req, info, path);
}
/// <summary>
/// Inline method for routing.
/// </summary>
/// <param name="req">The request for which the response is being generated.</param>
/// <param name="info">Information about the client.</param>
/// <param name="path">The path segments relevant to the router.</param>
protected abstract Task<HttpResponse?> GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment<string> path);
}

View file

@ -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<HttpResponse?> GetResponseInner(HttpRequest req, HttpClientInfo info, ArraySegment<string> path)
{
return Task.FromResult(Response)!;
}
}

View file

@ -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
}

View file

@ -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<byte> Payload;
public DataFrame(WSOpcode opcode, bool endOfMessage, ArraySegment<byte> payload)
{
Opcode = opcode;
EndOfMessage = endOfMessage;
Payload = payload;
}
public string AsString()
{
return Encoding.UTF8.GetString(Payload);
}
}

View file

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

View file

@ -0,0 +1,289 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Text;
namespace Uwaa.HTTP.Websockets;
/// <summary>
/// A websocket wrapper over a HTTP stream.
/// </summary>
public class Websocket
{
/// <summary>
/// The chosen sub-protocol negotiated with the remote endpoint.
/// </summary>
public readonly string? SubProtocol;
internal readonly HttpStream Stream;
readonly List<byte> finalPayload = new List<byte>();
WSOpcode currentOpcode;
internal Websocket(HttpStream stream, string? subProtocol)
{
Stream = stream;
SubProtocol = subProtocol;
}
/// <summary>
/// Reads a data frame from the websocket.
/// </summary>
/// <returns></returns>
/// <exception cref="EndOfStreamException">Thrown when the stream stops unexpectedly.</exception>
/// <exception cref="IOException">Thrown when the client declares a payload which is too large.</exception>
public async Task<DataFrame> Read()
{
var pool = ArrayPool<byte>.Shared;
byte[] recvBuffer = pool.Rent(10);
try
{
while (true)
{
//First byte
if (await 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<byte> payload = new ArraySegment<byte>(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<byte>()));
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<byte>.Shared;
byte[] writeBuf = pool.Rent(10);
try
{
byte firstByte = 0;
if (frame.EndOfMessage)
firstByte |= 0b10000000;
firstByte |= (byte)((int)frame.Opcode & 0b00001111);
writeBuf[0] = firstByte;
await 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<byte>.Shared;
byte[] closeBuf = pool.Rent(2);
try
{
BinaryPrimitives.WriteUInt16BigEndian(closeBuf, (ushort)status);
await Write(new DataFrame(WSOpcode.Close, true, new ArraySegment<byte>(closeBuf, 0, 2)));
}
finally
{
pool.Return(closeBuf);
}
}
}
/// <summary>
/// A remote websocket connected to a local HTTP server.
/// </summary>
public class WebsocketRemote : Websocket
{
/// <summary>
/// The HTTP request encompassing the websocket.
/// </summary>
public readonly HttpRequest Request;
/// <summary>
/// The HTTP request encompassing the websocket.
/// </summary>
public readonly HttpClientInfo ClientInfo;
internal WebsocketRemote(HttpRequest request, HttpClientInfo clientInfo, HttpStream stream, string? subProtocol) : base(stream, subProtocol)
{
Request = request;
ClientInfo = clientInfo;
}
}

View file

@ -0,0 +1,8 @@
namespace Uwaa.HTTP.Websockets;
/// <summary>
/// A delegate called once a HTTP request is upgrade to a websocket.
/// </summary>
/// <param name="ws">The websocket to the remote endpoint.</param>
/// <returns>The status to send to the client when closing the websocket.</returns>
public delegate Task<CloseStatus> WebsocketHandler(WebsocketRemote ws);

25
PNG.sln Normal file
View file

@ -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

95
PNG/Adam7.cs Normal file
View file

@ -0,0 +1,95 @@
namespace Uwaa.PNG;
internal static class Adam7
{
/// <summary>
/// For a given pass number (1 indexed) the scanline indexes of the lines included in that pass in the 8x8 grid.
/// </summary>
static readonly IReadOnlyDictionary<int, int[]> PassToScanlineGridIndex = new Dictionary<int, int[]>
{
{ 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<int, int[]> PassToScanlineColumnIndex = new Dictionary<int, int[]>
{
{ 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]);
}
}

58
PNG/ChunkHeader.cs Normal file
View file

@ -0,0 +1,58 @@
namespace Uwaa.PNG;
/// <summary>
/// The header for a data chunk in a PNG file.
/// </summary>
public readonly struct ChunkHeader
{
/// <summary>
/// The position/start of the chunk header within the stream.
/// </summary>
public long Position { get; }
/// <summary>
/// The length of the chunk in bytes.
/// </summary>
public int Length { get; }
/// <summary>
/// The name of the chunk, uppercase first letter means the chunk is critical (vs. ancillary).
/// </summary>
public string Name { get; }
/// <summary>
/// Whether the chunk is critical (must be read by all readers) or ancillary (may be ignored).
/// </summary>
public bool IsCritical => char.IsUpper(Name[0]);
/// <summary>
/// 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.
/// </summary>
public bool IsPublic => char.IsUpper(Name[1]);
/// <summary>
/// Whether the (if unrecognized) chunk is safe to copy.
/// </summary>
public bool IsSafeToCopy => char.IsUpper(Name[3]);
/// <summary>
/// Create a new <see cref="ChunkHeader"/>.
/// </summary>
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}).";
}
}

28
PNG/ColorType.cs Normal file
View file

@ -0,0 +1,28 @@
namespace Uwaa.PNG;
/// <summary>
/// Describes the interpretation of the image data.
/// </summary>
[Flags]
public enum ColorType : byte
{
/// <summary>
/// Grayscale.
/// </summary>
None = 0,
/// <summary>
/// Colors are stored in a palette rather than directly in the data.
/// </summary>
PaletteUsed = 1,
/// <summary>
/// The image uses color.
/// </summary>
ColorUsed = 2,
/// <summary>
/// The image has an alpha channel.
/// </summary>
AlphaChannelUsed = 4
}

84
PNG/Crc32.cs Normal file
View file

@ -0,0 +1,84 @@
namespace Uwaa.PNG;
/// <summary>
/// 32-bit Cyclic Redundancy Code used by the PNG for checking the data is intact.
/// </summary>
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;
}
}
/// <summary>
/// Calculate the CRC32 for data.
/// </summary>
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;
}
/// <summary>
/// Calculate the CRC32 for data.
/// </summary>
public static uint Calculate(List<byte> 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;
}
/// <summary>
/// Calculate the combined CRC32 for data.
/// </summary>
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;
}
}

189
PNG/Decoder.cs Normal file
View file

@ -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);
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
}

25
PNG/FilterType.cs Normal file
View file

@ -0,0 +1,25 @@
namespace Uwaa.PNG;
internal enum FilterType
{
/// <summary>
/// The raw byte is unaltered.
/// </summary>
None = 0,
/// <summary>
/// The byte to the left.
/// </summary>
Sub = 1,
/// <summary>
/// The byte above.
/// </summary>
Up = 2,
/// <summary>
/// The mean of bytes left and above, rounded down.
/// </summary>
Average = 3,
/// <summary>
/// Byte to the left, above or top-left based on Paeth's algorithm.
/// </summary>
Paeth = 4
}

71
PNG/ImageHeader.cs Normal file
View file

@ -0,0 +1,71 @@
namespace Uwaa.PNG;
/// <summary>
/// The high level information about the image.
/// </summary>
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<ColorType, HashSet<byte>> PermittedBitDepths = new Dictionary<ColorType, HashSet<byte>>
{
{ColorType.None, new HashSet<byte> {1, 2, 4, 8, 16}},
{ColorType.ColorUsed, new HashSet<byte> {8, 16}},
{ColorType.PaletteUsed | ColorType.ColorUsed, new HashSet<byte> {1, 2, 4, 8}},
{ColorType.AlphaChannelUsed, new HashSet<byte> {8, 16}},
{ColorType.AlphaChannelUsed | ColorType.ColorUsed, new HashSet<byte> {8, 16}},
};
/// <summary>
/// The width of the image in pixels.
/// </summary>
public int Width { get; }
/// <summary>
/// The height of the image in pixels.
/// </summary>
public int Height { get; }
/// <summary>
/// The bit depth of the image.
/// </summary>
public byte BitDepth { get; }
/// <summary>
/// The color type of the image.
/// </summary>
public ColorType ColorType { get; }
/// <summary>
/// The interlace method used by the image..
/// </summary>
public InterlaceMethod InterlaceMethod { get; }
/// <summary>
/// Create a new <see cref="ImageHeader"/>.
/// </summary>
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}.";
}
}

17
PNG/InterlaceMethod.cs Normal file
View file

@ -0,0 +1,17 @@
namespace Uwaa.PNG;
/// <summary>
/// Indicates the transmission order of the image data.
/// </summary>
public enum InterlaceMethod : byte
{
/// <summary>
/// No interlace.
/// </summary>
None = 0,
/// <summary>
/// Adam7 interlace.
/// </summary>
Adam7 = 1
}

8
PNG/PNG.csproj Normal file
View file

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

42
PNG/Palette.cs Normal file
View file

@ -0,0 +1,42 @@
using System.Runtime.CompilerServices;
namespace Uwaa.PNG;
internal class Palette
{
public bool HasAlphaValues { get; private set; }
public byte[] Data { get; }
/// <summary>
/// Creates a palette object. Input palette data length from PLTE chunk must be a multiple of 3.
/// </summary>
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;
}
}
/// <summary>
/// Adds transparency values from tRNS chunk.
/// </summary>
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<byte, Pixel>(ref Data[index * 4]);
}
}

93
PNG/Pixel.cs Normal file
View file

@ -0,0 +1,93 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Uwaa.PNG;
/// <summary>
/// A 32-bit RGBA pixel in a <see cref="Png"/> image.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public readonly struct Pixel : IEquatable<Pixel>
{
/// <summary>
/// The red value for the pixel.
/// </summary>
public byte R { get; }
/// <summary>
/// The green value for the pixel.
/// </summary>
public byte G { get; }
/// <summary>
/// The blue value for the pixel.
/// </summary>
public byte B { get; }
/// <summary>
/// The alpha transparency value for the pixel.
/// </summary>
public byte A { get; }
/// <summary>
/// Create a new <see cref="Pixel"/>.
/// </summary>
/// <param name="r">The red value for the pixel.</param>
/// <param name="g">The green value for the pixel.</param>
/// <param name="b">The blue value for the pixel.</param>
/// <param name="a">The alpha transparency value for the pixel.</param>
public Pixel(byte r, byte g, byte b, byte a)
{
R = r;
G = g;
B = b;
A = a;
}
/// <summary>
/// Create a new <see cref="Pixel"/> which is fully opaque.
/// </summary>
/// <param name="r">The red value for the pixel.</param>
/// <param name="g">The green value for the pixel.</param>
/// <param name="b">The blue value for the pixel.</param>
public Pixel(byte r, byte g, byte b)
{
R = r;
G = g;
B = b;
A = 255;
}
/// <summary>
/// Create a new grayscale <see cref="Pixel"/>.
/// </summary>
/// <param name="grayscale">The grayscale value.</param>
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);
/// <summary>
/// Whether the pixel values are equal.
/// </summary>
/// <param name="other">The other pixel.</param>
/// <returns><see langword="true"/> if all pixel values are equal otherwise <see langword="false"/>.</returns>
public bool Equals(Pixel other)
{
Pixel this_ = this;
return Unsafe.As<Pixel, uint>(ref this_) == Unsafe.As<Pixel, uint>(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);
}

80
PNG/Png.cs Normal file
View file

@ -0,0 +1,80 @@
namespace Uwaa.PNG;
/// <summary>
/// A PNG image. Call <see cref="Open(byte[])"/> to open from file or bytes.
/// </summary>
public class Png
{
readonly RawPngData data;
readonly bool hasTransparencyChunk;
/// <summary>
/// The header data from the PNG image.
/// </summary>
public ImageHeader Header { get; }
/// <summary>
/// The width of the image in pixels.
/// </summary>
public int Width => Header.Width;
/// <summary>
/// The height of the image in pixels.
/// </summary>
public int Height => Header.Height;
/// <summary>
/// Whether the image has an alpha (transparency) layer.
/// </summary>
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;
}
/// <summary>
/// Get the pixel at the given column and row (x, y).
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="x">The x coordinate (column).</param>
/// <param name="y">The y coordinate (row).</param>
/// <returns>The pixel at the coordinate.</returns>
public Pixel GetPixel(int x, int y) => data.GetPixel(x, y);
/// <summary>
/// Read the PNG image from the stream.
/// </summary>
/// <param name="stream">The stream containing PNG data to be read.</param>
/// <returns>The <see cref="Png"/> data from the stream.</returns>
public static Png Open(Stream stream)
=> PngOpener.Open(stream);
/// <summary>
/// Read the PNG image from the bytes.
/// </summary>
/// <param name="bytes">The bytes of the PNG data to be read.</param>
/// <returns>The <see cref="Png"/> data from the bytes.</returns>
public static Png Open(byte[] bytes)
{
using var memoryStream = new MemoryStream(bytes);
return PngOpener.Open(memoryStream);
}
/// <summary>
/// Read the PNG from the file path.
/// </summary>
/// <param name="path">The path to the PNG file to open.</param>
/// <remarks>This will open the file to obtain a <see cref="FileStream"/> so will lock the file during reading.</remarks>
/// <returns>The <see cref="Png"/> data from the file.</returns>
public static Png Open(string path)
{
using var fileStream = File.OpenRead(path);
return Open(fileStream);
}
}

514
PNG/PngBuilder.cs Normal file
View file

@ -0,0 +1,514 @@
using System.IO.Compression;
using System.Text;
namespace Uwaa.PNG;
/// <summary>
/// Used to construct PNG images. Call <see cref="Create"/> to make a new builder.
/// </summary>
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<int, int> colorCounts;
readonly List<(string keyword, byte[] data)> storedStrings = new List<(string keyword, byte[] data)>();
/// <summary>
/// Create a builder for a PNG with the given width and size.
/// </summary>
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);
}
/// <summary>
/// Create a builder from a <see cref="Png"/>.
/// </summary>
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;
}
/// <summary>
/// Create a builder from the bytes of the specified PNG image.
/// </summary>
public static PngBuilder FromPngBytes(byte[] png)
{
var pngActual = Png.Open(png);
return FromPng(pngActual);
}
/// <summary>
/// Create a builder from the bytes in the BGRA32 pixel format.
/// https://docs.microsoft.com/en-us/dotnet/api/system.windows.media.pixelformats.bgra32
/// </summary>
/// <param name="data">The pixels in BGRA32 format.</param>
/// <param name="width">The width in pixels.</param>
/// <param name="height">The height in pixels.</param>
/// <param name="useAlphaChannel">Whether to include an alpha channel in the output.</param>
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);
}
/// <summary>
/// Create a builder from the bytes in the BGRA32 pixel format.
/// https://docs.microsoft.com/en-us/dotnet/api/system.windows.media.pixelformats.bgra32
/// </summary>
/// <param name="data">The pixels in BGRA32 format.</param>
/// <param name="width">The width in pixels.</param>
/// <param name="height">The height in pixels.</param>
/// <param name="useAlphaChannel">Whether to include an alpha channel in the output.</param>
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<int, int>()
{
{ backgroundColorInt, width * height}
};
}
/// <summary>
/// Sets the RGB pixel value for the given column (x) and row (y).
/// </summary>
public void SetPixel(byte r, byte g, byte b, int x, int y) => SetPixel(new Pixel(r, g, b), x, y);
/// <summary>
/// Set the pixel value for the given column (x) and row (y).
/// </summary>
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;
}
}
/// <summary>
/// Allows you to store arbitrary text data in the "iTXt" international textual data
/// chunks of the generated PNG image.
/// </summary>
/// <param name="keyword">
/// 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.
/// </param>
/// <param name="text">
/// The text data to store. Encoded as UTF-8 that may not contain zero (0) bytes but can be zero-length.
/// </param>
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;
}
/// <summary>
/// Get the bytes of the PNG file for this builder.
/// </summary>
public byte[] Save(SaveOptions? options = null)
{
using var memoryStream = new MemoryStream();
Save(memoryStream, options);
return memoryStream.ToArray();
}
/// <summary>
/// Write the PNG file bytes to the provided stream.
/// </summary>
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;
}
}
/// <summary>
/// Calculate the Adler-32 checksum for some data.
/// </summary>
/// <remarks>
/// Complies with the RFC 1950: ZLIB Compressed Data Format Specification.
/// </remarks>
public static int Adler32Checksum(IEnumerable<byte> 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;
}
/// <summary>
/// Attempt to improve compressability of the raw data by using adaptive filtering.
/// </summary>
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));
/// <summary>
/// Options for configuring generation of PNGs from a <see cref="PngBuilder"/>.
/// </summary>
public class SaveOptions
{
/// <summary>
/// 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.
/// </summary>
public bool AttemptCompression { get; set; }
/// <summary>
/// The number of parallel tasks allowed during compression.
/// </summary>
public int MaxDegreeOfParallelism { get; set; } = 1;
}
}

158
PNG/PngOpener.cs Normal file
View file

@ -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<byte> 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<byte> 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);
}
}

View file

@ -0,0 +1,57 @@
namespace Uwaa.PNG;
internal class PngStreamWriteHelper : Stream
{
readonly Stream inner;
readonly List<byte> written = new List<byte>();
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);
}
}

1
PNG/README.md Normal file
View file

@ -0,0 +1 @@
Minimal PNG encoder and decoder based on biggustav.

120
PNG/RawPngData.cs Normal file
View file

@ -0,0 +1,120 @@
namespace Uwaa.PNG;
/// <summary>
/// Provides convenience methods for indexing into a raw byte array to extract pixel values.
/// </summary>
internal class RawPngData
{
readonly byte[] data;
readonly int bytesPerPixel;
readonly int width;
readonly Palette? palette;
readonly ColorType colorType;
readonly int rowOffset;
readonly int bitDepth;
/// <summary>
/// Create a new <see cref="RawPngData"/>.
/// </summary>
/// <param name="data">The decoded pixel data as bytes.</param>
/// <param name="bytesPerPixel">The number of bytes in each pixel.</param>
/// <param name="palette">The palette for the image.</param>
/// <param name="imageHeader">The image header.</param>
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;
}
}

26
PNG/StreamHelper.cs Normal file
View file

@ -0,0 +1,26 @@
using System.Buffers.Binary;
namespace Uwaa.PNG;
internal static class StreamHelper
{
public static int ReadBigEndianInt32(Stream stream)
{
Span<byte> buffer = stackalloc byte[4];
stream.Read(buffer);
return BinaryPrimitives.ReadInt32BigEndian(buffer);
}
public static void WriteBigEndianInt32(Stream stream, int value)
{
Span<byte> 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;
}
}