From 7f971e8549adbb9b6b5f8f69b3410befae6337c6 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 2 Oct 2024 00:28:07 +0200 Subject: [PATCH] chore: add csharpier to husky, format backend with csharpier --- .config/dotnet-tools.json | 13 +- .husky/task-runner.json | 5 +- Foxnouns.Backend/BuildInfo.cs | 17 +- Foxnouns.Backend/Config.cs | 2 +- .../Controllers/ApiControllerBase.cs | 2 +- .../Authentication/AuthController.cs | 46 +-- .../Authentication/DiscordAuthController.cs | 102 ++++-- .../Authentication/EmailAuthController.cs | 117 +++++-- .../Controllers/FlagsController.cs | 101 ++++-- .../Controllers/InternalController.cs | 47 ++- .../Controllers/MembersController.cs | 139 +++++--- .../Controllers/MetaController.cs | 43 ++- .../Controllers/UsersController.cs | 137 +++++--- Foxnouns.Backend/Database/BaseModel.cs | 2 +- Foxnouns.Backend/Database/DatabaseContext.cs | 37 +- .../Database/DatabaseQueryExtensions.cs | 150 +++++--- .../Database/FlagQueryExtensions.cs | 41 ++- .../Database/ISnowflakeGenerator.cs | 2 +- .../Migrations/20240527132444_Init.cs | 77 ++-- .../20240528125310_AddApplications.cs | 39 +-- .../20240528145744_AddListHidden.cs | 11 +- .../Migrations/20240604142522_AddPassword.cs | 11 +- .../20240611225328_AddTemporaryKeyCache.cs | 26 +- .../20240712233806_AddUserLastActive.cs | 11 +- .../Migrations/20240713000719_AddDeleted.cs | 25 +- .../20240821210355_AddCustomPreferences.cs | 9 +- .../20240905191709_AddUserSettings.cs | 7 +- .../Migrations/20240926124950_AddSids.cs | 57 +-- .../20240926130208_NonNullableSids.cs | 16 +- .../Migrations/20240926180037_AddFlags.cs | 74 ++-- .../Database/Models/Application.cs | 22 +- .../Database/Models/AuthMethod.cs | 2 +- .../Database/Models/FediverseApplication.cs | 4 +- Foxnouns.Backend/Database/Models/Field.cs | 2 +- Foxnouns.Backend/Database/Models/Member.cs | 2 +- Foxnouns.Backend/Database/Models/PrideFlag.cs | 2 +- .../Database/Models/TemporaryKey.cs | 2 +- Foxnouns.Backend/Database/Models/Token.cs | 2 +- Foxnouns.Backend/Database/Models/User.cs | 6 +- Foxnouns.Backend/Database/Snowflake.cs | 47 ++- .../Database/SnowflakeGenerator.cs | 12 +- Foxnouns.Backend/ExpectedError.cs | 88 +++-- .../Extensions/AvatarObjectExtensions.cs | 42 ++- .../Extensions/KeyCacheExtensions.cs | 49 ++- .../Extensions/WebApplicationExtensions.cs | 112 +++--- Foxnouns.Backend/FoxnounsMetrics.cs | 44 ++- Foxnouns.Backend/GlobalUsing.cs | 2 +- Foxnouns.Backend/Jobs/CreateFlagInvocable.cs | 24 +- .../Jobs/MemberAvatarUpdateInvocable.cs | 31 +- Foxnouns.Backend/Jobs/Payloads.cs | 8 +- .../Jobs/UserAvatarUpdateInvocable.cs | 31 +- .../Mailables/AccountCreationMailable.cs | 6 +- Foxnouns.Backend/Mailables/BaseView.cs | 2 +- .../Middleware/AuthenticationMiddleware.cs | 7 +- .../Middleware/AuthorizationMiddleware.cs | 26 +- .../Middleware/ErrorHandlerMiddleware.cs | 115 +++--- Foxnouns.Backend/Program.cs | 39 ++- Foxnouns.Backend/Services/AuthService.cs | 133 ++++--- Foxnouns.Backend/Services/KeyCacheService.cs | 73 +++- Foxnouns.Backend/Services/MailService.cs | 19 +- .../Services/MemberRendererService.cs | 73 ++-- .../Services/MetricsCollectionService.cs | 25 +- .../Services/ObjectStorageService.cs | 26 +- .../Services/PeriodicTasksService.cs | 2 +- .../Services/RemoteAuthService.cs | 59 ++-- .../Services/UserRendererService.cs | 96 +++-- Foxnouns.Backend/Utils/AuthUtils.cs | 42 ++- Foxnouns.Backend/Utils/Limits.cs | 2 +- Foxnouns.Backend/Utils/PatchRequest.cs | 12 +- .../Utils/ScreamingSnakeCaseEnumConverter.cs | 5 +- Foxnouns.Backend/Utils/ValidationUtils.cs | 328 +++++++++++++----- migrators/NetImporter/ImportUser.cs | 130 ++++--- migrators/NetImporter/NetImporter.cs | 26 +- 73 files changed, 2098 insertions(+), 1048 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 4a287b1..9e52e96 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -4,8 +4,17 @@ "tools": { "husky": { "version": "0.7.1", - "commands": ["husky"], + "commands": [ + "husky" + ], + "rollForward": false + }, + "csharpier": { + "version": "0.29.2", + "commands": [ + "dotnet-csharpier" + ], "rollForward": false } } -} +} \ No newline at end of file diff --git a/.husky/task-runner.json b/.husky/task-runner.json index 8ebba21..aaa37a4 100644 --- a/.husky/task-runner.json +++ b/.husky/task-runner.json @@ -8,9 +8,10 @@ "pathMode": "absolute" }, { - "name": "dotnet-format", + "name": "run-csharpier", "command": "dotnet", - "args": ["format"] + "args": [ "csharpier", "${staged}" ], + "include": [ "**/*.cs" ] } ] } diff --git a/Foxnouns.Backend/BuildInfo.cs b/Foxnouns.Backend/BuildInfo.cs index fe39640..debd2ba 100644 --- a/Foxnouns.Backend/BuildInfo.cs +++ b/Foxnouns.Backend/BuildInfo.cs @@ -8,19 +8,24 @@ public static class BuildInfo public static async Task ReadBuildInfo() { await using var stream = typeof(BuildInfo).Assembly.GetManifestResourceStream("version"); - if (stream == null) return; + if (stream == null) + return; using var reader = new StreamReader(stream); var data = (await reader.ReadToEndAsync()).Trim().Split("\n"); - if (data.Length < 3) return; + if (data.Length < 3) + return; Hash = data[0]; var dirty = data[2] == "dirty"; var versionData = data[1].Split("-"); - if (versionData.Length < 3) return; + if (versionData.Length < 3) + return; Version = versionData[0]; - if (versionData[1] != "0" || dirty) Version += $"+{versionData[2]}"; - if (dirty) Version += ".dirty"; + if (versionData[1] != "0" || dirty) + Version += $"+{versionData[2]}"; + if (dirty) + Version += ".dirty"; } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index 6b31a40..2f73d4e 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -77,4 +77,4 @@ public class Config public string? ClientId { get; init; } public string? ClientSecret { get; init; } } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Controllers/ApiControllerBase.cs b/Foxnouns.Backend/Controllers/ApiControllerBase.cs index d30803e..9fffa67 100644 --- a/Foxnouns.Backend/Controllers/ApiControllerBase.cs +++ b/Foxnouns.Backend/Controllers/ApiControllerBase.cs @@ -10,4 +10,4 @@ public class ApiControllerBase : ControllerBase { internal Token? CurrentToken => HttpContext.GetToken(); internal User? CurrentUser => HttpContext.GetUser(); -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index e8b3e76..53aa171 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -11,8 +11,12 @@ using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; [Route("/api/internal/auth")] -public class AuthController(Config config, DatabaseContext db, KeyCacheService keyCache, ILogger logger) - : ApiControllerBase +public class AuthController( + Config config, + DatabaseContext db, + KeyCacheService keyCache, + ILogger logger +) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); @@ -20,27 +24,25 @@ public class AuthController(Config config, DatabaseContext db, KeyCacheService k [ProducesResponseType(StatusCodes.Status200OK)] public async Task UrlsAsync(CancellationToken ct = default) { - _logger.Debug("Generating auth URLs for Discord: {Discord}, Google: {Google}, Tumblr: {Tumblr}", + _logger.Debug( + "Generating auth URLs for Discord: {Discord}, Google: {Google}, Tumblr: {Tumblr}", config.DiscordAuth.Enabled, config.GoogleAuth.Enabled, - config.TumblrAuth.Enabled); + config.TumblrAuth.Enabled + ); var state = HttpUtility.UrlEncode(await keyCache.GenerateAuthStateAsync(ct)); string? discord = null; if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) discord = - $"https://discord.com/oauth2/authorize?response_type=code" + - $"&client_id={config.DiscordAuth.ClientId}&scope=identify" + - $"&prompt=none&state={state}" + - $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}"; + $"https://discord.com/oauth2/authorize?response_type=code" + + $"&client_id={config.DiscordAuth.ClientId}&scope=identify" + + $"&prompt=none&state={state}" + + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}"; return Ok(new UrlsResponse(discord, null, null)); } - private record UrlsResponse( - string? Discord, - string? Google, - string? Tumblr - ); + private record UrlsResponse(string? Discord, string? Google, string? Tumblr); public record AuthResponse( UserRendererService.UserResponse User, @@ -50,16 +52,13 @@ public class AuthController(Config config, DatabaseContext db, KeyCacheService k public record CallbackResponse( bool HasAccount, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - string? Ticket, + string? RemoteUsername, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - string? RemoteUsername, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - UserRendererService.UserResponse? User, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - string? Token, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - Instant? ExpiresAt + UserRendererService.UserResponse? User, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt ); public record OauthRegisterRequest(string Ticket, string Username); @@ -71,9 +70,10 @@ public class AuthController(Config config, DatabaseContext db, KeyCacheService k public async Task ForceLogoutAsync() { _logger.Information("Invalidating all tokens for user {UserId}", CurrentUser!.Id); - await db.Tokens.Where(t => t.UserId == CurrentUser.Id) + await db + .Tokens.Where(t => t.UserId == CurrentUser.Id) .ExecuteUpdateAsync(s => s.SetProperty(t => t.ManuallyExpired, true)); return NoContent(); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index f08569a..344d8ff 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -19,7 +19,8 @@ public class DiscordAuthController( KeyCacheService keyCacheService, AuthService authService, RemoteAuthService remoteAuthService, - UserRendererService userRenderer) : ApiControllerBase + UserRendererService userRenderer +) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); @@ -27,59 +28,93 @@ public class DiscordAuthController( // TODO: duplicating attribute doesn't work, find another way to mark both as possible response // leaving it here for documentation purposes [ProducesResponseType(StatusCodes.Status200OK)] - public async Task CallbackAsync([FromBody] AuthController.CallbackRequest req, - CancellationToken ct = default) + public async Task CallbackAsync( + [FromBody] AuthController.CallbackRequest req, + CancellationToken ct = default + ) { CheckRequirements(); await keyCacheService.ValidateAuthStateAsync(req.State, ct); var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code, req.State, ct); var user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id, ct: ct); - if (user != null) return Ok(await GenerateUserTokenAsync(user, ct)); + if (user != null) + return Ok(await GenerateUserTokenAsync(user, ct)); - _logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username, - remoteUser.Id); + _logger.Debug( + "Discord user {Username} ({Id}) authenticated with no local account", + remoteUser.Username, + remoteUser.Id + ); var ticket = AuthUtils.RandomToken(); - await keyCacheService.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20), ct); + await keyCacheService.SetKeyAsync( + $"discord:{ticket}", + remoteUser, + Duration.FromMinutes(20), + ct + ); - return Ok(new AuthController.CallbackResponse( - HasAccount: false, - Ticket: ticket, - RemoteUsername: remoteUser.Username, - User: null, - Token: null, - ExpiresAt: null - )); + return Ok( + new AuthController.CallbackResponse( + HasAccount: false, + Ticket: ticket, + RemoteUsername: remoteUser.Username, + User: null, + Token: null, + ExpiresAt: null + ) + ); } [HttpPost("register")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task RegisterAsync([FromBody] AuthController.OauthRegisterRequest req) + public async Task RegisterAsync( + [FromBody] AuthController.OauthRegisterRequest req + ) { - var remoteUser = await keyCacheService.GetKeyAsync($"discord:{req.Ticket}"); - if (remoteUser == null) throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); - if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id)) + var remoteUser = await keyCacheService.GetKeyAsync( + $"discord:{req.Ticket}" + ); + if (remoteUser == null) + throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); + if ( + await db.AuthMethods.AnyAsync(a => + a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id + ) + ) { - _logger.Error("Discord user {Id} has valid ticket but is already linked to an existing account", - remoteUser.Id); + _logger.Error( + "Discord user {Id} has valid ticket but is already linked to an existing account", + remoteUser.Id + ); throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); } - var user = await authService.CreateUserWithRemoteAuthAsync(req.Username, AuthType.Discord, remoteUser.Id, - remoteUser.Username); + var user = await authService.CreateUserWithRemoteAuthAsync( + req.Username, + AuthType.Discord, + remoteUser.Id, + remoteUser.Username + ); return Ok(await GenerateUserTokenAsync(user)); } - private async Task GenerateUserTokenAsync(User user, - CancellationToken ct = default) + private async Task GenerateUserTokenAsync( + User user, + CancellationToken ct = default + ) { var frontendApp = await db.GetFrontendApplicationAsync(ct); _logger.Debug("Logging user {Id} in with Discord", user.Id); - var (tokenStr, token) = - authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); + var (tokenStr, token) = authService.GenerateToken( + user, + frontendApp, + ["*"], + clock.GetCurrentInstant() + Duration.FromDays(365) + ); db.Add(token); _logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id); @@ -90,7 +125,12 @@ public class DiscordAuthController( HasAccount: true, Ticket: null, RemoteUsername: null, - User: await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), + User: await userRenderer.RenderUserAsync( + user, + selfUser: user, + renderMembers: false, + ct: ct + ), Token: tokenStr, ExpiresAt: token.ExpiresAt ); @@ -99,6 +139,8 @@ public class DiscordAuthController( private void CheckRequirements() { if (!config.DiscordAuth.Enabled) - throw new ApiError.BadRequest("Discord authentication is not enabled on this instance."); + throw new ApiError.BadRequest( + "Discord authentication is not enabled on this instance." + ); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index 251fb5f..41eab25 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -20,21 +20,35 @@ public class EmailAuthController( KeyCacheService keyCacheService, UserRendererService userRenderer, IClock clock, - ILogger logger) : ApiControllerBase + ILogger logger +) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); [HttpPost("register")] - public async Task RegisterAsync([FromBody] RegisterRequest req, CancellationToken ct = default) + public async Task RegisterAsync( + [FromBody] RegisterRequest req, + CancellationToken ct = default + ) { CheckRequirements(); - if (!req.Email.Contains('@')) throw new ApiError.BadRequest("Email is invalid", "email", req.Email); + if (!req.Email.Contains('@')) + throw new ApiError.BadRequest("Email is invalid", "email", req.Email); - var state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, userId: null, ct); + var state = await keyCacheService.GenerateRegisterEmailStateAsync( + req.Email, + userId: null, + ct + ); // If there's already a user with that email address, pretend we sent an email but actually ignore it - if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Email && a.RemoteId == req.Email, ct)) + if ( + await db.AuthMethods.AnyAsync( + a => a.AuthType == AuthType.Email && a.RemoteId == req.Email, + ct + ) + ) return NoContent(); mailService.QueueAccountCreationEmail(req.Email, state); @@ -47,29 +61,48 @@ public class EmailAuthController( CheckRequirements(); var state = await keyCacheService.GetRegisterEmailStateAsync(req.State); - if (state == null) throw new ApiError.BadRequest("Invalid state", "state", req.State); + if (state == null) + throw new ApiError.BadRequest("Invalid state", "state", req.State); // If this callback is for an existing user, add the email address to their auth methods if (state.ExistingUserId != null) { - var authMethod = - await authService.AddAuthMethodAsync(state.ExistingUserId.Value, AuthType.Email, state.Email); - _logger.Debug("Added email auth {AuthId} for user {UserId}", authMethod.Id, state.ExistingUserId); + var authMethod = await authService.AddAuthMethodAsync( + state.ExistingUserId.Value, + AuthType.Email, + state.Email + ); + _logger.Debug( + "Added email auth {AuthId} for user {UserId}", + authMethod.Id, + state.ExistingUserId + ); return NoContent(); } var ticket = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); - return Ok(new AuthController.CallbackResponse(HasAccount: false, Ticket: ticket, RemoteUsername: state.Email, - User: null, Token: null, ExpiresAt: null)); + return Ok( + new AuthController.CallbackResponse( + HasAccount: false, + Ticket: ticket, + RemoteUsername: state.Email, + User: null, + Token: null, + ExpiresAt: null + ) + ); } [HttpPost("complete-registration")] - public async Task CompleteRegistrationAsync([FromBody] CompleteRegistrationRequest req) + public async Task CompleteRegistrationAsync( + [FromBody] CompleteRegistrationRequest req + ) { var email = await keyCacheService.GetKeyAsync($"email:{req.Ticket}"); - if (email == null) throw new ApiError.BadRequest("Unknown ticket", "ticket", req.Ticket); + if (email == null) + throw new ApiError.BadRequest("Unknown ticket", "ticket", req.Ticket); // Check if username is valid at all ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(req.Username))]); @@ -80,28 +113,41 @@ public class EmailAuthController( var user = await authService.CreateUserWithPasswordAsync(req.Username, email, req.Password); var frontendApp = await db.GetFrontendApplicationAsync(); - var (tokenStr, token) = - authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); + var (tokenStr, token) = authService.GenerateToken( + user, + frontendApp, + ["*"], + clock.GetCurrentInstant() + Duration.FromDays(365) + ); db.Add(token); await db.SaveChangesAsync(); await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}"); - return Ok(new AuthController.AuthResponse( - await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false), - tokenStr, - token.ExpiresAt - )); + return Ok( + new AuthController.AuthResponse( + await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false), + tokenStr, + token.ExpiresAt + ) + ); } [HttpPost("login")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task LoginAsync([FromBody] LoginRequest req, CancellationToken ct = default) + public async Task LoginAsync( + [FromBody] LoginRequest req, + CancellationToken ct = default + ) { CheckRequirements(); - var (user, authenticationResult) = await authService.AuthenticateUserAsync(req.Email, req.Password, ct); + var (user, authenticationResult) = await authService.AuthenticateUserAsync( + req.Email, + req.Password, + ct + ); if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired) throw new NotImplementedException("MFA is not implemented yet"); @@ -109,19 +155,30 @@ public class EmailAuthController( _logger.Debug("Logging user {Id} in with email and password", user.Id); - var (tokenStr, token) = - authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); + var (tokenStr, token) = authService.GenerateToken( + user, + frontendApp, + ["*"], + clock.GetCurrentInstant() + Duration.FromDays(365) + ); db.Add(token); _logger.Debug("Generated token {TokenId} for {UserId}", token.Id, user.Id); await db.SaveChangesAsync(ct); - return Ok(new AuthController.AuthResponse( - await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), - tokenStr, - token.ExpiresAt - )); + return Ok( + new AuthController.AuthResponse( + await userRenderer.RenderUserAsync( + user, + selfUser: user, + renderMembers: false, + ct: ct + ), + tokenStr, + token.ExpiresAt + ) + ); } [HttpPost("add")] @@ -148,4 +205,4 @@ public class EmailAuthController( public record CompleteRegistrationRequest(string Ticket, string Username, string Password); public record CallbackRequest(string State); -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs index efae036..d414dba 100644 --- a/Foxnouns.Backend/Controllers/FlagsController.cs +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -18,13 +18,16 @@ public class FlagsController( UserRendererService userRenderer, ObjectStorageService objectStorageService, ISnowflakeGenerator snowflakeGenerator, - IQueue queue) : ApiControllerBase + IQueue queue +) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); [HttpGet] [Authorize("identify")] - [ProducesResponseType>(statusCode: StatusCodes.Status200OK)] + [ProducesResponseType>( + statusCode: StatusCodes.Status200OK + )] public async Task GetFlagsAsync(CancellationToken ct = default) { var flags = await db.PrideFlags.Where(f => f.UserId == CurrentUser!.Id).ToListAsync(ct); @@ -34,7 +37,9 @@ public class FlagsController( [HttpPost] [Authorize("user.update")] - [ProducesResponseType(statusCode: StatusCodes.Status202Accepted)] + [ProducesResponseType( + statusCode: StatusCodes.Status202Accepted + )] public IActionResult CreateFlag([FromBody] CreateFlagRequest req) { ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image)); @@ -42,7 +47,8 @@ public class FlagsController( var id = snowflakeGenerator.GenerateSnowflake(); queue.QueueInvocableWithPayload( - new CreateFlagPayload(id, CurrentUser!.Id, req.Name, req.Image, req.Description)); + new CreateFlagPayload(id, CurrentUser!.Id, req.Name, req.Image, req.Description) + ); return Accepted(new CreateFlagResponse(id, req.Name, req.Description)); } @@ -57,10 +63,14 @@ public class FlagsController( { ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, null)); - var flag = await db.PrideFlags.FirstOrDefaultAsync(f => f.Id == id && f.UserId == CurrentUser!.Id); - if (flag == null) throw new ApiError.NotFound("Unknown flag ID, or it's not your flag."); + var flag = await db.PrideFlags.FirstOrDefaultAsync(f => + f.Id == id && f.UserId == CurrentUser!.Id + ); + if (flag == null) + throw new ApiError.NotFound("Unknown flag ID, or it's not your flag."); - if (req.Name != null) flag.Name = req.Name; + if (req.Name != null) + flag.Name = req.Name; if (req.HasProperty(nameof(req.Description))) flag.Description = req.Description; @@ -83,8 +93,11 @@ public class FlagsController( { await using var tx = await db.Database.BeginTransactionAsync(); - var flag = await db.PrideFlags.FirstOrDefaultAsync(f => f.Id == id && f.UserId == CurrentUser!.Id); - if (flag == null) throw new ApiError.NotFound("Unknown flag ID, or it's not your flag."); + var flag = await db.PrideFlags.FirstOrDefaultAsync(f => + f.Id == id && f.UserId == CurrentUser!.Id + ); + if (flag == null) + throw new ApiError.NotFound("Unknown flag ID, or it's not your flag."); var hash = flag.Hash; @@ -96,7 +109,10 @@ public class FlagsController( { try { - _logger.Information("Deleting flag file {Hash} as it is no longer used by any flags", hash); + _logger.Information( + "Deleting flag file {Hash} as it is no longer used by any flags", + hash + ); await objectStorageService.DeleteFlagAsync(hash); } catch (Exception e) @@ -104,14 +120,19 @@ public class FlagsController( _logger.Error(e, "Error deleting flag file {Hash}", hash); } } - else _logger.Debug("Flag file {Hash} is used by other flags, not deleting", hash); + else + _logger.Debug("Flag file {Hash} is used by other flags, not deleting", hash); await tx.CommitAsync(); return NoContent(); } - private static List<(string, ValidationError?)> ValidateFlag(string? name, string? description, string? imageData) + private static List<(string, ValidationError?)> ValidateFlag( + string? name, + string? description, + string? imageData + ) { var errors = new List<(string, ValidationError?)>(); @@ -120,10 +141,20 @@ public class FlagsController( switch (name.Length) { case < 1: - errors.Add(("name", ValidationError.LengthError("Name is too short", 1, 100, name.Length))); + errors.Add( + ( + "name", + ValidationError.LengthError("Name is too short", 1, 100, name.Length) + ) + ); break; case > 100: - errors.Add(("name", ValidationError.LengthError("Name is too long", 1, 100, name.Length))); + errors.Add( + ( + "name", + ValidationError.LengthError("Name is too long", 1, 100, name.Length) + ) + ); break; } } @@ -133,12 +164,30 @@ public class FlagsController( switch (description.Length) { case < 1: - errors.Add(("description", - ValidationError.LengthError("Description is too short", 1, 100, description.Length))); + errors.Add( + ( + "description", + ValidationError.LengthError( + "Description is too short", + 1, + 100, + description.Length + ) + ) + ); break; case > 500: - errors.Add(("description", - ValidationError.LengthError("Description is too long", 1, 100, description.Length))); + errors.Add( + ( + "description", + ValidationError.LengthError( + "Description is too long", + 1, + 100, + description.Length + ) + ) + ); break; } } @@ -148,14 +197,24 @@ public class FlagsController( switch (imageData.Length) { case 0: - errors.Add(("image", ValidationError.GenericValidationError("Image cannot be empty", null))); + errors.Add( + ( + "image", + ValidationError.GenericValidationError("Image cannot be empty", null) + ) + ); break; case > 1_500_000: - errors.Add(("image", ValidationError.GenericValidationError("Image is too large", null))); + errors.Add( + ( + "image", + ValidationError.GenericValidationError("Image is too large", null) + ) + ); break; } } return errors; } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Controllers/InternalController.cs b/Foxnouns.Backend/Controllers/InternalController.cs index b79de1c..0f857ef 100644 --- a/Foxnouns.Backend/Controllers/InternalController.cs +++ b/Foxnouns.Backend/Controllers/InternalController.cs @@ -17,11 +17,13 @@ public partial class InternalController(DatabaseContext db) : ControllerBase private static string GetCleanedTemplate(string template) { - if (template.StartsWith("api/v2")) template = template["api/v2".Length..]; + if (template.StartsWith("api/v2")) + template = template["api/v2".Length..]; template = PathVarRegex() .Replace(template, "{id}") // Replace all path variables (almost always IDs) with `{id}` .Replace("@me", "{id}"); // Also replace hardcoded `@me` with `{id}` - if (template.Contains("{id}")) return template.Split("{id}")[0] + "{id}"; + if (template.Contains("{id}")) + return template.Split("{id}")[0] + "{id}"; return template; } @@ -29,11 +31,13 @@ public partial class InternalController(DatabaseContext db) : ControllerBase public async Task GetRequestDataAsync([FromBody] RequestDataRequest req) { var endpoint = GetEndpoint(HttpContext, req.Path, req.Method); - if (endpoint == null) throw new ApiError.BadRequest("Path/method combination is invalid"); + if (endpoint == null) + throw new ApiError.BadRequest("Path/method combination is invalid"); var actionDescriptor = endpoint.Metadata.GetMetadata(); var template = actionDescriptor?.AttributeRouteInfo?.Template; - if (template == null) throw new FoxnounsError("Template value was null on valid endpoint"); + if (template == null) + throw new FoxnounsError("Template value was null on valid endpoint"); template = GetCleanedTemplate(template); // If no token was supplied, or it isn't valid base 64, return a null user ID (limiting by IP) @@ -46,30 +50,41 @@ public partial class InternalController(DatabaseContext db) : ControllerBase public record RequestDataRequest(string? Token, string Method, string Path); - public record RequestDataResponse( - Snowflake? UserId, - string Template); + public record RequestDataResponse(Snowflake? UserId, string Template); - private static RouteEndpoint? GetEndpoint(HttpContext httpContext, string url, string requestMethod) + private static RouteEndpoint? GetEndpoint( + HttpContext httpContext, + string url, + string requestMethod + ) { var endpointDataSource = httpContext.RequestServices.GetService(); - if (endpointDataSource == null) return null; + if (endpointDataSource == null) + return null; var endpoints = endpointDataSource.Endpoints.OfType(); foreach (var endpoint in endpoints) { - if (endpoint.RoutePattern.RawText == null) continue; + if (endpoint.RoutePattern.RawText == null) + continue; - var templateMatcher = new TemplateMatcher(TemplateParser.Parse(endpoint.RoutePattern.RawText), - new RouteValueDictionary()); - if (!templateMatcher.TryMatch(url, new())) continue; + var templateMatcher = new TemplateMatcher( + TemplateParser.Parse(endpoint.RoutePattern.RawText), + new RouteValueDictionary() + ); + if (!templateMatcher.TryMatch(url, new())) + continue; var httpMethodAttribute = endpoint.Metadata.GetMetadata(); - if (httpMethodAttribute != null && - !httpMethodAttribute.HttpMethods.Any(x => x.Equals(requestMethod, StringComparison.OrdinalIgnoreCase))) + if ( + httpMethodAttribute != null + && !httpMethodAttribute.HttpMethods.Any(x => + x.Equals(requestMethod, StringComparison.OrdinalIgnoreCase) + ) + ) continue; return endpoint; } return null; } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index 3564090..22d23d4 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -21,12 +21,15 @@ public class MembersController( ISnowflakeGenerator snowflakeGenerator, ObjectStorageService objectStorageService, IQueue queue, - IClock clock) : ApiControllerBase + IClock clock +) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); [HttpGet] - [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType>( + StatusCodes.Status200OK + )] public async Task GetMembersAsync(string userRef, CancellationToken ct = default) { var user = await db.ResolveUserAsync(userRef, CurrentToken, ct); @@ -35,7 +38,11 @@ public class MembersController( [HttpGet("{memberRef}")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task GetMemberAsync(string userRef, string memberRef, CancellationToken ct = default) + public async Task GetMemberAsync( + string userRef, + string memberRef, + CancellationToken ct = default + ) { var member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken, ct); return Ok(memberRenderer.RenderMember(member, CurrentToken)); @@ -46,19 +53,30 @@ public class MembersController( [HttpPost("/api/v2/users/@me/members")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize("member.create")] - public async Task CreateMemberAsync([FromBody] CreateMemberRequest req, - CancellationToken ct = default) + public async Task CreateMemberAsync( + [FromBody] CreateMemberRequest req, + CancellationToken ct = default + ) { - ValidationUtils.Validate([ - ("name", ValidationUtils.ValidateMemberName(req.Name)), - ("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)), - ("bio", ValidationUtils.ValidateBio(req.Bio)), - ("avatar", ValidationUtils.ValidateAvatar(req.Avatar)), - .. ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences), - .. ValidationUtils.ValidateFieldEntries(req.Names?.ToArray(), CurrentUser!.CustomPreferences, "names"), - .. ValidationUtils.ValidatePronouns(req.Pronouns?.ToArray(), CurrentUser!.CustomPreferences), - .. ValidationUtils.ValidateLinks(req.Links) - ]); + ValidationUtils.Validate( + [ + ("name", ValidationUtils.ValidateMemberName(req.Name)), + ("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)), + ("bio", ValidationUtils.ValidateBio(req.Bio)), + ("avatar", ValidationUtils.ValidateAvatar(req.Avatar)), + .. ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences), + .. ValidationUtils.ValidateFieldEntries( + req.Names?.ToArray(), + CurrentUser!.CustomPreferences, + "names" + ), + .. ValidationUtils.ValidatePronouns( + req.Pronouns?.ToArray(), + CurrentUser!.CustomPreferences + ), + .. ValidationUtils.ValidateLinks(req.Links), + ] + ); var memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct); if (memberCount >= MaxMemberCount) @@ -75,11 +93,16 @@ public class MembersController( Fields = req.Fields ?? [], Names = req.Names ?? [], Pronouns = req.Pronouns ?? [], - Unlisted = req.Unlisted ?? false + Unlisted = req.Unlisted ?? false, }; db.Add(member); - _logger.Debug("Creating member {MemberName} ({Id}) for {UserId}", member.Name, member.Id, CurrentUser!.Id); + _logger.Debug( + "Creating member {MemberName} ({Id}) for {UserId}", + member.Name, + member.Id, + CurrentUser!.Id + ); try { @@ -88,19 +111,27 @@ public class MembersController( catch (UniqueConstraintException) { _logger.Debug("Could not create member {Id} due to name conflict", member.Id); - throw new ApiError.BadRequest("A member with that name already exists", "name", req.Name); + throw new ApiError.BadRequest( + "A member with that name already exists", + "name", + req.Name + ); } if (req.Avatar != null) queue.QueueInvocableWithPayload( - new AvatarUpdatePayload(member.Id, req.Avatar)); + new AvatarUpdatePayload(member.Id, req.Avatar) + ); return Ok(memberRenderer.RenderMember(member, CurrentToken)); } [HttpPatch("/api/v2/users/@me/members/{memberRef}")] [Authorize("member.update")] - public async Task UpdateMemberAsync(string memberRef, [FromBody] UpdateMemberRequest req) + public async Task UpdateMemberAsync( + string memberRef, + [FromBody] UpdateMemberRequest req + ) { await using var tx = await db.Database.BeginTransactionAsync(); var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef); @@ -134,26 +165,37 @@ public class MembersController( if (req.Names != null) { - errors.AddRange(ValidationUtils.ValidateFieldEntries(req.Names, CurrentUser!.CustomPreferences, "names")); + errors.AddRange( + ValidationUtils.ValidateFieldEntries( + req.Names, + CurrentUser!.CustomPreferences, + "names" + ) + ); member.Names = req.Names.ToList(); } if (req.Pronouns != null) { - errors.AddRange(ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)); + errors.AddRange( + ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences) + ); member.Pronouns = req.Pronouns.ToList(); } if (req.Fields != null) { - errors.AddRange(ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences)); + errors.AddRange( + ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences) + ); member.Fields = req.Fields.ToList(); } if (req.Flags != null) { var flagError = await db.SetMemberFlagsAsync(CurrentUser!.Id, member.Id, req.Flags); - if (flagError != null) errors.Add(("flags", flagError)); + if (flagError != null) + errors.Add(("flags", flagError)); } if (req.HasProperty(nameof(req.Avatar))) @@ -165,16 +207,25 @@ public class MembersController( // so it's in a separate block to the validation above. if (req.HasProperty(nameof(req.Avatar))) queue.QueueInvocableWithPayload( - new AvatarUpdatePayload(member.Id, req.Avatar)); + new AvatarUpdatePayload(member.Id, req.Avatar) + ); try { await db.SaveChangesAsync(); } catch (UniqueConstraintException) { - _logger.Debug("Could not update member {Id} due to name conflict ({CurrentName} / {NewName})", member.Id, - member.Name, req.Name); - throw new ApiError.BadRequest("A member with that name already exists", "name", req.Name!); + _logger.Debug( + "Could not update member {Id} due to name conflict ({CurrentName} / {NewName})", + member.Id, + member.Name, + req.Name + ); + throw new ApiError.BadRequest( + "A member with that name already exists", + "name", + req.Name! + ); } await tx.CommitAsync(); @@ -199,15 +250,20 @@ public class MembersController( public async Task DeleteMemberAsync(string memberRef) { var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef); - var deleteCount = await db.Members.Where(m => m.UserId == CurrentUser!.Id && m.Id == member.Id) + var deleteCount = await db + .Members.Where(m => m.UserId == CurrentUser!.Id && m.Id == member.Id) .ExecuteDeleteAsync(); if (deleteCount == 0) { - _logger.Warning("Successfully resolved member {Id} but could not delete them", member.Id); + _logger.Warning( + "Successfully resolved member {Id} but could not delete them", + member.Id + ); return NoContent(); } - if (member.Avatar != null) await objectStorageService.DeleteMemberAvatarAsync(member.Id, member.Avatar); + if (member.Avatar != null) + await objectStorageService.DeleteMemberAvatarAsync(member.Id, member.Avatar); return NoContent(); } @@ -220,7 +276,8 @@ public class MembersController( string[]? Links, List? Names, List? Pronouns, - List? Fields); + List? Fields + ); [HttpPost("/api/v2/users/@me/members/{memberRef}/reroll-sid")] [Authorize("member.update")] @@ -234,17 +291,19 @@ public class MembersController( throw new ApiError.BadRequest("Cannot reroll short ID yet"); // Using ExecuteUpdateAsync here as the new short ID is generated by the database - await db.Members.Where(m => m.Id == member.Id) - .ExecuteUpdateAsync(s => s - .SetProperty(m => m.Sid, _ => db.FindFreeMemberSid())); + await db + .Members.Where(m => m.Id == member.Id) + .ExecuteUpdateAsync(s => s.SetProperty(m => m.Sid, _ => db.FindFreeMemberSid())); - await db.Users.Where(u => u.Id == CurrentUser.Id) - .ExecuteUpdateAsync(s => s - .SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant()) - .SetProperty(u => u.LastActive, clock.GetCurrentInstant())); + await db + .Users.Where(u => u.Id == CurrentUser.Id) + .ExecuteUpdateAsync(s => + s.SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant()) + .SetProperty(u => u.LastActive, clock.GetCurrentInstant()) + ); // Re-fetch member to fetch the new sid var updatedMember = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef); return Ok(memberRenderer.RenderMember(updatedMember, CurrentToken)); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Controllers/MetaController.cs b/Foxnouns.Backend/Controllers/MetaController.cs index 53a38e7..411e2e8 100644 --- a/Foxnouns.Backend/Controllers/MetaController.cs +++ b/Foxnouns.Backend/Controllers/MetaController.cs @@ -12,23 +12,30 @@ public class MetaController : ApiControllerBase [ProducesResponseType(StatusCodes.Status200OK)] public IActionResult GetMeta() { - return Ok(new MetaResponse( - Repository, BuildInfo.Version, BuildInfo.Hash, (int)FoxnounsMetrics.MemberCount.Value, - new UserInfo( - (int)FoxnounsMetrics.UsersCount.Value, - (int)FoxnounsMetrics.UsersActiveMonthCount.Value, - (int)FoxnounsMetrics.UsersActiveWeekCount.Value, - (int)FoxnounsMetrics.UsersActiveDayCount.Value - ), - new Limits( - MemberCount: MembersController.MaxMemberCount, - BioLength: ValidationUtils.MaxBioLength, - CustomPreferences: UsersController.MaxCustomPreferences)) + return Ok( + new MetaResponse( + Repository, + BuildInfo.Version, + BuildInfo.Hash, + (int)FoxnounsMetrics.MemberCount.Value, + new UserInfo( + (int)FoxnounsMetrics.UsersCount.Value, + (int)FoxnounsMetrics.UsersActiveMonthCount.Value, + (int)FoxnounsMetrics.UsersActiveWeekCount.Value, + (int)FoxnounsMetrics.UsersActiveDayCount.Value + ), + new Limits( + MemberCount: MembersController.MaxMemberCount, + BioLength: ValidationUtils.MaxBioLength, + CustomPreferences: UsersController.MaxCustomPreferences + ) + ) ); } [HttpGet("/api/v2/coffee")] - public IActionResult BrewCoffee() => Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot); + public IActionResult BrewCoffee() => + Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot); private record MetaResponse( string Repository, @@ -36,13 +43,11 @@ public class MetaController : ApiControllerBase string Hash, int Members, UserInfo Users, - Limits Limits); + Limits Limits + ); private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay); // All limits that the frontend should know about (for UI purposes) - private record Limits( - int MemberCount, - int BioLength, - int CustomPreferences); -} \ No newline at end of file + private record Limits(int MemberCount, int BioLength, int CustomPreferences); +} diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index fb0e301..1d85c77 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -20,7 +20,8 @@ public class UsersController( UserRendererService userRenderer, ISnowflakeGenerator snowflakeGenerator, IQueue queue, - IClock clock) : ApiControllerBase + IClock clock +) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); @@ -29,20 +30,25 @@ public class UsersController( public async Task GetUserAsync(string userRef, CancellationToken ct = default) { var user = await db.ResolveUserAsync(userRef, CurrentToken, ct); - return Ok(await userRenderer.RenderUserAsync( - user, - selfUser: CurrentUser, - token: CurrentToken, - renderMembers: true, - renderAuthMethods: true, - ct: ct - )); + return Ok( + await userRenderer.RenderUserAsync( + user, + selfUser: CurrentUser, + token: CurrentToken, + renderMembers: true, + renderAuthMethods: true, + ct: ct + ) + ); } [HttpPatch("@me")] [Authorize("user.update")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public async Task UpdateUserAsync([FromBody] UpdateUserRequest req, CancellationToken ct = default) + public async Task UpdateUserAsync( + [FromBody] UpdateUserRequest req, + CancellationToken ct = default + ) { await using var tx = await db.Database.BeginTransactionAsync(ct); var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct); @@ -74,26 +80,37 @@ public class UsersController( if (req.Names != null) { - errors.AddRange(ValidationUtils.ValidateFieldEntries(req.Names, CurrentUser!.CustomPreferences, "names")); + errors.AddRange( + ValidationUtils.ValidateFieldEntries( + req.Names, + CurrentUser!.CustomPreferences, + "names" + ) + ); user.Names = req.Names.ToList(); } if (req.Pronouns != null) { - errors.AddRange(ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)); + errors.AddRange( + ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences) + ); user.Pronouns = req.Pronouns.ToList(); } if (req.Fields != null) { - errors.AddRange(ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences)); + errors.AddRange( + ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences) + ); user.Fields = req.Fields.ToList(); } if (req.Flags != null) { var flagError = await db.SetUserFlagsAsync(CurrentUser!.Id, req.Flags); - if (flagError != null) errors.Add(("flags", flagError)); + if (flagError != null) + errors.Add(("flags", flagError)); } if (req.HasProperty(nameof(req.Avatar))) @@ -105,7 +122,8 @@ public class UsersController( // so it's in a separate block to the validation above. if (req.HasProperty(nameof(req.Avatar))) queue.QueueInvocableWithPayload( - new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)); + new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar) + ); try { @@ -113,26 +131,45 @@ public class UsersController( } catch (UniqueConstraintException) { - _logger.Debug("Could not update user {Id} due to name conflict ({CurrentName} / {NewName})", user.Id, - user.Username, req.Username); - throw new ApiError.BadRequest("That username is already taken.", "username", req.Username!); + _logger.Debug( + "Could not update user {Id} due to name conflict ({CurrentName} / {NewName})", + user.Id, + user.Username, + req.Username + ); + throw new ApiError.BadRequest( + "That username is already taken.", + "username", + req.Username! + ); } await tx.CommitAsync(ct); - return Ok(await userRenderer.RenderUserAsync(user, CurrentUser, renderMembers: false, - renderAuthMethods: false, ct: ct)); + return Ok( + await userRenderer.RenderUserAsync( + user, + CurrentUser, + renderMembers: false, + renderAuthMethods: false, + ct: ct + ) + ); } [HttpPatch("@me/custom-preferences")] [Authorize("user.update")] [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task UpdateCustomPreferencesAsync([FromBody] List req, - CancellationToken ct = default) + public async Task UpdateCustomPreferencesAsync( + [FromBody] List req, + CancellationToken ct = default + ) { ValidationUtils.Validate(ValidateCustomPreferences(req)); var user = await db.ResolveUserAsync(CurrentUser!.Id, ct); - var preferences = user.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key)).ToDictionary(); + var preferences = user + .CustomPreferences.Where(x => req.Any(r => r.Id == x.Key)) + .ToDictionary(); foreach (var r in req) { @@ -144,7 +181,7 @@ public class UsersController( Icon = r.Icon, Muted = r.Muted, Size = r.Size, - Tooltip = r.Tooltip + Tooltip = r.Tooltip, }; } else @@ -155,7 +192,7 @@ public class UsersController( Icon = r.Icon, Muted = r.Muted, Size = r.Size, - Tooltip = r.Tooltip + Tooltip = r.Tooltip, }; } } @@ -180,15 +217,25 @@ public class UsersController( public const int MaxCustomPreferences = 25; private static List<(string, ValidationError?)> ValidateCustomPreferences( - List preferences) + List preferences + ) { var errors = new List<(string, ValidationError?)>(); if (preferences.Count > MaxCustomPreferences) - errors.Add(("custom_preferences", - ValidationError.LengthError("Too many custom preferences", 0, MaxCustomPreferences, - preferences.Count))); - if (preferences.Count > 50) return errors; + errors.Add( + ( + "custom_preferences", + ValidationError.LengthError( + "Too many custom preferences", + 0, + MaxCustomPreferences, + preferences.Count + ) + ) + ); + if (preferences.Count > 50) + return errors; // TODO: validate individual preferences @@ -208,7 +255,6 @@ public class UsersController( public Snowflake[]? Flags { get; init; } } - [HttpGet("@me/settings")] [Authorize("user.read_hidden")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] @@ -221,8 +267,10 @@ public class UsersController( [HttpPatch("@me/settings")] [Authorize("user.read_hidden", "user.update")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public async Task UpdateUserSettingsAsync([FromBody] UpdateUserSettingsRequest req, - CancellationToken ct = default) + public async Task UpdateUserSettingsAsync( + [FromBody] UpdateUserSettingsRequest req, + CancellationToken ct = default + ) { var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct); @@ -250,13 +298,22 @@ public class UsersController( throw new ApiError.BadRequest("Cannot reroll short ID yet"); // Using ExecuteUpdateAsync here as the new short ID is generated by the database - await db.Users.Where(u => u.Id == CurrentUser.Id) - .ExecuteUpdateAsync(s => s - .SetProperty(u => u.Sid, _ => db.FindFreeUserSid()) - .SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant()) - .SetProperty(u => u.LastActive, clock.GetCurrentInstant())); + await db + .Users.Where(u => u.Id == CurrentUser.Id) + .ExecuteUpdateAsync(s => + s.SetProperty(u => u.Sid, _ => db.FindFreeUserSid()) + .SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant()) + .SetProperty(u => u.LastActive, clock.GetCurrentInstant()) + ); var user = await db.ResolveUserAsync(CurrentUser.Id); - return Ok(await userRenderer.RenderUserAsync(user, CurrentUser, CurrentToken, renderMembers: false)); + return Ok( + await userRenderer.RenderUserAsync( + user, + CurrentUser, + CurrentToken, + renderMembers: false + ) + ); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/BaseModel.cs b/Foxnouns.Backend/Database/BaseModel.cs index d0dcbca..d87cf22 100644 --- a/Foxnouns.Backend/Database/BaseModel.cs +++ b/Foxnouns.Backend/Database/BaseModel.cs @@ -3,4 +3,4 @@ namespace Foxnouns.Backend.Database; public abstract class BaseModel { public required Snowflake Id { get; init; } = SnowflakeGenerator.Instance.GenerateSnowflake(); -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index dbf38c5..95e0317 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -45,11 +45,12 @@ public class DatabaseContext : DbContext _loggerFactory = loggerFactory; } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => + optionsBuilder .ConfigureWarnings(c => c.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning) - .Ignore(CoreEventId.SaveChangesFailed)) + .Ignore(CoreEventId.SaveChangesFailed) + ) .UseNpgsql(_dataSource, o => o.UseNodaTime()) .UseSnakeCaseNamingConvention() .UseLoggerFactory(_loggerFactory) @@ -76,7 +77,10 @@ public class DatabaseContext : DbContext modelBuilder.Entity().Property(u => u.CustomPreferences).HasColumnType("jsonb"); modelBuilder.Entity().Property(u => u.Settings).HasColumnType("jsonb"); - modelBuilder.Entity().Property(m => m.Sid).HasDefaultValueSql("find_free_member_sid()"); + modelBuilder + .Entity() + .Property(m => m.Sid) + .HasDefaultValueSql("find_free_member_sid()"); modelBuilder.Entity().Property(m => m.Fields).HasColumnType("jsonb"); modelBuilder.Entity().Property(m => m.Names).HasColumnType("jsonb"); modelBuilder.Entity().Property(m => m.Pronouns).HasColumnType("jsonb"); @@ -84,10 +88,12 @@ public class DatabaseContext : DbContext modelBuilder.Entity().Navigation(f => f.PrideFlag).AutoInclude(); modelBuilder.Entity().Navigation(f => f.PrideFlag).AutoInclude(); - modelBuilder.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeUserSid))!) + modelBuilder + .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeUserSid))!) .HasName("find_free_user_sid"); - modelBuilder.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!) + modelBuilder + .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!) .HasName("find_free_member_sid"); } @@ -102,18 +108,23 @@ public class DatabaseContext : DbContext public string FindFreeMemberSid() => throw new NotSupportedException(); } -[SuppressMessage("ReSharper", "UnusedType.Global", Justification = "Used by EF Core's migration generator")] +[SuppressMessage( + "ReSharper", + "UnusedType.Global", + Justification = "Used by EF Core's migration generator" +)] public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory { public DatabaseContext CreateDbContext(string[] args) { // Read the configuration file - var config = new ConfigurationBuilder() - .AddConfiguration() - .Build() - // Get the configuration as our config class - .Get() ?? new(); + var config = + new ConfigurationBuilder() + .AddConfiguration() + .Build() + // Get the configuration as our config class + .Get() ?? new(); return new DatabaseContext(config, null); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs index d5df79d..e8d2a98 100644 --- a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs +++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs @@ -8,89 +8,128 @@ namespace Foxnouns.Backend.Database; public static class DatabaseQueryExtensions { - public static async Task ResolveUserAsync(this DatabaseContext context, string userRef, Token? token, - CancellationToken ct = default) + public static async Task ResolveUserAsync( + this DatabaseContext context, + string userRef, + Token? token, + CancellationToken ct = default + ) { if (userRef == "@me") { return token != null ? await context.Users.FirstAsync(u => u.Id == token.UserId, ct) - : throw new ApiError.Unauthorized("This endpoint requires an authenticated user.", - ErrorCode.AuthenticationRequired); + : throw new ApiError.Unauthorized( + "This endpoint requires an authenticated user.", + ErrorCode.AuthenticationRequired + ); } User? user; if (Snowflake.TryParse(userRef, out var snowflake)) { - user = await context.Users - .Where(u => !u.Deleted) + user = await context + .Users.Where(u => !u.Deleted) .FirstOrDefaultAsync(u => u.Id == snowflake, ct); - if (user != null) return user; + if (user != null) + return user; } - user = await context.Users - .Where(u => !u.Deleted) + user = await context + .Users.Where(u => !u.Deleted) .FirstOrDefaultAsync(u => u.Username == userRef, ct); - if (user != null) return user; - throw new ApiError.NotFound("No user with that ID or username found.", code: ErrorCode.UserNotFound); + if (user != null) + return user; + throw new ApiError.NotFound( + "No user with that ID or username found.", + code: ErrorCode.UserNotFound + ); } - public static async Task ResolveUserAsync(this DatabaseContext context, Snowflake id, - CancellationToken ct = default) + public static async Task ResolveUserAsync( + this DatabaseContext context, + Snowflake id, + CancellationToken ct = default + ) { - var user = await context.Users - .Where(u => !u.Deleted) + var user = await context + .Users.Where(u => !u.Deleted) .FirstOrDefaultAsync(u => u.Id == id, ct); - if (user != null) return user; + if (user != null) + return user; throw new ApiError.NotFound("No user with that ID found.", code: ErrorCode.UserNotFound); } - public static async Task ResolveMemberAsync(this DatabaseContext context, Snowflake id, - CancellationToken ct = default) + public static async Task ResolveMemberAsync( + this DatabaseContext context, + Snowflake id, + CancellationToken ct = default + ) { - var member = await context.Members - .Include(m => m.User) + var member = await context + .Members.Include(m => m.User) .Where(m => !m.User.Deleted) .FirstOrDefaultAsync(m => m.Id == id, ct); - if (member != null) return member; - throw new ApiError.NotFound("No member with that ID found.", code: ErrorCode.MemberNotFound); + if (member != null) + return member; + throw new ApiError.NotFound( + "No member with that ID found.", + code: ErrorCode.MemberNotFound + ); } - public static async Task ResolveMemberAsync(this DatabaseContext context, string userRef, string memberRef, - Token? token, CancellationToken ct = default) + public static async Task ResolveMemberAsync( + this DatabaseContext context, + string userRef, + string memberRef, + Token? token, + CancellationToken ct = default + ) { var user = await context.ResolveUserAsync(userRef, token, ct); return await context.ResolveMemberAsync(user.Id, memberRef, ct); } - public static async Task ResolveMemberAsync(this DatabaseContext context, Snowflake userId, - string memberRef, CancellationToken ct = default) + public static async Task ResolveMemberAsync( + this DatabaseContext context, + Snowflake userId, + string memberRef, + CancellationToken ct = default + ) { Member? member; if (Snowflake.TryParse(memberRef, out var snowflake)) { - member = await context.Members - .Include(m => m.User) + member = await context + .Members.Include(m => m.User) .Include(m => m.ProfileFlags) .Where(m => !m.User.Deleted) .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct); - if (member != null) return member; + if (member != null) + return member; } - member = await context.Members - .Include(m => m.User) + member = await context + .Members.Include(m => m.User) .Include(m => m.ProfileFlags) .Where(m => !m.User.Deleted) .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct); - if (member != null) return member; - throw new ApiError.NotFound("No member with that ID or name found.", code: ErrorCode.MemberNotFound); + if (member != null) + return member; + throw new ApiError.NotFound( + "No member with that ID or name found.", + code: ErrorCode.MemberNotFound + ); } - public static async Task GetFrontendApplicationAsync(this DatabaseContext context, - CancellationToken ct = default) + public static async Task GetFrontendApplicationAsync( + this DatabaseContext context, + CancellationToken ct = default + ) { var app = await context.Applications.FirstOrDefaultAsync(a => a.Id == new Snowflake(0), ct); - if (app != null) return app; + if (app != null) + return app; app = new Application { @@ -107,27 +146,42 @@ public static class DatabaseQueryExtensions return app; } - public static async Task GetToken(this DatabaseContext context, byte[] rawToken, - CancellationToken ct = default) + public static async Task GetToken( + this DatabaseContext context, + byte[] rawToken, + CancellationToken ct = default + ) { var hash = SHA512.HashData(rawToken); - var oauthToken = await context.Tokens - .Include(t => t.Application) + var oauthToken = await context + .Tokens.Include(t => t.Application) .Include(t => t.User) .FirstOrDefaultAsync( - t => t.Hash == hash && t.ExpiresAt > SystemClock.Instance.GetCurrentInstant() && !t.ManuallyExpired, - ct); + t => + t.Hash == hash + && t.ExpiresAt > SystemClock.Instance.GetCurrentInstant() + && !t.ManuallyExpired, + ct + ); return oauthToken; } - public static async Task GetTokenUserId(this DatabaseContext context, byte[] rawToken, - CancellationToken ct = default) + public static async Task GetTokenUserId( + this DatabaseContext context, + byte[] rawToken, + CancellationToken ct = default + ) { var hash = SHA512.HashData(rawToken); - return await context.Tokens - .Where(t => t.Hash == hash && t.ExpiresAt > SystemClock.Instance.GetCurrentInstant() && !t.ManuallyExpired) - .Select(t => t.UserId).FirstOrDefaultAsync(ct); + return await context + .Tokens.Where(t => + t.Hash == hash + && t.ExpiresAt > SystemClock.Instance.GetCurrentInstant() + && !t.ManuallyExpired + ) + .Select(t => t.UserId) + .FirstOrDefaultAsync(ct); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/FlagQueryExtensions.cs b/Foxnouns.Backend/Database/FlagQueryExtensions.cs index cbdd710..cce6eb4 100644 --- a/Foxnouns.Backend/Database/FlagQueryExtensions.cs +++ b/Foxnouns.Backend/Database/FlagQueryExtensions.cs @@ -5,23 +5,30 @@ namespace Foxnouns.Backend.Database; public static class FlagQueryExtensions { - private static async Task> GetFlagsAsync(this DatabaseContext db, Snowflake userId) => - await db.PrideFlags.Where(f => f.UserId == userId).OrderBy(f => f.Id).ToListAsync(); + private static async Task> GetFlagsAsync( + this DatabaseContext db, + Snowflake userId + ) => await db.PrideFlags.Where(f => f.UserId == userId).OrderBy(f => f.Id).ToListAsync(); /// /// Sets the user's profile flags to the given IDs. Returns a validation error if any of the flag IDs are unknown /// or if too many IDs are given. Duplicates are allowed. /// - public static async Task SetUserFlagsAsync(this DatabaseContext db, Snowflake userId, - Snowflake[] flagIds) + public static async Task SetUserFlagsAsync( + this DatabaseContext db, + Snowflake userId, + Snowflake[] flagIds + ) { var currentFlags = await db.UserFlags.Where(f => f.UserId == userId).ToListAsync(); foreach (var flag in currentFlags) db.UserFlags.Remove(flag); // If there's no new flags to set, we're done - if (flagIds.Length == 0) return null; - if (flagIds.Length > 100) return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length); + if (flagIds.Length == 0) + return null; + if (flagIds.Length > 100) + return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length); var flags = await db.GetFlagsAsync(userId); var unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray(); @@ -34,24 +41,34 @@ public static class FlagQueryExtensions return null; } - public static async Task SetMemberFlagsAsync(this DatabaseContext db, Snowflake userId, - Snowflake memberId, Snowflake[] flagIds) + public static async Task SetMemberFlagsAsync( + this DatabaseContext db, + Snowflake userId, + Snowflake memberId, + Snowflake[] flagIds + ) { var currentFlags = await db.MemberFlags.Where(f => f.MemberId == memberId).ToListAsync(); foreach (var flag in currentFlags) db.MemberFlags.Remove(flag); - if (flagIds.Length == 0) return null; - if (flagIds.Length > 100) return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length); + if (flagIds.Length == 0) + return null; + if (flagIds.Length > 100) + return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length); var flags = await db.GetFlagsAsync(userId); var unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray(); if (unknownFlagIds.Length != 0) return ValidationError.GenericValidationError("Unknown flag IDs", unknownFlagIds); - var memberFlags = flagIds.Select(id => new MemberFlag { PrideFlagId = id, MemberId = memberId }); + var memberFlags = flagIds.Select(id => new MemberFlag + { + PrideFlagId = id, + MemberId = memberId, + }); db.MemberFlags.AddRange(memberFlags); return null; } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/ISnowflakeGenerator.cs b/Foxnouns.Backend/Database/ISnowflakeGenerator.cs index dd76ab3..cfe6085 100644 --- a/Foxnouns.Backend/Database/ISnowflakeGenerator.cs +++ b/Foxnouns.Backend/Database/ISnowflakeGenerator.cs @@ -5,4 +5,4 @@ namespace Foxnouns.Backend.Database; public interface ISnowflakeGenerator { Snowflake GenerateSnowflake(Instant? time = null); -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/Migrations/20240527132444_Init.cs b/Foxnouns.Backend/Database/Migrations/20240527132444_Init.cs index f501fe0..7a29aa0 100644 --- a/Foxnouns.Backend/Database/Migrations/20240527132444_Init.cs +++ b/Foxnouns.Backend/Database/Migrations/20240527132444_Init.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; using NodaTime; #nullable disable @@ -22,12 +22,13 @@ namespace Foxnouns.Backend.Database.Migrations domain = table.Column(type: "text", nullable: false), client_id = table.Column(type: "text", nullable: false), client_secret = table.Column(type: "text", nullable: false), - instance_type = table.Column(type: "integer", nullable: false) + instance_type = table.Column(type: "integer", nullable: false), }, constraints: table => { table.PrimaryKey("pk_fediverse_applications", x => x.id); - }); + } + ); migrationBuilder.CreateTable( name: "users", @@ -43,12 +44,13 @@ namespace Foxnouns.Backend.Database.Migrations role = table.Column(type: "integer", nullable: false), fields = table.Column(type: "jsonb", nullable: false), names = table.Column(type: "jsonb", nullable: false), - pronouns = table.Column(type: "jsonb", nullable: false) + pronouns = table.Column(type: "jsonb", nullable: false), }, constraints: table => { table.PrimaryKey("pk_users", x => x.id); - }); + } + ); migrationBuilder.CreateTable( name: "auth_methods", @@ -59,7 +61,7 @@ namespace Foxnouns.Backend.Database.Migrations remote_id = table.Column(type: "text", nullable: false), remote_username = table.Column(type: "text", nullable: true), user_id = table.Column(type: "bigint", nullable: false), - fediverse_application_id = table.Column(type: "bigint", nullable: true) + fediverse_application_id = table.Column(type: "bigint", nullable: true), }, constraints: table => { @@ -68,14 +70,17 @@ namespace Foxnouns.Backend.Database.Migrations name: "fk_auth_methods_fediverse_applications_fediverse_application_id", column: x => x.fediverse_application_id, principalTable: "fediverse_applications", - principalColumn: "id"); + principalColumn: "id" + ); table.ForeignKey( name: "fk_auth_methods_users_user_id", column: x => x.user_id, principalTable: "users", principalColumn: "id", - onDelete: ReferentialAction.Cascade); - }); + onDelete: ReferentialAction.Cascade + ); + } + ); migrationBuilder.CreateTable( name: "members", @@ -91,7 +96,7 @@ namespace Foxnouns.Backend.Database.Migrations user_id = table.Column(type: "bigint", nullable: false), fields = table.Column(type: "jsonb", nullable: false), names = table.Column(type: "jsonb", nullable: false), - pronouns = table.Column(type: "jsonb", nullable: false) + pronouns = table.Column(type: "jsonb", nullable: false), }, constraints: table => { @@ -101,18 +106,23 @@ namespace Foxnouns.Backend.Database.Migrations column: x => x.user_id, principalTable: "users", principalColumn: "id", - onDelete: ReferentialAction.Cascade); - }); + onDelete: ReferentialAction.Cascade + ); + } + ); migrationBuilder.CreateTable( name: "tokens", columns: table => new { id = table.Column(type: "bigint", nullable: false), - expires_at = table.Column(type: "timestamp with time zone", nullable: false), + expires_at = table.Column( + type: "timestamp with time zone", + nullable: false + ), scopes = table.Column(type: "text[]", nullable: false), manually_expired = table.Column(type: "boolean", nullable: false), - user_id = table.Column(type: "bigint", nullable: false) + user_id = table.Column(type: "bigint", nullable: false), }, constraints: table => { @@ -122,53 +132,56 @@ namespace Foxnouns.Backend.Database.Migrations column: x => x.user_id, principalTable: "users", principalColumn: "id", - onDelete: ReferentialAction.Cascade); - }); + onDelete: ReferentialAction.Cascade + ); + } + ); migrationBuilder.CreateIndex( name: "ix_auth_methods_fediverse_application_id", table: "auth_methods", - column: "fediverse_application_id"); + column: "fediverse_application_id" + ); migrationBuilder.CreateIndex( name: "ix_auth_methods_user_id", table: "auth_methods", - column: "user_id"); + column: "user_id" + ); // EF Core doesn't support creating indexes on arbitrary expressions, so we have to create it manually. // Due to historical reasons (I made a mistake while writing the initial migration for the Go version) // only members have case-insensitive names. - migrationBuilder.Sql("CREATE UNIQUE INDEX ix_members_user_id_name ON members (user_id, lower(name))"); + migrationBuilder.Sql( + "CREATE UNIQUE INDEX ix_members_user_id_name ON members (user_id, lower(name))" + ); migrationBuilder.CreateIndex( name: "ix_tokens_user_id", table: "tokens", - column: "user_id"); + column: "user_id" + ); migrationBuilder.CreateIndex( name: "ix_users_username", table: "users", column: "username", - unique: true); + unique: true + ); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable( - name: "auth_methods"); + migrationBuilder.DropTable(name: "auth_methods"); - migrationBuilder.DropTable( - name: "members"); + migrationBuilder.DropTable(name: "members"); - migrationBuilder.DropTable( - name: "tokens"); + migrationBuilder.DropTable(name: "tokens"); - migrationBuilder.DropTable( - name: "fediverse_applications"); + migrationBuilder.DropTable(name: "fediverse_applications"); - migrationBuilder.DropTable( - name: "users"); + migrationBuilder.DropTable(name: "users"); } } } diff --git a/Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.cs b/Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.cs index d8486f4..366fba3 100644 --- a/Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.cs +++ b/Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -18,14 +18,16 @@ namespace Foxnouns.Backend.Database.Migrations table: "tokens", type: "bigint", nullable: false, - defaultValue: 0L); + defaultValue: 0L + ); migrationBuilder.AddColumn( name: "hash", table: "tokens", type: "bytea", nullable: false, - defaultValue: new byte[0]); + defaultValue: new byte[0] + ); migrationBuilder.CreateTable( name: "applications", @@ -36,17 +38,19 @@ namespace Foxnouns.Backend.Database.Migrations client_secret = table.Column(type: "text", nullable: false), name = table.Column(type: "text", nullable: false), scopes = table.Column(type: "text[]", nullable: false), - redirect_uris = table.Column(type: "text[]", nullable: false) + redirect_uris = table.Column(type: "text[]", nullable: false), }, constraints: table => { table.PrimaryKey("pk_applications", x => x.id); - }); + } + ); migrationBuilder.CreateIndex( name: "ix_tokens_application_id", table: "tokens", - column: "application_id"); + column: "application_id" + ); migrationBuilder.AddForeignKey( name: "fk_tokens_applications_application_id", @@ -54,7 +58,8 @@ namespace Foxnouns.Backend.Database.Migrations column: "application_id", principalTable: "applications", principalColumn: "id", - onDelete: ReferentialAction.Cascade); + onDelete: ReferentialAction.Cascade + ); } /// @@ -62,22 +67,16 @@ namespace Foxnouns.Backend.Database.Migrations { migrationBuilder.DropForeignKey( name: "fk_tokens_applications_application_id", - table: "tokens"); + table: "tokens" + ); - migrationBuilder.DropTable( - name: "applications"); + migrationBuilder.DropTable(name: "applications"); - migrationBuilder.DropIndex( - name: "ix_tokens_application_id", - table: "tokens"); + migrationBuilder.DropIndex(name: "ix_tokens_application_id", table: "tokens"); - migrationBuilder.DropColumn( - name: "application_id", - table: "tokens"); + migrationBuilder.DropColumn(name: "application_id", table: "tokens"); - migrationBuilder.DropColumn( - name: "hash", - table: "tokens"); + migrationBuilder.DropColumn(name: "hash", table: "tokens"); } } } diff --git a/Foxnouns.Backend/Database/Migrations/20240528145744_AddListHidden.cs b/Foxnouns.Backend/Database/Migrations/20240528145744_AddListHidden.cs index aa9483c..e440cc1 100644 --- a/Foxnouns.Backend/Database/Migrations/20240528145744_AddListHidden.cs +++ b/Foxnouns.Backend/Database/Migrations/20240528145744_AddListHidden.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -18,15 +18,14 @@ namespace Foxnouns.Backend.Database.Migrations table: "users", type: "boolean", nullable: false, - defaultValue: false); + defaultValue: false + ); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropColumn( - name: "list_hidden", - table: "users"); + migrationBuilder.DropColumn(name: "list_hidden", table: "users"); } } } diff --git a/Foxnouns.Backend/Database/Migrations/20240604142522_AddPassword.cs b/Foxnouns.Backend/Database/Migrations/20240604142522_AddPassword.cs index 02add03..6d73c86 100644 --- a/Foxnouns.Backend/Database/Migrations/20240604142522_AddPassword.cs +++ b/Foxnouns.Backend/Database/Migrations/20240604142522_AddPassword.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -17,15 +17,14 @@ namespace Foxnouns.Backend.Database.Migrations name: "password", table: "users", type: "text", - nullable: true); + nullable: true + ); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropColumn( - name: "password", - table: "users"); + migrationBuilder.DropColumn(name: "password", table: "users"); } } } diff --git a/Foxnouns.Backend/Database/Migrations/20240611225328_AddTemporaryKeyCache.cs b/Foxnouns.Backend/Database/Migrations/20240611225328_AddTemporaryKeyCache.cs index 5976175..7d131da 100644 --- a/Foxnouns.Backend/Database/Migrations/20240611225328_AddTemporaryKeyCache.cs +++ b/Foxnouns.Backend/Database/Migrations/20240611225328_AddTemporaryKeyCache.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; using NodaTime; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; @@ -19,29 +19,37 @@ namespace Foxnouns.Backend.Database.Migrations name: "temporary_keys", columns: table => new { - id = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + id = table + .Column(type: "bigint", nullable: false) + .Annotation( + "Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn + ), key = table.Column(type: "text", nullable: false), value = table.Column(type: "text", nullable: false), - expires = table.Column(type: "timestamp with time zone", nullable: false) + expires = table.Column( + type: "timestamp with time zone", + nullable: false + ), }, constraints: table => { table.PrimaryKey("pk_temporary_keys", x => x.id); - }); + } + ); migrationBuilder.CreateIndex( name: "ix_temporary_keys_key", table: "temporary_keys", column: "key", - unique: true); + unique: true + ); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable( - name: "temporary_keys"); + migrationBuilder.DropTable(name: "temporary_keys"); } } } diff --git a/Foxnouns.Backend/Database/Migrations/20240712233806_AddUserLastActive.cs b/Foxnouns.Backend/Database/Migrations/20240712233806_AddUserLastActive.cs index ab928be..732724e 100644 --- a/Foxnouns.Backend/Database/Migrations/20240712233806_AddUserLastActive.cs +++ b/Foxnouns.Backend/Database/Migrations/20240712233806_AddUserLastActive.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; using NodaTime; #nullable disable @@ -19,15 +19,14 @@ namespace Foxnouns.Backend.Database.Migrations table: "users", type: "timestamp with time zone", nullable: false, - defaultValueSql: "now()"); + defaultValueSql: "now()" + ); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropColumn( - name: "last_active", - table: "users"); + migrationBuilder.DropColumn(name: "last_active", table: "users"); } } } diff --git a/Foxnouns.Backend/Database/Migrations/20240713000719_AddDeleted.cs b/Foxnouns.Backend/Database/Migrations/20240713000719_AddDeleted.cs index 08336cc..79675a3 100644 --- a/Foxnouns.Backend/Database/Migrations/20240713000719_AddDeleted.cs +++ b/Foxnouns.Backend/Database/Migrations/20240713000719_AddDeleted.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; using NodaTime; #nullable disable @@ -19,35 +19,32 @@ namespace Foxnouns.Backend.Database.Migrations table: "users", type: "boolean", nullable: false, - defaultValue: false); + defaultValue: false + ); migrationBuilder.AddColumn( name: "deleted_at", table: "users", type: "timestamp with time zone", - nullable: true); + nullable: true + ); migrationBuilder.AddColumn( name: "deleted_by", table: "users", type: "bigint", - nullable: true); + nullable: true + ); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropColumn( - name: "deleted", - table: "users"); + migrationBuilder.DropColumn(name: "deleted", table: "users"); - migrationBuilder.DropColumn( - name: "deleted_at", - table: "users"); + migrationBuilder.DropColumn(name: "deleted_at", table: "users"); - migrationBuilder.DropColumn( - name: "deleted_by", - table: "users"); + migrationBuilder.DropColumn(name: "deleted_by", table: "users"); } } } diff --git a/Foxnouns.Backend/Database/Migrations/20240821210355_AddCustomPreferences.cs b/Foxnouns.Backend/Database/Migrations/20240821210355_AddCustomPreferences.cs index 96bf5c7..0ec835d 100644 --- a/Foxnouns.Backend/Database/Migrations/20240821210355_AddCustomPreferences.cs +++ b/Foxnouns.Backend/Database/Migrations/20240821210355_AddCustomPreferences.cs @@ -1,7 +1,7 @@ using System; -using Microsoft.EntityFrameworkCore.Infrastructure; using System.Collections.Generic; using Foxnouns.Backend.Database.Models; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -21,15 +21,14 @@ namespace Foxnouns.Backend.Database.Migrations table: "users", type: "jsonb", nullable: false, - defaultValueSql: "'{}'"); + defaultValueSql: "'{}'" + ); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropColumn( - name: "custom_preferences", - table: "users"); + migrationBuilder.DropColumn(name: "custom_preferences", table: "users"); } } } diff --git a/Foxnouns.Backend/Database/Migrations/20240905191709_AddUserSettings.cs b/Foxnouns.Backend/Database/Migrations/20240905191709_AddUserSettings.cs index ffd8fae..a44b29c 100644 --- a/Foxnouns.Backend/Database/Migrations/20240905191709_AddUserSettings.cs +++ b/Foxnouns.Backend/Database/Migrations/20240905191709_AddUserSettings.cs @@ -19,15 +19,14 @@ namespace Foxnouns.Backend.Database.Migrations table: "users", type: "jsonb", nullable: false, - defaultValueSql: "'{}'"); + defaultValueSql: "'{}'" + ); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropColumn( - name: "settings", - table: "users"); + migrationBuilder.DropColumn(name: "settings", table: "users"); } } } diff --git a/Foxnouns.Backend/Database/Migrations/20240926124950_AddSids.cs b/Foxnouns.Backend/Database/Migrations/20240926124950_AddSids.cs index 6d8c22e..6cdc1b4 100644 --- a/Foxnouns.Backend/Database/Migrations/20240926124950_AddSids.cs +++ b/Foxnouns.Backend/Database/Migrations/20240926124950_AddSids.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; using NodaTime; #nullable disable @@ -18,38 +18,46 @@ namespace Foxnouns.Backend.Database.Migrations name: "sid", table: "users", type: "text", - nullable: true); + nullable: true + ); migrationBuilder.AddColumn( name: "last_sid_reroll", table: "users", type: "timestamp with time zone", nullable: false, - defaultValueSql: "now() - '1 hour'::interval"); + defaultValueSql: "now() - '1 hour'::interval" + ); migrationBuilder.AddColumn( name: "sid", table: "members", type: "text", - nullable: true); + nullable: true + ); migrationBuilder.CreateIndex( name: "ix_users_sid", table: "users", column: "sid", - unique: true); + unique: true + ); migrationBuilder.CreateIndex( name: "ix_members_sid", table: "members", column: "sid", - unique: true); + unique: true + ); - migrationBuilder.Sql(@"create function generate_sid(len int) returns text as $$ + migrationBuilder.Sql( + @"create function generate_sid(len int) returns text as $$ select string_agg(substr('abcdefghijklmnopqrstuvwxyz', ceil(random() * 26)::integer, 1), '') from generate_series(1, len) $$ language sql volatile; -"); - migrationBuilder.Sql(@"create function find_free_user_sid() returns text as $$ +" + ); + migrationBuilder.Sql( + @"create function find_free_user_sid() returns text as $$ declare new_sid text; begin loop @@ -58,8 +66,10 @@ begin end loop; end $$ language plpgsql volatile; -"); - migrationBuilder.Sql(@"create function find_free_member_sid() returns text as $$ +" + ); + migrationBuilder.Sql( + @"create function find_free_member_sid() returns text as $$ declare new_sid text; begin loop @@ -67,7 +77,8 @@ begin if not exists (select 1 from members where sid = new_sid) then return new_sid; end if; end loop; end -$$ language plpgsql volatile;"); +$$ language plpgsql volatile;" + ); } /// @@ -77,25 +88,15 @@ $$ language plpgsql volatile;"); migrationBuilder.Sql("drop function find_free_user_sid;"); migrationBuilder.Sql("drop function generate_sid;"); - migrationBuilder.DropIndex( - name: "ix_users_sid", - table: "users"); + migrationBuilder.DropIndex(name: "ix_users_sid", table: "users"); - migrationBuilder.DropIndex( - name: "ix_members_sid", - table: "members"); + migrationBuilder.DropIndex(name: "ix_members_sid", table: "members"); - migrationBuilder.DropColumn( - name: "sid", - table: "users"); + migrationBuilder.DropColumn(name: "sid", table: "users"); - migrationBuilder.DropColumn( - name: "last_sid_reroll", - table: "users"); + migrationBuilder.DropColumn(name: "last_sid_reroll", table: "users"); - migrationBuilder.DropColumn( - name: "sid", - table: "members"); + migrationBuilder.DropColumn(name: "sid", table: "members"); } } } diff --git a/Foxnouns.Backend/Database/Migrations/20240926130208_NonNullableSids.cs b/Foxnouns.Backend/Database/Migrations/20240926130208_NonNullableSids.cs index ed06ec5..8e7821a 100644 --- a/Foxnouns.Backend/Database/Migrations/20240926130208_NonNullableSids.cs +++ b/Foxnouns.Backend/Database/Migrations/20240926130208_NonNullableSids.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; using NodaTime; #nullable disable @@ -22,7 +22,8 @@ namespace Foxnouns.Backend.Database.Migrations defaultValueSql: "find_free_user_sid()", oldClrType: typeof(string), oldType: "text", - oldNullable: true); + oldNullable: true + ); migrationBuilder.AlterColumn( name: "sid", @@ -32,7 +33,8 @@ namespace Foxnouns.Backend.Database.Migrations defaultValueSql: "find_free_member_sid()", oldClrType: typeof(string), oldType: "text", - oldNullable: true); + oldNullable: true + ); } /// @@ -45,7 +47,8 @@ namespace Foxnouns.Backend.Database.Migrations nullable: true, oldClrType: typeof(string), oldType: "text", - oldDefaultValueSql: "find_free_user_sid()"); + oldDefaultValueSql: "find_free_user_sid()" + ); migrationBuilder.AlterColumn( name: "sid", @@ -54,7 +57,8 @@ namespace Foxnouns.Backend.Database.Migrations nullable: true, oldClrType: typeof(string), oldType: "text", - oldDefaultValueSql: "find_free_member_sid()"); + oldDefaultValueSql: "find_free_member_sid()" + ); } } } diff --git a/Foxnouns.Backend/Database/Migrations/20240926180037_AddFlags.cs b/Foxnouns.Backend/Database/Migrations/20240926180037_AddFlags.cs index ae59b0b..08097d6 100644 --- a/Foxnouns.Backend/Database/Migrations/20240926180037_AddFlags.cs +++ b/Foxnouns.Backend/Database/Migrations/20240926180037_AddFlags.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -22,7 +22,7 @@ namespace Foxnouns.Backend.Database.Migrations user_id = table.Column(type: "bigint", nullable: false), hash = table.Column(type: "text", nullable: false), name = table.Column(type: "text", nullable: false), - description = table.Column(type: "text", nullable: true) + description = table.Column(type: "text", nullable: true), }, constraints: table => { @@ -32,17 +32,23 @@ namespace Foxnouns.Backend.Database.Migrations column: x => x.user_id, principalTable: "users", principalColumn: "id", - onDelete: ReferentialAction.Cascade); - }); + onDelete: ReferentialAction.Cascade + ); + } + ); migrationBuilder.CreateTable( name: "member_flags", columns: table => new { - id = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + id = table + .Column(type: "bigint", nullable: false) + .Annotation( + "Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn + ), member_id = table.Column(type: "bigint", nullable: false), - pride_flag_id = table.Column(type: "bigint", nullable: false) + pride_flag_id = table.Column(type: "bigint", nullable: false), }, constraints: table => { @@ -52,23 +58,30 @@ namespace Foxnouns.Backend.Database.Migrations column: x => x.member_id, principalTable: "members", principalColumn: "id", - onDelete: ReferentialAction.Cascade); + onDelete: ReferentialAction.Cascade + ); table.ForeignKey( name: "fk_member_flags_pride_flags_pride_flag_id", column: x => x.pride_flag_id, principalTable: "pride_flags", principalColumn: "id", - onDelete: ReferentialAction.Cascade); - }); + onDelete: ReferentialAction.Cascade + ); + } + ); migrationBuilder.CreateTable( name: "user_flags", columns: table => new { - id = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + id = table + .Column(type: "bigint", nullable: false) + .Annotation( + "Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn + ), user_id = table.Column(type: "bigint", nullable: false), - pride_flag_id = table.Column(type: "bigint", nullable: false) + pride_flag_id = table.Column(type: "bigint", nullable: false), }, constraints: table => { @@ -78,52 +91,57 @@ namespace Foxnouns.Backend.Database.Migrations column: x => x.pride_flag_id, principalTable: "pride_flags", principalColumn: "id", - onDelete: ReferentialAction.Cascade); + onDelete: ReferentialAction.Cascade + ); table.ForeignKey( name: "fk_user_flags_users_user_id", column: x => x.user_id, principalTable: "users", principalColumn: "id", - onDelete: ReferentialAction.Cascade); - }); + onDelete: ReferentialAction.Cascade + ); + } + ); migrationBuilder.CreateIndex( name: "ix_member_flags_member_id", table: "member_flags", - column: "member_id"); + column: "member_id" + ); migrationBuilder.CreateIndex( name: "ix_member_flags_pride_flag_id", table: "member_flags", - column: "pride_flag_id"); + column: "pride_flag_id" + ); migrationBuilder.CreateIndex( name: "ix_pride_flags_user_id", table: "pride_flags", - column: "user_id"); + column: "user_id" + ); migrationBuilder.CreateIndex( name: "ix_user_flags_pride_flag_id", table: "user_flags", - column: "pride_flag_id"); + column: "pride_flag_id" + ); migrationBuilder.CreateIndex( name: "ix_user_flags_user_id", table: "user_flags", - column: "user_id"); + column: "user_id" + ); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable( - name: "member_flags"); + migrationBuilder.DropTable(name: "member_flags"); - migrationBuilder.DropTable( - name: "user_flags"); + migrationBuilder.DropTable(name: "user_flags"); - migrationBuilder.DropTable( - name: "pride_flags"); + migrationBuilder.DropTable(name: "pride_flags"); } } } diff --git a/Foxnouns.Backend/Database/Models/Application.cs b/Foxnouns.Backend/Database/Models/Application.cs index 49b711e..90787de 100644 --- a/Foxnouns.Backend/Database/Models/Application.cs +++ b/Foxnouns.Backend/Database/Models/Application.cs @@ -11,20 +11,30 @@ public class Application : BaseModel public required string[] Scopes { get; init; } public required string[] RedirectUris { get; init; } - public static Application Create(ISnowflakeGenerator snowflakeGenerator, string name, string[] scopes, - string[] redirectUrls) + public static Application Create( + ISnowflakeGenerator snowflakeGenerator, + string name, + string[] scopes, + string[] redirectUrls + ) { var clientId = RandomNumberGenerator.GetHexString(32, true); var clientSecret = AuthUtils.RandomToken(); if (scopes.Except(AuthUtils.ApplicationScopes).Any()) { - throw new ArgumentException("Invalid scopes passed to Application.Create", nameof(scopes)); + throw new ArgumentException( + "Invalid scopes passed to Application.Create", + nameof(scopes) + ); } if (redirectUrls.Any(s => !AuthUtils.ValidateRedirectUri(s))) { - throw new ArgumentException("Invalid redirect URLs passed to Application.Create", nameof(redirectUrls)); + throw new ArgumentException( + "Invalid redirect URLs passed to Application.Create", + nameof(redirectUrls) + ); } return new Application @@ -34,7 +44,7 @@ public class Application : BaseModel ClientSecret = clientSecret, Name = name, Scopes = scopes, - RedirectUris = redirectUrls + RedirectUris = redirectUrls, }; } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/Models/AuthMethod.cs b/Foxnouns.Backend/Database/Models/AuthMethod.cs index 3b6de20..b5602ec 100644 --- a/Foxnouns.Backend/Database/Models/AuthMethod.cs +++ b/Foxnouns.Backend/Database/Models/AuthMethod.cs @@ -20,4 +20,4 @@ public enum AuthType Tumblr, Fediverse, Email, -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/Models/FediverseApplication.cs b/Foxnouns.Backend/Database/Models/FediverseApplication.cs index 8da8b29..6dc813d 100644 --- a/Foxnouns.Backend/Database/Models/FediverseApplication.cs +++ b/Foxnouns.Backend/Database/Models/FediverseApplication.cs @@ -11,5 +11,5 @@ public class FediverseApplication : BaseModel public enum FediverseInstanceType { MastodonApi, - MisskeyApi -} \ No newline at end of file + MisskeyApi, +} diff --git a/Foxnouns.Backend/Database/Models/Field.cs b/Foxnouns.Backend/Database/Models/Field.cs index c123e5b..9b5dd31 100644 --- a/Foxnouns.Backend/Database/Models/Field.cs +++ b/Foxnouns.Backend/Database/Models/Field.cs @@ -15,4 +15,4 @@ public class FieldEntry public class Pronoun : FieldEntry { public string? DisplayText { get; set; } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/Models/Member.cs b/Foxnouns.Backend/Database/Models/Member.cs index ceaf84a..c1e5bde 100644 --- a/Foxnouns.Backend/Database/Models/Member.cs +++ b/Foxnouns.Backend/Database/Models/Member.cs @@ -18,4 +18,4 @@ public class Member : BaseModel public Snowflake UserId { get; init; } public User User { get; init; } = null!; -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/Models/PrideFlag.cs b/Foxnouns.Backend/Database/Models/PrideFlag.cs index b7f91cf..45a3a7c 100644 --- a/Foxnouns.Backend/Database/Models/PrideFlag.cs +++ b/Foxnouns.Backend/Database/Models/PrideFlag.cs @@ -22,4 +22,4 @@ public class MemberFlag public required Snowflake MemberId { get; init; } public required Snowflake PrideFlagId { get; init; } public PrideFlag PrideFlag { get; init; } = null!; -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/Models/TemporaryKey.cs b/Foxnouns.Backend/Database/Models/TemporaryKey.cs index d3dbfc8..607eb4a 100644 --- a/Foxnouns.Backend/Database/Models/TemporaryKey.cs +++ b/Foxnouns.Backend/Database/Models/TemporaryKey.cs @@ -8,4 +8,4 @@ public class TemporaryKey public required string Key { get; init; } public required string Value { get; set; } public Instant Expires { get; init; } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/Models/Token.cs b/Foxnouns.Backend/Database/Models/Token.cs index 1078cf1..3ecd39d 100644 --- a/Foxnouns.Backend/Database/Models/Token.cs +++ b/Foxnouns.Backend/Database/Models/Token.cs @@ -14,4 +14,4 @@ public class Token : BaseModel public Snowflake ApplicationId { get; set; } public Application Application { get; set; } = null!; -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/Models/User.cs b/Foxnouns.Backend/Database/Models/User.cs index 2328fbe..c8c12c5 100644 --- a/Foxnouns.Backend/Database/Models/User.cs +++ b/Foxnouns.Backend/Database/Models/User.cs @@ -37,7 +37,9 @@ public class User : BaseModel public bool Deleted { get; set; } public Instant? DeletedAt { get; set; } public Snowflake? DeletedBy { get; set; } - [NotMapped] public bool? SelfDelete => Deleted ? DeletedBy != null : null; + + [NotMapped] + public bool? SelfDelete => Deleted ? DeletedBy != null : null; public class CustomPreference { @@ -69,4 +71,4 @@ public enum PreferenceSize public class UserSettings { public bool? DarkMode { get; set; } = null; -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/Snowflake.cs b/Foxnouns.Backend/Database/Snowflake.cs index 5c1c4bb..37b3c31 100644 --- a/Foxnouns.Backend/Database/Snowflake.cs +++ b/Foxnouns.Backend/Database/Snowflake.cs @@ -41,19 +41,26 @@ public readonly struct Snowflake(ulong value) : IEquatable public short Increment => (short)(Value & 0xFFF); public static bool operator <(Snowflake arg1, Snowflake arg2) => arg1.Value < arg2.Value; + public static bool operator >(Snowflake arg1, Snowflake arg2) => arg1.Value > arg2.Value; + public static bool operator ==(Snowflake arg1, Snowflake arg2) => arg1.Value == arg2.Value; + public static bool operator !=(Snowflake arg1, Snowflake arg2) => arg1.Value != arg2.Value; public static implicit operator ulong(Snowflake s) => s.Value; + public static implicit operator long(Snowflake s) => (long)s.Value; + public static implicit operator Snowflake(ulong n) => new(n); + public static implicit operator Snowflake(long n) => new((ulong)n); public static bool TryParse(string input, [NotNullWhen(true)] out Snowflake? snowflake) { snowflake = null; - if (!ulong.TryParse(input, out var res)) return false; + if (!ulong.TryParse(input, out var res)) + return false; snowflake = new Snowflake(res); return true; } @@ -66,27 +73,37 @@ public readonly struct Snowflake(ulong value) : IEquatable } public override int GetHashCode() => Value.GetHashCode(); + public override string ToString() => Value.ToString(); /// /// An Entity Framework ValueConverter for Snowflakes to longs. /// // ReSharper disable once ClassNeverInstantiated.Global - public class ValueConverter() : ValueConverter( - convertToProviderExpression: x => x, - convertFromProviderExpression: x => x - ); + public class ValueConverter() + : ValueConverter( + convertToProviderExpression: x => x, + convertFromProviderExpression: x => x + ); private class JsonConverter : JsonConverter { - public override void WriteJson(JsonWriter writer, Snowflake value, JsonSerializer serializer) + public override void WriteJson( + JsonWriter writer, + Snowflake value, + JsonSerializer serializer + ) { writer.WriteValue(value.Value.ToString()); } - public override Snowflake ReadJson(JsonReader reader, Type objectType, Snowflake existingValue, + public override Snowflake ReadJson( + JsonReader reader, + Type objectType, + Snowflake existingValue, bool hasExistingValue, - JsonSerializer serializer) + JsonSerializer serializer + ) { return ulong.Parse((string)reader.Value!); } @@ -97,12 +114,18 @@ public readonly struct Snowflake(ulong value) : IEquatable public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => sourceType == typeof(string); - public override bool CanConvertTo(ITypeDescriptorContext? context, [NotNullWhen(true)] Type? destinationType) => - destinationType == typeof(Snowflake); + public override bool CanConvertTo( + ITypeDescriptorContext? context, + [NotNullWhen(true)] Type? destinationType + ) => destinationType == typeof(Snowflake); - public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + public override object? ConvertFrom( + ITypeDescriptorContext? context, + CultureInfo? culture, + object value + ) { return TryParse((string)value, out var snowflake) ? snowflake : null; } } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Database/SnowflakeGenerator.cs b/Foxnouns.Backend/Database/SnowflakeGenerator.cs index e070e20..446fe7b 100644 --- a/Foxnouns.Backend/Database/SnowflakeGenerator.cs +++ b/Foxnouns.Backend/Database/SnowflakeGenerator.cs @@ -32,14 +32,20 @@ public class SnowflakeGenerator : ISnowflakeGenerator var threadId = Environment.CurrentManagedThreadId % 32; var timestamp = time.Value.ToUnixTimeMilliseconds() - Snowflake.Epoch; - return (timestamp << 22) | (uint)(_processId << 17) | (uint)(threadId << 12) | (increment % 4096); + return (timestamp << 22) + | (uint)(_processId << 17) + | (uint)(threadId << 12) + | (increment % 4096); } } public static class SnowflakeGeneratorServiceExtensions { - public static IServiceCollection AddSnowflakeGenerator(this IServiceCollection services, int? processId = null) + public static IServiceCollection AddSnowflakeGenerator( + this IServiceCollection services, + int? processId = null + ) { return services.AddSingleton(new SnowflakeGenerator(processId)); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/ExpectedError.cs b/Foxnouns.Backend/ExpectedError.cs index 3c1c355..0630892 100644 --- a/Foxnouns.Backend/ExpectedError.cs +++ b/Foxnouns.Backend/ExpectedError.cs @@ -9,39 +9,47 @@ public class FoxnounsError(string message, Exception? inner = null) : Exception( { public Exception? Inner => inner; - public class DatabaseError(string message, Exception? inner = null) : FoxnounsError(message, inner); + public class DatabaseError(string message, Exception? inner = null) + : FoxnounsError(message, inner); public class UnknownEntityError(Type entityType, Exception? inner = null) : DatabaseError($"Entity of type {entityType.Name} not found", inner); } -public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCode? errorCode = null) - : FoxnounsError(message) +public class ApiError( + string message, + HttpStatusCode? statusCode = null, + ErrorCode? errorCode = null +) : FoxnounsError(message) { public readonly HttpStatusCode StatusCode = statusCode ?? HttpStatusCode.InternalServerError; public readonly ErrorCode ErrorCode = errorCode ?? ErrorCode.InternalServerError; - public class Unauthorized(string message, ErrorCode errorCode = ErrorCode.AuthenticationError) : ApiError(message, - statusCode: HttpStatusCode.Unauthorized, - errorCode: errorCode); + public class Unauthorized(string message, ErrorCode errorCode = ErrorCode.AuthenticationError) + : ApiError(message, statusCode: HttpStatusCode.Unauthorized, errorCode: errorCode); public class Forbidden( string message, IEnumerable? scopes = null, - ErrorCode errorCode = ErrorCode.Forbidden) - : ApiError(message, statusCode: HttpStatusCode.Forbidden, errorCode: errorCode) + ErrorCode errorCode = ErrorCode.Forbidden + ) : ApiError(message, statusCode: HttpStatusCode.Forbidden, errorCode: errorCode) { public readonly string[] Scopes = scopes?.ToArray() ?? []; } - public class BadRequest(string message, IReadOnlyDictionary>? errors = null) - : ApiError(message, statusCode: HttpStatusCode.BadRequest) + public class BadRequest( + string message, + IReadOnlyDictionary>? errors = null + ) : ApiError(message, statusCode: HttpStatusCode.BadRequest) { - public BadRequest(string message, string field, object actualValue) : this("Error validating input", - new Dictionary> - { { field, [ValidationError.GenericValidationError(message, actualValue)] } }) - { - } + public BadRequest(string message, string field, object actualValue) + : this( + "Error validating input", + new Dictionary> + { + { field, [ValidationError.GenericValidationError(message, actualValue)] }, + } + ) { } public JObject ToJson() { @@ -49,9 +57,10 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo { { "status", (int)HttpStatusCode.BadRequest }, { "message", Message }, - { "code", "BAD_REQUEST" } + { "code", "BAD_REQUEST" }, }; - if (errors == null) return o; + if (errors == null) + return o; var a = new JArray(); foreach (var error in errors) @@ -59,7 +68,7 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo var errorObj = new JObject { { "key", error.Key }, - { "errors", JArray.FromObject(error.Value) } + { "errors", JArray.FromObject(error.Value) }, }; a.Add(errorObj); } @@ -82,9 +91,10 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo { { "status", (int)HttpStatusCode.BadRequest }, { "message", Message }, - { "code", "BAD_REQUEST" } + { "code", "BAD_REQUEST" }, }; - if (modelState == null) return o; + if (modelState == null) + return o; var a = new JArray(); foreach (var error in modelState.Where(e => e.Value is { Errors.Count: > 0 })) @@ -94,8 +104,13 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo { "key", error.Key }, { "errors", - new JArray(error.Value!.Errors.Select(e => new JObject { { "message", e.ErrorMessage } })) - } + new JArray( + error.Value!.Errors.Select(e => new JObject + { + { "message", e.ErrorMessage }, + }) + ) + }, }; a.Add(errorObj); } @@ -108,7 +123,8 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo public class NotFound(string message, ErrorCode? code = null) : ApiError(message, statusCode: HttpStatusCode.NotFound, errorCode: code); - public class AuthenticationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest); + public class AuthenticationError(string message) + : ApiError(message, statusCode: HttpStatusCode.BadRequest); } public enum ErrorCode @@ -143,34 +159,38 @@ public class ValidationError [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public object? ActualValue { get; init; } - public static ValidationError LengthError(string message, int minLength, int maxLength, int actualLength) + public static ValidationError LengthError( + string message, + int minLength, + int maxLength, + int actualLength + ) { return new ValidationError { Message = message, MinLength = minLength, MaxLength = maxLength, - ActualLength = actualLength + ActualLength = actualLength, }; } - public static ValidationError DisallowedValueError(string message, IEnumerable allowedValues, - object actualValue) + public static ValidationError DisallowedValueError( + string message, + IEnumerable allowedValues, + object actualValue + ) { return new ValidationError { Message = message, AllowedValues = allowedValues, - ActualValue = actualValue + ActualValue = actualValue, }; } public static ValidationError GenericValidationError(string message, object? actualValue) { - return new ValidationError - { - Message = message, - ActualValue = actualValue - }; + return new ValidationError { Message = message, ActualValue = actualValue }; } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs b/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs index 6aa1626..efa2d60 100644 --- a/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs +++ b/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs @@ -14,21 +14,35 @@ public static class AvatarObjectExtensions { private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"]; - public static async Task - DeleteMemberAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash, - CancellationToken ct = default) => - await objectStorageService.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash), ct); + public static async Task DeleteMemberAvatarAsync( + this ObjectStorageService objectStorageService, + Snowflake id, + string hash, + CancellationToken ct = default + ) => + await objectStorageService.RemoveObjectAsync( + MemberAvatarUpdateInvocable.Path(id, hash), + ct + ); - public static async Task - DeleteUserAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash, - CancellationToken ct = default) => - await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); + public static async Task DeleteUserAvatarAsync( + this ObjectStorageService objectStorageService, + Snowflake id, + string hash, + CancellationToken ct = default + ) => await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); - public static async Task DeleteFlagAsync(this ObjectStorageService objectStorageService, string hash, - CancellationToken ct = default) => - await objectStorageService.RemoveObjectAsync(CreateFlagInvocable.Path(hash), ct); + public static async Task DeleteFlagAsync( + this ObjectStorageService objectStorageService, + string hash, + CancellationToken ct = default + ) => await objectStorageService.RemoveObjectAsync(CreateFlagInvocable.Path(hash), ct); - public static async Task<(string Hash, Stream Image)> ConvertBase64UriToImage(this string uri, int size, bool crop) + public static async Task<(string Hash, Stream Image)> ConvertBase64UriToImage( + this string uri, + int size, + bool crop + ) { if (!uri.StartsWith("data:image/")) throw new ArgumentException("Not a data URI", nameof(uri)); @@ -49,7 +63,7 @@ public static class AvatarObjectExtensions { Size = new Size(size), Mode = crop ? ResizeMode.Crop : ResizeMode.Max, - Position = AnchorPositionMode.Center + Position = AnchorPositionMode.Center, }, image.Size ); @@ -65,4 +79,4 @@ public static class AvatarObjectExtensions return (hash, stream); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index 4f70bee..8178dd6 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -8,37 +8,58 @@ namespace Foxnouns.Backend.Extensions; public static class KeyCacheExtensions { - public static async Task GenerateAuthStateAsync(this KeyCacheService keyCacheService, - CancellationToken ct = default) + public static async Task GenerateAuthStateAsync( + this KeyCacheService keyCacheService, + CancellationToken ct = default + ) { var state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); return state; } - public static async Task ValidateAuthStateAsync(this KeyCacheService keyCacheService, string state, - CancellationToken ct = default) + public static async Task ValidateAuthStateAsync( + this KeyCacheService keyCacheService, + string state, + CancellationToken ct = default + ) { var val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", delete: true, ct); - if (val == null) throw new ApiError.BadRequest("Invalid OAuth state"); + if (val == null) + throw new ApiError.BadRequest("Invalid OAuth state"); } - public static async Task GenerateRegisterEmailStateAsync(this KeyCacheService keyCacheService, string email, - Snowflake? userId = null, CancellationToken ct = default) + public static async Task GenerateRegisterEmailStateAsync( + this KeyCacheService keyCacheService, + string email, + Snowflake? userId = null, + CancellationToken ct = default + ) { // This state is used in links, not just as JSON values, so make it URL-safe var state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); - await keyCacheService.SetKeyAsync($"email_state:{state}", new RegisterEmailState(email, userId), - Duration.FromDays(1), ct); + await keyCacheService.SetKeyAsync( + $"email_state:{state}", + new RegisterEmailState(email, userId), + Duration.FromDays(1), + ct + ); return state; } - public static async Task GetRegisterEmailStateAsync(this KeyCacheService keyCacheService, - string state, CancellationToken ct = default) => - await keyCacheService.GetKeyAsync($"email_state:{state}", delete: true, ct); + public static async Task GetRegisterEmailStateAsync( + this KeyCacheService keyCacheService, + string state, + CancellationToken ct = default + ) => + await keyCacheService.GetKeyAsync( + $"email_state:{state}", + delete: true, + ct + ); } public record RegisterEmailState( string Email, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - Snowflake? ExistingUserId); \ No newline at end of file + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId +); diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index e249fd7..3e6926c 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -29,8 +29,10 @@ public static class WebApplicationExtensions // ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead. // Serilog doesn't disable the built-in logs, so we do it here. .MinimumLevel.Override("Microsoft", LogEventLevel.Information) - .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", - config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal) + .MinimumLevel.Override( + "Microsoft.EntityFrameworkCore.Database.Command", + config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal + ) .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) @@ -38,7 +40,10 @@ public static class WebApplicationExtensions if (config.Logging.SeqLogUrl != null) { - logCfg.WriteTo.Seq(config.Logging.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose); + logCfg.WriteTo.Seq( + config.Logging.SeqLogUrl, + restrictedToMinimumLevel: LogEventLevel.Verbose + ); } // AddSerilog doesn't seem to add an ILogger to the service collection, so add that manually. @@ -74,63 +79,74 @@ public static class WebApplicationExtensions /// public static IServiceCollection AddServices(this WebApplicationBuilder builder, Config config) { - builder.Host.ConfigureServices((ctx, services) => - { - services - .AddQueue() - .AddSmtpMailer(ctx.Configuration) - .AddDbContext() - .AddMetricServer(o => o.Port = config.Logging.MetricsPort) - .AddMinio(c => - c.WithEndpoint(config.Storage.Endpoint) - .WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey) - .Build()) - .AddSingleton() - .AddSingleton(SystemClock.Instance) - .AddSnowflakeGenerator() - .AddSingleton() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - // Background services - .AddHostedService() - // Transient jobs - .AddTransient() - .AddTransient() - .AddTransient(); + builder.Host.ConfigureServices( + (ctx, services) => + { + services + .AddQueue() + .AddSmtpMailer(ctx.Configuration) + .AddDbContext() + .AddMetricServer(o => o.Port = config.Logging.MetricsPort) + .AddMinio(c => + c.WithEndpoint(config.Storage.Endpoint) + .WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey) + .Build() + ) + .AddSingleton() + .AddSingleton(SystemClock.Instance) + .AddSnowflakeGenerator() + .AddSingleton() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + // Background services + .AddHostedService() + // Transient jobs + .AddTransient() + .AddTransient() + .AddTransient(); - if (!config.Logging.EnableMetrics) - services.AddHostedService(); - }); + if (!config.Logging.EnableMetrics) + services.AddHostedService(); + } + ); return builder.Services; } - public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services - .AddScoped() - .AddScoped() - .AddScoped(); + public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => + services + .AddScoped() + .AddScoped() + .AddScoped(); - public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) => app - .UseMiddleware() - .UseMiddleware() - .UseMiddleware(); + public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) => + app.UseMiddleware() + .UseMiddleware() + .UseMiddleware(); 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>()); + app.Services.ConfigureQueue() + .LogQueuedTaskProgress(app.Services.GetRequiredService>()); await using var scope = app.Services.CreateAsyncScope(); - var logger = scope.ServiceProvider.GetRequiredService().ForContext(); + var logger = scope + .ServiceProvider.GetRequiredService() + .ForContext(); var db = scope.ServiceProvider.GetRequiredService(); - logger.Information("Starting Foxnouns.NET {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash); + logger.Information( + "Starting Foxnouns.NET {Version} ({Hash})", + BuildInfo.Version, + BuildInfo.Hash + ); var pendingMigrations = (await db.Database.GetPendingMigrationsAsync()).ToList(); if (args.Contains("--migrate") || args.Contains("--migrate-and-start")) @@ -146,17 +162,19 @@ public static class WebApplicationExtensions logger.Information("Successfully migrated database"); } - if (!args.Contains("--migrate-and-start")) Environment.Exit(0); + if (!args.Contains("--migrate-and-start")) + Environment.Exit(0); } else if (pendingMigrations.Count > 0) { logger.Fatal( "There are {Count} pending migrations, run server with --migrate or --migrate-and-start to run migrations.", - pendingMigrations.Count); + pendingMigrations.Count + ); Environment.Exit(1); } logger.Information("Initializing frontend OAuth application"); _ = await db.GetFrontendApplicationAsync(); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/FoxnounsMetrics.cs b/Foxnouns.Backend/FoxnounsMetrics.cs index 648b97a..71cfd39 100644 --- a/Foxnouns.Backend/FoxnounsMetrics.cs +++ b/Foxnouns.Backend/FoxnounsMetrics.cs @@ -4,23 +4,35 @@ namespace Foxnouns.Backend; public static class FoxnounsMetrics { - public static readonly Gauge UsersCount = - Metrics.CreateGauge("foxnouns_user_count", "Number of total users"); + public static readonly Gauge UsersCount = Metrics.CreateGauge( + "foxnouns_user_count", + "Number of total users" + ); - public static readonly Gauge UsersActiveMonthCount = - Metrics.CreateGauge("foxnouns_user_count_active_month", "Number of users active in the last month"); + public static readonly Gauge UsersActiveMonthCount = Metrics.CreateGauge( + "foxnouns_user_count_active_month", + "Number of users active in the last month" + ); - public static readonly Gauge UsersActiveWeekCount = - Metrics.CreateGauge("foxnouns_user_count_active_week", "Number of users active in the last week"); + public static readonly Gauge UsersActiveWeekCount = Metrics.CreateGauge( + "foxnouns_user_count_active_week", + "Number of users active in the last week" + ); - public static readonly Gauge UsersActiveDayCount = - Metrics.CreateGauge("foxnouns_user_count_active_day", "Number of users active in the last day"); + public static readonly Gauge UsersActiveDayCount = Metrics.CreateGauge( + "foxnouns_user_count_active_day", + "Number of users active in the last day" + ); - public static readonly Gauge MemberCount = - Metrics.CreateGauge("foxnouns_member_count", "Number of total members"); + public static readonly Gauge MemberCount = Metrics.CreateGauge( + "foxnouns_member_count", + "Number of total members" + ); - public static readonly Summary MetricsCollectionTime = - Metrics.CreateSummary("foxnouns_time_metrics", "Time it took to collect metrics"); + public static readonly Summary MetricsCollectionTime = Metrics.CreateSummary( + "foxnouns_time_metrics", + "Time it took to collect metrics" + ); public static Gauge ProcessPhysicalMemory => Metrics.CreateGauge("foxnouns_process_physical_memory", "Process physical memory"); @@ -31,7 +43,9 @@ public static class FoxnounsMetrics public static Gauge ProcessPrivateMemory => Metrics.CreateGauge("foxnouns_process_private_memory", "Process private memory"); - public static Gauge ProcessThreads => Metrics.CreateGauge("foxnouns_process_threads", "Process thread count"); + public static Gauge ProcessThreads => + Metrics.CreateGauge("foxnouns_process_threads", "Process thread count"); - public static Gauge ProcessHandles => Metrics.CreateGauge("foxnouns_process_handles", "Process handle count"); -} \ No newline at end of file + public static Gauge ProcessHandles => + Metrics.CreateGauge("foxnouns_process_handles", "Process handle count"); +} diff --git a/Foxnouns.Backend/GlobalUsing.cs b/Foxnouns.Backend/GlobalUsing.cs index 8c73595..5275c8d 100644 --- a/Foxnouns.Backend/GlobalUsing.cs +++ b/Foxnouns.Backend/GlobalUsing.cs @@ -1,2 +1,2 @@ global using ILogger = Serilog.ILogger; -global using Log = Serilog.Log; \ No newline at end of file +global using Log = Serilog.Log; diff --git a/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs index 0d99244..cfe1ca0 100644 --- a/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs +++ b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs @@ -6,20 +6,30 @@ using Foxnouns.Backend.Services; namespace Foxnouns.Backend.Jobs; -public class CreateFlagInvocable(DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger) - : IInvocable, IInvocableWithPayload +public class CreateFlagInvocable( + DatabaseContext db, + ObjectStorageService objectStorageService, + ILogger logger +) : IInvocable, IInvocableWithPayload { private readonly ILogger _logger = logger.ForContext(); public required CreateFlagPayload Payload { get; set; } public async Task Invoke() { - _logger.Information("Creating flag {FlagId} for user {UserId} with image data length {DataLength}", Payload.Id, - Payload.UserId, Payload.ImageData.Length); + _logger.Information( + "Creating flag {FlagId} for user {UserId} with image data length {DataLength}", + Payload.Id, + Payload.UserId, + Payload.ImageData.Length + ); try { - var (hash, image) = await Payload.ImageData.ConvertBase64UriToImage(size: 256, crop: false); + var (hash, image) = await Payload.ImageData.ConvertBase64UriToImage( + size: 256, + crop: false + ); await objectStorageService.PutObjectAsync(Path(hash), image, "image/webp"); var flag = new PrideFlag @@ -28,7 +38,7 @@ public class CreateFlagInvocable(DatabaseContext db, ObjectStorageService object UserId = Payload.UserId, Hash = hash, Name = Payload.Name, - Description = Payload.Description + Description = Payload.Description, }; db.Add(flag); @@ -45,4 +55,4 @@ public class CreateFlagInvocable(DatabaseContext db, ObjectStorageService object } public static string Path(string hash) => $"flags/{hash}.webp"; -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs index baab54a..91640cb 100644 --- a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs @@ -6,16 +6,21 @@ using Foxnouns.Backend.Services; namespace Foxnouns.Backend.Jobs; -public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger) - : IInvocable, IInvocableWithPayload +public class MemberAvatarUpdateInvocable( + DatabaseContext db, + ObjectStorageService objectStorageService, + ILogger logger +) : IInvocable, IInvocableWithPayload { private readonly ILogger _logger = logger.ForContext(); public required AvatarUpdatePayload Payload { get; set; } public async Task Invoke() { - if (Payload.NewAvatar != null) await UpdateMemberAvatarAsync(Payload.Id, Payload.NewAvatar); - else await ClearMemberAvatarAsync(Payload.Id); + if (Payload.NewAvatar != null) + await UpdateMemberAvatarAsync(Payload.Id, Payload.NewAvatar); + else + await ClearMemberAvatarAsync(Payload.Id); } private async Task UpdateMemberAvatarAsync(Snowflake id, string newAvatar) @@ -25,7 +30,10 @@ public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageServic var member = await db.Members.FindAsync(id); if (member == null) { - _logger.Warning("Update avatar job queued for {MemberId} but no member with that ID exists", id); + _logger.Warning( + "Update avatar job queued for {MemberId} but no member with that ID exists", + id + ); return; } @@ -46,7 +54,11 @@ public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageServic } catch (ArgumentException ae) { - _logger.Warning("Invalid data URI for new avatar for member {MemberId}: {Reason}", id, ae.Message); + _logger.Warning( + "Invalid data URI for new avatar for member {MemberId}: {Reason}", + id, + ae.Message + ); } } @@ -57,7 +69,10 @@ public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageServic var member = await db.Members.FindAsync(id); if (member == null) { - _logger.Warning("Clear avatar job queued for {MemberId} but no member with that ID exists", id); + _logger.Warning( + "Clear avatar job queued for {MemberId} but no member with that ID exists", + id + ); return; } @@ -74,4 +89,4 @@ public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageServic } public static string Path(Snowflake id, string hash) => $"members/{id}/avatars/{hash}.webp"; -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Jobs/Payloads.cs b/Foxnouns.Backend/Jobs/Payloads.cs index 672f6d6..759235c 100644 --- a/Foxnouns.Backend/Jobs/Payloads.cs +++ b/Foxnouns.Backend/Jobs/Payloads.cs @@ -4,4 +4,10 @@ namespace Foxnouns.Backend.Jobs; public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar); -public record CreateFlagPayload(Snowflake Id, Snowflake UserId, string Name, string ImageData, string? Description); \ No newline at end of file +public record CreateFlagPayload( + Snowflake Id, + Snowflake UserId, + string Name, + string ImageData, + string? Description +); diff --git a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs index aab0240..31433f9 100644 --- a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs @@ -6,16 +6,21 @@ using Foxnouns.Backend.Services; namespace Foxnouns.Backend.Jobs; -public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger) - : IInvocable, IInvocableWithPayload +public class UserAvatarUpdateInvocable( + DatabaseContext db, + ObjectStorageService objectStorageService, + ILogger logger +) : IInvocable, IInvocableWithPayload { private readonly ILogger _logger = logger.ForContext(); public required AvatarUpdatePayload Payload { get; set; } public async Task Invoke() { - if (Payload.NewAvatar != null) await UpdateUserAvatarAsync(Payload.Id, Payload.NewAvatar); - else await ClearUserAvatarAsync(Payload.Id); + if (Payload.NewAvatar != null) + await UpdateUserAvatarAsync(Payload.Id, Payload.NewAvatar); + else + await ClearUserAvatarAsync(Payload.Id); } private async Task UpdateUserAvatarAsync(Snowflake id, string newAvatar) @@ -25,7 +30,10 @@ public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService var user = await db.Users.FindAsync(id); if (user == null) { - _logger.Warning("Update avatar job queued for {UserId} but no user with that ID exists", id); + _logger.Warning( + "Update avatar job queued for {UserId} but no user with that ID exists", + id + ); return; } @@ -47,7 +55,11 @@ public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService } catch (ArgumentException ae) { - _logger.Warning("Invalid data URI for new avatar for user {UserId}: {Reason}", id, ae.Message); + _logger.Warning( + "Invalid data URI for new avatar for user {UserId}: {Reason}", + id, + ae.Message + ); } } @@ -58,7 +70,10 @@ public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService var user = await db.Users.FindAsync(id); if (user == null) { - _logger.Warning("Clear avatar job queued for {UserId} but no user with that ID exists", id); + _logger.Warning( + "Clear avatar job queued for {UserId} but no user with that ID exists", + id + ); return; } @@ -75,4 +90,4 @@ public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService } public static string Path(Snowflake id, string hash) => $"users/{id}/avatars/{hash}.webp"; -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Mailables/AccountCreationMailable.cs b/Foxnouns.Backend/Mailables/AccountCreationMailable.cs index cec17cf..d1f1f3c 100644 --- a/Foxnouns.Backend/Mailables/AccountCreationMailable.cs +++ b/Foxnouns.Backend/Mailables/AccountCreationMailable.cs @@ -7,13 +7,11 @@ public class AccountCreationMailable(Config config, AccountCreationMailableView { public override void Build() { - To(view.To) - .From(config.EmailAuth.From!) - .View("~/Views/Mail/AccountCreation.cshtml", view); + To(view.To).From(config.EmailAuth.From!).View("~/Views/Mail/AccountCreation.cshtml", view); } } public class AccountCreationMailableView : BaseView { public required string Code { get; init; } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Mailables/BaseView.cs b/Foxnouns.Backend/Mailables/BaseView.cs index e664f4e..c934fd3 100644 --- a/Foxnouns.Backend/Mailables/BaseView.cs +++ b/Foxnouns.Backend/Mailables/BaseView.cs @@ -4,4 +4,4 @@ public abstract class BaseView { public required string BaseUrl { get; init; } public required string To { get; init; } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs b/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs index 9190fac..e842627 100644 --- a/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs +++ b/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs @@ -17,7 +17,9 @@ public class AuthenticationMiddleware(DatabaseContext db) : IMiddleware return; } - if (!AuthUtils.TryParseToken(ctx.Request.Headers.Authorization.ToString(), out var rawToken)) + if ( + !AuthUtils.TryParseToken(ctx.Request.Headers.Authorization.ToString(), out var rawToken) + ) { await next(ctx); return; @@ -40,6 +42,7 @@ public static class HttpContextExtensions private const string Key = "token"; public static void SetToken(this HttpContext ctx, Token token) => ctx.Items.Add(Key, token); + public static User? GetUser(this HttpContext ctx) => ctx.GetToken()?.User; public static User GetUserOrThrow(this HttpContext ctx) => @@ -54,4 +57,4 @@ public static class HttpContextExtensions } [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] -public class AuthenticateAttribute : Attribute; \ No newline at end of file +public class AuthenticateAttribute : Attribute; diff --git a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs index 1cab41c..4fb8dd1 100644 --- a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs +++ b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs @@ -18,14 +18,26 @@ public class AuthorizationMiddleware : IMiddleware var token = ctx.GetToken(); if (token == null) - throw new ApiError.Unauthorized("This endpoint requires an authenticated user.", - ErrorCode.AuthenticationRequired); - if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()) - throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", - attribute.Scopes.Except(token.Scopes.ExpandScopes()), ErrorCode.MissingScopes); + throw new ApiError.Unauthorized( + "This endpoint requires an authenticated user.", + ErrorCode.AuthenticationRequired + ); + if ( + attribute.Scopes.Length > 0 + && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any() + ) + throw new ApiError.Forbidden( + "This endpoint requires ungranted scopes.", + attribute.Scopes.Except(token.Scopes.ExpandScopes()), + ErrorCode.MissingScopes + ); if (attribute.RequireAdmin && token.User.Role != UserRole.Admin) throw new ApiError.Forbidden("This endpoint can only be used by admins."); - if (attribute.RequireModerator && token.User.Role != UserRole.Admin && token.User.Role != UserRole.Moderator) + if ( + attribute.RequireModerator + && token.User.Role != UserRole.Admin + && token.User.Role != UserRole.Moderator + ) throw new ApiError.Forbidden("This endpoint can only be used by moderators."); await next(ctx); @@ -39,4 +51,4 @@ public class AuthorizeAttribute(params string[] scopes) : Attribute public readonly bool RequireModerator = scopes.Contains(":moderator"); public readonly string[] Scopes = scopes.Except([":admin", ":moderator"]).ToArray(); -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs b/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs index c28b58e..70fa6ff 100644 --- a/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs +++ b/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs @@ -21,19 +21,26 @@ public class ErrorHandlerMiddleware(ILogger baseLogger, IHub sentry) : IMiddlewa if (ctx.Response.HasStarted) { - logger.Error(e, "Error in {ClassName} ({Path}) after response started being sent", typeName, - ctx.Request.Path); + logger.Error( + e, + "Error in {ClassName} ({Path}) after response started being sent", + typeName, + ctx.Request.Path + ); - sentry.CaptureException(e, scope => - { - var user = ctx.GetUser(); - if (user != null) - scope.User = new SentryUser - { - Id = user.Id.ToString(), - Username = user.Username - }; - }); + sentry.CaptureException( + e, + scope => + { + var user = ctx.GetUser(); + if (user != null) + scope.User = new SentryUser + { + Id = user.Id.ToString(), + Username = user.Username, + }; + } + ); return; } @@ -45,13 +52,17 @@ public class ErrorHandlerMiddleware(ILogger baseLogger, IHub sentry) : IMiddlewa ctx.Response.ContentType = "application/json; charset=utf-8"; if (ae is ApiError.Forbidden fe) { - await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError - { - Status = (int)fe.StatusCode, - Code = ErrorCode.Forbidden, - Message = fe.Message, - Scopes = fe.Scopes.Length > 0 ? fe.Scopes : null - })); + await ctx.Response.WriteAsync( + JsonConvert.SerializeObject( + new HttpApiError + { + Status = (int)fe.StatusCode, + Code = ErrorCode.Forbidden, + Message = fe.Message, + Scopes = fe.Scopes.Length > 0 ? fe.Scopes : null, + } + ) + ); return; } @@ -61,45 +72,61 @@ public class ErrorHandlerMiddleware(ILogger baseLogger, IHub sentry) : IMiddlewa return; } - await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError - { - Status = (int)ae.StatusCode, - Code = ae.ErrorCode, - Message = ae.Message, - })); + await ctx.Response.WriteAsync( + JsonConvert.SerializeObject( + new HttpApiError + { + Status = (int)ae.StatusCode, + Code = ae.ErrorCode, + Message = ae.Message, + } + ) + ); return; } if (e is FoxnounsError fce) { - logger.Error(fce.Inner ?? fce, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path); + logger.Error( + fce.Inner ?? fce, + "Exception in {ClassName} ({Path})", + typeName, + ctx.Request.Path + ); } else { logger.Error(e, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path); } - var errorId = sentry.CaptureException(e, scope => - { - var user = ctx.GetUser(); - if (user != null) - scope.User = new SentryUser - { - Id = user.Id.ToString(), - Username = user.Username - }; - }); + var errorId = sentry.CaptureException( + e, + scope => + { + var user = ctx.GetUser(); + if (user != null) + scope.User = new SentryUser + { + Id = user.Id.ToString(), + Username = user.Username, + }; + } + ); ctx.Response.StatusCode = (int)HttpStatusCode.InternalServerError; ctx.Response.Headers.RequestId = ctx.TraceIdentifier; ctx.Response.ContentType = "application/json; charset=utf-8"; - await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError - { - Status = (int)HttpStatusCode.InternalServerError, - Code = ErrorCode.InternalServerError, - ErrorId = errorId.ToString(), - Message = "Internal server error", - })); + await ctx.Response.WriteAsync( + JsonConvert.SerializeObject( + new HttpApiError + { + Status = (int)HttpStatusCode.InternalServerError, + Code = ErrorCode.InternalServerError, + ErrorId = errorId.ToString(), + Message = "Internal server error", + } + ) + ); } } } @@ -118,4 +145,4 @@ public record HttpApiError [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public string[]? Scopes { get; init; } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index 316b45a..f0c3e19 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -1,5 +1,4 @@ using Foxnouns.Backend; -using Serilog; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; using Foxnouns.Backend.Utils; @@ -8,6 +7,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Serialization; using Prometheus; using Sentry.Extensibility; +using Serilog; var builder = WebApplication.CreateBuilder(args); @@ -15,8 +15,8 @@ var config = builder.AddConfiguration(); builder.AddSerilog(); -builder.WebHost - .UseSentry(opts => +builder + .WebHost.UseSentry(opts => { opts.Dsn = config.Logging.SentryUrl; opts.TracesSampleRate = config.Logging.SentryTracesSampleRate; @@ -30,13 +30,13 @@ builder.WebHost opts.Limits.MaxRequestBodySize = 2 * 1024 * 1024; }); -builder.Services - .AddControllers() +builder + .Services.AddControllers() .AddNewtonsoftJson(options => { options.SerializerSettings.ContractResolver = new PatchRequestContractResolver { - NamingStrategy = new SnakeCaseNamingStrategy() + NamingStrategy = new SnakeCaseNamingStrategy(), }; }) .ConfigureApiBehaviorOptions(options => @@ -47,18 +47,16 @@ builder.Services }); // Set the default converter to snake case as we use it in a couple places. -JsonConvert.DefaultSettings = () => new JsonSerializerSettings -{ - ContractResolver = new DefaultContractResolver +JsonConvert.DefaultSettings = () => + new JsonSerializerSettings { - NamingStrategy = new SnakeCaseNamingStrategy() - } -}; + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy(), + }, + }; -builder.AddServices(config) - .AddCustomMiddleware() - .AddEndpointsApiExplorer() - .AddSwaggerGen(); +builder.AddServices(config).AddCustomMiddleware().AddEndpointsApiExplorer().AddSwaggerGen(); var app = builder.Build(); @@ -66,9 +64,11 @@ await app.Initialize(args); app.UseSerilogRequestLogging(); app.UseRouting(); + // Not all environments will want tracing (from experience, it's expensive to use in production, even with a low sample rate), // so it's locked behind a config option. -if (config.Logging.SentryTracing) app.UseSentryTracing(); +if (config.Logging.SentryTracing) + app.UseSentryTracing(); app.UseSwagger(); app.UseSwaggerUI(); app.UseCors(); @@ -80,7 +80,8 @@ app.Urls.Add(config.Address); // Make sure metrics are updated whenever Prometheus scrapes them Metrics.DefaultRegistry.AddBeforeCollectCallback(async ct => - await app.Services.GetRequiredService().CollectMetricsAsync(ct)); + await app.Services.GetRequiredService().CollectMetricsAsync(ct) +); app.Run(); -Log.CloseAndFlush(); \ No newline at end of file +Log.CloseAndFlush(); diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index f1e907b..1aaa5e4 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -16,8 +16,12 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// Creates a new user with the given email address and password. /// This method does not save the resulting user, the caller must still call . /// - public async Task CreateUserWithPasswordAsync(string username, string email, string password, - CancellationToken ct = default) + public async Task CreateUserWithPasswordAsync( + string username, + string email, + string password, + CancellationToken ct = default + ) { var user = new User { @@ -26,9 +30,13 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s AuthMethods = { new AuthMethod - { Id = snowflakeGenerator.GenerateSnowflake(), AuthType = AuthType.Email, RemoteId = email } + { + Id = snowflakeGenerator.GenerateSnowflake(), + AuthType = AuthType.Email, + RemoteId = email, + }, }, - LastActive = clock.GetCurrentInstant() + LastActive = clock.GetCurrentInstant(), }; db.Add(user); @@ -42,8 +50,14 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// To create a user with email authentication, use /// This method does not save the resulting user, the caller must still call . /// - public async Task CreateUserWithRemoteAuthAsync(string username, AuthType authType, string remoteId, - string remoteUsername, FediverseApplication? instance = null, CancellationToken ct = default) + public async Task CreateUserWithRemoteAuthAsync( + string username, + AuthType authType, + string remoteId, + string remoteUsername, + FediverseApplication? instance = null, + CancellationToken ct = default + ) { AssertValidAuthType(authType, instance); @@ -58,11 +72,14 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s { new AuthMethod { - Id = snowflakeGenerator.GenerateSnowflake(), AuthType = authType, RemoteId = remoteId, - RemoteUsername = remoteUsername, FediverseApplication = instance - } + Id = snowflakeGenerator.GenerateSnowflake(), + AuthType = authType, + RemoteId = remoteId, + RemoteUsername = remoteUsername, + FediverseApplication = instance, + }, }, - LastActive = clock.GetCurrentInstant() + LastActive = clock.GetCurrentInstant(), }; db.Add(user); @@ -78,19 +95,31 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// A tuple of the authenticated user and whether multi-factor authentication is required /// Thrown if the email address is not associated with any user /// or if the password is incorrect - public async Task<(User, EmailAuthenticationResult)> AuthenticateUserAsync(string email, string password, - CancellationToken ct = default) + public async Task<(User, EmailAuthenticationResult)> AuthenticateUserAsync( + string email, + string password, + CancellationToken ct = default + ) { - var user = await db.Users.FirstOrDefaultAsync(u => - u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email), ct); + var user = await db.Users.FirstOrDefaultAsync( + u => u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email), + ct + ); if (user == null) - throw new ApiError.NotFound("No user with that email address found, or password is incorrect", - ErrorCode.UserNotFound); + throw new ApiError.NotFound( + "No user with that email address found, or password is incorrect", + ErrorCode.UserNotFound + ); - var pwResult = await Task.Run(() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password), ct); + var pwResult = await Task.Run( + () => _passwordHasher.VerifyHashedPassword(user, user.Password!, password), + ct + ); if (pwResult == PasswordVerificationResult.Failed) // TODO: this seems to fail on some valid passwords? - throw new ApiError.NotFound("No user with that email address found, or password is incorrect", - ErrorCode.UserNotFound); + throw new ApiError.NotFound( + "No user with that email address found, or password is incorrect", + ErrorCode.UserNotFound + ); if (pwResult == PasswordVerificationResult.SuccessRehashNeeded) { user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); @@ -117,19 +146,33 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// A user object, or null if the remote account isn't linked to any user. /// Thrown if instance is passed when not required, /// or not passed when required - public async Task AuthenticateUserAsync(AuthType authType, string remoteId, - FediverseApplication? instance = null, CancellationToken ct = default) + public async Task AuthenticateUserAsync( + AuthType authType, + string remoteId, + FediverseApplication? instance = null, + CancellationToken ct = default + ) { AssertValidAuthType(authType, instance); - return await db.Users.FirstOrDefaultAsync(u => - u.AuthMethods.Any(a => - a.AuthType == authType && a.RemoteId == remoteId && a.FediverseApplication == instance), ct); + return await db.Users.FirstOrDefaultAsync( + u => + u.AuthMethods.Any(a => + a.AuthType == authType + && a.RemoteId == remoteId + && a.FediverseApplication == instance + ), + ct + ); } - public async Task AddAuthMethodAsync(Snowflake userId, AuthType authType, string remoteId, + public async Task AddAuthMethodAsync( + Snowflake userId, + AuthType authType, + string remoteId, string? remoteUsername = null, - CancellationToken ct = default) + CancellationToken ct = default + ) { AssertValidAuthType(authType, null); @@ -139,7 +182,7 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s AuthType = authType, RemoteId = remoteId, RemoteUsername = remoteUsername, - UserId = userId + UserId = userId, }; db.Add(authMethod); @@ -147,21 +190,33 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s return authMethod; } - public (string, Token) GenerateToken(User user, Application application, string[] scopes, Instant expires) + public (string, Token) GenerateToken( + User user, + Application application, + string[] scopes, + Instant expires + ) { if (!AuthUtils.ValidateScopes(application, scopes)) - throw new ApiError.BadRequest("Invalid scopes requested for this token", "scopes", scopes); + throw new ApiError.BadRequest( + "Invalid scopes requested for this token", + "scopes", + scopes + ); var (token, hash) = GenerateToken(); - return (token, new Token - { - Id = snowflakeGenerator.GenerateSnowflake(), - Hash = hash, - Application = application, - User = user, - ExpiresAt = expires, - Scopes = scopes - }); + return ( + token, + new Token + { + Id = snowflakeGenerator.GenerateSnowflake(), + Hash = hash, + Application = application, + User = user, + ExpiresAt = expires, + Scopes = scopes, + } + ); } private static (string, byte[]) GenerateToken() @@ -179,4 +234,4 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s if (authType != AuthType.Fediverse && instance != null) throw new FoxnounsError("Non-Fediverse authentication does not require an instance."); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs index 78ea3ae..ef60e8e 100644 --- a/Foxnouns.Backend/Services/KeyCacheService.cs +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -10,26 +10,43 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) { private readonly ILogger _logger = logger.ForContext(); - public Task SetKeyAsync(string key, string value, Duration expireAfter, CancellationToken ct = default) => - SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter, ct); + public Task SetKeyAsync( + string key, + string value, + Duration expireAfter, + CancellationToken ct = default + ) => SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter, ct); - public async Task SetKeyAsync(string key, string value, Instant expires, CancellationToken ct = default) + public async Task SetKeyAsync( + string key, + string value, + Instant expires, + CancellationToken ct = default + ) { - db.TemporaryKeys.Add(new TemporaryKey - { - Expires = expires, - Key = key, - Value = value, - }); + db.TemporaryKeys.Add( + new TemporaryKey + { + Expires = expires, + Key = key, + Value = value, + } + ); await db.SaveChangesAsync(ct); } - public async Task GetKeyAsync(string key, bool delete = false, CancellationToken ct = default) + public async Task GetKeyAsync( + string key, + bool delete = false, + CancellationToken ct = default + ) { var value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key, ct); - if (value == null) return null; + if (value == null) + return null; - if (delete) await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); + if (delete) + await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); return value.Value; } @@ -39,23 +56,41 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) public async Task DeleteExpiredKeysAsync(CancellationToken ct) { - var count = await db.TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant()).ExecuteDeleteAsync(ct); - if (count != 0) _logger.Information("Removed {Count} expired keys from the database", count); + var count = await db + .TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant()) + .ExecuteDeleteAsync(ct); + if (count != 0) + _logger.Information("Removed {Count} expired keys from the database", count); } - public Task SetKeyAsync(string key, T obj, Duration expiresAt, CancellationToken ct = default) where T : class => - SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct); + public Task SetKeyAsync( + string key, + T obj, + Duration expiresAt, + CancellationToken ct = default + ) + where T : class => SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct); - public async Task SetKeyAsync(string key, T obj, Instant expires, CancellationToken ct = default) where T : class + public async Task SetKeyAsync( + string key, + T obj, + Instant expires, + CancellationToken ct = default + ) + where T : class { var value = JsonConvert.SerializeObject(obj); await SetKeyAsync(key, value, expires, ct); } - public async Task GetKeyAsync(string key, bool delete = false, CancellationToken ct = default) + public async Task GetKeyAsync( + string key, + bool delete = false, + CancellationToken ct = default + ) where T : class { var value = await GetKeyAsync(key, delete, ct); return value == null ? default : JsonConvert.DeserializeObject(value); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Services/MailService.cs b/Foxnouns.Backend/Services/MailService.cs index e030425..c605866 100644 --- a/Foxnouns.Backend/Services/MailService.cs +++ b/Foxnouns.Backend/Services/MailService.cs @@ -15,12 +15,17 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co _logger.Debug("Sending account creation email to {ToEmail}", to); try { - await mailer.SendAsync(new AccountCreationMailable(config, new AccountCreationMailableView - { - BaseUrl = config.BaseUrl, - To = to, - Code = code - })); + await mailer.SendAsync( + new AccountCreationMailable( + config, + new AccountCreationMailableView + { + BaseUrl = config.BaseUrl, + To = to, + Code = code, + } + ) + ); } catch (Exception exc) { @@ -28,4 +33,4 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co } }); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Services/MemberRendererService.cs b/Foxnouns.Backend/Services/MemberRendererService.cs index 9ebea49..f8588c2 100644 --- a/Foxnouns.Backend/Services/MemberRendererService.cs +++ b/Foxnouns.Backend/Services/MemberRendererService.cs @@ -10,17 +10,17 @@ public class MemberRendererService(DatabaseContext db, Config config) { public async Task> RenderUserMembersAsync(User user, Token? token) { - var canReadHiddenMembers = token != null && token.UserId == user.Id && token.HasScope("member.read"); - var renderUnlisted = token != null && token.UserId == user.Id && token.HasScope("user.read_hidden"); + var canReadHiddenMembers = + token != null && token.UserId == user.Id && token.HasScope("member.read"); + var renderUnlisted = + token != null && token.UserId == user.Id && token.HasScope("user.read_hidden"); var canReadMemberList = !user.ListHidden || canReadHiddenMembers; IEnumerable members = canReadMemberList - ? await db.Members - .Where(m => m.UserId == user.Id) - .OrderBy(m => m.Name) - .ToListAsync() + ? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync() : []; - if (!canReadHiddenMembers) members = members.Where(m => !m.Unlisted); + if (!canReadHiddenMembers) + members = members.Where(m => !m.Unlisted); return members.Select(m => RenderPartialMember(m, renderUnlisted)); } @@ -29,25 +29,54 @@ public class MemberRendererService(DatabaseContext db, Config config) var renderUnlisted = token?.UserId == member.UserId && token.HasScope("user.read_hidden"); return new MemberResponse( - member.Id, member.Sid, member.Name, member.DisplayName, member.Bio, - AvatarUrlFor(member), member.Links, member.Names, member.Pronouns, member.Fields, + member.Id, + member.Sid, + member.Name, + member.DisplayName, + member.Bio, + AvatarUrlFor(member), + member.Links, + member.Names, + member.Pronouns, + member.Fields, member.ProfileFlags.Select(f => RenderPrideFlag(f.PrideFlag)), - RenderPartialUser(member.User), renderUnlisted ? member.Unlisted : null); + RenderPartialUser(member.User), + renderUnlisted ? member.Unlisted : null + ); } private UserRendererService.PartialUser RenderPartialUser(User user) => - new(user.Id, user.Sid, user.Username, user.DisplayName, AvatarUrlFor(user), user.CustomPreferences); + new( + user.Id, + user.Sid, + user.Username, + user.DisplayName, + AvatarUrlFor(user), + user.CustomPreferences + ); - public PartialMember RenderPartialMember(Member member, bool renderUnlisted = false) => new(member.Id, member.Sid, - member.Name, - member.DisplayName, member.Bio, AvatarUrlFor(member), member.Names, member.Pronouns, - renderUnlisted ? member.Unlisted : null); + public PartialMember RenderPartialMember(Member member, bool renderUnlisted = false) => + new( + member.Id, + member.Sid, + member.Name, + member.DisplayName, + member.Bio, + AvatarUrlFor(member), + member.Names, + member.Pronouns, + renderUnlisted ? member.Unlisted : null + ); private string? AvatarUrlFor(Member member) => - member.Avatar != null ? $"{config.MediaBaseUrl}/members/{member.Id}/avatars/{member.Avatar}.webp" : null; + member.Avatar != null + ? $"{config.MediaBaseUrl}/members/{member.Id}/avatars/{member.Avatar}.webp" + : null; private string? AvatarUrlFor(User user) => - user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null; + user.Avatar != null + ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" + : null; private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp"; @@ -63,8 +92,8 @@ public class MemberRendererService(DatabaseContext db, Config config) string? AvatarUrl, IEnumerable Names, IEnumerable Pronouns, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - bool? Unlisted); + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted + ); public record MemberResponse( Snowflake Id, @@ -79,6 +108,6 @@ public class MemberRendererService(DatabaseContext db, Config config) IEnumerable Fields, IEnumerable Flags, UserRendererService.PartialUser User, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - bool? Unlisted); -} \ No newline at end of file + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted + ); +} diff --git a/Foxnouns.Backend/Services/MetricsCollectionService.cs b/Foxnouns.Backend/Services/MetricsCollectionService.cs index c5c924e..573c1a2 100644 --- a/Foxnouns.Backend/Services/MetricsCollectionService.cs +++ b/Foxnouns.Backend/Services/MetricsCollectionService.cs @@ -6,10 +6,7 @@ using Prometheus; namespace Foxnouns.Backend.Services; -public class MetricsCollectionService( - ILogger logger, - IServiceProvider services, - IClock clock) +public class MetricsCollectionService(ILogger logger, IServiceProvider services, IClock clock) { private readonly ILogger _logger = logger.ForContext(); @@ -31,8 +28,10 @@ public class MetricsCollectionService( FoxnounsMetrics.UsersActiveWeekCount.Set(users.Count(i => i > now - Week)); FoxnounsMetrics.UsersActiveDayCount.Set(users.Count(i => i > now - Day)); - var memberCount = await db.Members.Include(m => m.User) - .Where(m => !m.Unlisted && !m.User.ListHidden && !m.User.Deleted).CountAsync(ct); + var memberCount = await db + .Members.Include(m => m.User) + .Where(m => !m.Unlisted && !m.User.ListHidden && !m.User.Deleted) + .CountAsync(ct); FoxnounsMetrics.MemberCount.Set(memberCount); var process = Process.GetCurrentProcess(); @@ -42,13 +41,17 @@ public class MetricsCollectionService( FoxnounsMetrics.ProcessThreads.Set(process.Threads.Count); FoxnounsMetrics.ProcessHandles.Set(process.HandleCount); - _logger.Information("Collected metrics in {DurationMilliseconds} ms", - timer.ObserveDuration().TotalMilliseconds); + _logger.Information( + "Collected metrics in {DurationMilliseconds} ms", + timer.ObserveDuration().TotalMilliseconds + ); } } -public class BackgroundMetricsCollectionService(ILogger logger, MetricsCollectionService metricsCollectionService) - : BackgroundService +public class BackgroundMetricsCollectionService( + ILogger logger, + MetricsCollectionService metricsCollectionService +) : BackgroundService { private readonly ILogger _logger = logger.ForContext(); @@ -63,4 +66,4 @@ public class BackgroundMetricsCollectionService(ILogger logger, MetricsCollectio await metricsCollectionService.CollectMetricsAsync(ct); } } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Services/ObjectStorageService.cs b/Foxnouns.Backend/Services/ObjectStorageService.cs index e31a270..df9709c 100644 --- a/Foxnouns.Backend/Services/ObjectStorageService.cs +++ b/Foxnouns.Backend/Services/ObjectStorageService.cs @@ -15,7 +15,8 @@ public class ObjectStorageService(ILogger logger, Config config, IMinioClient mi { await minioClient.RemoveObjectAsync( new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path), - ct); + ct + ); } catch (InvalidObjectNameException) { @@ -23,17 +24,28 @@ public class ObjectStorageService(ILogger logger, Config config, IMinioClient mi } } - public async Task PutObjectAsync(string path, Stream data, string contentType, CancellationToken ct = default) + public async Task PutObjectAsync( + string path, + Stream data, + string contentType, + CancellationToken ct = default + ) { - _logger.Debug("Putting object at path {Path} with length {Length} and content type {ContentType}", path, - data.Length, contentType); + _logger.Debug( + "Putting object at path {Path} with length {Length} and content type {ContentType}", + path, + data.Length, + contentType + ); - await minioClient.PutObjectAsync(new PutObjectArgs() + await minioClient.PutObjectAsync( + new PutObjectArgs() .WithBucket(config.Storage.Bucket) .WithObject(path) .WithObjectSize(data.Length) .WithStreamData(data) - .WithContentType(contentType), ct + .WithContentType(contentType), + ct ); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Services/PeriodicTasksService.cs b/Foxnouns.Backend/Services/PeriodicTasksService.cs index b799abe..757ebcf 100644 --- a/Foxnouns.Backend/Services/PeriodicTasksService.cs +++ b/Foxnouns.Backend/Services/PeriodicTasksService.cs @@ -23,4 +23,4 @@ public class PeriodicTasksService(ILogger logger, IServiceProvider services) : B var keyCacheSvc = scope.ServiceProvider.GetRequiredService(); await keyCacheSvc.DeleteExpiredKeysAsync(ct); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Services/RemoteAuthService.cs b/Foxnouns.Backend/Services/RemoteAuthService.cs index fefaf16..505029d 100644 --- a/Foxnouns.Backend/Services/RemoteAuthService.cs +++ b/Foxnouns.Backend/Services/RemoteAuthService.cs @@ -11,30 +11,42 @@ public class RemoteAuthService(Config config, ILogger logger) private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token"); private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me"); - public async Task RequestDiscordTokenAsync(string code, string state, CancellationToken ct = default) + public async Task RequestDiscordTokenAsync( + string code, + string state, + CancellationToken ct = default + ) { var redirectUri = $"{config.BaseUrl}/auth/callback/discord"; - var resp = await _httpClient.PostAsync(_discordTokenUri, new FormUrlEncodedContent( - new Dictionary - { - { "client_id", config.DiscordAuth.ClientId! }, - { "client_secret", config.DiscordAuth.ClientSecret! }, - { "grant_type", "authorization_code" }, - { "code", code }, - { "redirect_uri", redirectUri } - } - ), ct); + var resp = await _httpClient.PostAsync( + _discordTokenUri, + new FormUrlEncodedContent( + new Dictionary + { + { "client_id", config.DiscordAuth.ClientId! }, + { "client_secret", config.DiscordAuth.ClientSecret! }, + { "grant_type", "authorization_code" }, + { "code", code }, + { "redirect_uri", redirectUri }, + } + ), + ct + ); if (!resp.IsSuccessStatusCode) { var respBody = await resp.Content.ReadAsStringAsync(ct); - _logger.Error("Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}", - (int)resp.StatusCode, respBody); + _logger.Error( + "Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}", + (int)resp.StatusCode, + respBody + ); throw new FoxnounsError("Invalid Discord OAuth response"); } resp.EnsureSuccessStatusCode(); var token = await resp.Content.ReadFromJsonAsync(ct); - if (token == null) throw new FoxnounsError("Discord token response was null"); + if (token == null) + throw new FoxnounsError("Discord token response was null"); var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri); req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}"); @@ -42,20 +54,27 @@ public class RemoteAuthService(Config config, ILogger logger) var resp2 = await _httpClient.SendAsync(req, ct); resp2.EnsureSuccessStatusCode(); var user = await resp2.Content.ReadFromJsonAsync(ct); - if (user == null) throw new FoxnounsError("Discord user response was null"); + if (user == null) + throw new FoxnounsError("Discord user response was null"); return new RemoteUser(user.id, user.username); } - [SuppressMessage("ReSharper", "InconsistentNaming", - Justification = "Easier to use snake_case here, rather than passing in JSON converter options")] + [SuppressMessage( + "ReSharper", + "InconsistentNaming", + Justification = "Easier to use snake_case here, rather than passing in JSON converter options" + )] [UsedImplicitly] private record DiscordTokenResponse(string access_token, string token_type); - [SuppressMessage("ReSharper", "InconsistentNaming", - Justification = "Easier to use snake_case here, rather than passing in JSON converter options")] + [SuppressMessage( + "ReSharper", + "InconsistentNaming", + Justification = "Easier to use snake_case here, rather than passing in JSON converter options" + )] [UsedImplicitly] private record DiscordUserResponse(string id, string username); public record RemoteUser(string Id, string Username); -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index ee64fe1..b47832d 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -7,48 +7,73 @@ using NodaTime; namespace Foxnouns.Backend.Services; -public class UserRendererService(DatabaseContext db, MemberRendererService memberRenderer, Config config) +public class UserRendererService( + DatabaseContext db, + MemberRendererService memberRenderer, + Config config +) { - public async Task RenderUserAsync(User user, User? selfUser = null, + public async Task RenderUserAsync( + User user, + User? selfUser = null, Token? token = null, bool renderMembers = true, bool renderAuthMethods = false, - CancellationToken ct = default) + CancellationToken ct = default + ) { var isSelfUser = selfUser?.Id == user.Id; var tokenCanReadHiddenMembers = token.HasScope("member.read") && isSelfUser; var tokenHidden = token.HasScope("user.read_hidden") && isSelfUser; var tokenPrivileged = token.HasScope("user.read_privileged") && isSelfUser; - renderMembers = renderMembers && - (!user.ListHidden || tokenCanReadHiddenMembers); + renderMembers = renderMembers && (!user.ListHidden || tokenCanReadHiddenMembers); renderAuthMethods = renderAuthMethods && tokenPrivileged; - IEnumerable members = - renderMembers ? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync(ct) : []; + IEnumerable members = renderMembers + ? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync(ct) + : []; // Unless the user is requesting their own members AND the token can read hidden members, we filter out unlisted members. - if (!(isSelfUser && tokenCanReadHiddenMembers)) members = members.Where(m => !m.Unlisted); + if (!(isSelfUser && tokenCanReadHiddenMembers)) + members = members.Where(m => !m.Unlisted); - var flags = await db.UserFlags.Where(f => f.UserId == user.Id).OrderBy(f => f.Id).ToListAsync(ct); + var flags = await db + .UserFlags.Where(f => f.UserId == user.Id) + .OrderBy(f => f.Id) + .ToListAsync(ct); var authMethods = renderAuthMethods - ? await db.AuthMethods - .Where(a => a.UserId == user.Id) + ? await db + .AuthMethods.Where(a => a.UserId == user.Id) .Include(a => a.FediverseApplication) .ToListAsync(ct) : []; return new UserResponse( - user.Id, user.Sid, user.Username, user.DisplayName, user.Bio, user.MemberTitle, AvatarUrlFor(user), + user.Id, + user.Sid, + user.Username, + user.DisplayName, + user.Bio, + user.MemberTitle, + AvatarUrlFor(user), user.Links, - user.Names, user.Pronouns, user.Fields, user.CustomPreferences, + user.Names, + user.Pronouns, + user.Fields, + user.CustomPreferences, flags.Select(f => RenderPrideFlag(f.PrideFlag)), user.Role, - renderMembers ? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden)) : null, + renderMembers + ? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden)) + : null, renderAuthMethods ? authMethods.Select(a => new AuthenticationMethodResponse( - a.Id, a.AuthType, a.RemoteId, - a.RemoteUsername, a.FediverseApplication?.Domain + a.Id, + a.AuthType, + a.RemoteId, + a.RemoteUsername, + a.FediverseApplication?.Domain )) : null, tokenHidden ? user.ListHidden : null, @@ -58,10 +83,19 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe } public PartialUser RenderPartialUser(User user) => - new(user.Id, user.Sid, user.Username, user.DisplayName, AvatarUrlFor(user), user.CustomPreferences); + new( + user.Id, + user.Sid, + user.Username, + user.DisplayName, + AvatarUrlFor(user), + user.CustomPreferences + ); private string? AvatarUrlFor(User user) => - user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null; + user.Avatar != null + ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" + : null; public string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp"; @@ -79,29 +113,26 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe IEnumerable Fields, Dictionary CustomPreferences, IEnumerable Flags, - [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] - UserRole Role, + [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - IEnumerable? Members, + IEnumerable? Members, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - IEnumerable? AuthMethods, + IEnumerable? AuthMethods, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - bool? MemberListHidden, + bool? MemberListHidden, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - Instant? LastActive, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - Instant? LastSidReroll + Instant? LastSidReroll ); public record AuthenticationMethodResponse( Snowflake Id, - [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] - AuthType Type, + [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type, string RemoteId, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - string? RemoteUsername, + string? RemoteUsername, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - string? FediverseInstance + string? FediverseInstance ); public record PartialUser( @@ -120,5 +151,6 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe Snowflake Id, string ImageUrl, string Name, - string? Description); -} \ No newline at end of file + string? Description + ); +} diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs index c7bd717..aaf0c08 100644 --- a/Foxnouns.Backend/Utils/AuthUtils.cs +++ b/Foxnouns.Backend/Utils/AuthUtils.cs @@ -7,12 +7,28 @@ public static class AuthUtils { public const string ClientCredentials = "client_credentials"; public const string AuthorizationCode = "authorization_code"; - private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"]; + private static readonly string[] ForbiddenSchemes = + [ + "javascript", + "file", + "data", + "mailto", + "tel", + ]; public static readonly string[] UserScopes = - ["user.read_hidden", "user.read_privileged", "user.update"]; + [ + "user.read_hidden", + "user.read_privileged", + "user.update", + ]; - public static readonly string[] MemberScopes = ["member.read", "member.update", "member.create"]; + public static readonly string[] MemberScopes = + [ + "member.read", + "member.update", + "member.create", + ]; /// /// All scopes endpoints can be secured by. This does *not* include the catch-all token scopes. @@ -27,10 +43,13 @@ public static class AuthUtils public static string[] ExpandScopes(this string[] scopes) { - if (scopes.Contains("*")) return ["*", ..Scopes]; + if (scopes.Contains("*")) + return ["*", .. Scopes]; List expandedScopes = ["identify"]; - if (scopes.Contains("user")) expandedScopes.AddRange(UserScopes); - if (scopes.Contains("member")) expandedScopes.AddRange(MemberScopes); + if (scopes.Contains("user")) + expandedScopes.AddRange(UserScopes); + if (scopes.Contains("member")) + expandedScopes.AddRange(MemberScopes); return expandedScopes.ToArray(); } @@ -41,8 +60,10 @@ public static class AuthUtils private static string[] ExpandAppScopes(this string[] scopes) { var expandedScopes = scopes.ExpandScopes().ToList(); - if (scopes.Contains("user")) expandedScopes.Add("user"); - if (scopes.Contains("member")) expandedScopes.Add("member"); + if (scopes.Contains("user")) + expandedScopes.Add("user"); + if (scopes.Contains("member")) + expandedScopes.Add("member"); return expandedScopes.ToArray(); } @@ -84,7 +105,8 @@ public static class AuthUtils { rawToken = []; - if (string.IsNullOrWhiteSpace(input)) return false; + if (string.IsNullOrWhiteSpace(input)) + return false; if (input.StartsWith("bearer ", StringComparison.InvariantCultureIgnoreCase)) input = input["bearer ".Length..]; @@ -95,4 +117,4 @@ public static class AuthUtils Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('='); public const int MaxAuthMethodsPerType = 3; // Maximum of 3 Discord accounts, 3 emails, etc -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Utils/Limits.cs b/Foxnouns.Backend/Utils/Limits.cs index c86df0b..e396637 100644 --- a/Foxnouns.Backend/Utils/Limits.cs +++ b/Foxnouns.Backend/Utils/Limits.cs @@ -6,4 +6,4 @@ public static class Limits public const int FieldNameLimit = 100; public const int FieldEntryTextLimit = 100; public const int FieldEntriesLimit = 100; -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Utils/PatchRequest.cs b/Foxnouns.Backend/Utils/PatchRequest.cs index 4e3ac1c..a836efe 100644 --- a/Foxnouns.Backend/Utils/PatchRequest.cs +++ b/Foxnouns.Backend/Utils/PatchRequest.cs @@ -13,7 +13,9 @@ namespace Foxnouns.Backend.Utils; public abstract class PatchRequest { private readonly HashSet _properties = []; + public bool HasProperty(string propertyName) => _properties.Contains(propertyName); + public void SetHasProperty(string propertyName) => _properties.Add(propertyName); } @@ -23,16 +25,20 @@ public abstract class PatchRequest /// public class PatchRequestContractResolver : DefaultContractResolver { - protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + protected override JsonProperty CreateProperty( + MemberInfo member, + MemberSerialization memberSerialization + ) { var prop = base.CreateProperty(member, memberSerialization); prop.SetIsSpecified += (o, _) => { - if (o is not PatchRequest patchRequest) return; + if (o is not PatchRequest patchRequest) + return; patchRequest.SetHasProperty(prop.UnderlyingName!); }; return prop; } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Utils/ScreamingSnakeCaseEnumConverter.cs b/Foxnouns.Backend/Utils/ScreamingSnakeCaseEnumConverter.cs index dafc2b8..4bfa2e5 100644 --- a/Foxnouns.Backend/Utils/ScreamingSnakeCaseEnumConverter.cs +++ b/Foxnouns.Backend/Utils/ScreamingSnakeCaseEnumConverter.cs @@ -8,11 +8,12 @@ namespace Foxnouns.Backend.Utils; /// A custom StringEnumConverter that converts enum members to SCREAMING_SNAKE_CASE, rather than CamelCase as is the default. /// Newtonsoft.Json doesn't provide a screaming snake case naming strategy, so we just wrap the normal snake case one and convert it to uppercase. /// -public class ScreamingSnakeCaseEnumConverter() : StringEnumConverter(new ScreamingSnakeCaseNamingStrategy(), false) +public class ScreamingSnakeCaseEnumConverter() + : StringEnumConverter(new ScreamingSnakeCaseNamingStrategy(), false) { private class ScreamingSnakeCaseNamingStrategy : SnakeCaseNamingStrategy { protected override string ResolvePropertyName(string name) => base.ResolvePropertyName(name).ToUpper(CultureInfo.InvariantCulture); } -} \ No newline at end of file +} diff --git a/Foxnouns.Backend/Utils/ValidationUtils.cs b/Foxnouns.Backend/Utils/ValidationUtils.cs index 2dd52b9..0969a47 100644 --- a/Foxnouns.Backend/Utils/ValidationUtils.cs +++ b/Foxnouns.Backend/Utils/ValidationUtils.cs @@ -21,7 +21,7 @@ public static partial class ValidationUtils "pronouns", "settings", "pronouns.cc", - "pronounscc" + "pronounscc", ]; private static readonly string[] InvalidMemberNames = @@ -30,7 +30,7 @@ public static partial class ValidationUtils ".", "..", // the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible - "edit" + "edit", ]; public static ValidationError? ValidateUsername(string username) @@ -42,10 +42,15 @@ public static partial class ValidationUtils > 40 => ValidationError.LengthError("Username is too long", 2, 40, username.Length), _ => ValidationError.GenericValidationError( "Username is invalid, can only contain alphanumeric characters, dashes, underscores, and periods", - username) + username + ), }; - if (InvalidUsernames.Any(u => string.Equals(u, username, StringComparison.InvariantCultureIgnoreCase))) + if ( + InvalidUsernames.Any(u => + string.Equals(u, username, StringComparison.InvariantCultureIgnoreCase) + ) + ) return ValidationError.GenericValidationError("Username is not allowed", username); return null; } @@ -58,13 +63,18 @@ public static partial class ValidationUtils < 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length), > 100 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length), _ => ValidationError.GenericValidationError( - "Member name cannot contain any of the following: " + - " @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , " + - "and cannot be one or two periods", - memberName) + "Member name cannot contain any of the following: " + + " @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , " + + "and cannot be one or two periods", + memberName + ), }; - if (InvalidMemberNames.Any(u => string.Equals(u, memberName, StringComparison.InvariantCultureIgnoreCase))) + if ( + InvalidMemberNames.Any(u => + string.Equals(u, memberName, StringComparison.InvariantCultureIgnoreCase) + ) + ) return ValidationError.GenericValidationError("Name is not allowed", memberName); return null; } @@ -72,12 +82,14 @@ public static partial class ValidationUtils public static void Validate(IEnumerable<(string, ValidationError?)> errors) { errors = errors.Where(e => e.Item2 != null).ToList(); - if (!errors.Any()) return; + if (!errors.Any()) + return; var errorDict = new Dictionary>(); foreach (var error in errors) { - if (errorDict.TryGetValue(error.Item1, out var value)) errorDict[error.Item1] = value.Append(error.Item2!); + if (errorDict.TryGetValue(error.Item1, out var value)) + errorDict[error.Item1] = value.Append(error.Item2!); errorDict.Add(error.Item1, [error.Item2!]); } @@ -88,9 +100,19 @@ public static partial class ValidationUtils { return displayName?.Length switch { - 0 => ValidationError.LengthError("Display name is too short", 1, 100, displayName.Length), - > 100 => ValidationError.LengthError("Display name is too long", 1, 100, displayName.Length), - _ => null + 0 => ValidationError.LengthError( + "Display name is too short", + 1, + 100, + displayName.Length + ), + > 100 => ValidationError.LengthError( + "Display name is too long", + 1, + 100, + displayName.Length + ), + _ => null, }; } @@ -99,9 +121,13 @@ public static partial class ValidationUtils public static IEnumerable<(string, ValidationError?)> ValidateLinks(string[]? links) { - if (links == null) return []; + if (links == null) + return []; if (links.Length > MaxLinks) - return [("links", ValidationError.LengthError("Too many links", 0, MaxLinks, links.Length))]; + return + [ + ("links", ValidationError.LengthError("Too many links", 0, MaxLinks, links.Length)), + ]; var errors = new List<(string, ValidationError?)>(); foreach (var (link, idx) in links.Select((l, i) => (l, i))) @@ -109,12 +135,25 @@ public static partial class ValidationUtils switch (link.Length) { case 0: - errors.Add(($"links.{idx}", - ValidationError.LengthError("Link cannot be empty", 1, 256, 0))); + errors.Add( + ( + $"links.{idx}", + ValidationError.LengthError("Link cannot be empty", 1, 256, 0) + ) + ); break; case > MaxLinkLength: - errors.Add(($"links.{idx}", - ValidationError.LengthError("Link is too long", 1, MaxLinkLength, link.Length))); + errors.Add( + ( + $"links.{idx}", + ValidationError.LengthError( + "Link is too long", + 1, + MaxLinkLength, + link.Length + ) + ) + ); break; } } @@ -129,8 +168,13 @@ public static partial class ValidationUtils return bio?.Length switch { 0 => ValidationError.LengthError("Bio is too short", 1, MaxBioLength, bio.Length), - > MaxBioLength => ValidationError.LengthError("Bio is too long", 1, MaxBioLength, bio.Length), - _ => null + > MaxBioLength => ValidationError.LengthError( + "Bio is too long", + 1, + MaxBioLength, + bio.Length + ), + _ => null, }; } @@ -140,121 +184,222 @@ public static partial class ValidationUtils { 0 => ValidationError.GenericValidationError("Avatar cannot be empty", null), > 1_500_000 => ValidationError.GenericValidationError("Avatar is too large", null), - _ => null + _ => null, }; } - private static readonly string[] DefaultStatusOptions = [ "favourite", "okay", "jokingly", "friends_only", - "avoid" + "avoid", ]; - public static IEnumerable<(string, ValidationError?)> ValidateFields(List? fields, - IReadOnlyDictionary customPreferences) + public static IEnumerable<(string, ValidationError?)> ValidateFields( + List? fields, + IReadOnlyDictionary customPreferences + ) { - if (fields == null) return []; + if (fields == null) + return []; var errors = new List<(string, ValidationError?)>(); if (fields.Count > 25) - errors.Add(("fields", ValidationError.LengthError("Too many fields", 0, Limits.FieldLimit, fields.Count))); + errors.Add( + ( + "fields", + ValidationError.LengthError( + "Too many fields", + 0, + Limits.FieldLimit, + fields.Count + ) + ) + ); // No overwhelming this function, thank you - if (fields.Count > 100) return errors; + if (fields.Count > 100) + return errors; foreach (var (field, index) in fields.Select((field, index) => (field, index))) { switch (field.Name.Length) { case > Limits.FieldNameLimit: - errors.Add(($"fields.{index}.name", - ValidationError.LengthError("Field name is too long", 1, Limits.FieldNameLimit, - field.Name.Length))); + errors.Add( + ( + $"fields.{index}.name", + ValidationError.LengthError( + "Field name is too long", + 1, + Limits.FieldNameLimit, + field.Name.Length + ) + ) + ); break; case < 1: - errors.Add(($"fields.{index}.name", - ValidationError.LengthError("Field name is too short", 1, Limits.FieldNameLimit, - field.Name.Length))); + errors.Add( + ( + $"fields.{index}.name", + ValidationError.LengthError( + "Field name is too short", + 1, + Limits.FieldNameLimit, + field.Name.Length + ) + ) + ); break; } - errors = errors.Concat(ValidateFieldEntries(field.Entries, customPreferences, $"fields.{index}.entries")) + errors = errors + .Concat( + ValidateFieldEntries( + field.Entries, + customPreferences, + $"fields.{index}.entries" + ) + ) .ToList(); } return errors; } - public static IEnumerable<(string, ValidationError?)> ValidateFieldEntries(FieldEntry[]? entries, - IReadOnlyDictionary customPreferences, string errorPrefix = "fields") + public static IEnumerable<(string, ValidationError?)> ValidateFieldEntries( + FieldEntry[]? entries, + IReadOnlyDictionary customPreferences, + string errorPrefix = "fields" + ) { - if (entries == null || entries.Length == 0) return []; + if (entries == null || entries.Length == 0) + return []; var errors = new List<(string, ValidationError?)>(); if (entries.Length > Limits.FieldEntriesLimit) - errors.Add((errorPrefix, - ValidationError.LengthError("Field has too many entries", 0, Limits.FieldEntriesLimit, - entries.Length))); + errors.Add( + ( + errorPrefix, + ValidationError.LengthError( + "Field has too many entries", + 0, + Limits.FieldEntriesLimit, + entries.Length + ) + ) + ); // Same as above, no overwhelming this function with a ridiculous amount of entries - if (entries.Length > Limits.FieldEntriesLimit + 50) return errors; + if (entries.Length > Limits.FieldEntriesLimit + 50) + return errors; foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) { switch (entry.Value.Length) { case > Limits.FieldEntryTextLimit: - errors.Add(($"{errorPrefix}.{entryIdx}.value", - ValidationError.LengthError("Field value is too long", 1, Limits.FieldEntryTextLimit, - entry.Value.Length))); + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.value", + ValidationError.LengthError( + "Field value is too long", + 1, + Limits.FieldEntryTextLimit, + entry.Value.Length + ) + ) + ); break; case < 1: - errors.Add(($"{errorPrefix}.{entryIdx}.value", - ValidationError.LengthError("Field value is too short", 1, Limits.FieldEntryTextLimit, - entry.Value.Length))); + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.value", + ValidationError.LengthError( + "Field value is too short", + 1, + Limits.FieldEntryTextLimit, + entry.Value.Length + ) + ) + ); break; } var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? []; - if (!DefaultStatusOptions.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status)) - errors.Add(($"{errorPrefix}.{entryIdx}.status", - ValidationError.GenericValidationError("Invalid status", entry.Status))); + if ( + !DefaultStatusOptions.Contains(entry.Status) + && !customPreferenceIds.Contains(entry.Status) + ) + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.status", + ValidationError.GenericValidationError("Invalid status", entry.Status) + ) + ); } return errors; } - public static IEnumerable<(string, ValidationError?)> ValidatePronouns(Pronoun[]? entries, - IReadOnlyDictionary customPreferences, string errorPrefix = "pronouns") + public static IEnumerable<(string, ValidationError?)> ValidatePronouns( + Pronoun[]? entries, + IReadOnlyDictionary customPreferences, + string errorPrefix = "pronouns" + ) { - if (entries == null || entries.Length == 0) return []; + if (entries == null || entries.Length == 0) + return []; var errors = new List<(string, ValidationError?)>(); if (entries.Length > Limits.FieldEntriesLimit) - errors.Add((errorPrefix, - ValidationError.LengthError("Too many pronouns", 0, Limits.FieldEntriesLimit, - entries.Length))); + errors.Add( + ( + errorPrefix, + ValidationError.LengthError( + "Too many pronouns", + 0, + Limits.FieldEntriesLimit, + entries.Length + ) + ) + ); // Same as above, no overwhelming this function with a ridiculous amount of entries - if (entries.Length > Limits.FieldEntriesLimit + 50) return errors; + if (entries.Length > Limits.FieldEntriesLimit + 50) + return errors; foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) { switch (entry.Value.Length) { case > Limits.FieldEntryTextLimit: - errors.Add(($"{errorPrefix}.{entryIdx}.value", - ValidationError.LengthError("Pronoun value is too long", 1, Limits.FieldEntryTextLimit, - entry.Value.Length))); + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.value", + ValidationError.LengthError( + "Pronoun value is too long", + 1, + Limits.FieldEntryTextLimit, + entry.Value.Length + ) + ) + ); break; case < 1: - errors.Add(($"{errorPrefix}.{entryIdx}.value", - ValidationError.LengthError("Pronoun value is too short", 1, Limits.FieldEntryTextLimit, - entry.Value.Length))); + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.value", + ValidationError.LengthError( + "Pronoun value is too short", + 1, + Limits.FieldEntryTextLimit, + entry.Value.Length + ) + ) + ); break; } @@ -263,25 +408,46 @@ public static partial class ValidationUtils switch (entry.DisplayText.Length) { case > Limits.FieldEntryTextLimit: - errors.Add(($"{errorPrefix}.{entryIdx}.value", - ValidationError.LengthError("Pronoun display text is too long", 1, - Limits.FieldEntryTextLimit, - entry.Value.Length))); + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.value", + ValidationError.LengthError( + "Pronoun display text is too long", + 1, + Limits.FieldEntryTextLimit, + entry.Value.Length + ) + ) + ); break; case < 1: - errors.Add(($"{errorPrefix}.{entryIdx}.value", - ValidationError.LengthError("Pronoun display text is too short", 1, - Limits.FieldEntryTextLimit, - entry.Value.Length))); + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.value", + ValidationError.LengthError( + "Pronoun display text is too short", + 1, + Limits.FieldEntryTextLimit, + entry.Value.Length + ) + ) + ); break; } } var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? []; - if (!DefaultStatusOptions.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status)) - errors.Add(($"{errorPrefix}.{entryIdx}.status", - ValidationError.GenericValidationError("Invalid status", entry.Status))); + if ( + !DefaultStatusOptions.Contains(entry.Status) + && !customPreferenceIds.Contains(entry.Status) + ) + errors.Add( + ( + $"{errorPrefix}.{entryIdx}.status", + ValidationError.GenericValidationError("Invalid status", entry.Status) + ) + ); } return errors; @@ -290,6 +456,10 @@ public static partial class ValidationUtils [GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")] private static partial Regex UsernameRegex(); - [GeneratedRegex("""^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""", RegexOptions.IgnoreCase, "en-NL")] + [GeneratedRegex( + """^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""", + RegexOptions.IgnoreCase, + "en-NL" + )] private static partial Regex MemberRegex(); -} \ No newline at end of file +} diff --git a/migrators/NetImporter/ImportUser.cs b/migrators/NetImporter/ImportUser.cs index 9547980..d1c264a 100644 --- a/migrators/NetImporter/ImportUser.cs +++ b/migrators/NetImporter/ImportUser.cs @@ -19,12 +19,19 @@ public static class Users var stopwatch = new Stopwatch(); stopwatch.Start(); - var users = NetImporter.ReadFromFile(filename).Output.Select(ConvertUser).ToList(); + var users = NetImporter + .ReadFromFile(filename) + .Output.Select(ConvertUser) + .ToList(); db.AddRange(users); await db.SaveChangesAsync(); stopwatch.Stop(); - Log.Information("Imported {Count} users in {Duration}", users.Count, stopwatch.ElapsedDuration()); + Log.Information( + "Imported {Count} users in {Duration}", + users.Count, + stopwatch.ElapsedDuration() + ); } private static User ConvertUser(ImportUser oldUser) @@ -43,40 +50,46 @@ public static class Users Role = oldUser.ParseRole(), Deleted = oldUser.Deleted, DeletedAt = oldUser.DeletedAt?.ToInstant(), - DeletedBy = null + DeletedBy = null, }; if (oldUser is { DiscordId: not null, DiscordUsername: not null }) { - user.AuthMethods.Add(new AuthMethod - { - Id = SnowflakeGenerator.Instance.GenerateSnowflake(), - AuthType = AuthType.Discord, - RemoteId = oldUser.DiscordId, - RemoteUsername = oldUser.DiscordUsername - }); + user.AuthMethods.Add( + new AuthMethod + { + Id = SnowflakeGenerator.Instance.GenerateSnowflake(), + AuthType = AuthType.Discord, + RemoteId = oldUser.DiscordId, + RemoteUsername = oldUser.DiscordUsername, + } + ); } if (oldUser is { TumblrId: not null, TumblrUsername: not null }) { - user.AuthMethods.Add(new AuthMethod - { - Id = SnowflakeGenerator.Instance.GenerateSnowflake(), - AuthType = AuthType.Tumblr, - RemoteId = oldUser.TumblrId, - RemoteUsername = oldUser.TumblrUsername - }); + user.AuthMethods.Add( + new AuthMethod + { + Id = SnowflakeGenerator.Instance.GenerateSnowflake(), + AuthType = AuthType.Tumblr, + RemoteId = oldUser.TumblrId, + RemoteUsername = oldUser.TumblrUsername, + } + ); } if (oldUser is { GoogleId: not null, GoogleUsername: not null }) { - user.AuthMethods.Add(new AuthMethod - { - Id = SnowflakeGenerator.Instance.GenerateSnowflake(), - AuthType = AuthType.Google, - RemoteId = oldUser.GoogleId, - RemoteUsername = oldUser.GoogleUsername - }); + user.AuthMethods.Add( + new AuthMethod + { + Id = SnowflakeGenerator.Instance.GenerateSnowflake(), + AuthType = AuthType.Google, + RemoteId = oldUser.GoogleId, + RemoteUsername = oldUser.GoogleUsername, + } + ); } // Convert all custom preference UUIDs to snowflakes @@ -90,41 +103,44 @@ public static class Users foreach (var name in oldUser.Names ?? []) { - user.Names.Add(new FieldEntry - { - Value = name.Value, - Status = prefMapping.TryGetValue(name.Status, out var newStatus) ? newStatus.ToString() : name.Status, - }); + user.Names.Add( + new FieldEntry + { + Value = name.Value, + Status = prefMapping.TryGetValue(name.Status, out var newStatus) + ? newStatus.ToString() + : name.Status, + } + ); } foreach (var pronoun in oldUser.Pronouns ?? []) { - user.Pronouns.Add(new Pronoun - { - Value = pronoun.Value, - DisplayText = pronoun.DisplayText, - Status = prefMapping.TryGetValue(pronoun.Status, out var newStatus) - ? newStatus.ToString() - : pronoun.Status, - }); + user.Pronouns.Add( + new Pronoun + { + Value = pronoun.Value, + DisplayText = pronoun.DisplayText, + Status = prefMapping.TryGetValue(pronoun.Status, out var newStatus) + ? newStatus.ToString() + : pronoun.Status, + } + ); } foreach (var field in oldUser.Fields ?? []) { - var entries = field.Entries.Select(entry => new FieldEntry - { - Value = entry.Value, - Status = prefMapping.TryGetValue(entry.Status, out var newStatus) + var entries = field + .Entries.Select(entry => new FieldEntry + { + Value = entry.Value, + Status = prefMapping.TryGetValue(entry.Status, out var newStatus) ? newStatus.ToString() : entry.Status, - }) + }) .ToList(); - user.Fields.Add(new Field - { - Name = field.Name, - Entries = entries.ToArray() - }); + user.Fields.Add(new Field { Name = field.Name, Entries = entries.ToArray() }); } Log.Debug("Converted user {UserId}", oldUser.Id); @@ -161,14 +177,16 @@ public static class Users bool Deleted, OffsetDateTime? DeletedAt, string? DeleteReason, - Dictionary CustomPreferences) + Dictionary CustomPreferences + ) { - public UserRole ParseRole() => Role switch - { - "USER" => UserRole.User, - "MODERATOR" => UserRole.Moderator, - "ADMIN" => UserRole.Admin, - _ => UserRole.User - }; + public UserRole ParseRole() => + Role switch + { + "USER" => UserRole.User, + "MODERATOR" => UserRole.Moderator, + "ADMIN" => UserRole.Admin, + _ => UserRole.User, + }; } -} \ No newline at end of file +} diff --git a/migrators/NetImporter/NetImporter.cs b/migrators/NetImporter/NetImporter.cs index 05cc42d..1f1bd9a 100644 --- a/migrators/NetImporter/NetImporter.cs +++ b/migrators/NetImporter/NetImporter.cs @@ -19,7 +19,10 @@ internal static class NetImporter .Enrich.FromLogContext() .MinimumLevel.Debug() .MinimumLevel.Override("Microsoft", LogEventLevel.Information) - .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", LogEventLevel.Information) + .MinimumLevel.Override( + "Microsoft.EntityFrameworkCore.Database.Command", + LogEventLevel.Information + ) .WriteTo.Console() .CreateLogger(); @@ -47,16 +50,11 @@ internal static class NetImporter internal static async Task GetContextAsync() { var connString = Environment.GetEnvironmentVariable("DATABASE"); - if (connString == null) throw new Exception("$DATABASE not set, must be an ADO.NET connection string"); + if (connString == null) + throw new Exception("$DATABASE not set, must be an ADO.NET connection string"); var loggerFactory = new LoggerFactory().AddSerilog(Log.Logger); - var config = new Config - { - Database = new Config.DatabaseConfig - { - Url = connString - } - }; + var config = new Config { Database = new Config.DatabaseConfig { Url = connString } }; var db = new DatabaseContext(config, loggerFactory); @@ -70,14 +68,18 @@ internal static class NetImporter private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings { - ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy() } + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy(), + }, }.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); internal static Input ReadFromFile(string path) { var data = File.ReadAllText(path); - return JsonConvert.DeserializeObject>(data, Settings) ?? throw new Exception("Invalid input file"); + return JsonConvert.DeserializeObject>(data, Settings) + ?? throw new Exception("Invalid input file"); } } -internal record Input(List Output, List Skipped); \ No newline at end of file +internal record Input(List Output, List Skipped);