- Implement better start/stop for the webinterface
- Save session tokens between startups
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
using WatsonWebserver.Core;
|
||||
using WatsonWebserver.Lite;
|
||||
|
||||
namespace ChatTwo.Http;
|
||||
|
||||
public class HostContext
|
||||
{
|
||||
private readonly Plugin Plugin;
|
||||
|
||||
public bool IsActive;
|
||||
public bool IsStopping;
|
||||
|
||||
internal WebserverLite Host;
|
||||
internal Processing Processing;
|
||||
internal RouteController RouteController;
|
||||
|
||||
internal readonly List<SSEConnection> EventConnections = [];
|
||||
|
||||
internal readonly CancellationTokenSource TokenSource = new();
|
||||
internal readonly string StaticDir = Path.Combine(Plugin.Interface.AssemblyLocation.DirectoryName!, "Http");
|
||||
|
||||
public HostContext(Plugin plugin)
|
||||
{
|
||||
Plugin = plugin;
|
||||
}
|
||||
|
||||
public bool Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
Host = new WebserverLite(new WebserverSettings("*", Plugin.Config.WebinterfacePort), DefaultRoute);
|
||||
|
||||
Processing = new Processing(Plugin);
|
||||
RouteController = new RouteController(Plugin, this);
|
||||
|
||||
Host.Routes.PreAuthentication.Content.BaseDirectory = StaticDir;
|
||||
Host.Routes.AuthenticateRequest = CheckAuthenticationCookie;
|
||||
Host.Events.ExceptionEncountered += ExceptionEncountered;
|
||||
|
||||
// Settings
|
||||
Host.Settings.Debug.Requests = true;
|
||||
Host.Settings.Debug.Routing = true;
|
||||
Host.Settings.Debug.Responses = true;
|
||||
Host.Settings.Debug.AccessControl = true;
|
||||
Host.Events.Logger = logMessage => Plugin.Log.Information(logMessage);
|
||||
|
||||
IsActive = true;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsActive = false;
|
||||
Plugin.Log.Error(ex, "Initialization of the webserver failed.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
Host.Start(TokenSource.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, "Webserver failed to boot up.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> Stop()
|
||||
{
|
||||
// Is already stopped
|
||||
if (!IsActive)
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
IsActive = false;
|
||||
IsStopping = true;
|
||||
await Task.Delay(10000);
|
||||
Host.Stop();
|
||||
|
||||
// Save our session tokens
|
||||
Plugin.SaveConfig();
|
||||
|
||||
// We get a copy, so that the original can be cleaned up successfully
|
||||
foreach (var eventServer in EventConnections.ToArray())
|
||||
await eventServer.DisposeAsync();
|
||||
|
||||
EventConnections.Clear();
|
||||
Host.Dispose();
|
||||
RouteController.Dispose();
|
||||
IsStopping = false;
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, "Webserver failed to stop and dispose all resources.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Stop();
|
||||
}
|
||||
|
||||
#region GeneralHandlers
|
||||
private static void ExceptionEncountered(object? _, ExceptionEventArgs args)
|
||||
{
|
||||
Plugin.Log.Error(args.Exception, "Webserver threw an exception.");
|
||||
}
|
||||
|
||||
private async Task DefaultRoute(HttpContextBase ctx)
|
||||
{
|
||||
await ctx.Response.Send("Nothing to see here.");
|
||||
}
|
||||
#endregion
|
||||
|
||||
private async Task CheckAuthenticationCookie(HttpContextBase ctx)
|
||||
{
|
||||
if (Plugin.Config.SessionTokens.IsEmpty)
|
||||
{
|
||||
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
|
||||
return;
|
||||
}
|
||||
|
||||
var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? "");
|
||||
if (!cookies.TryGetValue("ChatTwo-token", out var token) || !Plugin.Config.SessionTokens.ContainsKey(token))
|
||||
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
|
||||
|
||||
// Do nothing to let auth pass
|
||||
}
|
||||
}
|
||||
@@ -14,20 +14,19 @@ namespace ChatTwo.Http;
|
||||
public class RouteController
|
||||
{
|
||||
private readonly Plugin Plugin;
|
||||
private readonly ServerCore Core;
|
||||
private readonly HostContext Core;
|
||||
|
||||
private readonly string AuthTemplate;
|
||||
private readonly string ChatBoxTemplate;
|
||||
|
||||
private readonly ConcurrentDictionary<string, long> RateLimit = [];
|
||||
internal readonly ConcurrentDictionary<string, bool> SessionTokens = [];
|
||||
|
||||
private readonly JsonSerializerSettings JsonSettings = new()
|
||||
{
|
||||
Error = delegate(object? sender, ErrorEventArgs args) { args.ErrorContext.Handled = true; }
|
||||
Error = delegate(object? _, ErrorEventArgs args) { args.ErrorContext.Handled = true; }
|
||||
};
|
||||
|
||||
public RouteController(Plugin plugin, ServerCore core)
|
||||
public RouteController(Plugin plugin, HostContext core)
|
||||
{
|
||||
Plugin = plugin;
|
||||
Core = core;
|
||||
@@ -36,21 +35,21 @@ public class RouteController
|
||||
ChatBoxTemplate = File.ReadAllText(Path.Combine(Core.StaticDir, "templates", "chat.html"));
|
||||
|
||||
// Pre Auth
|
||||
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/", AuthRoute, ExceptionRoute);
|
||||
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.POST, "/auth", AuthenticateClient, ExceptionRoute);
|
||||
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/gfdata.gfd", GetGfdData, ExceptionRoute);
|
||||
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/fonticon_ps5.tex", GetTexData, ExceptionRoute);
|
||||
Core.HostContext.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute);
|
||||
Core.HostContext.Routes.PreAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
|
||||
Core.HostContext.Routes.PreAuthentication.Content.Add("/static", true, ExceptionRoute);
|
||||
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/", AuthRoute, ExceptionRoute);
|
||||
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.POST, "/auth", AuthenticateClient, ExceptionRoute);
|
||||
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/gfdata.gfd", GetGfdData, ExceptionRoute);
|
||||
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/fonticon_ps5.tex", GetTexData, ExceptionRoute);
|
||||
Core.Host.Routes.PreAuthentication.Static.Add(HttpMethod.GET, "/files/FFXIV_Lodestone_SSF.ttf", GetLodestoneFont, ExceptionRoute);
|
||||
Core.Host.Routes.PreAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
|
||||
Core.Host.Routes.PreAuthentication.Content.Add("/static", true, ExceptionRoute);
|
||||
|
||||
// Post Auth
|
||||
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute);
|
||||
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
|
||||
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/channel", ReceiveChannelSwitch, ExceptionRoute);
|
||||
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/chat", ChatBoxRoute, ExceptionRoute);
|
||||
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
|
||||
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/channel", ReceiveChannelSwitch, ExceptionRoute);
|
||||
|
||||
// Server-Sent Events Route
|
||||
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/sse", NewSSEConnection, ExceptionRoute);
|
||||
Core.Host.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/sse", NewSSEConnection, ExceptionRoute);
|
||||
}
|
||||
|
||||
private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
|
||||
@@ -135,7 +134,7 @@ public class RouteController
|
||||
return await Redirect(ctx, "/", ("message", "Authentication failed."));
|
||||
|
||||
var token = WebinterfaceUtil.GenerateSimpleToken();
|
||||
SessionTokens.TryAdd(token, true);
|
||||
Plugin.Config.SessionTokens.TryAdd(token, true);
|
||||
|
||||
ctx.Response.Headers.Add("Set-Cookie", $"ChatTwo-token={token}");
|
||||
return await Redirect(ctx, "/chat");
|
||||
|
||||
+48
-84
@@ -1,52 +1,29 @@
|
||||
using ChatTwo.Http.MessageProtocol;
|
||||
using WatsonWebserver.Core;
|
||||
using WatsonWebserver.Lite;
|
||||
using ExceptionEventArgs = WatsonWebserver.Core.ExceptionEventArgs;
|
||||
|
||||
namespace ChatTwo.Http;
|
||||
|
||||
public class ServerCore : IAsyncDisposable
|
||||
{
|
||||
private readonly Plugin Plugin;
|
||||
internal readonly Processing Processing;
|
||||
internal readonly RouteController RouteController;
|
||||
|
||||
internal readonly WebserverLite HostContext;
|
||||
|
||||
internal readonly CancellationTokenSource TokenSource = new();
|
||||
internal readonly string StaticDir = Path.Combine(Plugin.Interface.AssemblyLocation.DirectoryName!, "Http");
|
||||
|
||||
internal readonly List<SSEConnection> EventConnections = [];
|
||||
private readonly HostContext HostContext;
|
||||
|
||||
public ServerCore(Plugin plugin)
|
||||
{
|
||||
Plugin = plugin;
|
||||
HostContext = new WebserverLite(new WebserverSettings("*", 9000), DefaultRoute);
|
||||
|
||||
Processing = new Processing(plugin);
|
||||
RouteController = new RouteController(plugin, this);
|
||||
|
||||
HostContext.Routes.PreAuthentication.Content.BaseDirectory = StaticDir;
|
||||
HostContext.Routes.AuthenticateRequest = CheckAuthenticationCookie;
|
||||
HostContext.Events.ExceptionEncountered += ExceptionEncountered;
|
||||
|
||||
// Settings
|
||||
HostContext.Settings.Debug.Requests = true;
|
||||
HostContext.Settings.Debug.Routing = true;
|
||||
HostContext.Settings.Debug.Responses = true;
|
||||
HostContext.Settings.Debug.AccessControl = true;
|
||||
HostContext.Events.Logger = logMessage => Plugin.Log.Information(logMessage);
|
||||
HostContext = new HostContext(plugin);
|
||||
}
|
||||
|
||||
#region SSEFunctions
|
||||
#region SSE Helper
|
||||
internal void SendNewMessage(Message message)
|
||||
{
|
||||
if (!HostContext.IsActive)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Plugin.Framework.RunOnTick(() =>
|
||||
{
|
||||
var bundledResponse = new NewMessageEvent(new Messages([Processing.ReadMessageContent(message)]));
|
||||
foreach (var eventServer in EventConnections)
|
||||
var bundledResponse = new NewMessageEvent(new Messages([HostContext.Processing.ReadMessageContent(message)]));
|
||||
foreach (var eventServer in HostContext.EventConnections)
|
||||
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
||||
});
|
||||
}
|
||||
@@ -58,12 +35,15 @@ public class ServerCore : IAsyncDisposable
|
||||
|
||||
internal void SendBulkMessageList()
|
||||
{
|
||||
if (!HostContext.IsActive)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Plugin.Framework.RunOnTick(() =>
|
||||
{
|
||||
foreach (var eventServer in EventConnections)
|
||||
eventServer.OutboundQueue.Enqueue(new BulkMessagesEvent(new Messages(Processing.ReadMessageList().Result)));
|
||||
foreach (var eventServer in HostContext.EventConnections)
|
||||
eventServer.OutboundQueue.Enqueue(new BulkMessagesEvent(new Messages(HostContext.Processing.ReadMessageList().Result)));
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -74,12 +54,15 @@ public class ServerCore : IAsyncDisposable
|
||||
|
||||
internal void SendChannelSwitch(Chunk[] channelName)
|
||||
{
|
||||
if (!HostContext.IsActive)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Plugin.Framework.RunOnTick(() =>
|
||||
{
|
||||
var bundledResponse = new SwitchChannelEvent(new SwitchChannel(Processing.ReadChannelName(channelName)));
|
||||
foreach (var eventServer in EventConnections)
|
||||
var bundledResponse = new SwitchChannelEvent(new SwitchChannel(HostContext.Processing.ReadChannelName(channelName)));
|
||||
foreach (var eventServer in HostContext.EventConnections)
|
||||
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
||||
});
|
||||
}
|
||||
@@ -91,13 +74,16 @@ public class ServerCore : IAsyncDisposable
|
||||
|
||||
internal void SendChannelList()
|
||||
{
|
||||
if (!HostContext.IsActive)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Plugin.Framework.RunOnTick(() =>
|
||||
{
|
||||
var channels = Plugin.ChatLogWindow.GetAvailableChannels();
|
||||
var bundledResponse = new ChannelListEvent(new ChannelList(channels.ToDictionary(pair => pair.Key, pair => (uint)pair.Value)));
|
||||
foreach (var eventServer in EventConnections)
|
||||
foreach (var eventServer in HostContext.EventConnections)
|
||||
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
||||
});
|
||||
}
|
||||
@@ -108,65 +94,43 @@ public class ServerCore : IAsyncDisposable
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region GeneralHandlers
|
||||
private static void ExceptionEncountered(object? _, ExceptionEventArgs args)
|
||||
{
|
||||
Plugin.Log.Error(args.Exception, "Webserver threw an exception.");
|
||||
}
|
||||
|
||||
private async Task DefaultRoute(HttpContextBase ctx)
|
||||
{
|
||||
await ctx.Response.Send("Nothing to see here.");
|
||||
}
|
||||
#endregion
|
||||
|
||||
private async Task CheckAuthenticationCookie(HttpContextBase ctx)
|
||||
{
|
||||
if (RouteController.SessionTokens.IsEmpty)
|
||||
{
|
||||
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
|
||||
return;
|
||||
}
|
||||
|
||||
var cookies = WebserverUtil.GetCookieData(ctx.Request.Headers.Get("Cookie") ?? "");
|
||||
if (!cookies.TryGetValue("ChatTwo-token", out var token) || !RouteController.SessionTokens.ContainsKey(token))
|
||||
await RouteController.Redirect(ctx, "/", ("message", "Invalid session token."));
|
||||
|
||||
// Do nothing to let auth pass
|
||||
}
|
||||
|
||||
public void InvalidateSessions()
|
||||
{
|
||||
RouteController.SessionTokens.Clear();
|
||||
if (!HostContext.IsActive)
|
||||
return;
|
||||
|
||||
Plugin.Config.SessionTokens.Clear();
|
||||
Plugin.SaveConfig();
|
||||
}
|
||||
|
||||
public bool GetStats()
|
||||
public bool IsActive()
|
||||
{
|
||||
return HostContext.IsListening;
|
||||
return HostContext is { IsActive: true, Host.IsListening: true };
|
||||
}
|
||||
|
||||
public void Start()
|
||||
public bool IsStopping()
|
||||
{
|
||||
try
|
||||
{
|
||||
HostContext.Start(TokenSource.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Plugin.Log.Error(ex, "Startup failed with an error.");
|
||||
}
|
||||
return HostContext is { IsActive: false, IsStopping: true };
|
||||
}
|
||||
|
||||
|
||||
public bool Start()
|
||||
{
|
||||
return HostContext.Start();
|
||||
}
|
||||
|
||||
public void Run()
|
||||
{
|
||||
HostContext.Run();
|
||||
}
|
||||
|
||||
public async ValueTask<bool> Stop()
|
||||
{
|
||||
return await HostContext.Stop();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await TokenSource.CancelAsync();
|
||||
HostContext.Stop();
|
||||
|
||||
// We get a copy, so that the original can be cleaned up succesfully
|
||||
foreach (var eventServer in EventConnections.ToArray())
|
||||
await eventServer.DisposeAsync();
|
||||
|
||||
HostContext.Dispose();
|
||||
RouteController.Dispose();
|
||||
await HostContext.DisposeAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user