Skip to content

Commit 4370698

Browse files
committed
MicroWebHelper first writeup/experiment
1 parent 6d28862 commit 4370698

File tree

2 files changed

+276
-2
lines changed

2 files changed

+276
-2
lines changed

MicroWebHelper.cs

+274
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.IO;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using System.Net;
9+
using FreneticUtilities.FreneticToolkit;
10+
using FreneticUtilities.FreneticExtensions;
11+
12+
namespace DiscordBotBase
13+
{
14+
/// <summary>Helper to generate an internal micro-website.</summary>
15+
public class MicroWebHelper
16+
{
17+
/// <summary>Helper to limit file paths to valid ones.</summary>
18+
public static AsciiMatcher FilePathLimiter = new(AsciiMatcher.LowercaseLetters + AsciiMatcher.Digits + "./_");
19+
20+
/// <summary>The backing <see cref="HttpListener"/> instance.</summary>
21+
public HttpListener Listener;
22+
23+
/// <summary>The file path for raw files, if any.</summary>
24+
public string RawFileRoot;
25+
26+
/// <summary>The token that can cancel this web helper.</summary>
27+
public CancellationTokenSource CancelToken = new();
28+
29+
/// <summary>Token indicates that the whole system is done.</summary>
30+
public CancellationTokenSource IsEnded = new ();
31+
32+
/// <summary>Pre-scanned list of valid paths for the raw file root.</summary>
33+
public HashSet<string> RawRootStartPaths = new();
34+
35+
/// <summary>Function to get dynamic pages as-needed.</summary>
36+
public Func<string, HttpListenerContext, WebResult> PageGetter;
37+
38+
/// <summary>Matcher for values escaped by <see cref="HtmlEscape"/>.</summary>
39+
public static AsciiMatcher NeedsHTMLEscapeMatcher = new("&<>");
40+
41+
/// <summary>Escapes a string to safely output it into HTML.</summary>
42+
public static string HtmlEscape(string input)
43+
{
44+
if (!NeedsHTMLEscapeMatcher.ContainsAnyMatch(input))
45+
{
46+
return input;
47+
}
48+
return input.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
49+
}
50+
51+
/// <summary>Helper class to make injectable HTML based on the &lt;%INJECT_ID%&gt; format.</summary>
52+
public class InjectableHtml
53+
{
54+
/// <summary>A single part of the HTML page.</summary>
55+
public class Component
56+
{
57+
/// <summary>If true: injectable text. If false: raw text.</summary>
58+
public bool DoReplace;
59+
60+
/// <summary>The raw content of the component.</summary>
61+
public string Content;
62+
}
63+
64+
/// <summary>The parts that make up this page.</summary>
65+
public List<Component> Components = new(4);
66+
67+
/// <summary>An estimate of the length of this page.</summary>
68+
public int Length;
69+
70+
/// <summary>Constructs the injectable HTML from the raw pseudo-html string.</summary>
71+
public InjectableHtml(string raw)
72+
{
73+
Length = raw.Length;
74+
int start = raw.IndexOf("<%");
75+
int lastEnd = 0;
76+
while (start != -1)
77+
{
78+
Components.Add(new Component() { DoReplace = false, Content = raw[lastEnd..start] });
79+
int end = raw.IndexOf("%>", start + 2);
80+
if (end == -1)
81+
{
82+
throw new Exception("Invalid injectable html, mismatched inject blocks");
83+
}
84+
Components.Add(new Component() { DoReplace = true, Content = raw[(start + 2)..end] });
85+
lastEnd = end + 2;
86+
start = raw.IndexOf("<%", end);
87+
}
88+
Components.Add(new Component() { DoReplace = false, Content = raw[lastEnd..] });
89+
}
90+
91+
/// <summary>Gets the final form of the HTML, using the injecter method.</summary>
92+
public string Get(Func<string, string> inject)
93+
{
94+
StringBuilder output = new(Length * 2);
95+
foreach (Component component in Components)
96+
{
97+
output.Append(component.DoReplace ? inject(component.Content) : component.Content);
98+
}
99+
return output.ToString();
100+
}
101+
}
102+
103+
/// <summary>Holds a cacheable response to a web request.</summary>
104+
public class WebResult
105+
{
106+
/// <summary>The default utter failure response.</summary>
107+
public static WebResult FAIL = new() { Code = 400, ContentType = "text/plain", Data = StringConversionHelper.UTF8Encoding.GetBytes("Server did not provide a response to the request.") };
108+
109+
/// <summary>The status code, eg '200' for 'OK' or '404' for 'File Not Found'</summary>
110+
public int Code = 200;
111+
112+
/// <summary>The content type of the response, eg 'text/html' for HTML text.</summary>
113+
public string ContentType = "text/plain";
114+
115+
/// <summary>The raw data of the response.</summary>
116+
public byte[] Data;
117+
118+
/// <summary>The time this result was generated per <see cref="Environment.TickCount64"/>, for cache management.</summary>
119+
public long GeneratedTickTime = Environment.TickCount64;
120+
121+
/// <summary>Applies the cached response to a request.</summary>
122+
public void Apply(HttpListenerResponse response)
123+
{
124+
response.StatusCode = Code;
125+
response.ContentType = ContentType;
126+
response.ContentLength64 = Data.LongLength;
127+
response.OutputStream.Write(Data, 0, Data.Length);
128+
response.Close();
129+
}
130+
}
131+
132+
/// <summary>Cache of raw root files.</summary>
133+
public Dictionary<string, WebResult> RawFileCache = new();
134+
135+
/// <summary>Creates and immediately starts the web helper.</summary>
136+
public MicroWebHelper(string bind, Func<string, HttpListenerContext, WebResult> _pageGetter, string _rawFileRoot = "wwwroot/")
137+
{
138+
PageGetter = _pageGetter;
139+
RawFileRoot = _rawFileRoot;
140+
if (_rawFileRoot is not null)
141+
{
142+
RawRootStartPaths.UnionWith(Directory.GetFiles(_rawFileRoot).Select(f => f.AfterLast('/')));
143+
RawRootStartPaths.UnionWith(Directory.GetDirectories(_rawFileRoot).Select(f => f.AfterLast('/')));
144+
}
145+
Listener = new HttpListener();
146+
Listener.Prefixes.Add(bind);
147+
Listener.Start();
148+
new Thread(InternalLoop) { Name = "MicroWebHelper" }.Start();
149+
}
150+
151+
/// <summary>Cancels the web helper. Signals web thread to close ASAP.</summary>
152+
public void Cancel()
153+
{
154+
CancelToken.Cancel();
155+
try
156+
{
157+
Task.Delay(TimeSpan.FromSeconds(5)).Wait(IsEnded.Token);
158+
}
159+
catch (OperationCanceledException)
160+
{
161+
// Ignore - expected
162+
}
163+
}
164+
165+
/// <summary>A mapping of common file extensions to their content type.</summary>
166+
public static Dictionary<string, string> CommonContentTypes = new()
167+
{
168+
{ "png", "image/png" },
169+
{ "jpg", "image/jpeg" },
170+
{ "jpeg", "image/jpeg" },
171+
{ "gif", "image/gif" },
172+
{ "ico", "image/x-icon" },
173+
{ "svg", "image/svg+xml" },
174+
{ "mp3", "audio/mpeg" },
175+
{ "wav", "audio/x-wav" },
176+
{ "js", "application/javascript" },
177+
{ "ogg", "application/ogg" },
178+
{ "json", "application/json" },
179+
{ "zip", "application/zip" },
180+
{ "dat", "application/octet-stream" },
181+
{ "css", "text/css" },
182+
{ "htm", "text/html" },
183+
{ "html", "text/html" },
184+
{ "txt", "text/plain" },
185+
{ "yml", "text/plain" },
186+
{ "fds", "text/plain" },
187+
{ "xml", "text/xml" },
188+
{ "mp4", "video/mp4" },
189+
{ "mpeg", "video/mpeg" },
190+
{ "webm", "video/webm" }
191+
};
192+
193+
/// <summary>Guesses the content type based on path for common file types.</summary>
194+
public static string GuessContentType(string path)
195+
{
196+
string extension = path.AfterLast('.');
197+
if (CommonContentTypes.TryGetValue(extension, out string type))
198+
{
199+
return type;
200+
}
201+
return "application/octet-stream";
202+
}
203+
204+
/// <summary>Internal thread loop, do not reference directly.</summary>
205+
public void InternalLoop()
206+
{
207+
string lastUrl = "unknown";
208+
try
209+
{
210+
while (true)
211+
{
212+
if (CancelToken.IsCancellationRequested)
213+
{
214+
return;
215+
}
216+
try
217+
{
218+
Task<HttpListenerContext> contextGetter = Listener.GetContextAsync();
219+
contextGetter.Wait(CancelToken.Token);
220+
HttpListenerContext context = contextGetter.Result;
221+
string url = context.Request.Url.AbsolutePath;
222+
if (url.StartsWithFast('/'))
223+
{
224+
url = url[1..];
225+
}
226+
string fixedUrl = FilePathLimiter.TrimToMatches(url);
227+
lastUrl = fixedUrl;
228+
if (!fixedUrl.Contains("..") && !fixedUrl.Contains("/.") && !fixedUrl.Contains("./") && RawRootStartPaths.Contains(fixedUrl.Before('/')))
229+
{
230+
if (RawFileCache.TryGetValue(fixedUrl, out WebResult cache))
231+
{
232+
cache.Apply(context.Response);
233+
continue;
234+
}
235+
string filePath = RawFileRoot + fixedUrl;
236+
if (File.Exists(filePath))
237+
{
238+
WebResult newResult = new()
239+
{
240+
Code = 200,
241+
ContentType = GuessContentType(fixedUrl),
242+
Data = File.ReadAllBytes(filePath)
243+
};
244+
RawFileCache[fixedUrl] = newResult;
245+
newResult.Apply(context.Response);
246+
continue;
247+
}
248+
}
249+
WebResult result = PageGetter(fixedUrl, context);
250+
if (result is not null)
251+
{
252+
result.Apply(context.Response);
253+
continue;
254+
}
255+
WebResult.FAIL.Apply(context.Response);
256+
}
257+
catch (OperationCanceledException)
258+
{
259+
return;
260+
}
261+
catch (Exception ex)
262+
{
263+
Console.Error.WriteLine($"Web request ({lastUrl}) failed: {ex}");
264+
}
265+
}
266+
}
267+
finally
268+
{
269+
Listener.Abort();
270+
IsEnded.Cancel();
271+
}
272+
}
273+
}
274+
}

mcmonkeyDiscordBotBase.csproj

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
<EmbeddedResource Include="**\*.resx" />
1515
</ItemGroup>
1616
<ItemGroup>
17-
<PackageReference Include="Discord.Net" Version="3.4.0" />
18-
<PackageReference Include="Discord.Net.WebSocket" Version="3.4.0" />
17+
<PackageReference Include="Discord.Net" Version="3.4.1" />
18+
<PackageReference Include="Discord.Net.WebSocket" Version="3.4.1" />
1919
</ItemGroup>
2020
<ItemGroup>
2121
<Compile Remove="FreneticUtilities\*.*" />

0 commit comments

Comments
 (0)