~furry/mamoru-server

8c9819cc69b1333cdb87f6241674f311c03bec43 — nora a month ago df97234
use watson for webserver
12 files changed, 109 insertions(+), 266 deletions(-)

M .gitignore
M Connections/ServerLogConnection.cs
M Mamoru.csproj
M Mamoru.sln.DotSettings.user
M Managers/RoutesManager.cs
R Routes/StatusRoute.cs => Resources/MiscResource.cs
R Routes/PlayersRoute.cs => Resources/PlayersResource.cs
D Routes/AbstractRoute.cs
A Utils/Attributes.cs
D Utils/Response.cs
D Utils/Router.cs
A Utils/Serializers.cs
M .gitignore => .gitignore +1 -1
@@ 3,4 3,4 @@ obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
.idea
\ No newline at end of file
.idea

M Connections/ServerLogConnection.cs => Connections/ServerLogConnection.cs +18 -18
@@ 1,18 1,18 @@
using WebSocketSharp;
using WebSocketSharp.Server;

namespace Mamoru.Connections;

public class ServerLogConnection : WebSocketBehavior {
  public static string Path = "/serverlog";

  protected override void OnMessage(MessageEventArgs e) {
    Send(e.Data);
    Log.Info(e.Data);
  }

  protected override void OnOpen() {
    Log.Info("New client connected.");
    base.OnOpen();
  }
}
\ No newline at end of file
// using WebSocketSharp;
// using WebSocketSharp.Server;
//
// namespace Mamoru.Connections;
//
// public class ServerLogConnection : WebSocketBehavior {
//   public static string Path = "/serverlog";
//
//   protected override void OnMessage(MessageEventArgs e) {
//     Send(e.Data);
//     Log.Info(e.Data);
//   }
//
//   protected override void OnOpen() {
//     Log.Info("New client connected.");
//     base.OnOpen();
//   }
// }
\ No newline at end of file

M Mamoru.csproj => Mamoru.csproj +3 -4
@@ 1,7 1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <LangVersion>preview</LangVersion>
        <LangVersion>13</LangVersion>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <UnityVersion>2022.3.43f1</UnityVersion>


@@ 9,12 9,11 @@
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="EXILED" Version="9.0.0-beta.4"/>
        <PackageReference Include="WebSocketSharp" Version="1.0.3-rc11"/>
        <PackageReference Include="EXILED" Version="9.0.0-beta.5" />
        <PackageReference Include="Watson" Version="6.2.2" />
    </ItemGroup>

    <ItemGroup>
        <Folder Include="Models.EXILED\"/>
        <Folder Include="Router\"/>
    </ItemGroup>
</Project>

M Mamoru.sln.DotSettings.user => Mamoru.sln.DotSettings.user +3 -2
@@ 1,6 1,7 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
	<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue">&lt;AssemblyExplorer&gt;
  &lt;Assembly Path="/home/nora/.steam/steam/steamapps/common/SCP Secret Laboratory Dedicated Server/SCPSL_Data/Managed/Assembly-CSharp.dll" /&gt;
  &lt;Assembly Path="/home/nora/.nuget/packages/exiled/9.0.0-beta.5/lib/net48/Exiled.API.dll" /&gt;
&lt;/AssemblyExplorer&gt;</s:String>
	<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/CustomBuildToolPath/@EntryValue">/home/nora/.dotnet/sdk/9.0.100-preview.7.24407.12/MSBuild.dll</s:String>
	<s:Int64 x:Key="/Default/Environment/Hierarchy/Build/BuildTool/MsbuildVersion/@EntryValue">1114112</s:Int64></wpf:ResourceDictionary>
\ No newline at end of file
	<s:String x:Key="/Default/Environment/Hierarchy/Build/BuildTool/DotNetCliExePath/@EntryValue">/home/nora/.dotnet/dotnet</s:String>
	</wpf:ResourceDictionary>
\ No newline at end of file

M Managers/RoutesManager.cs => Managers/RoutesManager.cs +29 -56
@@ 1,78 1,51 @@
using System.Net;
using System.Reflection;
using System.Text.RegularExpressions;
using Exiled.API.Features;
using Mamoru.Connections;
using Mamoru.Routes;
using Mamoru.Resources;
using Mamoru.Utils;
using WebSocketSharp.Server;
using HttpStatusCode = WebSocketSharp.Net.HttpStatusCode;
using WatsonWebserver.Core;
using WatsonWebserver;

namespace Mamoru.Managers;

public class RoutesManager {
  private readonly HttpServer _httpServer = new(IPAddress.Loopback, 4649);
  private readonly Node _router = new("/");
  private readonly Webserver _httpServer = new(
    new WebserverSettings(IPAddress.Loopback.ToString(), 4649),
    MiscResource.DefaultRoute
  );

