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"
+ }
+}
|