summary refs log tree commit diff
path: root/MxApiExtensions
diff options
context:
space:
mode:
authorTheArcaneBrony <myrainbowdash949@gmail.com>2023-08-14 10:36:19 +0200
committerTheArcaneBrony <myrainbowdash949@gmail.com>2023-08-14 10:36:19 +0200
commit977220895d8127116d0000fa98088b235d4a8801 (patch)
tree09b9f295e9e0e82b8137edbd98afc3aebbab1a56 /MxApiExtensions
downloadMxApiExtensions-977220895d8127116d0000fa98088b235d4a8801.tar.xz
Initial commit
Diffstat (limited to 'MxApiExtensions')
-rw-r--r--MxApiExtensions/Auth.cs82
-rw-r--r--MxApiExtensions/CacheConfiguration.cs9
-rw-r--r--MxApiExtensions/Controllers/GenericProxyController.cs131
-rw-r--r--MxApiExtensions/Controllers/SyncController.cs111
-rw-r--r--MxApiExtensions/MatrixException.cs71
-rw-r--r--MxApiExtensions/MxApiExtensions.csproj16
-rw-r--r--MxApiExtensions/Program.cs48
-rw-r--r--MxApiExtensions/Properties/launchSettings.json41
-rw-r--r--MxApiExtensions/appsettings.Development.json10
-rw-r--r--MxApiExtensions/appsettings.json12
10 files changed, 531 insertions, 0 deletions
diff --git a/MxApiExtensions/Auth.cs b/MxApiExtensions/Auth.cs
new file mode 100644
index 0000000..1f0cc80
--- /dev/null
+++ b/MxApiExtensions/Auth.cs
@@ -0,0 +1,82 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using MatrixRoomUtils.Core.Extensions;
+
+namespace MxApiExtensions;
+
+public class Auth {
+    private readonly ILogger<Auth> _logger;
+    private readonly CacheConfiguration _config;
+    private readonly HttpRequest _request;
+
+    private static Dictionary<string, string> _tokenMap = new();
+
+    public Auth(ILogger<Auth> logger, CacheConfiguration config, IHttpContextAccessor request) {
+        _logger = logger;
+        _config = config;
+        _request = request.HttpContext.Request;
+    }
+
+    internal string? GetToken(bool fail = true) {
+        string? token;
+        if (_request.Headers.TryGetValue("Authorization", out var tokens)) {
+            token = tokens.FirstOrDefault()?[7..];
+        }
+        else {
+            token = _request.Query["access_token"];
+        }
+
+        if (token == null && fail) {
+            throw new MatrixException() {
+                ErrorCode = "M_MISSING_TOKEN",
+                Error = "Missing access token"
+            };
+        }
+
+        return token;
+    }
+
+    public string GetUserId(bool fail = true) {
+        var token = GetToken(fail);
+        if (token == null) {
+            if(fail) {
+                throw new MatrixException() {
+                    ErrorCode = "M_MISSING_TOKEN",
+                    Error = "Missing access token"
+                };
+            }
+            return "@anonymous:*";
+        }
+        try {
+            return _tokenMap.GetOrCreate(token, GetMxidFromToken);
+        }
+        catch {
+            return GetUserId();
+        }
+    }
+
+    private string GetMxidFromToken(string token) {
+        using var hc = new HttpClient();
+        hc.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+        var resp = hc.GetAsync($"{_config.Homeserver}/_matrix/client/v3/account/whoami").Result;
+        if (!resp.IsSuccessStatusCode) {
+            throw new MatrixException() {
+                ErrorCode = "M_UNKNOWN",
+                Error = "[Rory&::MxSyncCache] Whoami request failed"
+            };
+        }
+
+        if (resp.Content is null) {
+            throw new MatrixException() {
+                ErrorCode = "M_UNKNOWN",
+                Error = "No content in response"
+            };
+        }
+
+        var json = JsonDocument.Parse(resp.Content.ReadAsStream()).RootElement;
+        var mxid = json.GetProperty("user_id").GetString()!;
+        _logger.LogInformation($"Got mxid {mxid} from token {token}");
+        return mxid;
+    }
+
+}
diff --git a/MxApiExtensions/CacheConfiguration.cs b/MxApiExtensions/CacheConfiguration.cs
new file mode 100644
index 0000000..f1da404
--- /dev/null
+++ b/MxApiExtensions/CacheConfiguration.cs
@@ -0,0 +1,9 @@
+namespace MxApiExtensions;
+
+public class CacheConfiguration {
+    public CacheConfiguration(IConfiguration config) {
+        config.GetRequiredSection("MxSyncCache").Bind(this);
+    }
+
+    public string Homeserver { get; set; } = "";
+}
diff --git a/MxApiExtensions/Controllers/GenericProxyController.cs b/MxApiExtensions/Controllers/GenericProxyController.cs
new file mode 100644
index 0000000..91ae55a
--- /dev/null
+++ b/MxApiExtensions/Controllers/GenericProxyController.cs
@@ -0,0 +1,131 @@
+using System.Net.Http.Headers;
+using Microsoft.AspNetCore.Mvc;
+
+namespace MxApiExtensions.Controllers;
+
+[ApiController]
+[Route("/")]
+public class GenericController : ControllerBase {
+    private readonly ILogger<GenericController> _logger;
+    private readonly CacheConfiguration _config;
+    private readonly Auth _auth;
+    private static Dictionary<string, string> _tokenMap = new();
+
+    public GenericController(ILogger<GenericController> logger, CacheConfiguration config, Auth auth) {
+        _logger = logger;
+        _config = config;
+        _auth = auth;
+    }
+
+    [HttpGet("{*_}")]
+    public async Task Proxy([FromQuery] string? access_token, string _) {
+        try {
+            access_token ??= _auth.GetToken(fail: false);
+            var mxid = _auth.GetUserId(fail: false);
+
+            _logger.LogInformation($"Proxying request for {mxid}: {Request.Path}{Request.QueryString}");
+
+            using var hc = new HttpClient();
+            hc.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access_token);
+            hc.Timeout = TimeSpan.FromMinutes(10);
+            //remove access_token from query string
+            Request.QueryString = new QueryString(
+                Request.QueryString.Value
+                    .Replace("&access_token", "access_token")
+                    .Replace($"access_token={access_token}", "")
+            );
+
+            var resp = await hc.GetAsync($"{_config.Homeserver}{Request.Path}{Request.QueryString}");
+
+            if (resp.Content is null) {
+                throw new MatrixException {
+                    ErrorCode = "M_UNKNOWN",
+                    Error = "No content in response"
+                };
+            }
+
+            Response.StatusCode = (int)resp.StatusCode;
+            Response.ContentType = resp.Content.Headers.ContentType?.ToString() ?? "application/json";
+            await Response.StartAsync();
+            await using var stream = await resp.Content.ReadAsStreamAsync();
+            await stream.CopyToAsync(Response.Body);
+            await Response.Body.FlushAsync();
+            await Response.CompleteAsync();
+
+        }
+        catch (MatrixException e) {
+            _logger.LogError(e, "Matrix error");
+            Response.StatusCode = StatusCodes.Status500InternalServerError;
+            Response.ContentType = "application/json";
+
+            await Response.WriteAsJsonAsync(e.GetAsJson());
+            await Response.CompleteAsync();
+        }
+        catch (Exception e) {
+            _logger.LogError(e, "Unhandled error");
+            Response.StatusCode = StatusCodes.Status500InternalServerError;
+            Response.ContentType = "text/plain";
+
+            await Response.WriteAsync(e.ToString());
+            await Response.CompleteAsync();
+        }
+    }
+
+    [HttpPost("{*_}")]
+    public async Task ProxyPost([FromQuery] string? access_token, string _) {
+        try {
+            access_token ??= _auth.GetToken(fail: false);
+            var mxid = _auth.GetUserId(fail: false);
+
+            _logger.LogInformation($"Proxying request for {mxid}: {Request.Path}{Request.QueryString}");
+
+            using var hc = new HttpClient();
+            hc.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access_token);
+            hc.Timeout = TimeSpan.FromMinutes(10);
+            //remove access_token from query string
+            Request.QueryString = new QueryString(
+                Request.QueryString.Value
+                    .Replace("&access_token", "access_token")
+                    .Replace($"access_token={access_token}", "")
+            );
+
+            var resp = await hc.SendAsync(new() {
+                Method = HttpMethod.Post,
+                RequestUri = new Uri($"{_config.Homeserver}{Request.Path}{Request.QueryString}"),
+                Content = new StreamContent(Request.Body),
+            });
+
+            if (resp.Content is null) {
+                throw new MatrixException {
+                    ErrorCode = "M_UNKNOWN",
+                    Error = "No content in response"
+                };
+            }
+
+            Response.StatusCode = (int)resp.StatusCode;
+            Response.ContentType = resp.Content.Headers.ContentType?.ToString() ?? "application/json";
+            await Response.StartAsync();
+            await using var stream = await resp.Content.ReadAsStreamAsync();
+            await stream.CopyToAsync(Response.Body);
+            await Response.Body.FlushAsync();
+            await Response.CompleteAsync();
+
+        }
+        catch (MatrixException e) {
+            _logger.LogError(e, "Matrix error");
+            Response.StatusCode = StatusCodes.Status500InternalServerError;
+            Response.ContentType = "application/json";
+
+            await Response.WriteAsJsonAsync(e.GetAsJson());
+            await Response.CompleteAsync();
+        }
+        catch (Exception e) {
+            _logger.LogError(e, "Unhandled error");
+            Response.StatusCode = StatusCodes.Status500InternalServerError;
+            Response.ContentType = "text/plain";
+
+            await Response.WriteAsync(e.ToString());
+            await Response.CompleteAsync();
+        }
+    }
+}
diff --git a/MxApiExtensions/Controllers/SyncController.cs b/MxApiExtensions/Controllers/SyncController.cs
new file mode 100644
index 0000000..e2b724f
--- /dev/null
+++ b/MxApiExtensions/Controllers/SyncController.cs
@@ -0,0 +1,111 @@
+using System.Net.Http.Headers;
+using Microsoft.AspNetCore.Mvc;
+
+namespace MxApiExtensions.Controllers;
+
+[ApiController]
+[Route("/")]
+public class SyncController : ControllerBase {
+    private readonly ILogger<SyncController> _logger;
+    private readonly CacheConfiguration _config;
+    private readonly Auth _auth;
+
+    public SyncController(ILogger<SyncController> logger, CacheConfiguration config, Auth auth) {
+        _logger = logger;
+        _config = config;
+        _auth = auth;
+    }
+
+    [HttpGet("/_matrix/client/v3/sync")]
+    public async Task Sync([FromQuery] string? since, [FromQuery] string? access_token) {
+        try {
+            access_token ??= _auth.GetToken();
+            var mxid = _auth.GetUserId();
+            var cacheFile = GetFilePath(mxid, since);
+
+            if (!await TrySendCached(cacheFile)) {
+                using var hc = new HttpClient();
+                hc.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access_token);
+                hc.Timeout = TimeSpan.FromMinutes(10);
+                //remove access_token from query string
+                Request.QueryString = new QueryString(
+                    Request.QueryString.Value
+                        .Replace("&access_token", "access_token")
+                        .Replace($"access_token={access_token}", "")
+                );
+
+                var resp = hc.GetAsync($"{_config.Homeserver}{Request.Path}{Request.QueryString}").Result;
+                // var resp = await hs._httpClient.GetAsync($"/_matrix/client/v3/sync?since={since}");
+
+                if (resp.Content is null) {
+                    throw new MatrixException() {
+                        ErrorCode = "M_UNKNOWN",
+                        Error = "No content in response"
+                    };
+                }
+
+                Response.StatusCode = (int)resp.StatusCode;
+                Response.ContentType = "application/json";
+                await Response.StartAsync();
+                await using var stream = await resp.Content.ReadAsStreamAsync();
+                await using var target = System.IO.File.OpenWrite(cacheFile);
+                byte[] buffer = new byte[1];
+
+                int bytesRead;
+                while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0) {
+                    await Response.Body.WriteAsync(buffer.AsMemory(0, bytesRead));
+                    target.Write(buffer, 0, bytesRead);
+                }
+
+                await target.FlushAsync();
+                await Response.CompleteAsync();
+            }
+        }
+        catch (MatrixException e) {
+            Response.StatusCode = StatusCodes.Status500InternalServerError;
+            Response.ContentType = "application/json";
+
+            await Response.WriteAsJsonAsync(e.GetAsJson());
+            await Response.CompleteAsync();
+        }
+        catch (Exception e) {
+            Response.StatusCode = StatusCodes.Status500InternalServerError;
+            Response.ContentType = "text/plain";
+
+            await Response.WriteAsync(e.ToString());
+            await Response.CompleteAsync();
+        }
+    }
+
+    private async Task<bool> TrySendCached(string cacheFile) {
+        if (System.IO.File.Exists(cacheFile)) {
+            Response.StatusCode = 200;
+            Response.ContentType = "application/json";
+            await Response.StartAsync();
+            await using var stream = System.IO.File.OpenRead(cacheFile);
+            await stream.CopyToAsync(Response.Body);
+            await Response.CompleteAsync();
+            return true;
+        }
+
+        return false;
+    }
+
+#region Cache management
+
+    public string GetFilePath(string mxid, string since) {
+        var cacheDir = Path.Join("cache", mxid);
+        Directory.CreateDirectory(cacheDir);
+        var cacheFile = Path.Join(cacheDir, $"sync-{since}.json");
+        if (!Path.GetFullPath(cacheFile).StartsWith(Path.GetFullPath(cacheDir))) {
+            throw new MatrixException() {
+                ErrorCode = "M_UNKNOWN",
+                Error = "[Rory&::MxSyncCache] Cache file path is not in cache directory"
+            };
+        }
+
+        return cacheFile;
+    }
+
+#endregion
+}
diff --git a/MxApiExtensions/MatrixException.cs b/MxApiExtensions/MatrixException.cs
new file mode 100644
index 0000000..1daf5d1
--- /dev/null
+++ b/MxApiExtensions/MatrixException.cs
@@ -0,0 +1,71 @@
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using ArcaneLibs.Extensions;
+
+namespace MxApiExtensions;
+
+public class MatrixException : Exception {
+    [JsonPropertyName("errcode")]
+    public string ErrorCode { get; set; }
+
+    [JsonPropertyName("error")]
+    public string Error { get; set; }
+
+    [JsonPropertyName("soft_logout")]
+    public bool? SoftLogout { get; set; }
+
+    [JsonPropertyName("retry_after_ms")]
+    public int? RetryAfterMs { get; set; }
+
+    public string RawContent { get; set; }
+
+    //turn this into json
+    public JsonObject GetAsJson() {
+        var jsonObject = new JsonObject();
+        jsonObject["errcode"] = ErrorCode;
+        jsonObject["error"] = Error;
+        if(SoftLogout is not null) jsonObject["soft_logout"] = SoftLogout;
+        if(RetryAfterMs is not null) jsonObject["retry_after_ms"] = RetryAfterMs;
+        return jsonObject;
+    }
+
+
+    public override string Message =>
+        $"{ErrorCode}: {ErrorCode switch {
+            // common
+            "M_FORBIDDEN" => $"You do not have permission to perform this action: {Error}",
+            "M_UNKNOWN_TOKEN" => $"The access token specified was not recognised: {Error}{(SoftLogout == true ? " (soft logout)" : "")}",
+            "M_MISSING_TOKEN" => $"No access token was specified: {Error}",
+            "M_BAD_JSON" => $"Request contained valid JSON, but it was malformed in some way: {Error}",
+            "M_NOT_JSON" => $"Request did not contain valid JSON: {Error}",
+            "M_NOT_FOUND" => $"The requested resource was not found: {Error}",
+            "M_LIMIT_EXCEEDED" => $"Too many requests have been sent in a short period of time. Wait a while then try again: {Error}",
+            "M_UNRECOGNISED" => $"The server did not recognise the request: {Error}",
+            "M_UNKOWN" => $"The server encountered an unexpected error: {Error}",
+            // endpoint specific
+            "M_UNAUTHORIZED" => $"The request did not contain valid authentication information for the target of the request: {Error}",
+            "M_USER_DEACTIVATED" => $"The user ID associated with the request has been deactivated: {Error}",
+            "M_USER_IN_USE" => $"The user ID associated with the request is already in use: {Error}",
+            "M_INVALID_USERNAME" => $"The requested user ID is not valid: {Error}",
+            "M_ROOM_IN_USE" => $"The room alias requested is already taken: {Error}",
+            "M_INVALID_ROOM_STATE" => $"The room associated with the request is not in a valid state to perform the request: {Error}",
+            "M_THREEPID_IN_USE" => $"The threepid requested is already associated with a user ID on this server: {Error}",
+            "M_THREEPID_NOT_FOUND" => $"The threepid requested is not associated with any user ID: {Error}",
+            "M_THREEPID_AUTH_FAILED" => $"The provided threepid and/or token was invalid: {Error}",
+            "M_THREEPID_DENIED" => $"The homeserver does not permit the third party identifier in question: {Error}",
+            "M_SERVER_NOT_TRUSTED" => $"The homeserver does not trust the identity server: {Error}",
+            "M_UNSUPPORTED_ROOM_VERSION" => $"The room version is not supported: {Error}",
+            "M_INCOMPATIBLE_ROOM_VERSION" => $"The room version is incompatible: {Error}",
+            "M_BAD_STATE" => $"The request was invalid because the state was invalid: {Error}",
+            "M_GUEST_ACCESS_FORBIDDEN" => $"Guest access is forbidden: {Error}",
+            "M_CAPTCHA_NEEDED" => $"Captcha needed: {Error}",
+            "M_CAPTCHA_INVALID" => $"Captcha invalid: {Error}",
+            "M_MISSING_PARAM" => $"Missing parameter: {Error}",
+            "M_INVALID_PARAM" => $"Invalid parameter: {Error}",
+            "M_TOO_LARGE" => $"The request or entity was too large: {Error}",
+            "M_EXCLUSIVE" => $"The resource being requested is reserved by an application service, or the application service making the request has not created the resource: {Error}",
+            "M_RESOURCE_LIMIT_EXCEEDED" => $"Exceeded resource limit: {Error}",
+            "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM" => $"Cannot leave server notice room: {Error}",
+            _ => $"Unknown error: {new { ErrorCode, Error, SoftLogout, RetryAfterMs }.ToJson(ignoreNull: true)}"
+        }}";
+}
diff --git a/MxApiExtensions/MxApiExtensions.csproj b/MxApiExtensions/MxApiExtensions.csproj
new file mode 100644
index 0000000..0b88ce4
--- /dev/null
+++ b/MxApiExtensions/MxApiExtensions.csproj
@@ -0,0 +1,16 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+    <PropertyGroup>
+        <TargetFramework>net8.0</TargetFramework>
+        <Nullable>enable</Nullable>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <InvariantGlobalization>true</InvariantGlobalization>
+    </PropertyGroup>
+
+    <ItemGroup>
+        <PackageReference Include="ArcaneLibs" Version="1.0.0-preview5671923708.4c89a6e" />
+        <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0-preview.6.23329.11" />
+        <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
+    </ItemGroup>
+
+</Project>
diff --git a/MxApiExtensions/Program.cs b/MxApiExtensions/Program.cs
new file mode 100644
index 0000000..00afe09
--- /dev/null
+++ b/MxApiExtensions/Program.cs
@@ -0,0 +1,48 @@
+using MxApiExtensions;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+
+builder.Services.AddControllers();
+// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
+
+builder.Services.AddSingleton<CacheConfiguration>();
+builder.Services.AddScoped<Auth>();
+
+builder.Services.AddRequestTimeouts(x => {
+    x.DefaultPolicy = new() {
+        Timeout = TimeSpan.FromMinutes(10),
+        WriteTimeoutResponse = async context => {
+            context.Response.StatusCode = 504;
+            context.Response.ContentType = "application/json";
+            await context.Response.StartAsync();
+            await context.Response.WriteAsJsonAsync(new MatrixException() {
+                ErrorCode = "M_TIMEOUT",
+                Error = "Request timed out"
+            }.GetAsJson());
+            await context.Response.CompleteAsync();
+        }
+    };
+});
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment())
+{
+    app.UseSwagger();
+    app.UseSwaggerUI();
+}
+
+// app.UseHttpsRedirection();
+
+app.UseAuthorization();
+
+app.MapControllers();
+
+app.Run();
diff --git a/MxApiExtensions/Properties/launchSettings.json b/MxApiExtensions/Properties/launchSettings.json
new file mode 100644
index 0000000..bf22b91
--- /dev/null
+++ b/MxApiExtensions/Properties/launchSettings.json
@@ -0,0 +1,41 @@
+{
+  "$schema": "http://json.schemastore.org/launchsettings.json",
+  "iisSettings": {
+    "windowsAuthentication": false,
+    "anonymousAuthentication": true,
+    "iisExpress": {
+      "applicationUrl": "http://localhost:33875",
+      "sslPort": 44326
+    }
+  },
+  "profiles": {
+    "http": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": false,
+      "launchUrl": "swagger",
+      "applicationUrl": "http://localhost:5258",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    },
+    "https": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": true,
+      "launchUrl": "swagger",
+      "applicationUrl": "https://localhost:7049;http://localhost:5258",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    },
+    "IIS Express": {
+      "commandName": "IISExpress",
+      "launchBrowser": true,
+      "launchUrl": "swagger",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    }
+  }
+}
diff --git a/MxApiExtensions/appsettings.Development.json b/MxApiExtensions/appsettings.Development.json
new file mode 100644
index 0000000..df83ec5
--- /dev/null
+++ b/MxApiExtensions/appsettings.Development.json
@@ -0,0 +1,10 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Information",
+      "Microsoft.AspNetCore.Routing": "Warning",
+        "Microsoft.AspNetCore.Mvc": "Warning"
+    }
+  }
+}
diff --git a/MxApiExtensions/appsettings.json b/MxApiExtensions/appsettings.json
new file mode 100644
index 0000000..0163bad
--- /dev/null
+++ b/MxApiExtensions/appsettings.json
@@ -0,0 +1,12 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  },
+  "AllowedHosts": "*",
+  "MxSyncCache": {
+    "Homeserver": "https://matrix.rory.gay"
+  }
+}