  public RoutesManager() {
    Log.Info("Setting up HTTP server...");
    LoadRoutes();

    _router.Traverse(n => Log.Info(n.Path));

    Log.Info("Registering HandleRequest events...");
    RegisterEvents();

    Log.Info("Starting HTTP server...");
    _httpServer.Start();
    Log.Info($"HTTP server listening on port {_httpServer.Port}");
  }

  private void HandleRequest(object sender, HttpRequestEventArgs ev) {
    var req = ev.Request;
    var res = ev.Response;
    var path = req.RawUrl;
    var ctx = new Context(new Dictionary<string, string>(), new Dictionary<string, string>());
    var route = _router.MatchRoute(path, ref ctx);
    Log.Info($"{req.HttpMethod} {path} {route} {string.Join(", ", ctx.QueryParams)}");
    if (route is null) {
      EmptyResponse.Create(ref res, HttpStatusCode.NotFound);
      return;
    }

    var methodName = req.HttpMethod[0] + req.HttpMethod.ToLower().Substring(1);
    var method = route.GetType().GetMethod(methodName);
    method!.Invoke(route, [req, res, ctx]);
    Log.Info($"HTTP server listening on {_httpServer.Settings.Hostname}:{_httpServer.Settings.Port}");
  }

  private void LoadRoutes() {
    Log.Info("Loading routes...");
    Assembly.GetExecutingAssembly()
      .GetTypes()
      .Where(type => type.IsClass && type is { IsAbstract: false, Namespace: "Mamoru.Routes" })
      .ToArray()
      .ForEach(type => {
        try {
          var clazz = (AbstractRoute)Activator.CreateInstance(type);
          _router.AddRoute(ref clazz, clazz.Path.Trim('/'));
          Log.Info($"Loaded http route {type.Name} at {clazz.Path}");
    var assembly = Assembly.GetExecutingAssembly();
    var typeClasses = assembly.GetTypes().Where(type => type.IsClass && type.Namespace == "Mamoru.Resources" && !type.Name.Contains("<"));
    foreach (var classType in typeClasses) {
      Log.Info($"Loading resource {classType.Name}...");
      var methods = classType.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly);
      foreach (var methodType in methods) {
        if (methodType.GetCustomAttribute<DoNotLoad>() is not null) {
          Log.Info($"Skipping method {methodType.Name}...");
          continue;
        }
        catch {
          Log.Warn($"Failed to load http route {type.Name}");
        }
      });

    _httpServer.AddWebSocketService<ServerLogConnection>(ServerLogConnection.Path);
  }

  private void RegisterEvents() {
    var methodInfo = GetType().GetMethod("HandleRequest", BindingFlags.Instance | BindingFlags.NonPublic);
    _httpServer.GetType().GetEvents().ForEach(eventInfo => {
      eventInfo.AddEventHandler(_httpServer, Delegate.CreateDelegate(
        eventInfo.EventHandlerType,
        this,
        methodInfo!
      ));
    });
        Log.Info($"Loading method {methodType.Name}...");
        var attributes = methodType.GetCustomAttribute<Route>();
        if (attributes is null) break;
        var method = (Func<HttpContextBase, Task>)Delegate.CreateDelegate(typeof(Func<HttpContextBase, Task>), methodType);
        if (attributes.IsDynamic) _httpServer.Routes.PreAuthentication.Dynamic.Add(attributes.Method, new Regex(attributes.Path), method);
        if (attributes.IsParameter) _httpServer.Routes.PreAuthentication.Parameter.Add(attributes.Method, attributes.Path, method);
        if (attributes.IsStatic) _httpServer.Routes.PreAuthentication.Static.Add(attributes.Method, attributes.Path, method);
        Log.Info($"Loaded method {methodType.Name} at {attributes.Path}.");
      }
      Log.Info($"Finished loading resource {classType.Name}.");
    }
  }

  public void StopHttpServer() {

R Routes/StatusRoute.cs => Resources/MiscResource.cs +19 -13
@@ 2,21 2,27 @@ using System.Reflection;
using Exiled.Loader;
using Mamoru.Models.Mamoru;
using Mamoru.Utils;
using WebSocketSharp.Net;
using WatsonWebserver.Core;

namespace Mamoru.Routes;
namespace Mamoru.Resources;

public class StatusRoute : AbstractRoute {
  public override string Path => "/.well-known/mamoru/status";
  public override bool Cors => true;
public static class MiscResource {
  [DoNotLoad]
  public static async Task DefaultRoute(HttpContextBase ctx) {
      ctx.Response.StatusCode = 404;
      await ctx.Response.Send("not found");
  }

  [Route(HttpMethod.GET, "/.well-known/mamoru/status")]
  public static async Task GetServerStatus(HttpContextBase ctx) {
  var status = new GetServerStatusResponse(
    true,
    true,
    Assembly.GetAssembly(typeof(Loader)).GetName().Version.ToString(),
    Assembly.GetExecutingAssembly().GetName().Version.ToString()
  );

  public override void Get(ref HttpListenerRequest req, ref HttpListenerResponse res, ref Context ctx) {
    var status = new GetServerStatusResponse(
      true,
      true,
      Assembly.GetAssembly(typeof(Loader)).GetName().Version.ToString(),
      Assembly.GetExecutingAssembly().GetName().Version.ToString()
    );
    YamlResponse.Create(ref res, status);
    ctx.Response.ContentType = "application/yaml";
    await ctx.Response.Send(Serializers.YamlSerializer.Serialize(status));
  }
}
\ No newline at end of file

R Routes/PlayersRoute.cs => Resources/PlayersResource.cs +9 -9
@@ 1,20 1,20 @@
using Exiled.API.Features;
using Mamoru.Models.Mamoru;
using Mamoru.Utils;
using WebSocketSharp.Net;
using WatsonWebserver.Core;

namespace Mamoru.Routes;
namespace Mamoru.Resources;

public class PlayersRoute : AbstractRoute {
  public override string Path => "/players";
  public override bool Cors => true;

  public override void Get(ref HttpListenerRequest req, ref HttpListenerResponse res, ref Context ctx) {
public static class PlayersResource {
  [Route(HttpMethod.GET, "/players/list")]
  public static async Task ListPlayers(HttpContextBase ctx) {
    List<ListPlayersResponse> players = [];

    Player.List.ToList().ForEach(player => {
      players.Add(new ListPlayersResponse((ushort)player.Id, player.UserId, player.Nickname, player.RemoteAdminAccess));
    });
    
    YamlResponse.Create(ref res, players);

    ctx.Response.ContentType = "application/yaml";
    await ctx.Response.Send(Serializers.YamlSerializer.Serialize(players));
  }
}
\ No newline at end of file

D Routes/AbstractRoute.cs => Routes/AbstractRoute.cs +0 -54
@@ 1,54 0,0 @@
using Mamoru.Utils;
using WebSocketSharp.Net;

namespace Mamoru.Routes;

public abstract class AbstractRoute {
  public abstract string Path { get; }
  public virtual bool Cors => false;

  public virtual void Connect(ref HttpListenerRequest req, ref HttpListenerResponse res, ref Context ctx) {
    EmptyResponse.Create(ref res, HttpStatusCode.MethodNotAllowed);
  }

  public virtual void Delete(ref HttpListenerRequest req, ref HttpListenerResponse res, ref Context ctx) {
    EmptyResponse.Create(ref res, HttpStatusCode.MethodNotAllowed);
  }

  public virtual void Get(ref HttpListenerRequest req, ref HttpListenerResponse res, ref Context ctx) {
    EmptyResponse.Create(ref res, HttpStatusCode.MethodNotAllowed);
  }

  public virtual void Head(ref HttpListenerRequest req, ref HttpListenerResponse res, ref Context ctx) {
    EmptyResponse.Create(ref res, HttpStatusCode.MethodNotAllowed);
  }

  public virtual void Options(ref HttpListenerRequest req, ref HttpListenerResponse res, ref Context ctx) {
    if (Cors) {
      res.Headers.Add("Access-Control-Allow-Origin", "*");
      res.Headers.Add("Access-Control-Allow-Headers", "*");
      res.Headers.Add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
      res.Headers.Add("Access-Control-Max-Age", "86400");
      EmptyResponse.Create(ref res);
      return;
    }

    EmptyResponse.Create(ref res, HttpStatusCode.MethodNotAllowed);
  }

  public virtual void Post(ref HttpListenerRequest req, ref HttpListenerResponse res, ref Context ctx) {
    EmptyResponse.Create(ref res, HttpStatusCode.MethodNotAllowed);
  }

  public virtual void Patch(ref HttpListenerRequest req, ref HttpListenerResponse res, ref Context ctx) {
    EmptyResponse.Create(ref res, HttpStatusCode.MethodNotAllowed);
  }

  public virtual void Put(ref HttpListenerRequest req, ref HttpListenerResponse res, ref Context ctx) {
    EmptyResponse.Create(ref res, HttpStatusCode.MethodNotAllowed);
  }

  public virtual void Trace(ref HttpListenerRequest req, ref HttpListenerResponse res, ref Context ctx) {
    EmptyResponse.Create(ref res, HttpStatusCode.MethodNotAllowed);
  }
}
\ No newline at end of file

A Utils/Attributes.cs => Utils/Attributes.cs +16 -0
@@ 0,0 1,16 @@
using System.Text.RegularExpressions;
using WatsonWebserver.Core;

namespace Mamoru.Utils;

[AttributeUsage(AttributeTargets.Method)]
public class Route(HttpMethod method, string path, bool isParameter = false, bool isDynamic = false) : Attribute {
  public HttpMethod Method => method;
  public string Path => path;
  public bool IsStatic => !isParameter && !isDynamic;
  public bool IsParameter => isParameter;
  public bool IsDynamic => isDynamic;
}

[AttributeUsage(AttributeTargets.Method)]
public class DoNotLoad : Attribute {}
\ No newline at end of file

D Utils/Response.cs => Utils/Response.cs +0 -30
@@ 1,30 0,0 @@
using System.Text;
using WebSocketSharp.Net;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;

namespace Mamoru.Utils;

public static class YamlResponse {
  private static readonly ISerializer Serializer = new SerializerBuilder()
    .WithNamingConvention(UnderscoredNamingConvention.Instance)
    .Build();

  public static void Create(ref HttpListenerResponse res, object value, HttpStatusCode statusCode = HttpStatusCode.OK) {
    var serialized = Serializer.Serialize(value);
    var data = Utf8.GetBytes(serialized);
    res.StatusCode = (int)statusCode;
    res.ContentType = "application/yaml";
    res.ContentEncoding = Encoding.UTF8;
    res.ContentLength64 = data.LongLength;
    res.Close(data, false);
  }
}

public static class EmptyResponse {
  public static void Create(ref HttpListenerResponse res, HttpStatusCode statusCode = HttpStatusCode.NoContent) {
    res.StatusCode = (int)statusCode;
    res.ContentLength64 = 0;
    res.Close();
  }
}
\ No newline at end of file

D Utils/Router.cs => Utils/Router.cs +0 -79
@@ 1,79 0,0 @@
using System.Text.RegularExpressions;
using Exiled.API.Features;
using Mamoru.Routes;

namespace Mamoru.Utils;

public record Context(Dictionary<string, string> QueryParams, Dictionary<string, string> RouteParams);

public class Node(string path, AbstractRoute? route = null) {
  public readonly List<Node> Children = [];
  public readonly string Path = path;
  public readonly AbstractRoute? Route = route;

  public void Add(Node child) {
    Children.Add(child);
  }

  public void Remove(Node child) {
    Children.Remove(child);
  }

  public Node? Find(string index) {
    return Children.FirstOrDefault(node => node.Path == index);
  }

  public void Traverse(Action<Node> action) {
    action(this);
    Children.ForEach(node => node.Traverse(action));
  }

  public void AddRoute(ref AbstractRoute route, string path, Node? parent = null) {
    path = path.Trim('/');
    parent ??= this;

    var sep = path.IndexOf('/');
    var seg = sep == -1 ? path : path.Substring(0, sep);
    var curr = parent.Find(seg);
    var n = new Node(seg, sep == -1 ? route : null);

    parent.Add(n);
    if (n.Route is not null) return;

    AddRoute(ref route, path.Substring(sep + 1), curr ?? parent.Find(seg));
  }

  public AbstractRoute? MatchRoute(string path, ref Context ctx, Node? parent = null) {
    path = path.Trim('/');
    string? query = null;
    if (path.Contains("?")) {
      var p = path.Split('?');
      path = p[0];
      query = "?" + p[1];
    }

    parent ??= this;

    var sep = path.IndexOf('/');
    var seg = sep == -1 ? path.Split('?')[0] : path.Substring(0, sep);
    if (string.IsNullOrEmpty(seg)) return MatchRoute(path.Substring(sep + 1), ref ctx, parent);
    var curr = parent.Find(seg);
    var routeParam = curr is null ? parent.Children.FirstOrDefault(node => node.Path.StartsWith(":")) : null;
    if (routeParam is not null) {
      ctx.RouteParams.Add(routeParam.Path.Substring(1), seg);
      curr ??= routeParam;
    }

    var queryParams = sep == -1 && query is not null
      ? new Regex(@"[\\?&](?<key>([^&=]+))=(?<value>[^&=]+)").Matches(query).Cast<Match>().ToArray()
      : null;
    if (queryParams is not null) {
      foreach (var match in queryParams) {
        ctx.QueryParams.Add(match.Groups["key"].Value, match.Groups["value"].Value);
      }
    }

    if (curr is null) return null;
    return sep == -1 ? curr.Route : MatchRoute(path.Substring(sep + 1), ref ctx, curr);
  }
}
\ No newline at end of file

A Utils/Serializers.cs => Utils/Serializers.cs +11 -0
@@ 0,0 1,11 @@
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;

namespace Mamoru.Utils;

public static class Serializers {
  public static readonly ISerializer YamlSerializer = new SerializerBuilder()
    .WithNamingConvention(UnderscoredNamingConvention.Instance)
    .Build();
  
}
\ No newline at end of file