From 200e6487722b1dac08337f8fc25d106652c8f3c0 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 5 Mar 2025 15:40:13 +0100 Subject: [PATCH 01/15] fix(backend): update User.LastActive in more places --- Foxnouns.Backend/Controllers/MembersController.cs | 6 ++++++ Foxnouns.Backend/Controllers/UsersController.cs | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index bc35f62..635dab9 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -121,6 +121,9 @@ public class MembersController( CurrentUser!.Id ); + CurrentUser.LastActive = clock.GetCurrentInstant(); + db.Update(CurrentUser); + try { await db.SaveChangesAsync(ct); @@ -238,6 +241,9 @@ public class MembersController( MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar)); } + CurrentUser.LastActive = clock.GetCurrentInstant(); + db.Update(CurrentUser); + try { await db.SaveChangesAsync(); diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index 787ff66..48a42aa 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -178,6 +178,8 @@ public class UsersController( UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)); } + user.LastActive = clock.GetCurrentInstant(); + try { await db.SaveChangesAsync(ct); @@ -253,6 +255,7 @@ public class UsersController( } user.CustomPreferences = preferences; + user.LastActive = clock.GetCurrentInstant(); await db.SaveChangesAsync(ct); return Ok(user.CustomPreferences); @@ -280,6 +283,7 @@ public class UsersController( if (req.HasProperty(nameof(req.DarkMode))) user.Settings.DarkMode = req.DarkMode; + user.LastActive = clock.GetCurrentInstant(); db.Update(user); await db.SaveChangesAsync(ct); From bba322bd22d39ab8f06c629cb7eb0baaaf7dcb8c Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 8 Mar 2025 23:46:46 +0100 Subject: [PATCH 02/15] chore(backend): update dependencies --- Foxnouns.Backend/Foxnouns.Backend.csproj | 12 ++--- Foxnouns.Backend/packages.lock.json | 60 ++++++++++++------------ 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index a8c21fb..a9972ab 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -29,8 +29,8 @@ - - + + @@ -38,14 +38,14 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - - + + diff --git a/Foxnouns.Backend/packages.lock.json b/Foxnouns.Backend/packages.lock.json index 3a7aec6..96daa5d 100644 --- a/Foxnouns.Backend/packages.lock.json +++ b/Foxnouns.Backend/packages.lock.json @@ -193,23 +193,23 @@ }, "Npgsql.EntityFrameworkCore.PostgreSQL": { "type": "Direct", - "requested": "[9.0.3, )", - "resolved": "9.0.3", - "contentHash": "1A6HpMPbzK+quxdtug1aDHI4BSRTgpi7OaDt8WQh7SFJd2sSQ0nNTZ7sYrwyxVf4AdKdN7XJL9tpiiJjRUaa4g==", + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "mw5vcY2IEc7L+IeGrxpp/J5OSnCcjkjAgJYCm/eD52wpZze8zsSifdqV7zXslSMmfJG2iIUGZyo3KuDtEFKwMQ==", "dependencies": { "Microsoft.EntityFrameworkCore": "[9.0.1, 10.0.0)", "Microsoft.EntityFrameworkCore.Relational": "[9.0.1, 10.0.0)", - "Npgsql": "9.0.2" + "Npgsql": "9.0.3" } }, "Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime": { "type": "Direct", - "requested": "[9.0.3, )", - "resolved": "9.0.3", - "contentHash": "Eks1o3NfIbS3EHhrXC0QABrQab7CJ64C2+kF0YJWLwlH/tu3ExrgrSLpLI6INdeMYcLr2PXu71LjJsrQNVciYg==", + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "QZ80CL3c9xzC83eVMWYWa1RcFZA6HJtpMAKFURlmz+1p0OyysSe8R6f/4sI9vk/nwqF6Fkw3lDgku/xH6HcJYg==", "dependencies": { - "Npgsql.EntityFrameworkCore.PostgreSQL": "9.0.3", - "Npgsql.NodaTime": "9.0.2" + "Npgsql.EntityFrameworkCore.PostgreSQL": "9.0.4", + "Npgsql.NodaTime": "9.0.3" } }, "Npgsql.Json.NET": { @@ -249,18 +249,18 @@ }, "Scalar.AspNetCore": { "type": "Direct", - "requested": "[2.0.18, )", - "resolved": "2.0.18", - "contentHash": "nS8Sw6wRO1A/dARn3q9R6znIBfddJcmAczI5uMROBGWkO2KG/ad/Ld+UeUePTxGr1+6humJSOxI7An+q4q3oGA==" + "requested": "[2.0.26, )", + "resolved": "2.0.26", + "contentHash": "0tKBFM7quBq0ifgRWo7eTTVpiTbnwpf/6ygtb/aYVuo0D2gMsYknAJRqEhH8HFBqzntNiYpzHbQSf2b+VAA8sA==" }, "Sentry.AspNetCore": { "type": "Direct", - "requested": "[5.2.0, )", - "resolved": "5.2.0", - "contentHash": "vEKanBDOxCnEQrcMq3j47z8HOblRfiyJotdm9Fyc24cmLrLsTYZnWWprCYstt++M9bGSXYf4jrM2aaWxgJ8aww==", + "requested": "[5.3.0, )", + "resolved": "5.3.0", + "contentHash": "zC2yhwQB0laYWGXLYDCsiKSIqleaEK3fUH9Z5t8Bgvfs2nGX0mHmh9oPqNAAbkVGvni56mhgHHCBxN/kpfkawA==", "dependencies": { "Microsoft.Extensions.Configuration.Binder": "9.0.0", - "Sentry.Extensions.Logging": "5.2.0" + "Sentry.Extensions.Logging": "5.3.0" } }, "Serilog": { @@ -305,15 +305,15 @@ }, "SixLabors.ImageSharp": { "type": "Direct", - "requested": "[3.1.6, )", - "resolved": "3.1.6", - "contentHash": "dHQ5jugF9v+5/LCVHCWVzaaIL6WOehqJy6eju/0VFYFPEj2WtqkGPoEV9EVQP83dHsdoqYaTuWpZdwAd37UwfA==" + "requested": "[3.1.7, )", + "resolved": "3.1.7", + "contentHash": "9fIOOAsyLFid6qKypM2Iy0Z3Q9yoanV8VoYAHtI2sYGMNKzhvRTjgFDHonIiVe+ANtxIxM6SuqUzj0r91nItpA==" }, "StackExchange.Redis": { "type": "Direct", - "requested": "[2.8.24, )", - "resolved": "2.8.24", - "contentHash": "GWllmsFAtLyhm4C47cOCipGxyEi1NQWTFUHXnJ8hiHOsK/bH3T5eLkWPVW+LRL6jDiB3g3izW3YEHgLuPoJSyA==", + "requested": "[2.8.31, )", + "resolved": "2.8.31", + "contentHash": "RCHVQa9Zke8k0oBgJn1Yl6BuYy8i6kv+sdMObiH60nOwD6QvWAjxdDwOm+LO78E8WsGiPqgOuItkz98fPS6haQ==", "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "6.0.0", "Pipelines.Sockets.Unofficial": "2.2.8" @@ -745,11 +745,11 @@ }, "Npgsql.NodaTime": { "type": "Transitive", - "resolved": "9.0.2", - "contentHash": "jURb6VGmmR3pPae2N3HrUixSZ/U5ovqZgg/qo3m5Rq/q0m2fpxbZcsHZo21s5MLa/AfJAx4hcFMY98D4RtLdcg==", + "resolved": "9.0.3", + "contentHash": "PMWXCft/iw+5A7eCeMcy6YZXBst6oeisbCkv2JMQVG4SAFa5vQaf6K2voXzUJCqzwOFcCWs+oT42w2uMDFpchw==", "dependencies": { "NodaTime": "3.2.0", - "Npgsql": "9.0.2" + "Npgsql": "9.0.3" } }, "Pipelines.Sockets.Unofficial": { @@ -762,18 +762,18 @@ }, "Sentry": { "type": "Transitive", - "resolved": "5.2.0", - "contentHash": "b3aZSOU2CjlIIFRtPRbXParKQ+9PF+JOqkSD7Gxq6PiR07t1rnK+crPtdrWMXfW6PVo/s67trCJ+fuLsgTeADw==" + "resolved": "5.3.0", + "contentHash": "zlBIP7YmYxySwcgapLMj1gdxPEz9rwdrOa4Yjub/TzcAaMQXusRH9hY4CE6pu0EIibZ7C7Hhjhr6xOTlyK8gFQ==" }, "Sentry.Extensions.Logging": { "type": "Transitive", - "resolved": "5.2.0", - "contentHash": "546bHsERKY7/pG5T4mVIp6WbHnQPMst6VDuxSaeU5DhQHLfh7KhgMmkdZ4Xvdlr95fvWk5/bX2xbipy6qoh/1A==", + "resolved": "5.3.0", + "contentHash": "DPN6NXvO4LTH21UM2gUFJwSwVa/fuT3B/UZmQyfSfecqViXrZO7WFuKz/h592YUoGNCumyt8x045bxbz6j9btg==", "dependencies": { "Microsoft.Extensions.Configuration.Binder": "9.0.0", "Microsoft.Extensions.Http": "9.0.0", "Microsoft.Extensions.Logging.Configuration": "9.0.0", - "Sentry": "5.2.0" + "Sentry": "5.3.0" } }, "Serilog.Extensions.Hosting": { From 5d452824cdb8785434bfa7f23a6f70a17dffc911 Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 11 Mar 2025 16:10:55 +0100 Subject: [PATCH 03/15] refactor(backend): use single shared HTTP client with backoff --- .../Extensions/WebApplicationExtensions.cs | 36 +++ Foxnouns.Backend/Foxnouns.Backend.csproj | 13 +- Foxnouns.Backend/Jobs/CreateDataExportJob.cs | 4 +- .../Services/Auth/FediverseAuthService.cs | 8 +- .../Auth/RemoteAuthService.Discord.cs | 4 +- .../Services/Auth/RemoteAuthService.Google.cs | 2 +- .../Services/Auth/RemoteAuthService.Tumblr.cs | 4 +- .../Services/Auth/RemoteAuthService.cs | 2 +- Foxnouns.Backend/packages.lock.json | 234 ++++++++++++++---- 9 files changed, 232 insertions(+), 75 deletions(-) diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 8db7a1b..6a010fc 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -21,8 +21,10 @@ using Foxnouns.Backend.Services; using Foxnouns.Backend.Services.Auth; using Foxnouns.Backend.Services.V1; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Http.Resilience; using Minio; using NodaTime; +using Polly; using Prometheus; using Serilog; using Serilog.Events; @@ -100,6 +102,40 @@ public static class WebApplicationExtensions builder.Host.ConfigureServices( (ctx, services) => { + // create a single HTTP client for all requests. + // it's also configured with a retry mechanism, so that requests aren't immediately lost to the void if they fail + services.AddSingleton(_ => + { + // ReSharper disable once SuggestVarOrType_Elsewhere + var retryPipeline = new ResiliencePipelineBuilder() + .AddRetry( + new HttpRetryStrategyOptions + { + BackoffType = DelayBackoffType.Linear, + MaxRetryAttempts = 3, + } + ) + .Build(); + + var resilienceHandler = new ResilienceHandler(retryPipeline) + { + InnerHandler = new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(15), + }, + }; + + var client = new HttpClient(resilienceHandler); + client.DefaultRequestHeaders.Remove("User-Agent"); + client.DefaultRequestHeaders.Remove("Accept"); + client.DefaultRequestHeaders.Add( + "User-Agent", + $"pronouns.cc/{BuildInfo.Version}" + ); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + return client; + }); + services .AddQueue() .AddSmtpMailer(ctx.Configuration) diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index a9972ab..42a3beb 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -25,12 +25,13 @@ all + - - + + @@ -38,14 +39,14 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - - + + diff --git a/Foxnouns.Backend/Jobs/CreateDataExportJob.cs b/Foxnouns.Backend/Jobs/CreateDataExportJob.cs index 3662e33..1c392f7 100644 --- a/Foxnouns.Backend/Jobs/CreateDataExportJob.cs +++ b/Foxnouns.Backend/Jobs/CreateDataExportJob.cs @@ -27,6 +27,7 @@ using NodaTime.Text; namespace Foxnouns.Backend.Jobs; public class CreateDataExportJob( + HttpClient client, DatabaseContext db, IClock clock, UserRendererService userRenderer, @@ -36,7 +37,6 @@ public class CreateDataExportJob( ILogger logger ) { - private static readonly HttpClient Client = new(); private readonly ILogger _logger = logger.ForContext(); public static void Enqueue(Snowflake userId) @@ -201,7 +201,7 @@ public class CreateDataExportJob( if (s3Path == null) return; - HttpResponseMessage resp = await Client.GetAsync(s3Path); + HttpResponseMessage resp = await client.GetAsync(s3Path); if (resp.StatusCode != HttpStatusCode.OK) { _logger.Warning("S3 path {S3Path} returned a non-200 status, not saving file", s3Path); diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs index 49afe1d..daa15ab 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs @@ -34,6 +34,7 @@ public partial class FediverseAuthService ILogger logger, Config config, DatabaseContext db, + HttpClient client, KeyCacheService keyCacheService, ISnowflakeGenerator snowflakeGenerator ) @@ -43,12 +44,7 @@ public partial class FediverseAuthService _db = db; _keyCacheService = keyCacheService; _snowflakeGenerator = snowflakeGenerator; - - _client = new HttpClient(); - _client.DefaultRequestHeaders.Remove("User-Agent"); - _client.DefaultRequestHeaders.Remove("Accept"); - _client.DefaultRequestHeaders.Add("User-Agent", $"pronouns.cc/{BuildInfo.Version}"); - _client.DefaultRequestHeaders.Add("Accept", "application/json"); + _client = client; } public async Task GenerateAuthUrlAsync( diff --git a/Foxnouns.Backend/Services/Auth/RemoteAuthService.Discord.cs b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Discord.cs index de16d2f..d884fda 100644 --- a/Foxnouns.Backend/Services/Auth/RemoteAuthService.Discord.cs +++ b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Discord.cs @@ -27,7 +27,7 @@ public partial class RemoteAuthService ) { var redirectUri = $"{config.BaseUrl}/auth/callback/discord"; - HttpResponseMessage resp = await _httpClient.PostAsync( + HttpResponseMessage resp = await client.PostAsync( _discordTokenUri, new FormUrlEncodedContent( new Dictionary @@ -59,7 +59,7 @@ public partial class RemoteAuthService var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri); req.Headers.Add("Authorization", $"{token.TokenType} {token.AccessToken}"); - HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct); + HttpResponseMessage resp2 = await client.SendAsync(req, ct); resp2.EnsureSuccessStatusCode(); DiscordUserResponse? user = await resp2.Content.ReadFromJsonAsync(ct); if (user == null) diff --git a/Foxnouns.Backend/Services/Auth/RemoteAuthService.Google.cs b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Google.cs index 3245858..938ba32 100644 --- a/Foxnouns.Backend/Services/Auth/RemoteAuthService.Google.cs +++ b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Google.cs @@ -28,7 +28,7 @@ public partial class RemoteAuthService ) { var redirectUri = $"{config.BaseUrl}/auth/callback/google"; - HttpResponseMessage resp = await _httpClient.PostAsync( + HttpResponseMessage resp = await client.PostAsync( _googleTokenUri, new FormUrlEncodedContent( new Dictionary diff --git a/Foxnouns.Backend/Services/Auth/RemoteAuthService.Tumblr.cs b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Tumblr.cs index d63ee1a..45b9161 100644 --- a/Foxnouns.Backend/Services/Auth/RemoteAuthService.Tumblr.cs +++ b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Tumblr.cs @@ -29,7 +29,7 @@ public partial class RemoteAuthService ) { var redirectUri = $"{config.BaseUrl}/auth/callback/tumblr"; - HttpResponseMessage resp = await _httpClient.PostAsync( + HttpResponseMessage resp = await client.PostAsync( _tumblrTokenUri, new FormUrlEncodedContent( new Dictionary @@ -62,7 +62,7 @@ public partial class RemoteAuthService var req = new HttpRequestMessage(HttpMethod.Get, _tumblrUserUri); req.Headers.Add("Authorization", $"Bearer {token.AccessToken}"); - HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct); + HttpResponseMessage resp2 = await client.SendAsync(req, ct); if (!resp2.IsSuccessStatusCode) { string respBody = await resp2.Content.ReadAsStringAsync(ct); diff --git a/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs b/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs index 6e0ba76..93b006b 100644 --- a/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs @@ -25,6 +25,7 @@ using Microsoft.EntityFrameworkCore; namespace Foxnouns.Backend.Services.Auth; public partial class RemoteAuthService( + HttpClient client, Config config, ILogger logger, DatabaseContext db, @@ -32,7 +33,6 @@ public partial class RemoteAuthService( ) { private readonly ILogger _logger = logger.ForContext(); - private readonly HttpClient _httpClient = new(); public record RemoteUser(string Id, string Username); diff --git a/Foxnouns.Backend/packages.lock.json b/Foxnouns.Backend/packages.lock.json index 96daa5d..365ee8c 100644 --- a/Foxnouns.Backend/packages.lock.json +++ b/Foxnouns.Backend/packages.lock.json @@ -155,6 +155,18 @@ "Microsoft.Extensions.Primitives": "9.0.2" } }, + "Microsoft.Extensions.Http.Resilience": { + "type": "Direct", + "requested": "[9.2.0, )", + "resolved": "9.2.0", + "contentHash": "Km+YyCuk1IaeOsAzPDygtgsUOh3Fi89hpA18si0tFJmpSBf9aKzP9ffV5j7YOoVDvRWirpumXAPQzk1inBsvKw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "9.0.2", + "Microsoft.Extensions.Http.Diagnostics": "9.2.0", + "Microsoft.Extensions.ObjectPool": "9.0.2", + "Microsoft.Extensions.Resilience": "9.2.0" + } + }, "MimeKit": { "type": "Direct", "requested": "[4.10.0, )", @@ -537,6 +549,16 @@ "Microsoft.Extensions.Logging": "9.0.2" } }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "9.2.0", + "contentHash": "GMCX3zybUB22aAADjYPXrWhhd1HNMkcY5EcFAJnXy/4k5pPpJ6TS4VRl37xfrtosNyzbpO2SI7pd2Q5PvggSdg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.2", + "Microsoft.Extensions.Hosting.Abstractions": "9.0.2", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.2" + } + }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", "resolved": "9.0.2", @@ -545,13 +567,22 @@ "Microsoft.Extensions.Primitives": "9.0.2" } }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "9.2.0", + "contentHash": "Te+N4xphDlGIS90lKJMZyezFiMWKLAtYV2/M8gGJG4thH6xyC7LWhMzgz2+tWMehxwZlBUq2D9DvVpjKBZFTPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2", + "Microsoft.Extensions.ObjectPool": "9.0.2" + } + }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "YIMO9T3JL8MeEXgVozKt2v79hquo/EFtnY0vgxmLnUvk1Rei/halI7kOWZL2RBeV9FMGzgM9LZA8CVaNwFMaNA==", + "resolved": "9.0.2", + "contentHash": "EBZW+u96tApIvNtjymXEIS44tH0I/jNwABHo4c33AchWOiDWCq2rL3klpnIo+xGrxoVGJzPDISV6hZ+a9C9SzQ==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", - "Microsoft.Extensions.Primitives": "9.0.0" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.2", + "Microsoft.Extensions.Primitives": "9.0.2" } }, "Microsoft.Extensions.Configuration.Abstractions": { @@ -564,10 +595,10 @@ }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "RiScL99DcyngY9zJA2ROrri7Br8tn5N4hP4YNvGdTN/bvg1A3dwvDOxHnNZ3Im7x2SJ5i4LkX1uPiR/MfSFBLQ==", + "resolved": "9.0.2", + "contentHash": "krJ04xR0aPXrOf5dkNASg6aJjsdzexvsMRL6UNOUjiTzqBvRr95sJ1owoKEm89bSONQCfZNhHrAFV9ahDqIPIw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.0" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.2" } }, "Microsoft.Extensions.DependencyInjection": { @@ -583,6 +614,14 @@ "resolved": "9.0.2", "contentHash": "MNe7GSTBf3jQx5vYrXF0NZvn6l7hUKF6J54ENfAgCO8y6xjN1XUmKKWG464LP2ye6QqDiA1dkaWEZBYnhoZzjg==" }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "9.2.0", + "contentHash": "WcwfTpl3IcPcaahTVEaJwMUg1eWog1SkIA6jQZZFqMXiMX9/tVkhNB6yzUQmBdGWdlWDDRKpOmK7T7x1Uu05pQ==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "9.0.2" + } + }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", "resolved": "9.0.2", @@ -590,54 +629,74 @@ }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "0CF9ZrNw5RAlRfbZuVIvzzhP8QeWqHiUmMBU/2H7Nmit8/vwP3/SbHeEctth7D4Gz2fBnEbokPc1NU8/j/1ZLw==", + "resolved": "9.0.2", + "contentHash": "kwFWk6DPaj1Roc0CExRv+TTwjsiERZA730jQIPlwCcS5tMaCAQtaGfwAK0z8CMFpVTiT+MgKXpd/P50qVCuIgg==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.0", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.0" + "Microsoft.Extensions.Configuration": "9.0.2", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.2", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.2" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "1K8P7XzuzX8W8pmXcZjcrqS6x5eSSdvhQohmcpgiQNY/HlDAlnrhR9dvlURfFz428A+RTCJpUyB+aKTA6AgVcQ==", + "resolved": "9.0.2", + "contentHash": "kFwIZEC/37cwKuEm/nXvjF7A/Myz9O7c7P9Csgz6AOiiDE62zdOG5Bu7VkROu1oMYaX0wgijPJ5LqVt6+JKjVg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "Microsoft.Extensions.Options": "9.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2", + "Microsoft.Extensions.Options": "9.0.2" + } + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "9.2.0", + "contentHash": "et5JevHsLv1w1O1Zhb6LiUfai/nmDRzIHnbrZJdzLsIbbMCKTZpeHuANYIppAD//n12KvgOne05j4cu0GhG9gw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "uK439QzYR0q2emLVtYzwyK3x+T5bTY4yWsd/k/ZUS9LR6Sflp8MIdhGXW8kQCd86dQD4tLqvcbLkku8qHY263Q==", + "resolved": "9.0.2", + "contentHash": "IcOBmTlr2jySswU+3x8c3ql87FRwTVPQgVKaV5AXzPT5u0VItfNU8SMbESpdSp5STwxT/1R99WYszgHWsVkzhg==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.0" + "Microsoft.Extensions.Primitives": "9.0.2" } }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "yUKJgu81ExjvqbNWqZKshBbLntZMbMVz/P7Way2SBx7bMqA08Mfdc9O7hWDKAiSp+zPUGT6LKcSCQIPeDK+CCw==", + "resolved": "9.0.2", + "contentHash": "PvjZW6CMdZbPbOwKsQXYN5VPtIWZQqdTRuBPZiW3skhU3hymB17XSlLVC4uaBbDZU+/3eHG3p80y+MzZxZqR7Q==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.0", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.0", - "Microsoft.Extensions.Logging.Abstractions": "9.0.0" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.2", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.2", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.2", + "Microsoft.Extensions.Logging.Abstractions": "9.0.2" } }, "Microsoft.Extensions.Http": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "DqI4q54U4hH7bIAq9M5a/hl5Odr/KBAoaZ0dcT4OgutD8dook34CbkvAfAIzkMVjYXiL+E5ul9etwwqiX4PHGw==", + "resolved": "9.0.2", + "contentHash": "34+kcwxPZr3Owk9eZx268+gqGNB8G/8Y96gZHomxam0IOH08FhPBjPrLWDtKdVn4+sVUUJnJMpECSTJi4XXCcg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "Microsoft.Extensions.Diagnostics": "9.0.0", - "Microsoft.Extensions.Logging": "9.0.0", - "Microsoft.Extensions.Logging.Abstractions": "9.0.0", - "Microsoft.Extensions.Options": "9.0.0" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.2", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2", + "Microsoft.Extensions.Diagnostics": "9.0.2", + "Microsoft.Extensions.Logging": "9.0.2", + "Microsoft.Extensions.Logging.Abstractions": "9.0.2", + "Microsoft.Extensions.Options": "9.0.2" + } + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "9.2.0", + "contentHash": "Eeup1LuD5hVk5SsKAuX1D7I9sF380MjrNG10IaaauRLOmrRg8rq2TA8PYTXVBXf3MLkZ6m2xpBqRbZdxf8ygkg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.AutoActivation": "9.2.0", + "Microsoft.Extensions.Http": "9.0.2", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.2", + "Microsoft.Extensions.Telemetry": "9.2.0", + "System.IO.Pipelines": "9.0.2" } }, "Microsoft.Extensions.Logging": { @@ -660,23 +719,23 @@ }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "H05HiqaNmg6GjH34ocYE9Wm1twm3Oz2aXZko8GTwGBzM7op2brpAA8pJ5yyD1OpS1mXUtModBYOlcZ/wXeWsSg==", + "resolved": "9.0.2", + "contentHash": "pnwYZE7U6d3Y6iMVqADOAUUMMBGYAQPsT3fMwVr/V1Wdpe5DuVGFcViZavUthSJ5724NmelIl1cYy+kRfKfRPQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", - "Microsoft.Extensions.Configuration.Binder": "9.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "Microsoft.Extensions.Logging": "9.0.0", - "Microsoft.Extensions.Logging.Abstractions": "9.0.0", - "Microsoft.Extensions.Options": "9.0.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.0" + "Microsoft.Extensions.Configuration": "9.0.2", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.2", + "Microsoft.Extensions.Configuration.Binder": "9.0.2", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2", + "Microsoft.Extensions.Logging": "9.0.2", + "Microsoft.Extensions.Logging.Abstractions": "9.0.2", + "Microsoft.Extensions.Options": "9.0.2", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.2" } }, "Microsoft.Extensions.ObjectPool": { "type": "Transitive", - "resolved": "7.0.0", - "contentHash": "udvKco0sAVgYGTBnHUb0tY9JQzJ/nPDiv/8PIyz69wl1AibeCDZOLVVI+6156dPfHmJH7ws5oUJRiW4ZmAvuuA==" + "resolved": "9.0.2", + "contentHash": "nWx7uY6lfkmtpyC2dGc0IxtrZZs/LnLCQHw3YYQucbqWj8a27U/dZ+eh72O3ZiolqLzzLkVzoC+w/M8dZwxRTw==" }, "Microsoft.Extensions.Options": { "type": "Transitive", @@ -689,14 +748,14 @@ }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "Ob3FXsXkcSMQmGZi7qP07EQ39kZpSBlTcAZLbJLdI4FIf0Jug8biv2HTavWmnTirchctPlq9bl/26CXtQRguzA==", + "resolved": "9.0.2", + "contentHash": "OPm1NXdMg4Kb4Kz+YHdbBQfekh7MqQZ7liZ5dYUd+IbJakinv9Fl7Ck6Strbgs0a6E76UGbP/jHR532K/7/feQ==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.0", - "Microsoft.Extensions.Configuration.Binder": "9.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "Microsoft.Extensions.Options": "9.0.0", - "Microsoft.Extensions.Primitives": "9.0.0" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.2", + "Microsoft.Extensions.Configuration.Binder": "9.0.2", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2", + "Microsoft.Extensions.Options": "9.0.2", + "Microsoft.Extensions.Primitives": "9.0.2" } }, "Microsoft.Extensions.Primitives": { @@ -704,6 +763,42 @@ "resolved": "9.0.2", "contentHash": "puBMtKe/wLuYa7H6docBkLlfec+h8L35DXqsDKKJgW0WY5oCwJ3cBJKcDaZchv6knAyqOMfsl6VUbaR++E5LXA==" }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "9.2.0", + "contentHash": "dyaM+Jeznh/i21bOrrRs3xceFfn0571EOjOq95dRXmL1rHDLC4ExhACJ2xipRBP6g1AgRNqmryi+hMrVWWgmlg==", + "dependencies": { + "Microsoft.Extensions.Diagnostics": "9.0.2", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "9.2.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.2", + "Microsoft.Extensions.Telemetry.Abstractions": "9.2.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "9.2.0", + "contentHash": "4+bw7W4RrAMrND9TxonnSmzJOdXiPxljoda8OPJiReIN607mKCc0t0Mf28sHNsTujO1XQw28wsI0poxeeQxohw==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "9.2.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "9.2.0", + "Microsoft.Extensions.Logging.Configuration": "9.0.2", + "Microsoft.Extensions.ObjectPool": "9.0.2", + "Microsoft.Extensions.Telemetry.Abstractions": "9.2.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "9.2.0", + "contentHash": "kEl+5G3RqS20XaEhHh/nOugcjKEK+rgVtMJra1iuwNzdzQXElelf3vu8TugcT7rIZ/T4T76EKW1OX/fmlxz4hw==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "9.2.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.2", + "Microsoft.Extensions.ObjectPool": "9.0.2", + "Microsoft.Extensions.Options": "9.0.2" + } + }, "Microsoft.NETCore.Platforms": { "type": "Transitive", "resolved": "1.1.1", @@ -760,6 +855,30 @@ "System.IO.Pipelines": "5.0.1" } }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, "Sentry": { "type": "Transitive", "resolved": "5.3.0", @@ -901,8 +1020,8 @@ }, "System.IO.Pipelines": { "type": "Transitive", - "resolved": "7.0.0", - "contentHash": "jRn6JYnNPW6xgQazROBLSfpdoczRw694vO5kKvMcNnpXuolEixUyw6IBuBs2Y2mlSX/LdLvyyWmfXhaI3ND1Yg==" + "resolved": "9.0.2", + "contentHash": "UIBaK7c/A3FyQxmX/747xw4rCUkm1BhNiVU617U5jweNJssNjLJkPUGhBsrlDG0BpKWCYKsncD+Kqpy4KmvZZQ==" }, "System.Reactive": { "type": "Transitive", @@ -940,6 +1059,11 @@ "type": "Transitive", "resolved": "7.0.0", "contentHash": "qmeeYNROMsONF6ndEZcIQ+VxR4Q/TX/7uIVLJqtwIWL7dDWeh0l1UIqgo4wYyjG//5lUNhwkLDSFl+pAWO6oiA==" + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" } } } From f5f04163463ca4a5274bfb359991164eaccc9b19 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 13 Mar 2025 15:18:35 +0100 Subject: [PATCH 04/15] refactor(backend): misc cleanup --- .../Extensions/WebApplicationExtensions.cs | 3 -- Foxnouns.Backend/Program.cs | 11 ++--- .../Auth/FediverseAuthService.Mastodon.cs | 24 +++++------ .../Auth/FediverseAuthService.Misskey.cs | 18 ++++----- .../Services/Auth/FediverseAuthService.cs | 40 ++++++------------- Foxnouns.Backend/Services/KeyCacheService.cs | 2 - .../Services/PeriodicTasksService.cs | 2 - .../Services/ValidationService.Strings.cs | 5 ++- 8 files changed, 43 insertions(+), 62 deletions(-) diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 6a010fc..567ae02 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -196,9 +196,6 @@ public static class WebApplicationExtensions public static async Task Initialize(this WebApplication app, string[] args) { - // Read version information from .version in the repository root - await BuildInfo.ReadBuildInfo(); - app.Services.ConfigureQueue() .LogQueuedTaskProgress(app.Services.GetRequiredService>()); diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index b5bc338..f27ad50 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -34,6 +34,9 @@ Config config = builder.AddConfiguration(); builder.AddSerilog(); +// Read version information from .version in the repository root +await BuildInfo.ReadBuildInfo(); + builder .WebHost.UseSentry(opts => { @@ -68,11 +71,9 @@ builder }) .ConfigureApiBehaviorOptions(options => { - // the type isn't needed but without it, rider keeps complaining for no reason (it compiles just fine) - options.InvalidModelStateResponseFactory = (ActionContext actionContext) => - new BadRequestObjectResult( - new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson() - ); + options.InvalidModelStateResponseFactory = actionContext => new BadRequestObjectResult( + new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson() + ); }); builder diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs index 25d5327..225461c 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs @@ -25,20 +25,20 @@ namespace Foxnouns.Backend.Services.Auth; public partial class FediverseAuthService { private string MastodonRedirectUri(string instance) => - $"{_config.BaseUrl}/auth/callback/mastodon/{instance}"; + $"{config.BaseUrl}/auth/callback/mastodon/{instance}"; private async Task CreateMastodonApplicationAsync( string instance, Snowflake? existingAppId = null ) { - HttpResponseMessage resp = await _client.PostAsJsonAsync( + HttpResponseMessage resp = await client.PostAsJsonAsync( $"https://{instance}/api/v1/apps", new CreateMastodonApplicationRequest( - $"pronouns.cc (+{_config.BaseUrl})", + $"pronouns.cc (+{config.BaseUrl})", MastodonRedirectUri(instance), "read read:accounts", - _config.BaseUrl + config.BaseUrl ) ); resp.EnsureSuccessStatusCode(); @@ -58,19 +58,19 @@ public partial class FediverseAuthService { app = new FediverseApplication { - Id = existingAppId ?? _snowflakeGenerator.GenerateSnowflake(), + Id = existingAppId ?? snowflakeGenerator.GenerateSnowflake(), ClientId = mastodonApp.ClientId, ClientSecret = mastodonApp.ClientSecret, Domain = instance, InstanceType = FediverseInstanceType.MastodonApi, }; - _db.Add(app); + db.Add(app); } else { app = - await _db.FediverseApplications.FindAsync(existingAppId) + await db.FediverseApplications.FindAsync(existingAppId) ?? throw new FoxnounsError($"Existing app with ID {existingAppId} was null"); app.ClientId = mastodonApp.ClientId; @@ -78,7 +78,7 @@ public partial class FediverseAuthService app.InstanceType = FediverseInstanceType.MastodonApi; } - await _db.SaveChangesAsync(); + await db.SaveChangesAsync(); return app; } @@ -90,9 +90,9 @@ public partial class FediverseAuthService ) { if (state != null) - await _keyCacheService.ValidateAuthStateAsync(state); + await keyCacheService.ValidateAuthStateAsync(state); - HttpResponseMessage tokenResp = await _client.PostAsync( + HttpResponseMessage tokenResp = await client.PostAsync( MastodonTokenUri(app.Domain), new FormUrlEncodedContent( new Dictionary @@ -123,7 +123,7 @@ public partial class FediverseAuthService var req = new HttpRequestMessage(HttpMethod.Get, MastodonCurrentUserUri(app.Domain)); req.Headers.Add("Authorization", $"Bearer {token}"); - HttpResponseMessage currentUserResp = await _client.SendAsync(req); + HttpResponseMessage currentUserResp = await client.SendAsync(req); currentUserResp.EnsureSuccessStatusCode(); FediverseUser? user = await currentUserResp.Content.ReadFromJsonAsync(); if (user == null) @@ -151,7 +151,7 @@ public partial class FediverseAuthService app = await CreateMastodonApplicationAsync(app.Domain, app.Id); } - state ??= HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync()); + state ??= HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync()); return $"https://{app.Domain}/oauth/authorize?response_type=code" + $"&client_id={app.ClientId}" diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs index 10a61e4..bf6c4b4 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs @@ -34,11 +34,11 @@ public partial class FediverseAuthService Snowflake? existingAppId = null ) { - HttpResponseMessage resp = await _client.PostAsJsonAsync( + HttpResponseMessage resp = await client.PostAsJsonAsync( MisskeyAppUri(instance), new CreateMisskeyApplicationRequest( - $"pronouns.cc (+{_config.BaseUrl})", - $"pronouns.cc on {_config.BaseUrl}", + $"pronouns.cc (+{config.BaseUrl})", + $"pronouns.cc on {config.BaseUrl}", ["read:account"], MastodonRedirectUri(instance) ) @@ -60,19 +60,19 @@ public partial class FediverseAuthService { app = new FediverseApplication { - Id = existingAppId ?? _snowflakeGenerator.GenerateSnowflake(), + Id = existingAppId ?? snowflakeGenerator.GenerateSnowflake(), ClientId = misskeyApp.Id, ClientSecret = misskeyApp.Secret, Domain = instance, InstanceType = FediverseInstanceType.MisskeyApi, }; - _db.Add(app); + db.Add(app); } else { app = - await _db.FediverseApplications.FindAsync(existingAppId) + await db.FediverseApplications.FindAsync(existingAppId) ?? throw new FoxnounsError($"Existing app with ID {existingAppId} was null"); app.ClientId = misskeyApp.Id; @@ -80,7 +80,7 @@ public partial class FediverseAuthService app.InstanceType = FediverseInstanceType.MisskeyApi; } - await _db.SaveChangesAsync(); + await db.SaveChangesAsync(); return app; } @@ -96,7 +96,7 @@ public partial class FediverseAuthService private async Task GetMisskeyUserAsync(FediverseApplication app, string code) { - HttpResponseMessage resp = await _client.PostAsJsonAsync( + HttpResponseMessage resp = await client.PostAsJsonAsync( MisskeyTokenUri(app.Domain), new GetMisskeySessionUserKeyRequest(app.ClientSecret, code) ); @@ -130,7 +130,7 @@ public partial class FediverseAuthService app = await CreateMisskeyApplicationAsync(app.Domain, app.Id); } - HttpResponseMessage resp = await _client.PostAsJsonAsync( + HttpResponseMessage resp = await client.PostAsJsonAsync( MisskeyGenerateSessionUri(app.Domain), new CreateMisskeySessionUriRequest(app.ClientSecret) ); diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs index daa15ab..3ffc28d 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs @@ -19,33 +19,17 @@ using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; namespace Foxnouns.Backend.Services.Auth; -public partial class FediverseAuthService +public partial class FediverseAuthService( + ILogger logger, + Config config, + DatabaseContext db, + HttpClient client, + KeyCacheService keyCacheService, + ISnowflakeGenerator snowflakeGenerator +) { private const string NodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/2.0"; - - private readonly HttpClient _client; - private readonly ILogger _logger; - private readonly Config _config; - private readonly DatabaseContext _db; - private readonly KeyCacheService _keyCacheService; - private readonly ISnowflakeGenerator _snowflakeGenerator; - - public FediverseAuthService( - ILogger logger, - Config config, - DatabaseContext db, - HttpClient client, - KeyCacheService keyCacheService, - ISnowflakeGenerator snowflakeGenerator - ) - { - _logger = logger.ForContext(); - _config = config; - _db = db; - _keyCacheService = keyCacheService; - _snowflakeGenerator = snowflakeGenerator; - _client = client; - } + private readonly ILogger _logger = logger.ForContext(); public async Task GenerateAuthUrlAsync( string instance, @@ -66,7 +50,7 @@ public partial class FediverseAuthService public async Task GetApplicationAsync(string instance) { - FediverseApplication? app = await _db.FediverseApplications.FirstOrDefaultAsync(a => + FediverseApplication? app = await db.FediverseApplications.FirstOrDefaultAsync(a => a.Domain == instance ); if (app != null) @@ -88,7 +72,7 @@ public partial class FediverseAuthService { _logger.Debug("Requesting software name for fediverse instance {Instance}", instance); - HttpResponseMessage wellKnownResp = await _client.GetAsync( + HttpResponseMessage wellKnownResp = await client.GetAsync( new Uri($"https://{instance}/.well-known/nodeinfo") ); wellKnownResp.EnsureSuccessStatusCode(); @@ -103,7 +87,7 @@ public partial class FediverseAuthService ); } - HttpResponseMessage nodeInfoResp = await _client.GetAsync(nodeInfoUrl); + HttpResponseMessage nodeInfoResp = await client.GetAsync(nodeInfoUrl); nodeInfoResp.EnsureSuccessStatusCode(); PartialNodeInfo? nodeInfo = await nodeInfoResp.Content.ReadFromJsonAsync(); diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs index 228a8fc..5c59bce 100644 --- a/Foxnouns.Backend/Services/KeyCacheService.cs +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -39,8 +39,6 @@ public class KeyCacheService(Config config) public async Task DeleteKeyAsync(string key) => await Multiplexer.GetDatabase().KeyDeleteAsync(key); - public Task DeleteExpiredKeysAsync(CancellationToken ct) => Task.CompletedTask; - public async Task SetKeyAsync(string key, T obj, Duration expiresAt) where T : class { diff --git a/Foxnouns.Backend/Services/PeriodicTasksService.cs b/Foxnouns.Backend/Services/PeriodicTasksService.cs index bf0f4af..e5efd28 100644 --- a/Foxnouns.Backend/Services/PeriodicTasksService.cs +++ b/Foxnouns.Backend/Services/PeriodicTasksService.cs @@ -33,11 +33,9 @@ public class PeriodicTasksService(ILogger logger, IServiceProvider services) : B // The type is literally written on the same line, we can just use `var` // ReSharper disable SuggestVarOrType_SimpleTypes - var keyCacheService = scope.ServiceProvider.GetRequiredService(); var dataCleanupService = scope.ServiceProvider.GetRequiredService(); // ReSharper restore SuggestVarOrType_SimpleTypes - await keyCacheService.DeleteExpiredKeysAsync(ct); await dataCleanupService.InvokeAsync(ct); } } diff --git a/Foxnouns.Backend/Services/ValidationService.Strings.cs b/Foxnouns.Backend/Services/ValidationService.Strings.cs index 8f43052..9244ed4 100644 --- a/Foxnouns.Backend/Services/ValidationService.Strings.cs +++ b/Foxnouns.Backend/Services/ValidationService.Strings.cs @@ -31,6 +31,7 @@ public partial class ValidationService "settings", "pronouns.cc", "pronounscc", + "null", ]; private static readonly string[] InvalidMemberNames = @@ -38,8 +39,10 @@ public partial class ValidationService // these break routing outright ".", "..", - // the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible + // TODO: remove this? i'm not sure if /@[username]/edit will redirect to settings "edit", + // this breaks the frontend, somehow + "null", ]; public ValidationError? ValidateUsername(string username) From f00f5b400e78d9d458315a2bebefdbdc45c8203f Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 17 Mar 2025 22:46:44 +0100 Subject: [PATCH 05/15] feat(frontend): allow configuring assets url --- Foxnouns.Frontend/package.json | 1 + Foxnouns.Frontend/pnpm-lock.yaml | 3 +++ Foxnouns.Frontend/svelte.config.js | 12 ++++++++++++ 3 files changed, 16 insertions(+) diff --git a/Foxnouns.Frontend/package.json b/Foxnouns.Frontend/package.json index a76a918..013e44f 100644 --- a/Foxnouns.Frontend/package.json +++ b/Foxnouns.Frontend/package.json @@ -21,6 +21,7 @@ "@types/markdown-it": "^14.1.2", "@types/sanitize-html": "^2.13.0", "bootstrap": "^5.3.3", + "dotenv": "^16.4.7", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.46.1", diff --git a/Foxnouns.Frontend/pnpm-lock.yaml b/Foxnouns.Frontend/pnpm-lock.yaml index 46b0010..68beeed 100644 --- a/Foxnouns.Frontend/pnpm-lock.yaml +++ b/Foxnouns.Frontend/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: bootstrap: specifier: ^5.3.3 version: 5.3.3(@popperjs/core@2.11.8) + dotenv: + specifier: ^16.4.7 + version: 16.4.7 eslint: specifier: ^9.17.0 version: 9.17.0 diff --git a/Foxnouns.Frontend/svelte.config.js b/Foxnouns.Frontend/svelte.config.js index fcd662a..f4ddf37 100644 --- a/Foxnouns.Frontend/svelte.config.js +++ b/Foxnouns.Frontend/svelte.config.js @@ -1,5 +1,14 @@ import adapter from "@sveltejs/adapter-node"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; +import * as path from "node:path"; + +import { config as dotenv } from "dotenv"; +dotenv({ + path: [path.resolve(process.cwd(), ".env"), path.resolve(process.cwd(), ".env.local")], +}); + +console.log(process.env.NODE_ENV); +const isProd = process.env.NODE_ENV === "production"; /** @type {import('@sveltejs/kit').Config} */ const config = { @@ -21,6 +30,9 @@ const config = { // we only disable it during development, during building NODE_ENV == production checkOrigin: process.env.NODE_ENV !== "development", }, + paths: { + assets: isProd ? process.env.PRIVATE_ASSETS_PREFIX || "" : "", + }, }, }; From 978b8e100e371b510d5e20e898033fa40ce74c97 Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 18 Mar 2025 15:03:03 +0100 Subject: [PATCH 06/15] remove unused MetricsAddress from config --- Foxnouns.Backend/Config.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index b48a2c4..461e55e 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -26,7 +26,6 @@ public class Config public string MediaBaseUrl { get; init; } = null!; public string Address => $"http://{Host}:{Port}"; - public string MetricsAddress => $"http://{Host}:{Logging.MetricsPort}"; public LoggingConfig Logging { get; init; } = new(); public DatabaseConfig Database { get; init; } = new(); From 3527acb8bae751b73dde932fc327787c7f48fbae Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 18 Mar 2025 15:38:06 +0100 Subject: [PATCH 07/15] feat: add pre-built docker images --- DOCKER.md | 27 ++++++++-- ...er-compose.yml => docker-compose.local.yml | 0 docker-compose.prebuilt.yml | 53 +++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) rename docker-compose.yml => docker-compose.local.yml (100%) create mode 100644 docker-compose.prebuilt.yml diff --git a/DOCKER.md b/DOCKER.md index a007aab..b670743 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -1,10 +1,29 @@ -# Running with Docker +# Running with Docker (pre-built backend and rate limiter) *(linux/arm64 only)* + +Because SvelteKit is a pain in the ass to build in a container, and processes secrets at build time, +there is no pre-built frontend image available. +If you don't want to build images on your server, I recommend running the frontend outside of Docker. +This is preconfigured in `docker-compose.prebuilt.yml`: the backend, database, and rate limiter will run in Docker, +while the frontend is run as a normal, non-containerized service. 1. Copy `docker/config.example.ini` to `docker/config.ini`, and change the settings to your liking. 2. Copy `docker/proxy-config.example.json` to `docker/proxy-config.json`, and do the same. -3. Copy `docker/frontend.example.env` to `docker/frontend.env`, and do the same. -4. Build with `docker compose build` -5. Run with `docker compose up` +3. Run with `docker compose up -f docker-compose.prebuilt.yml` + +The backend will listen on port 5001 and metrics will be available on port 5002. +The rate limiter (which is what should be exposed to the outside) will listen on port 5003. +You can use `docker/Caddyfile` as an example for your reverse proxy. If you use nginx, good luck. + +# Running with Docker (local builds) + +In order to run *everything* in Docker, you'll have to build every container yourself. +The advantage of this is that it's an all-in-one solution, where you only have to point your reverse proxy at a single container. +The disadvantage is that you'll likely have to build the images on the server you'll be running them on. + +1. Configure the backend and rate limiter as in the section above. +2. Copy `docker/frontend.example.env` to `docker/frontend.env`, and configure it. +3. Build with `docker compose build -f docker-compose.local.yml` +4. Run with `docker compose up -f docker-compose.local.yml` The Caddy server will listen on `localhost:5004` for the frontend and API, and on `localhost:5005` for the profile URL shortener. diff --git a/docker-compose.yml b/docker-compose.local.yml similarity index 100% rename from docker-compose.yml rename to docker-compose.local.yml diff --git a/docker-compose.prebuilt.yml b/docker-compose.prebuilt.yml new file mode 100644 index 0000000..091f1fb --- /dev/null +++ b/docker-compose.prebuilt.yml @@ -0,0 +1,53 @@ +services: + backend: + image: code.vulpine.solutions/sam/foxnouns-be:latest + environment: + - "Database:Url=Host=postgres;Database=postgres;Username=postgres;Password=postgres" + - "Database:EnablePooling=true" + - "Database:Redis=redis:6379" + - "Host=0.0.0.0" + - "Port=5000" + - "Logging:MetricsPort=5001" + restart: unless-stopped + ports: + - "5001:5000" + - "5002:5001" + volumes: + - ./docker/config.ini:/app/config.ini + - ./docker/static-pages:/app/static-pages + + rate: + image: code.vulpine.solutions/sam/foxnouns-rate:latest + environment: + - "PORT=5003" + ports: + - "5003:5003" + restart: unless-stopped + volumes: + - ./docker/proxy-config.json:/app/proxy-config.json + + postgres: + image: docker.io/postgres:16 + command: [ "postgres", + "-c", "max-connections=1000", + "-c", "timezone=Etc/UTC", + "-c", "max_wal_size=1GB", + "-c", "min_wal_size=80MB", + "-c", "shared_buffers=128MB" ] + environment: + - "POSTGRES_PASSWORD=postgres" + restart: unless-stopped + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: registry.redict.io/redict:7 + restart: unless-stopped + volumes: + - redict_data:/data + +volumes: + caddy_data: + caddy_config: + postgres_data: + redict_data: From 22be49976a24d4e64f49293b92c1f5d0b2884771 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 6 Apr 2025 15:32:26 +0200 Subject: [PATCH 08/15] feat(backend): return settings in GET /users/@me --- .../Controllers/UsersController.cs | 19 +++++++++---------- Foxnouns.Backend/Dto/User.cs | 3 ++- .../Services/UserRendererService.cs | 7 ++++++- Foxnouns.Frontend/src/lib/api/models/user.ts | 1 + 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index 48a42aa..31aa98a 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -46,7 +46,15 @@ public class UsersController( { User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); return Ok( - await userRenderer.RenderUserAsync(user, CurrentUser, CurrentToken, true, true, ct: ct) + await userRenderer.RenderUserAsync( + user, + CurrentUser, + CurrentToken, + renderMembers: true, + renderAuthMethods: true, + renderSettings: true, + ct: ct + ) ); } @@ -261,15 +269,6 @@ public class UsersController( return Ok(user.CustomPreferences); } - [HttpGet("@me/settings")] - [Authorize("user.read_hidden")] - [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public async Task GetUserSettingsAsync(CancellationToken ct = default) - { - User user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct); - return Ok(user.Settings); - } - [HttpPatch("@me/settings")] [Authorize("user.read_hidden", "user.update")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] diff --git a/Foxnouns.Backend/Dto/User.cs b/Foxnouns.Backend/Dto/User.cs index db4780a..83121d1 100644 --- a/Foxnouns.Backend/Dto/User.cs +++ b/Foxnouns.Backend/Dto/User.cs @@ -49,7 +49,8 @@ public record UserResponse( [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Suspended, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] UserSettings? Settings ); public record CustomPreferenceResponse( diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index 5a90c2d..0c1fc1b 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -33,6 +33,7 @@ public class UserRendererService( bool renderMembers = true, bool renderAuthMethods = false, string? overrideSid = null, + bool renderSettings = false, CancellationToken ct = default ) => await RenderUserInnerAsync( @@ -42,6 +43,7 @@ public class UserRendererService( renderMembers, renderAuthMethods, overrideSid, + renderSettings, ct ); @@ -52,6 +54,7 @@ public class UserRendererService( bool renderMembers = true, bool renderAuthMethods = false, string? overrideSid = null, + bool renderSettings = false, CancellationToken ct = default ) { @@ -62,6 +65,7 @@ public class UserRendererService( renderMembers = renderMembers && (!user.ListHidden || tokenCanReadHiddenMembers); renderAuthMethods = renderAuthMethods && tokenPrivileged; + renderSettings = renderSettings && tokenHidden; IEnumerable members = renderMembers ? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync(ct) @@ -117,7 +121,8 @@ public class UserRendererService( tokenHidden ? user.LastSidReroll : null, tokenHidden ? user.Timezone ?? "" : null, tokenHidden ? user is { Deleted: true, DeletedBy: not null } : null, - tokenHidden ? user.Deleted : null + tokenHidden ? user.Deleted : null, + renderSettings ? user.Settings : null ); } diff --git a/Foxnouns.Frontend/src/lib/api/models/user.ts b/Foxnouns.Frontend/src/lib/api/models/user.ts index 6cd8e4c..0610b9c 100644 --- a/Foxnouns.Frontend/src/lib/api/models/user.ts +++ b/Foxnouns.Frontend/src/lib/api/models/user.ts @@ -28,6 +28,7 @@ export type MeUser = UserWithMembers & { timezone: string; suspended: boolean; deleted: boolean; + settings: UserSettings; }; export type UserWithMembers = User & { members: PartialMember[] | null }; From b07f4b75c066749968a7b00896bbf37e2e5b9e6e Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 6 Apr 2025 15:32:44 +0200 Subject: [PATCH 09/15] feat(backend): global notices --- .editorconfig | 2 +- .../Controllers/MetaController.cs | 15 +- .../Moderation/NoticesController.cs | 77 ++ .../Controllers/UsersController.cs | 2 + Foxnouns.Backend/Database/DatabaseContext.cs | 1 + .../20250329131053_AddNotices.Designer.cs | 915 ++++++++++++++++++ .../Migrations/20250329131053_AddNotices.cs | 56 ++ .../DatabaseContextModelSnapshot.cs | 44 + Foxnouns.Backend/Database/Models/Notice.cs | 13 + Foxnouns.Backend/Database/Models/User.cs | 1 + Foxnouns.Backend/Dto/Meta.cs | 7 +- Foxnouns.Backend/Dto/Moderation.cs | 10 + Foxnouns.Backend/Dto/User.cs | 1 + .../Extensions/WebApplicationExtensions.cs | 3 + Foxnouns.Backend/Services/Auth/AuthService.cs | 4 +- .../Services/Caching/NoticeCacheService.cs | 39 + .../Services/Caching/SingletonCacheService.cs | 63 ++ Foxnouns.Frontend/src/lib/api/models/meta.ts | 1 + Foxnouns.Frontend/src/lib/api/models/user.ts | 1 + 19 files changed, 1247 insertions(+), 8 deletions(-) create mode 100644 Foxnouns.Backend/Controllers/Moderation/NoticesController.cs create mode 100644 Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.Designer.cs create mode 100644 Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.cs create mode 100644 Foxnouns.Backend/Database/Models/Notice.cs create mode 100644 Foxnouns.Backend/Services/Caching/NoticeCacheService.cs create mode 100644 Foxnouns.Backend/Services/Caching/SingletonCacheService.cs diff --git a/.editorconfig b/.editorconfig index e6b41f9..22061dc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,7 +7,7 @@ resharper_not_accessed_positional_property_local_highlighting = none # Microsoft .NET properties csharp_new_line_before_members_in_object_initializers = false -csharp_preferred_modifier_order = public, internal, protected, private, file, new, required, abstract, virtual, sealed, static, override, extern, unsafe, volatile, async, readonly:suggestion +csharp_preferred_modifier_order = public, internal, protected, private, file, new, virtual, override, required, abstract, sealed, static, extern, unsafe, volatile, async, readonly:suggestion # ReSharper properties resharper_align_multiline_binary_expressions_chain = false diff --git a/Foxnouns.Backend/Controllers/MetaController.cs b/Foxnouns.Backend/Controllers/MetaController.cs index 1f00a7a..0166e86 100644 --- a/Foxnouns.Backend/Controllers/MetaController.cs +++ b/Foxnouns.Backend/Controllers/MetaController.cs @@ -13,20 +13,23 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . using System.Text.RegularExpressions; +using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Dto; +using Foxnouns.Backend.Services.Caching; using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; namespace Foxnouns.Backend.Controllers; [Route("/api/v2/meta")] -public partial class MetaController(Config config) : ApiControllerBase +public partial class MetaController(Config config, NoticeCacheService noticeCache) + : ApiControllerBase { private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc"; [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] - public IActionResult GetMeta() => + public async Task GetMeta(CancellationToken ct = default) => Ok( new MetaResponse( Repository, @@ -45,10 +48,14 @@ public partial class MetaController(Config config) : ApiControllerBase ValidationUtils.MaxCustomPreferences, AuthUtils.MaxAuthMethodsPerType, FlagsController.MaxFlagCount - ) + ), + Notice: NoticeResponse(await noticeCache.GetAsync(ct)) ) ); + private static MetaNoticeResponse? NoticeResponse(Notice? notice) => + notice == null ? null : new MetaNoticeResponse(notice.Id, notice.Message); + [HttpGet("page/{page}")] public async Task GetStaticPageAsync(string page, CancellationToken ct = default) { @@ -71,7 +78,7 @@ public partial class MetaController(Config config) : ApiControllerBase [HttpGet("/api/v2/coffee")] public IActionResult BrewCoffee() => - Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot); + StatusCode(StatusCodes.Status418ImATeapot, "Sorry, I'm a teapot!"); [GeneratedRegex(@"^[a-z\-_]+$")] private static partial Regex PageRegex(); diff --git a/Foxnouns.Backend/Controllers/Moderation/NoticesController.cs b/Foxnouns.Backend/Controllers/Moderation/NoticesController.cs new file mode 100644 index 0000000..3d2d6bb --- /dev/null +++ b/Foxnouns.Backend/Controllers/Moderation/NoticesController.cs @@ -0,0 +1,77 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto; +using Foxnouns.Backend.Middleware; +using Foxnouns.Backend.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace Foxnouns.Backend.Controllers.Moderation; + +[Route("/api/v2/notices")] +[Authorize("user.moderation")] +[Limit(RequireModerator = true)] +public class NoticesController( + DatabaseContext db, + UserRendererService userRenderer, + ISnowflakeGenerator snowflakeGenerator, + IClock clock +) : ApiControllerBase +{ + [HttpGet] + public async Task GetNoticesAsync(CancellationToken ct = default) + { + List notices = await db + .Notices.Include(n => n.Author) + .OrderByDescending(n => n.Id) + .ToListAsync(ct); + return Ok(notices.Select(RenderNotice)); + } + + [HttpPost] + public async Task CreateNoticeAsync(CreateNoticeRequest req) + { + Instant now = clock.GetCurrentInstant(); + if (req.StartTime < now) + { + throw new ApiError.BadRequest( + "Start time cannot be in the past", + "start_time", + req.StartTime + ); + } + + if (req.EndTime < now) + { + throw new ApiError.BadRequest( + "End time cannot be in the past", + "end_time", + req.EndTime + ); + } + + var notice = new Notice + { + Id = snowflakeGenerator.GenerateSnowflake(), + Message = req.Message, + StartTime = req.StartTime ?? clock.GetCurrentInstant(), + EndTime = req.EndTime, + Author = CurrentUser!, + }; + + db.Add(notice); + await db.SaveChangesAsync(); + + return Ok(RenderNotice(notice)); + } + + private NoticeResponse RenderNotice(Notice notice) => + new( + notice.Id, + notice.Message, + notice.StartTime, + notice.EndTime, + userRenderer.RenderPartialUser(notice.Author) + ); +} diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index 31aa98a..ed9a48f 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -281,6 +281,8 @@ public class UsersController( if (req.HasProperty(nameof(req.DarkMode))) user.Settings.DarkMode = req.DarkMode; + if (req.HasProperty(nameof(req.LastReadNotice))) + user.Settings.LastReadNotice = req.LastReadNotice; user.LastActive = clock.GetCurrentInstant(); db.Update(user); diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index c9120f3..2bbcbc7 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -73,6 +73,7 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) public DbSet Reports { get; init; } = null!; public DbSet AuditLog { get; init; } = null!; public DbSet Notifications { get; init; } = null!; + public DbSet Notices { get; init; } = null!; protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { diff --git a/Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.Designer.cs b/Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.Designer.cs new file mode 100644 index 0000000..d2df141 --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.Designer.cs @@ -0,0 +1,915 @@ +// +using System.Collections.Generic; +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250329131053_AddNotices")] + partial class AddNotices + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.PrimitiveCollection("RedirectUris") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("redirect_uris"); + + b.PrimitiveCollection("Scopes") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("scopes"); + + b.HasKey("Id") + .HasName("pk_applications"); + + b.ToTable("applications", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.PrimitiveCollection("ClearedFields") + .HasColumnType("text[]") + .HasColumnName("cleared_fields"); + + b.Property("ModeratorId") + .HasColumnType("bigint") + .HasColumnName("moderator_id"); + + b.Property("ModeratorUsername") + .IsRequired() + .HasColumnType("text") + .HasColumnName("moderator_username"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("ReportId") + .HasColumnType("bigint") + .HasColumnName("report_id"); + + b.Property("TargetMemberId") + .HasColumnType("bigint") + .HasColumnName("target_member_id"); + + b.Property("TargetMemberName") + .HasColumnType("text") + .HasColumnName("target_member_name"); + + b.Property("TargetUserId") + .HasColumnType("bigint") + .HasColumnName("target_user_id"); + + b.Property("TargetUsername") + .HasColumnType("text") + .HasColumnName("target_username"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_audit_log"); + + b.HasIndex("ReportId") + .IsUnique() + .HasDatabaseName("ix_audit_log_report_id"); + + b.ToTable("audit_log", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("AuthType") + .HasColumnType("integer") + .HasColumnName("auth_type"); + + b.Property("FediverseApplicationId") + .HasColumnType("bigint") + .HasColumnName("fediverse_application_id"); + + b.Property("RemoteId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("remote_id"); + + b.Property("RemoteUsername") + .HasColumnType("text") + .HasColumnName("remote_username"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_auth_methods"); + + b.HasIndex("FediverseApplicationId") + .HasDatabaseName("ix_auth_methods_fediverse_application_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_auth_methods_user_id"); + + b.HasIndex("AuthType", "RemoteId") + .IsUnique() + .HasDatabaseName("ix_auth_methods_auth_type_remote_id") + .HasFilter("fediverse_application_id IS NULL"); + + b.HasIndex("AuthType", "RemoteId", "FediverseApplicationId") + .IsUnique() + .HasDatabaseName("ix_auth_methods_auth_type_remote_id_fediverse_application_id") + .HasFilter("fediverse_application_id IS NOT NULL"); + + b.ToTable("auth_methods", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("text") + .HasColumnName("filename"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_data_exports"); + + b.HasIndex("Filename") + .IsUnique() + .HasDatabaseName("ix_data_exports_filename"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_data_exports_user_id"); + + b.ToTable("data_exports", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("Domain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("ForceRefresh") + .HasColumnType("boolean") + .HasColumnName("force_refresh"); + + b.Property("InstanceType") + .HasColumnType("integer") + .HasColumnName("instance_type"); + + b.HasKey("Id") + .HasName("pk_fediverse_applications"); + + b.ToTable("fediverse_applications", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property>("Fields") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("fields"); + + b.Property("LegacyId") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasColumnName("legacy_id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.PrimitiveCollection("Links") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("links"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property>("Names") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("names"); + + b.Property>("Pronouns") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("pronouns"); + + b.Property("Sid") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasColumnName("sid") + .HasDefaultValueSql("find_free_member_sid()"); + + b.Property("Unlisted") + .HasColumnType("boolean") + .HasColumnName("unlisted"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_members"); + + b.HasIndex("LegacyId") + .IsUnique() + .HasDatabaseName("ix_members_legacy_id"); + + b.HasIndex("Sid") + .IsUnique() + .HasDatabaseName("ix_members_sid"); + + b.HasIndex("UserId", "Name") + .IsUnique() + .HasDatabaseName("ix_members_user_id_name"); + + b.ToTable("members", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasColumnName("member_id"); + + b.Property("PrideFlagId") + .HasColumnType("bigint") + .HasColumnName("pride_flag_id"); + + b.HasKey("Id") + .HasName("pk_member_flags"); + + b.HasIndex("MemberId") + .HasDatabaseName("ix_member_flags_member_id"); + + b.HasIndex("PrideFlagId") + .HasDatabaseName("ix_member_flags_pride_flag_id"); + + b.ToTable("member_flags", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("bigint") + .HasColumnName("author_id"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time"); + + b.HasKey("Id") + .HasName("pk_notices"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_notices_author_id"); + + b.ToTable("notices", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("LocalizationKey") + .HasColumnType("text") + .HasColumnName("localization_key"); + + b.Property>("LocalizationParams") + .IsRequired() + .HasColumnType("hstore") + .HasColumnName("localization_params"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("TargetId") + .HasColumnType("bigint") + .HasColumnName("target_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_notifications"); + + b.HasIndex("TargetId") + .HasDatabaseName("ix_notifications_target_id"); + + b.ToTable("notifications", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Hash") + .HasColumnType("text") + .HasColumnName("hash"); + + b.Property("LegacyId") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasColumnName("legacy_id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_pride_flags"); + + b.HasIndex("LegacyId") + .IsUnique() + .HasDatabaseName("ix_pride_flags_legacy_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_pride_flags_user_id"); + + b.ToTable("pride_flags", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("Reason") + .HasColumnType("integer") + .HasColumnName("reason"); + + b.Property("ReporterId") + .HasColumnType("bigint") + .HasColumnName("reporter_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetMemberId") + .HasColumnType("bigint") + .HasColumnName("target_member_id"); + + b.Property("TargetSnapshot") + .HasColumnType("text") + .HasColumnName("target_snapshot"); + + b.Property("TargetType") + .HasColumnType("integer") + .HasColumnName("target_type"); + + b.Property("TargetUserId") + .HasColumnType("bigint") + .HasColumnName("target_user_id"); + + b.HasKey("Id") + .HasName("pk_reports"); + + b.HasIndex("ReporterId") + .HasDatabaseName("ix_reports_reporter_id"); + + b.HasIndex("TargetMemberId") + .HasDatabaseName("ix_reports_target_member_id"); + + b.HasIndex("TargetUserId") + .HasDatabaseName("ix_reports_target_user_id"); + + b.ToTable("reports", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("ApplicationId") + .HasColumnType("bigint") + .HasColumnName("application_id"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hash"); + + b.Property("ManuallyExpired") + .HasColumnType("boolean") + .HasColumnName("manually_expired"); + + b.PrimitiveCollection("Scopes") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("scopes"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_tokens"); + + b.HasIndex("ApplicationId") + .HasDatabaseName("ix_tokens_application_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_tokens_user_id"); + + b.ToTable("tokens", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property>("CustomPreferences") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("custom_preferences"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property>("Fields") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("fields"); + + b.Property("LastActive") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_active"); + + b.Property("LastSidReroll") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sid_reroll"); + + b.Property("LegacyId") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasColumnName("legacy_id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.PrimitiveCollection("Links") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("links"); + + b.Property("ListHidden") + .HasColumnType("boolean") + .HasColumnName("list_hidden"); + + b.Property("MemberTitle") + .HasColumnType("text") + .HasColumnName("member_title"); + + b.Property>("Names") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("names"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property>("Pronouns") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("pronouns"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Sid") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasColumnName("sid") + .HasDefaultValueSql("find_free_user_sid()"); + + b.Property("Timezone") + .HasColumnType("text") + .HasColumnName("timezone"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("LegacyId") + .IsUnique() + .HasDatabaseName("ix_users_legacy_id"); + + b.HasIndex("Sid") + .IsUnique() + .HasDatabaseName("ix_users_sid"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_users_username"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PrideFlagId") + .HasColumnType("bigint") + .HasColumnName("pride_flag_id"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_flags"); + + b.HasIndex("PrideFlagId") + .HasDatabaseName("ix_user_flags_pride_flag_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_flags_user_id"); + + b.ToTable("user_flags", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report") + .WithOne("AuditLogEntry") + .HasForeignKey("Foxnouns.Backend.Database.Models.AuditLogEntry", "ReportId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_audit_log_reports_report_id"); + + b.Navigation("Report"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication") + .WithMany() + .HasForeignKey("FediverseApplicationId") + .HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.User", "User") + .WithMany("AuthMethods") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_methods_users_user_id"); + + b.Navigation("FediverseApplication"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", "User") + .WithMany("DataExports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_data_exports_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", "User") + .WithMany("Members") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_members_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.Member", null) + .WithMany("ProfileFlags") + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_member_flags_members_member_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag") + .WithMany() + .HasForeignKey("PrideFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_member_flags_pride_flags_pride_flag_id"); + + b.Navigation("PrideFlag"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notices_users_author_id"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", "Target") + .WithMany() + .HasForeignKey("TargetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifications_users_target_id"); + + b.Navigation("Target"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", null) + .WithMany("Flags") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pride_flags_users_user_id"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", "Reporter") + .WithMany() + .HasForeignKey("ReporterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reports_users_reporter_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.Member", "TargetMember") + .WithMany() + .HasForeignKey("TargetMemberId") + .HasConstraintName("fk_reports_members_target_member_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.User", "TargetUser") + .WithMany() + .HasForeignKey("TargetUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reports_users_target_user_id"); + + b.Navigation("Reporter"); + + b.Navigation("TargetMember"); + + b.Navigation("TargetUser"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application") + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tokens_applications_application_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tokens_users_user_id"); + + b.Navigation("Application"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag") + .WithMany() + .HasForeignKey("PrideFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_flags_pride_flags_pride_flag_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.User", null) + .WithMany("ProfileFlags") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_flags_users_user_id"); + + b.Navigation("PrideFlag"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => + { + b.Navigation("ProfileFlags"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b => + { + b.Navigation("AuditLogEntry"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => + { + b.Navigation("AuthMethods"); + + b.Navigation("DataExports"); + + b.Navigation("Flags"); + + b.Navigation("Members"); + + b.Navigation("ProfileFlags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.cs b/Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.cs new file mode 100644 index 0000000..24c5166 --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + public partial class AddNotices : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "notices", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + message = table.Column(type: "text", nullable: false), + start_time = table.Column( + type: "timestamp with time zone", + nullable: false + ), + end_time = table.Column( + type: "timestamp with time zone", + nullable: false + ), + author_id = table.Column(type: "bigint", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("pk_notices", x => x.id); + table.ForeignKey( + name: "fk_notices_users_author_id", + column: x => x.author_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "ix_notices_author_id", + table: "notices", + column: "author_id" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "notices"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index 922a599..70b035d 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -343,6 +343,38 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("member_flags", (string)null); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("AuthorId") + .HasColumnType("bigint") + .HasColumnName("author_id"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time"); + + b.HasKey("Id") + .HasName("pk_notices"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_notices_author_id"); + + b.ToTable("notices", (string)null); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b => { b.Property("Id") @@ -750,6 +782,18 @@ namespace Foxnouns.Backend.Database.Migrations b.Navigation("PrideFlag"); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notices_users_author_id"); + + b.Navigation("Author"); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b => { b.HasOne("Foxnouns.Backend.Database.Models.User", "Target") diff --git a/Foxnouns.Backend/Database/Models/Notice.cs b/Foxnouns.Backend/Database/Models/Notice.cs new file mode 100644 index 0000000..c3e6f0d --- /dev/null +++ b/Foxnouns.Backend/Database/Models/Notice.cs @@ -0,0 +1,13 @@ +using NodaTime; + +namespace Foxnouns.Backend.Database.Models; + +public class Notice : BaseModel +{ + public required string Message { get; set; } + public required Instant StartTime { get; set; } + public required Instant EndTime { get; set; } + + public Snowflake AuthorId { get; init; } + public User Author { get; init; } = null!; +} diff --git a/Foxnouns.Backend/Database/Models/User.cs b/Foxnouns.Backend/Database/Models/User.cs index 3ad7ae3..0e6eb43 100644 --- a/Foxnouns.Backend/Database/Models/User.cs +++ b/Foxnouns.Backend/Database/Models/User.cs @@ -95,4 +95,5 @@ public enum PreferenceSize public class UserSettings { public bool? DarkMode { get; set; } + public Snowflake? LastReadNotice { get; set; } } diff --git a/Foxnouns.Backend/Dto/Meta.cs b/Foxnouns.Backend/Dto/Meta.cs index 0ff6e80..168327a 100644 --- a/Foxnouns.Backend/Dto/Meta.cs +++ b/Foxnouns.Backend/Dto/Meta.cs @@ -14,6 +14,8 @@ // along with this program. If not, see . // ReSharper disable NotAccessedPositionalProperty.Global +using Foxnouns.Backend.Database; + namespace Foxnouns.Backend.Dto; public record MetaResponse( @@ -22,9 +24,12 @@ public record MetaResponse( string Hash, int Members, UserInfoResponse Users, - LimitsResponse Limits + LimitsResponse Limits, + MetaNoticeResponse? Notice ); +public record MetaNoticeResponse(Snowflake Id, string Message); + public record UserInfoResponse(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay); public record LimitsResponse( diff --git a/Foxnouns.Backend/Dto/Moderation.cs b/Foxnouns.Backend/Dto/Moderation.cs index 26fd0aa..bcc7e8e 100644 --- a/Foxnouns.Backend/Dto/Moderation.cs +++ b/Foxnouns.Backend/Dto/Moderation.cs @@ -122,3 +122,13 @@ public record QueryUserResponse( ); public record QuerySensitiveUserDataRequest(string Reason); + +public record NoticeResponse( + Snowflake Id, + string Message, + Instant StartTime, + Instant EndTime, + PartialUser Author +); + +public record CreateNoticeRequest(string Message, Instant? StartTime, Instant EndTime); diff --git a/Foxnouns.Backend/Dto/User.cs b/Foxnouns.Backend/Dto/User.cs index 83121d1..2ae38f1 100644 --- a/Foxnouns.Backend/Dto/User.cs +++ b/Foxnouns.Backend/Dto/User.cs @@ -80,6 +80,7 @@ public record PartialUser( public class UpdateUserSettingsRequest : PatchRequest { public bool? DarkMode { get; init; } + public Snowflake? LastReadNotice { get; init; } } public class CustomPreferenceUpdateRequest diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 567ae02..c3efda6 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -15,10 +15,12 @@ using Coravel; using Coravel.Queuing.Interfaces; using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; using Foxnouns.Backend.Services.Auth; +using Foxnouns.Backend.Services.Caching; using Foxnouns.Backend.Services.V1; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Http.Resilience; @@ -162,6 +164,7 @@ public static class WebApplicationExtensions .AddScoped() .AddTransient() .AddTransient() + .AddSingleton() // Background services .AddHostedService() // Transient jobs diff --git a/Foxnouns.Backend/Services/Auth/AuthService.cs b/Foxnouns.Backend/Services/Auth/AuthService.cs index 80d05ac..e3e3edb 100644 --- a/Foxnouns.Backend/Services/Auth/AuthService.cs +++ b/Foxnouns.Backend/Services/Auth/AuthService.cs @@ -253,14 +253,14 @@ public class AuthService( { AssertValidAuthType(authType, app); - // This is already checked when + // This is already checked when generating an add account state, but we check it here too just in case. int currentCount = await db .AuthMethods.Where(m => m.UserId == userId && m.AuthType == authType) .CountAsync(ct); if (currentCount >= AuthUtils.MaxAuthMethodsPerType) { throw new ApiError.BadRequest( - "Too many linked accounts of this type, maximum of 3 per account." + $"Too many linked accounts of this type, maximum of {AuthUtils.MaxAuthMethodsPerType} per account." ); } diff --git a/Foxnouns.Backend/Services/Caching/NoticeCacheService.cs b/Foxnouns.Backend/Services/Caching/NoticeCacheService.cs new file mode 100644 index 0000000..2a0b1f9 --- /dev/null +++ b/Foxnouns.Backend/Services/Caching/NoticeCacheService.cs @@ -0,0 +1,39 @@ +// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace Foxnouns.Backend.Services.Caching; + +public class NoticeCacheService(IServiceProvider serviceProvider, IClock clock, ILogger logger) + : SingletonCacheService(serviceProvider, clock, logger) +{ + public override Duration MaxAge { get; init; } = Duration.FromMinutes(5); + + public override Func< + DatabaseContext, + CancellationToken, + Task + > FetchFunc { get; init; } = + async (db, ct) => + await db + .Notices.Where(n => + n.StartTime < clock.GetCurrentInstant() && n.EndTime > clock.GetCurrentInstant() + ) + .OrderByDescending(n => n.Id) + .FirstOrDefaultAsync(ct); +} diff --git a/Foxnouns.Backend/Services/Caching/SingletonCacheService.cs b/Foxnouns.Backend/Services/Caching/SingletonCacheService.cs new file mode 100644 index 0000000..87b19a7 --- /dev/null +++ b/Foxnouns.Backend/Services/Caching/SingletonCacheService.cs @@ -0,0 +1,63 @@ +// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +using Foxnouns.Backend.Database; +using NodaTime; + +namespace Foxnouns.Backend.Services.Caching; + +public abstract class SingletonCacheService( + IServiceProvider serviceProvider, + IClock clock, + ILogger logger +) + where T : class +{ + private T? _item; + private Instant _lastUpdated = Instant.MinValue; + private readonly SemaphoreSlim _semaphore = new(1, 1); + private readonly ILogger _logger = logger.ForContext>(); + + public virtual Duration MaxAge { get; init; } = Duration.FromMinutes(5); + + public virtual Func> FetchFunc { get; init; } = + (_, __) => Task.FromResult(null); + + public async Task GetAsync(CancellationToken ct = default) + { + await _semaphore.WaitAsync(ct); + try + { + if (_lastUpdated > clock.GetCurrentInstant() - MaxAge) + { + return _item; + } + + _logger.Debug("Cached item of type {Type} is expired, fetching it.", typeof(T)); + + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + await using DatabaseContext db = + scope.ServiceProvider.GetRequiredService(); + + T? item = await FetchFunc(db, ct); + _item = item; + _lastUpdated = clock.GetCurrentInstant(); + return item; + } + finally + { + _semaphore.Release(); + } + } +} diff --git a/Foxnouns.Frontend/src/lib/api/models/meta.ts b/Foxnouns.Frontend/src/lib/api/models/meta.ts index 56f31c9..28ea494 100644 --- a/Foxnouns.Frontend/src/lib/api/models/meta.ts +++ b/Foxnouns.Frontend/src/lib/api/models/meta.ts @@ -10,6 +10,7 @@ export type Meta = { }; members: number; limits: Limits; + notice: { id: string; message: string } | null; }; export type Limits = { diff --git a/Foxnouns.Frontend/src/lib/api/models/user.ts b/Foxnouns.Frontend/src/lib/api/models/user.ts index 0610b9c..be9d961 100644 --- a/Foxnouns.Frontend/src/lib/api/models/user.ts +++ b/Foxnouns.Frontend/src/lib/api/models/user.ts @@ -41,6 +41,7 @@ export type UserWithHiddenFields = User & { export type UserSettings = { dark_mode: boolean | null; + last_read_notice: string | null; }; export type PartialMember = { From b0431ff962012d9f1d44da4c619cb137e8fc879a Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 6 Apr 2025 16:24:05 +0200 Subject: [PATCH 10/15] feat(frontend): global notices --- Foxnouns.Backend/Program.cs | 1 + .../src/lib/components/GlobalNotice.svelte | 49 +++++++++++++++++++ .../components/settings/Notification.svelte | 2 +- .../src/lib/i18n/locales/en.json | 4 +- Foxnouns.Frontend/src/routes/+page.svelte | 10 ++++ .../src/routes/settings/+layout.svelte | 17 ++++++- .../settings/notifications/+page.svelte | 3 +- 7 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 Foxnouns.Frontend/src/lib/components/GlobalNotice.svelte diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index f27ad50..b266248 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -68,6 +68,7 @@ builder { NamingStrategy = new SnakeCaseNamingStrategy(), }; + options.SerializerSettings.DateParseHandling = DateParseHandling.None; }) .ConfigureApiBehaviorOptions(options => { diff --git a/Foxnouns.Frontend/src/lib/components/GlobalNotice.svelte b/Foxnouns.Frontend/src/lib/components/GlobalNotice.svelte new file mode 100644 index 0000000..3d1c718 --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/GlobalNotice.svelte @@ -0,0 +1,49 @@ + + +{#if renderNotice} + +{/if} diff --git a/Foxnouns.Frontend/src/lib/components/settings/Notification.svelte b/Foxnouns.Frontend/src/lib/components/settings/Notification.svelte index c452f4c..796d60a 100644 --- a/Foxnouns.Frontend/src/lib/components/settings/Notification.svelte +++ b/Foxnouns.Frontend/src/lib/components/settings/Notification.svelte @@ -38,6 +38,6 @@ diff --git a/Foxnouns.Frontend/src/lib/i18n/locales/en.json b/Foxnouns.Frontend/src/lib/i18n/locales/en.json index 9f54943..dc74d2f 100644 --- a/Foxnouns.Frontend/src/lib/i18n/locales/en.json +++ b/Foxnouns.Frontend/src/lib/i18n/locales/en.json @@ -350,6 +350,8 @@ "notification": { "suspension": "Your account has been suspended for the following reason: {{reason}}", "warning": "You have been warned for the following reason: {{reason}}", - "warning-cleared-fields": "You have been warned for the following reason: {{reason}}\n\nAdditionally, the following fields have been cleared from your profile:\n{{clearedFields}}" + "warning-cleared-fields": "You have been warned for the following reason: {{reason}}\n\nAdditionally, the following fields have been cleared from your profile:\n{{clearedFields}}", + "mark-as-read": "Mark as read", + "no-notifications": "You have no notifications." } } diff --git a/Foxnouns.Frontend/src/routes/+page.svelte b/Foxnouns.Frontend/src/routes/+page.svelte index 47ab0e5..4fd36dc 100644 --- a/Foxnouns.Frontend/src/routes/+page.svelte +++ b/Foxnouns.Frontend/src/routes/+page.svelte @@ -1,4 +1,5 @@ {$t("title.settings")} • pronouns.cc +{#if data.meta.notice} +
+ +
+{/if} +