initial commit
This commit is contained in:
commit
a0b2c9f9b1
57 changed files with 4050 additions and 0 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*.cs eol=crlf
|
||||||
|
*.txt eol=lf
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.vs
|
||||||
|
.vscode
|
||||||
|
bin
|
||||||
|
obj
|
31
HTTP.Example.sln
Normal file
31
HTTP.Example.sln
Normal 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
|
22
HTTP.Example/HTTP.Example.csproj
Normal file
22
HTTP.Example/HTTP.Example.csproj
Normal 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
154
HTTP.Example/Program.cs
Normal 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}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
BIN
HTTP.Example/certs/localhost.pfx
Normal file
BIN
HTTP.Example/certs/localhost.pfx
Normal file
Binary file not shown.
BIN
HTTP.Example/www-static/favicon.ico
Normal file
BIN
HTTP.Example/www-static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
30
HTTP.Example/www-static/index.htm
Normal file
30
HTTP.Example/www-static/index.htm
Normal 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>
|
10
HTTP.Example/www-static/test.htm
Normal file
10
HTTP.Example/www-static/test.htm
Normal 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>
|
1
HTTP.Example/www-static/test.txt
Normal file
1
HTTP.Example/www-static/test.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Hello world
|
28
HTTP.Example/www-static/websocket.htm
Normal file
28
HTTP.Example/www-static/websocket.htm
Normal 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>
|
25
HTTP.Example/www-static/websocket.js
Normal file
25
HTTP.Example/www-static/websocket.js
Normal 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
29
HTTP.sln
Normal 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
9
HTTP/HTTP.csproj
Normal 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
149
HTTP/HttpClient.cs
Normal 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
31
HTTP/HttpClientInfo.cs
Normal 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
44
HTTP/HttpContent.cs
Normal 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
11
HTTP/HttpException.cs
Normal 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
172
HTTP/HttpFields.cs
Normal 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
66
HTTP/HttpHelpers.cs
Normal 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
18
HTTP/HttpMethod.cs
Normal 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
173
HTTP/HttpRequest.cs
Normal 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
120
HTTP/HttpResponse.cs
Normal 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
234
HTTP/HttpServer.cs
Normal 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
256
HTTP/HttpStream.cs
Normal 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
98
HTTP/MIMEType.cs
Normal 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
1
HTTP/README.md
Normal 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
33
HTTP/Routing/CORS.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
107
HTTP/Routing/FileEndpoint.cs
Normal file
107
HTTP/Routing/FileEndpoint.cs
Normal 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();
|
||||||
|
}
|
24
HTTP/Routing/FuncEndpoint.cs
Normal file
24
HTTP/Routing/FuncEndpoint.cs
Normal 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
70
HTTP/Routing/Router.cs
Normal 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);
|
||||||
|
}
|
38
HTTP/Routing/RouterBase.cs
Normal file
38
HTTP/Routing/RouterBase.cs
Normal 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);
|
||||||
|
}
|
20
HTTP/Routing/StaticEndpoint.cs
Normal file
20
HTTP/Routing/StaticEndpoint.cs
Normal 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)!;
|
||||||
|
}
|
||||||
|
}
|
16
HTTP/Websockets/CloseStatus.cs
Normal file
16
HTTP/Websockets/CloseStatus.cs
Normal 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
|
||||||
|
}
|
24
HTTP/Websockets/DataFrame.cs
Normal file
24
HTTP/Websockets/DataFrame.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
12
HTTP/Websockets/WSOpcode.cs
Normal file
12
HTTP/Websockets/WSOpcode.cs
Normal 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,
|
||||||
|
}
|
289
HTTP/Websockets/Websocket.cs
Normal file
289
HTTP/Websockets/Websocket.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
8
HTTP/Websockets/WebsocketHandler.cs
Normal file
8
HTTP/Websockets/WebsocketHandler.cs
Normal 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
25
PNG.sln
Normal 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
95
PNG/Adam7.cs
Normal 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
58
PNG/ChunkHeader.cs
Normal 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
28
PNG/ColorType.cs
Normal 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
84
PNG/Crc32.cs
Normal 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
189
PNG/Decoder.cs
Normal 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
25
PNG/FilterType.cs
Normal 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
71
PNG/ImageHeader.cs
Normal 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
17
PNG/InterlaceMethod.cs
Normal 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
8
PNG/PNG.csproj
Normal 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
42
PNG/Palette.cs
Normal 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
93
PNG/Pixel.cs
Normal 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
80
PNG/Png.cs
Normal 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
514
PNG/PngBuilder.cs
Normal 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
158
PNG/PngOpener.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
57
PNG/PngStreamWriteHelper.cs
Normal file
57
PNG/PngStreamWriteHelper.cs
Normal 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
1
PNG/README.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Minimal PNG encoder and decoder based on biggustav.
|
120
PNG/RawPngData.cs
Normal file
120
PNG/RawPngData.cs
Normal 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
26
PNG/StreamHelper.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue