diff options
author | TheArcaneBrony <myrainbowdash949@gmail.com> | 2023-08-14 10:36:19 +0200 |
---|---|---|
committer | TheArcaneBrony <myrainbowdash949@gmail.com> | 2023-08-14 10:36:19 +0200 |
commit | 977220895d8127116d0000fa98088b235d4a8801 (patch) | |
tree | 09b9f295e9e0e82b8137edbd98afc3aebbab1a56 /MxApiExtensions | |
download | MxApiExtensions-977220895d8127116d0000fa98088b235d4a8801.tar.xz |
Initial commit
Diffstat (limited to 'MxApiExtensions')
-rw-r--r-- | MxApiExtensions/Auth.cs | 82 | ||||
-rw-r--r-- | MxApiExtensions/CacheConfiguration.cs | 9 | ||||
-rw-r--r-- | MxApiExtensions/Controllers/GenericProxyController.cs | 131 | ||||
-rw-r--r-- | MxApiExtensions/Controllers/SyncController.cs | 111 | ||||
-rw-r--r-- | MxApiExtensions/MatrixException.cs | 71 | ||||
-rw-r--r-- | MxApiExtensions/MxApiExtensions.csproj | 16 | ||||
-rw-r--r-- | MxApiExtensions/Program.cs | 48 | ||||
-rw-r--r-- | MxApiExtensions/Properties/launchSettings.json | 41 | ||||
-rw-r--r-- | MxApiExtensions/appsettings.Development.json | 10 | ||||
-rw-r--r-- | MxApiExtensions/appsettings.json | 12 |
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" + } +} |