chore: add csharpier to husky, format backend with csharpier
This commit is contained in:
		
							parent
							
								
									5fab66444f
								
							
						
					
					
						commit
						7f971e8549
					
				
					 73 changed files with 2098 additions and 1048 deletions
				
			
		|  | @ -4,7 +4,16 @@ | ||||||
|   "tools": { |   "tools": { | ||||||
|     "husky": { |     "husky": { | ||||||
|       "version": "0.7.1", |       "version": "0.7.1", | ||||||
|       "commands": ["husky"], |       "commands": [ | ||||||
|  |         "husky" | ||||||
|  |       ], | ||||||
|  |       "rollForward": false | ||||||
|  |     }, | ||||||
|  |     "csharpier": { | ||||||
|  |       "version": "0.29.2", | ||||||
|  |       "commands": [ | ||||||
|  |         "dotnet-csharpier" | ||||||
|  |       ], | ||||||
|       "rollForward": false |       "rollForward": false | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -8,9 +8,10 @@ | ||||||
|       "pathMode": "absolute" |       "pathMode": "absolute" | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       "name": "dotnet-format", |       "name": "run-csharpier", | ||||||
|       "command": "dotnet", |       "command": "dotnet", | ||||||
|       "args": ["format"] |       "args": [ "csharpier", "${staged}" ], | ||||||
|  |       "include": [ "**/*.cs" ] | ||||||
|     } |     } | ||||||
|   ] |   ] | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -8,19 +8,24 @@ public static class BuildInfo | ||||||
|     public static async Task ReadBuildInfo() |     public static async Task ReadBuildInfo() | ||||||
|     { |     { | ||||||
|         await using var stream = typeof(BuildInfo).Assembly.GetManifestResourceStream("version"); |         await using var stream = typeof(BuildInfo).Assembly.GetManifestResourceStream("version"); | ||||||
|         if (stream == null) return; |         if (stream == null) | ||||||
|  |             return; | ||||||
| 
 | 
 | ||||||
|         using var reader = new StreamReader(stream); |         using var reader = new StreamReader(stream); | ||||||
|         var data = (await reader.ReadToEndAsync()).Trim().Split("\n"); |         var data = (await reader.ReadToEndAsync()).Trim().Split("\n"); | ||||||
|         if (data.Length < 3) return; |         if (data.Length < 3) | ||||||
|  |             return; | ||||||
| 
 | 
 | ||||||
|         Hash = data[0]; |         Hash = data[0]; | ||||||
|         var dirty = data[2] == "dirty"; |         var dirty = data[2] == "dirty"; | ||||||
| 
 | 
 | ||||||
|         var versionData = data[1].Split("-"); |         var versionData = data[1].Split("-"); | ||||||
|         if (versionData.Length < 3) return; |         if (versionData.Length < 3) | ||||||
|  |             return; | ||||||
|         Version = versionData[0]; |         Version = versionData[0]; | ||||||
|         if (versionData[1] != "0" || dirty) Version += $"+{versionData[2]}"; |         if (versionData[1] != "0" || dirty) | ||||||
|         if (dirty) Version += ".dirty"; |             Version += $"+{versionData[2]}"; | ||||||
|  |         if (dirty) | ||||||
|  |             Version += ".dirty"; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -11,8 +11,12 @@ using NodaTime; | ||||||
| namespace Foxnouns.Backend.Controllers.Authentication; | namespace Foxnouns.Backend.Controllers.Authentication; | ||||||
| 
 | 
 | ||||||
| [Route("/api/internal/auth")] | [Route("/api/internal/auth")] | ||||||
| public class AuthController(Config config, DatabaseContext db, KeyCacheService keyCache, ILogger logger) | public class AuthController( | ||||||
|     : ApiControllerBase |     Config config, | ||||||
|  |     DatabaseContext db, | ||||||
|  |     KeyCacheService keyCache, | ||||||
|  |     ILogger logger | ||||||
|  | ) : ApiControllerBase | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<AuthController>(); |     private readonly ILogger _logger = logger.ForContext<AuthController>(); | ||||||
| 
 | 
 | ||||||
|  | @ -20,27 +24,25 @@ public class AuthController(Config config, DatabaseContext db, KeyCacheService k | ||||||
|     [ProducesResponseType<UrlsResponse>(StatusCodes.Status200OK)] |     [ProducesResponseType<UrlsResponse>(StatusCodes.Status200OK)] | ||||||
|     public async Task<IActionResult> UrlsAsync(CancellationToken ct = default) |     public async Task<IActionResult> 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.DiscordAuth.Enabled, | ||||||
|             config.GoogleAuth.Enabled, |             config.GoogleAuth.Enabled, | ||||||
|             config.TumblrAuth.Enabled); |             config.TumblrAuth.Enabled | ||||||
|  |         ); | ||||||
|         var state = HttpUtility.UrlEncode(await keyCache.GenerateAuthStateAsync(ct)); |         var state = HttpUtility.UrlEncode(await keyCache.GenerateAuthStateAsync(ct)); | ||||||
|         string? discord = null; |         string? discord = null; | ||||||
|         if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) |         if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) | ||||||
|             discord = |             discord = | ||||||
|                 $"https://discord.com/oauth2/authorize?response_type=code" + |                 $"https://discord.com/oauth2/authorize?response_type=code" | ||||||
|                 $"&client_id={config.DiscordAuth.ClientId}&scope=identify" + |                 + $"&client_id={config.DiscordAuth.ClientId}&scope=identify" | ||||||
|                 $"&prompt=none&state={state}" + |                 + $"&prompt=none&state={state}" | ||||||
|                 $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}"; |                 + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}"; | ||||||
| 
 | 
 | ||||||
|         return Ok(new UrlsResponse(discord, null, null)); |         return Ok(new UrlsResponse(discord, null, null)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     private record UrlsResponse( |     private record UrlsResponse(string? Discord, string? Google, string? Tumblr); | ||||||
|         string? Discord, |  | ||||||
|         string? Google, |  | ||||||
|         string? Tumblr |  | ||||||
|     ); |  | ||||||
| 
 | 
 | ||||||
|     public record AuthResponse( |     public record AuthResponse( | ||||||
|         UserRendererService.UserResponse User, |         UserRendererService.UserResponse User, | ||||||
|  | @ -50,16 +52,13 @@ public class AuthController(Config config, DatabaseContext db, KeyCacheService k | ||||||
| 
 | 
 | ||||||
|     public record CallbackResponse( |     public record CallbackResponse( | ||||||
|         bool HasAccount, |         bool HasAccount, | ||||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket, | ||||||
|         string? Ticket, |  | ||||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||||
|             string? RemoteUsername, |             string? RemoteUsername, | ||||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||||
|             UserRendererService.UserResponse? User, |             UserRendererService.UserResponse? User, | ||||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token, | ||||||
|         string? Token, |         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt | ||||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |  | ||||||
|         Instant? ExpiresAt |  | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     public record OauthRegisterRequest(string Ticket, string Username); |     public record OauthRegisterRequest(string Ticket, string Username); | ||||||
|  | @ -71,7 +70,8 @@ public class AuthController(Config config, DatabaseContext db, KeyCacheService k | ||||||
|     public async Task<IActionResult> ForceLogoutAsync() |     public async Task<IActionResult> ForceLogoutAsync() | ||||||
|     { |     { | ||||||
|         _logger.Information("Invalidating all tokens for user {UserId}", CurrentUser!.Id); |         _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)); |             .ExecuteUpdateAsync(s => s.SetProperty(t => t.ManuallyExpired, true)); | ||||||
| 
 | 
 | ||||||
|         return NoContent(); |         return NoContent(); | ||||||
|  |  | ||||||
|  | @ -19,7 +19,8 @@ public class DiscordAuthController( | ||||||
|     KeyCacheService keyCacheService, |     KeyCacheService keyCacheService, | ||||||
|     AuthService authService, |     AuthService authService, | ||||||
|     RemoteAuthService remoteAuthService, |     RemoteAuthService remoteAuthService, | ||||||
|     UserRendererService userRenderer) : ApiControllerBase |     UserRendererService userRenderer | ||||||
|  | ) : ApiControllerBase | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<DiscordAuthController>(); |     private readonly ILogger _logger = logger.ForContext<DiscordAuthController>(); | ||||||
| 
 | 
 | ||||||
|  | @ -27,59 +28,93 @@ public class DiscordAuthController( | ||||||
|     // TODO: duplicating attribute doesn't work, find another way to mark both as possible response |     // TODO: duplicating attribute doesn't work, find another way to mark both as possible response | ||||||
|     // leaving it here for documentation purposes |     // leaving it here for documentation purposes | ||||||
|     [ProducesResponseType<AuthController.CallbackResponse>(StatusCodes.Status200OK)] |     [ProducesResponseType<AuthController.CallbackResponse>(StatusCodes.Status200OK)] | ||||||
|     public async Task<IActionResult> CallbackAsync([FromBody] AuthController.CallbackRequest req, |     public async Task<IActionResult> CallbackAsync( | ||||||
|         CancellationToken ct = default) |         [FromBody] AuthController.CallbackRequest req, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         CheckRequirements(); |         CheckRequirements(); | ||||||
|         await keyCacheService.ValidateAuthStateAsync(req.State, ct); |         await keyCacheService.ValidateAuthStateAsync(req.State, ct); | ||||||
| 
 | 
 | ||||||
|         var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code, req.State, ct); |         var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code, req.State, ct); | ||||||
|         var user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id, ct: 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, |         _logger.Debug( | ||||||
|             remoteUser.Id); |             "Discord user {Username} ({Id}) authenticated with no local account", | ||||||
|  |             remoteUser.Username, | ||||||
|  |             remoteUser.Id | ||||||
|  |         ); | ||||||
| 
 | 
 | ||||||
|         var ticket = AuthUtils.RandomToken(); |         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( |         return Ok( | ||||||
|  |             new AuthController.CallbackResponse( | ||||||
|                 HasAccount: false, |                 HasAccount: false, | ||||||
|                 Ticket: ticket, |                 Ticket: ticket, | ||||||
|                 RemoteUsername: remoteUser.Username, |                 RemoteUsername: remoteUser.Username, | ||||||
|                 User: null, |                 User: null, | ||||||
|                 Token: null, |                 Token: null, | ||||||
|                 ExpiresAt: null |                 ExpiresAt: null | ||||||
|         )); |             ) | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     [HttpPost("register")] |     [HttpPost("register")] | ||||||
|     [ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)] |     [ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)] | ||||||
|     public async Task<IActionResult> RegisterAsync([FromBody] AuthController.OauthRegisterRequest req) |     public async Task<IActionResult> RegisterAsync( | ||||||
|  |         [FromBody] AuthController.OauthRegisterRequest req | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var remoteUser = await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>($"discord:{req.Ticket}"); |         var remoteUser = await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>( | ||||||
|         if (remoteUser == null) throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); |             $"discord:{req.Ticket}" | ||||||
|         if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id)) |         ); | ||||||
|  |         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", |             _logger.Error( | ||||||
|                 remoteUser.Id); |                 "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); |             throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         var user = await authService.CreateUserWithRemoteAuthAsync(req.Username, AuthType.Discord, remoteUser.Id, |         var user = await authService.CreateUserWithRemoteAuthAsync( | ||||||
|             remoteUser.Username); |             req.Username, | ||||||
|  |             AuthType.Discord, | ||||||
|  |             remoteUser.Id, | ||||||
|  |             remoteUser.Username | ||||||
|  |         ); | ||||||
| 
 | 
 | ||||||
|         return Ok(await GenerateUserTokenAsync(user)); |         return Ok(await GenerateUserTokenAsync(user)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     private async Task<AuthController.CallbackResponse> GenerateUserTokenAsync(User user, |     private async Task<AuthController.CallbackResponse> GenerateUserTokenAsync( | ||||||
|         CancellationToken ct = default) |         User user, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var frontendApp = await db.GetFrontendApplicationAsync(ct); |         var frontendApp = await db.GetFrontendApplicationAsync(ct); | ||||||
|         _logger.Debug("Logging user {Id} in with Discord", user.Id); |         _logger.Debug("Logging user {Id} in with Discord", user.Id); | ||||||
| 
 | 
 | ||||||
|         var (tokenStr, token) = |         var (tokenStr, token) = authService.GenerateToken( | ||||||
|             authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); |             user, | ||||||
|  |             frontendApp, | ||||||
|  |             ["*"], | ||||||
|  |             clock.GetCurrentInstant() + Duration.FromDays(365) | ||||||
|  |         ); | ||||||
|         db.Add(token); |         db.Add(token); | ||||||
| 
 | 
 | ||||||
|         _logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id); |         _logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id); | ||||||
|  | @ -90,7 +125,12 @@ public class DiscordAuthController( | ||||||
|             HasAccount: true, |             HasAccount: true, | ||||||
|             Ticket: null, |             Ticket: null, | ||||||
|             RemoteUsername: 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, |             Token: tokenStr, | ||||||
|             ExpiresAt: token.ExpiresAt |             ExpiresAt: token.ExpiresAt | ||||||
|         ); |         ); | ||||||
|  | @ -99,6 +139,8 @@ public class DiscordAuthController( | ||||||
|     private void CheckRequirements() |     private void CheckRequirements() | ||||||
|     { |     { | ||||||
|         if (!config.DiscordAuth.Enabled) |         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." | ||||||
|  |             ); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -20,21 +20,35 @@ public class EmailAuthController( | ||||||
|     KeyCacheService keyCacheService, |     KeyCacheService keyCacheService, | ||||||
|     UserRendererService userRenderer, |     UserRendererService userRenderer, | ||||||
|     IClock clock, |     IClock clock, | ||||||
|     ILogger logger) : ApiControllerBase |     ILogger logger | ||||||
|  | ) : ApiControllerBase | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<EmailAuthController>(); |     private readonly ILogger _logger = logger.ForContext<EmailAuthController>(); | ||||||
| 
 | 
 | ||||||
|     [HttpPost("register")] |     [HttpPost("register")] | ||||||
|     public async Task<IActionResult> RegisterAsync([FromBody] RegisterRequest req, CancellationToken ct = default) |     public async Task<IActionResult> RegisterAsync( | ||||||
|  |         [FromBody] RegisterRequest req, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         CheckRequirements(); |         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 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(); |             return NoContent(); | ||||||
| 
 | 
 | ||||||
|         mailService.QueueAccountCreationEmail(req.Email, state); |         mailService.QueueAccountCreationEmail(req.Email, state); | ||||||
|  | @ -47,29 +61,48 @@ public class EmailAuthController( | ||||||
|         CheckRequirements(); |         CheckRequirements(); | ||||||
| 
 | 
 | ||||||
|         var state = await keyCacheService.GetRegisterEmailStateAsync(req.State); |         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 this callback is for an existing user, add the email address to their auth methods | ||||||
|         if (state.ExistingUserId != null) |         if (state.ExistingUserId != null) | ||||||
|         { |         { | ||||||
|             var authMethod = |             var authMethod = await authService.AddAuthMethodAsync( | ||||||
|                 await authService.AddAuthMethodAsync(state.ExistingUserId.Value, AuthType.Email, state.Email); |                 state.ExistingUserId.Value, | ||||||
|             _logger.Debug("Added email auth {AuthId} for user {UserId}", authMethod.Id, state.ExistingUserId); |                 AuthType.Email, | ||||||
|  |                 state.Email | ||||||
|  |             ); | ||||||
|  |             _logger.Debug( | ||||||
|  |                 "Added email auth {AuthId} for user {UserId}", | ||||||
|  |                 authMethod.Id, | ||||||
|  |                 state.ExistingUserId | ||||||
|  |             ); | ||||||
|             return NoContent(); |             return NoContent(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         var ticket = AuthUtils.RandomToken(); |         var ticket = AuthUtils.RandomToken(); | ||||||
|         await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); |         await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); | ||||||
| 
 | 
 | ||||||
|         return Ok(new AuthController.CallbackResponse(HasAccount: false, Ticket: ticket, RemoteUsername: state.Email, |         return Ok( | ||||||
|             User: null, Token: null, ExpiresAt: null)); |             new AuthController.CallbackResponse( | ||||||
|  |                 HasAccount: false, | ||||||
|  |                 Ticket: ticket, | ||||||
|  |                 RemoteUsername: state.Email, | ||||||
|  |                 User: null, | ||||||
|  |                 Token: null, | ||||||
|  |                 ExpiresAt: null | ||||||
|  |             ) | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     [HttpPost("complete-registration")] |     [HttpPost("complete-registration")] | ||||||
|     public async Task<IActionResult> CompleteRegistrationAsync([FromBody] CompleteRegistrationRequest req) |     public async Task<IActionResult> CompleteRegistrationAsync( | ||||||
|  |         [FromBody] CompleteRegistrationRequest req | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var email = await keyCacheService.GetKeyAsync($"email:{req.Ticket}"); |         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 |         // Check if username is valid at all | ||||||
|         ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(req.Username))]); |         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 user = await authService.CreateUserWithPasswordAsync(req.Username, email, req.Password); | ||||||
|         var frontendApp = await db.GetFrontendApplicationAsync(); |         var frontendApp = await db.GetFrontendApplicationAsync(); | ||||||
| 
 | 
 | ||||||
|         var (tokenStr, token) = |         var (tokenStr, token) = authService.GenerateToken( | ||||||
|             authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); |             user, | ||||||
|  |             frontendApp, | ||||||
|  |             ["*"], | ||||||
|  |             clock.GetCurrentInstant() + Duration.FromDays(365) | ||||||
|  |         ); | ||||||
|         db.Add(token); |         db.Add(token); | ||||||
| 
 | 
 | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
| 
 | 
 | ||||||
|         await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}"); |         await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}"); | ||||||
| 
 | 
 | ||||||
|         return Ok(new AuthController.AuthResponse( |         return Ok( | ||||||
|  |             new AuthController.AuthResponse( | ||||||
|                 await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false), |                 await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false), | ||||||
|                 tokenStr, |                 tokenStr, | ||||||
|                 token.ExpiresAt |                 token.ExpiresAt | ||||||
|         )); |             ) | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     [HttpPost("login")] |     [HttpPost("login")] | ||||||
|     [ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)] |     [ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)] | ||||||
|     public async Task<IActionResult> LoginAsync([FromBody] LoginRequest req, CancellationToken ct = default) |     public async Task<IActionResult> LoginAsync( | ||||||
|  |         [FromBody] LoginRequest req, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         CheckRequirements(); |         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) |         if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired) | ||||||
|             throw new NotImplementedException("MFA is not implemented yet"); |             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); |         _logger.Debug("Logging user {Id} in with email and password", user.Id); | ||||||
| 
 | 
 | ||||||
|         var (tokenStr, token) = |         var (tokenStr, token) = authService.GenerateToken( | ||||||
|             authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); |             user, | ||||||
|  |             frontendApp, | ||||||
|  |             ["*"], | ||||||
|  |             clock.GetCurrentInstant() + Duration.FromDays(365) | ||||||
|  |         ); | ||||||
|         db.Add(token); |         db.Add(token); | ||||||
| 
 | 
 | ||||||
|         _logger.Debug("Generated token {TokenId} for {UserId}", token.Id, user.Id); |         _logger.Debug("Generated token {TokenId} for {UserId}", token.Id, user.Id); | ||||||
| 
 | 
 | ||||||
|         await db.SaveChangesAsync(ct); |         await db.SaveChangesAsync(ct); | ||||||
| 
 | 
 | ||||||
|         return Ok(new AuthController.AuthResponse( |         return Ok( | ||||||
|             await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), |             new AuthController.AuthResponse( | ||||||
|  |                 await userRenderer.RenderUserAsync( | ||||||
|  |                     user, | ||||||
|  |                     selfUser: user, | ||||||
|  |                     renderMembers: false, | ||||||
|  |                     ct: ct | ||||||
|  |                 ), | ||||||
|                 tokenStr, |                 tokenStr, | ||||||
|                 token.ExpiresAt |                 token.ExpiresAt | ||||||
|         )); |             ) | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     [HttpPost("add")] |     [HttpPost("add")] | ||||||
|  |  | ||||||
|  | @ -18,13 +18,16 @@ public class FlagsController( | ||||||
|     UserRendererService userRenderer, |     UserRendererService userRenderer, | ||||||
|     ObjectStorageService objectStorageService, |     ObjectStorageService objectStorageService, | ||||||
|     ISnowflakeGenerator snowflakeGenerator, |     ISnowflakeGenerator snowflakeGenerator, | ||||||
|     IQueue queue) : ApiControllerBase |     IQueue queue | ||||||
|  | ) : ApiControllerBase | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<FlagsController>(); |     private readonly ILogger _logger = logger.ForContext<FlagsController>(); | ||||||
| 
 | 
 | ||||||
|     [HttpGet] |     [HttpGet] | ||||||
|     [Authorize("identify")] |     [Authorize("identify")] | ||||||
|     [ProducesResponseType<IEnumerable<UserRendererService.PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)] |     [ProducesResponseType<IEnumerable<UserRendererService.PrideFlagResponse>>( | ||||||
|  |         statusCode: StatusCodes.Status200OK | ||||||
|  |     )] | ||||||
|     public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default) |     public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default) | ||||||
|     { |     { | ||||||
|         var flags = await db.PrideFlags.Where(f => f.UserId == CurrentUser!.Id).ToListAsync(ct); |         var flags = await db.PrideFlags.Where(f => f.UserId == CurrentUser!.Id).ToListAsync(ct); | ||||||
|  | @ -34,7 +37,9 @@ public class FlagsController( | ||||||
| 
 | 
 | ||||||
|     [HttpPost] |     [HttpPost] | ||||||
|     [Authorize("user.update")] |     [Authorize("user.update")] | ||||||
|     [ProducesResponseType<UserRendererService.PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)] |     [ProducesResponseType<UserRendererService.PrideFlagResponse>( | ||||||
|  |         statusCode: StatusCodes.Status202Accepted | ||||||
|  |     )] | ||||||
|     public IActionResult CreateFlag([FromBody] CreateFlagRequest req) |     public IActionResult CreateFlag([FromBody] CreateFlagRequest req) | ||||||
|     { |     { | ||||||
|         ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image)); |         ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image)); | ||||||
|  | @ -42,7 +47,8 @@ public class FlagsController( | ||||||
|         var id = snowflakeGenerator.GenerateSnowflake(); |         var id = snowflakeGenerator.GenerateSnowflake(); | ||||||
| 
 | 
 | ||||||
|         queue.QueueInvocableWithPayload<CreateFlagInvocable, CreateFlagPayload>( |         queue.QueueInvocableWithPayload<CreateFlagInvocable, CreateFlagPayload>( | ||||||
|             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)); |         return Accepted(new CreateFlagResponse(id, req.Name, req.Description)); | ||||||
|     } |     } | ||||||
|  | @ -57,10 +63,14 @@ public class FlagsController( | ||||||
|     { |     { | ||||||
|         ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, null)); |         ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, null)); | ||||||
| 
 | 
 | ||||||
|         var flag = await db.PrideFlags.FirstOrDefaultAsync(f => f.Id == id && f.UserId == CurrentUser!.Id); |         var flag = await db.PrideFlags.FirstOrDefaultAsync(f => | ||||||
|         if (flag == null) throw new ApiError.NotFound("Unknown flag ID, or it's not your flag."); |             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))) |         if (req.HasProperty(nameof(req.Description))) | ||||||
|             flag.Description = req.Description; |             flag.Description = req.Description; | ||||||
|  | @ -83,8 +93,11 @@ public class FlagsController( | ||||||
|     { |     { | ||||||
|         await using var tx = await db.Database.BeginTransactionAsync(); |         await using var tx = await db.Database.BeginTransactionAsync(); | ||||||
| 
 | 
 | ||||||
|         var flag = await db.PrideFlags.FirstOrDefaultAsync(f => f.Id == id && f.UserId == CurrentUser!.Id); |         var flag = await db.PrideFlags.FirstOrDefaultAsync(f => | ||||||
|         if (flag == null) throw new ApiError.NotFound("Unknown flag ID, or it's not your flag."); |             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; |         var hash = flag.Hash; | ||||||
| 
 | 
 | ||||||
|  | @ -96,7 +109,10 @@ public class FlagsController( | ||||||
|         { |         { | ||||||
|             try |             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); |                 await objectStorageService.DeleteFlagAsync(hash); | ||||||
|             } |             } | ||||||
|             catch (Exception e) |             catch (Exception e) | ||||||
|  | @ -104,14 +120,19 @@ public class FlagsController( | ||||||
|                 _logger.Error(e, "Error deleting flag file {Hash}", hash); |                 _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(); |         await tx.CommitAsync(); | ||||||
| 
 | 
 | ||||||
|         return NoContent(); |         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?)>(); |         var errors = new List<(string, ValidationError?)>(); | ||||||
| 
 | 
 | ||||||
|  | @ -120,10 +141,20 @@ public class FlagsController( | ||||||
|             switch (name.Length) |             switch (name.Length) | ||||||
|             { |             { | ||||||
|                 case < 1: |                 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; |                     break; | ||||||
|                 case > 100: |                 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; |                     break; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  | @ -133,12 +164,30 @@ public class FlagsController( | ||||||
|             switch (description.Length) |             switch (description.Length) | ||||||
|             { |             { | ||||||
|                 case < 1: |                 case < 1: | ||||||
|                     errors.Add(("description", |                     errors.Add( | ||||||
|                         ValidationError.LengthError("Description is too short", 1, 100, description.Length))); |                         ( | ||||||
|  |                             "description", | ||||||
|  |                             ValidationError.LengthError( | ||||||
|  |                                 "Description is too short", | ||||||
|  |                                 1, | ||||||
|  |                                 100, | ||||||
|  |                                 description.Length | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     ); | ||||||
|                     break; |                     break; | ||||||
|                 case > 500: |                 case > 500: | ||||||
|                     errors.Add(("description", |                     errors.Add( | ||||||
|                         ValidationError.LengthError("Description is too long", 1, 100, description.Length))); |                         ( | ||||||
|  |                             "description", | ||||||
|  |                             ValidationError.LengthError( | ||||||
|  |                                 "Description is too long", | ||||||
|  |                                 1, | ||||||
|  |                                 100, | ||||||
|  |                                 description.Length | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     ); | ||||||
|                     break; |                     break; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  | @ -148,10 +197,20 @@ public class FlagsController( | ||||||
|             switch (imageData.Length) |             switch (imageData.Length) | ||||||
|             { |             { | ||||||
|                 case 0: |                 case 0: | ||||||
|                     errors.Add(("image", ValidationError.GenericValidationError("Image cannot be empty", null))); |                     errors.Add( | ||||||
|  |                         ( | ||||||
|  |                             "image", | ||||||
|  |                             ValidationError.GenericValidationError("Image cannot be empty", null) | ||||||
|  |                         ) | ||||||
|  |                     ); | ||||||
|                     break; |                     break; | ||||||
|                 case > 1_500_000: |                 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; |                     break; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | @ -17,11 +17,13 @@ public partial class InternalController(DatabaseContext db) : ControllerBase | ||||||
| 
 | 
 | ||||||
|     private static string GetCleanedTemplate(string template) |     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() |         template = PathVarRegex() | ||||||
|             .Replace(template, "{id}") // Replace all path variables (almost always IDs) with `{id}` |             .Replace(template, "{id}") // Replace all path variables (almost always IDs) with `{id}` | ||||||
|             .Replace("@me", "{id}"); // Also replace hardcoded `@me` 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; |         return template; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -29,11 +31,13 @@ public partial class InternalController(DatabaseContext db) : ControllerBase | ||||||
|     public async Task<IActionResult> GetRequestDataAsync([FromBody] RequestDataRequest req) |     public async Task<IActionResult> GetRequestDataAsync([FromBody] RequestDataRequest req) | ||||||
|     { |     { | ||||||
|         var endpoint = GetEndpoint(HttpContext, req.Path, req.Method); |         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<ControllerActionDescriptor>(); |         var actionDescriptor = endpoint.Metadata.GetMetadata<ControllerActionDescriptor>(); | ||||||
|         var template = actionDescriptor?.AttributeRouteInfo?.Template; |         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); |         template = GetCleanedTemplate(template); | ||||||
| 
 | 
 | ||||||
|         // If no token was supplied, or it isn't valid base 64, return a null user ID (limiting by IP) |         // If no token was supplied, or it isn't valid base 64, return a null user ID (limiting by IP) | ||||||
|  | @ -46,26 +50,37 @@ public partial class InternalController(DatabaseContext db) : ControllerBase | ||||||
| 
 | 
 | ||||||
|     public record RequestDataRequest(string? Token, string Method, string Path); |     public record RequestDataRequest(string? Token, string Method, string Path); | ||||||
| 
 | 
 | ||||||
|     public record RequestDataResponse( |     public record RequestDataResponse(Snowflake? UserId, string Template); | ||||||
|         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<EndpointDataSource>(); |         var endpointDataSource = httpContext.RequestServices.GetService<EndpointDataSource>(); | ||||||
|         if (endpointDataSource == null) return null; |         if (endpointDataSource == null) | ||||||
|  |             return null; | ||||||
|         var endpoints = endpointDataSource.Endpoints.OfType<RouteEndpoint>(); |         var endpoints = endpointDataSource.Endpoints.OfType<RouteEndpoint>(); | ||||||
| 
 | 
 | ||||||
|         foreach (var endpoint in endpoints) |         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), |             var templateMatcher = new TemplateMatcher( | ||||||
|                 new RouteValueDictionary()); |                 TemplateParser.Parse(endpoint.RoutePattern.RawText), | ||||||
|             if (!templateMatcher.TryMatch(url, new())) continue; |                 new RouteValueDictionary() | ||||||
|  |             ); | ||||||
|  |             if (!templateMatcher.TryMatch(url, new())) | ||||||
|  |                 continue; | ||||||
|             var httpMethodAttribute = endpoint.Metadata.GetMetadata<HttpMethodAttribute>(); |             var httpMethodAttribute = endpoint.Metadata.GetMetadata<HttpMethodAttribute>(); | ||||||
|             if (httpMethodAttribute != null && |             if ( | ||||||
|                 !httpMethodAttribute.HttpMethods.Any(x => x.Equals(requestMethod, StringComparison.OrdinalIgnoreCase))) |                 httpMethodAttribute != null | ||||||
|  |                 && !httpMethodAttribute.HttpMethods.Any(x => | ||||||
|  |                     x.Equals(requestMethod, StringComparison.OrdinalIgnoreCase) | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|                 continue; |                 continue; | ||||||
|             return endpoint; |             return endpoint; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | @ -21,12 +21,15 @@ public class MembersController( | ||||||
|     ISnowflakeGenerator snowflakeGenerator, |     ISnowflakeGenerator snowflakeGenerator, | ||||||
|     ObjectStorageService objectStorageService, |     ObjectStorageService objectStorageService, | ||||||
|     IQueue queue, |     IQueue queue, | ||||||
|     IClock clock) : ApiControllerBase |     IClock clock | ||||||
|  | ) : ApiControllerBase | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<MembersController>(); |     private readonly ILogger _logger = logger.ForContext<MembersController>(); | ||||||
| 
 | 
 | ||||||
|     [HttpGet] |     [HttpGet] | ||||||
|     [ProducesResponseType<IEnumerable<MemberRendererService.PartialMember>>(StatusCodes.Status200OK)] |     [ProducesResponseType<IEnumerable<MemberRendererService.PartialMember>>( | ||||||
|  |         StatusCodes.Status200OK | ||||||
|  |     )] | ||||||
|     public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default) |     public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default) | ||||||
|     { |     { | ||||||
|         var user = await db.ResolveUserAsync(userRef, CurrentToken, ct); |         var user = await db.ResolveUserAsync(userRef, CurrentToken, ct); | ||||||
|  | @ -35,7 +38,11 @@ public class MembersController( | ||||||
| 
 | 
 | ||||||
|     [HttpGet("{memberRef}")] |     [HttpGet("{memberRef}")] | ||||||
|     [ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)] |     [ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)] | ||||||
|     public async Task<IActionResult> GetMemberAsync(string userRef, string memberRef, CancellationToken ct = default) |     public async Task<IActionResult> GetMemberAsync( | ||||||
|  |         string userRef, | ||||||
|  |         string memberRef, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken, ct); |         var member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken, ct); | ||||||
|         return Ok(memberRenderer.RenderMember(member, CurrentToken)); |         return Ok(memberRenderer.RenderMember(member, CurrentToken)); | ||||||
|  | @ -46,19 +53,30 @@ public class MembersController( | ||||||
|     [HttpPost("/api/v2/users/@me/members")] |     [HttpPost("/api/v2/users/@me/members")] | ||||||
|     [ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)] |     [ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)] | ||||||
|     [Authorize("member.create")] |     [Authorize("member.create")] | ||||||
|     public async Task<IActionResult> CreateMemberAsync([FromBody] CreateMemberRequest req, |     public async Task<IActionResult> CreateMemberAsync( | ||||||
|         CancellationToken ct = default) |         [FromBody] CreateMemberRequest req, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         ValidationUtils.Validate([ |         ValidationUtils.Validate( | ||||||
|  |             [ | ||||||
|                 ("name", ValidationUtils.ValidateMemberName(req.Name)), |                 ("name", ValidationUtils.ValidateMemberName(req.Name)), | ||||||
|                 ("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)), |                 ("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)), | ||||||
|                 ("bio", ValidationUtils.ValidateBio(req.Bio)), |                 ("bio", ValidationUtils.ValidateBio(req.Bio)), | ||||||
|                 ("avatar", ValidationUtils.ValidateAvatar(req.Avatar)), |                 ("avatar", ValidationUtils.ValidateAvatar(req.Avatar)), | ||||||
|                 .. ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences), |                 .. ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences), | ||||||
|             .. ValidationUtils.ValidateFieldEntries(req.Names?.ToArray(), CurrentUser!.CustomPreferences, "names"), |                 .. ValidationUtils.ValidateFieldEntries( | ||||||
|             .. ValidationUtils.ValidatePronouns(req.Pronouns?.ToArray(), CurrentUser!.CustomPreferences), |                     req.Names?.ToArray(), | ||||||
|             .. ValidationUtils.ValidateLinks(req.Links) |                     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); |         var memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct); | ||||||
|         if (memberCount >= MaxMemberCount) |         if (memberCount >= MaxMemberCount) | ||||||
|  | @ -75,11 +93,16 @@ public class MembersController( | ||||||
|             Fields = req.Fields ?? [], |             Fields = req.Fields ?? [], | ||||||
|             Names = req.Names ?? [], |             Names = req.Names ?? [], | ||||||
|             Pronouns = req.Pronouns ?? [], |             Pronouns = req.Pronouns ?? [], | ||||||
|             Unlisted = req.Unlisted ?? false |             Unlisted = req.Unlisted ?? false, | ||||||
|         }; |         }; | ||||||
|         db.Add(member); |         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 |         try | ||||||
|         { |         { | ||||||
|  | @ -88,19 +111,27 @@ public class MembersController( | ||||||
|         catch (UniqueConstraintException) |         catch (UniqueConstraintException) | ||||||
|         { |         { | ||||||
|             _logger.Debug("Could not create member {Id} due to name conflict", member.Id); |             _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) |         if (req.Avatar != null) | ||||||
|             queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>( |             queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>( | ||||||
|                 new AvatarUpdatePayload(member.Id, req.Avatar)); |                 new AvatarUpdatePayload(member.Id, req.Avatar) | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|         return Ok(memberRenderer.RenderMember(member, CurrentToken)); |         return Ok(memberRenderer.RenderMember(member, CurrentToken)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     [HttpPatch("/api/v2/users/@me/members/{memberRef}")] |     [HttpPatch("/api/v2/users/@me/members/{memberRef}")] | ||||||
|     [Authorize("member.update")] |     [Authorize("member.update")] | ||||||
|     public async Task<IActionResult> UpdateMemberAsync(string memberRef, [FromBody] UpdateMemberRequest req) |     public async Task<IActionResult> UpdateMemberAsync( | ||||||
|  |         string memberRef, | ||||||
|  |         [FromBody] UpdateMemberRequest req | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         await using var tx = await db.Database.BeginTransactionAsync(); |         await using var tx = await db.Database.BeginTransactionAsync(); | ||||||
|         var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef); |         var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef); | ||||||
|  | @ -134,26 +165,37 @@ public class MembersController( | ||||||
| 
 | 
 | ||||||
|         if (req.Names != null) |         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(); |             member.Names = req.Names.ToList(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.Pronouns != null) |         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(); |             member.Pronouns = req.Pronouns.ToList(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.Fields != null) |         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(); |             member.Fields = req.Fields.ToList(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.Flags != null) |         if (req.Flags != null) | ||||||
|         { |         { | ||||||
|             var flagError = await db.SetMemberFlagsAsync(CurrentUser!.Id, member.Id, req.Flags); |             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))) |         if (req.HasProperty(nameof(req.Avatar))) | ||||||
|  | @ -165,16 +207,25 @@ public class MembersController( | ||||||
|         // so it's in a separate block to the validation above. |         // so it's in a separate block to the validation above. | ||||||
|         if (req.HasProperty(nameof(req.Avatar))) |         if (req.HasProperty(nameof(req.Avatar))) | ||||||
|             queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>( |             queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>( | ||||||
|                 new AvatarUpdatePayload(member.Id, req.Avatar)); |                 new AvatarUpdatePayload(member.Id, req.Avatar) | ||||||
|  |             ); | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             await db.SaveChangesAsync(); |             await db.SaveChangesAsync(); | ||||||
|         } |         } | ||||||
|         catch (UniqueConstraintException) |         catch (UniqueConstraintException) | ||||||
|         { |         { | ||||||
|             _logger.Debug("Could not update member {Id} due to name conflict ({CurrentName} / {NewName})", member.Id, |             _logger.Debug( | ||||||
|                 member.Name, req.Name); |                 "Could not update member {Id} due to name conflict ({CurrentName} / {NewName})", | ||||||
|             throw new ApiError.BadRequest("A member with that name already exists", "name", req.Name!); |                 member.Id, | ||||||
|  |                 member.Name, | ||||||
|  |                 req.Name | ||||||
|  |             ); | ||||||
|  |             throw new ApiError.BadRequest( | ||||||
|  |                 "A member with that name already exists", | ||||||
|  |                 "name", | ||||||
|  |                 req.Name! | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         await tx.CommitAsync(); |         await tx.CommitAsync(); | ||||||
|  | @ -199,15 +250,20 @@ public class MembersController( | ||||||
|     public async Task<IActionResult> DeleteMemberAsync(string memberRef) |     public async Task<IActionResult> DeleteMemberAsync(string memberRef) | ||||||
|     { |     { | ||||||
|         var member = await db.ResolveMemberAsync(CurrentUser!.Id, 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(); |             .ExecuteDeleteAsync(); | ||||||
|         if (deleteCount == 0) |         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(); |             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(); |         return NoContent(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -220,7 +276,8 @@ public class MembersController( | ||||||
|         string[]? Links, |         string[]? Links, | ||||||
|         List<FieldEntry>? Names, |         List<FieldEntry>? Names, | ||||||
|         List<Pronoun>? Pronouns, |         List<Pronoun>? Pronouns, | ||||||
|         List<Field>? Fields); |         List<Field>? Fields | ||||||
|  |     ); | ||||||
| 
 | 
 | ||||||
|     [HttpPost("/api/v2/users/@me/members/{memberRef}/reroll-sid")] |     [HttpPost("/api/v2/users/@me/members/{memberRef}/reroll-sid")] | ||||||
|     [Authorize("member.update")] |     [Authorize("member.update")] | ||||||
|  | @ -234,14 +291,16 @@ public class MembersController( | ||||||
|             throw new ApiError.BadRequest("Cannot reroll short ID yet"); |             throw new ApiError.BadRequest("Cannot reroll short ID yet"); | ||||||
| 
 | 
 | ||||||
|         // Using ExecuteUpdateAsync here as the new short ID is generated by the database |         // Using ExecuteUpdateAsync here as the new short ID is generated by the database | ||||||
|         await db.Members.Where(m => m.Id == member.Id) |         await db | ||||||
|             .ExecuteUpdateAsync(s => s |             .Members.Where(m => m.Id == member.Id) | ||||||
|                 .SetProperty(m => m.Sid, _ => db.FindFreeMemberSid())); |             .ExecuteUpdateAsync(s => s.SetProperty(m => m.Sid, _ => db.FindFreeMemberSid())); | ||||||
| 
 | 
 | ||||||
|         await db.Users.Where(u => u.Id == CurrentUser.Id) |         await db | ||||||
|             .ExecuteUpdateAsync(s => s |             .Users.Where(u => u.Id == CurrentUser.Id) | ||||||
|                 .SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant()) |             .ExecuteUpdateAsync(s => | ||||||
|                 .SetProperty(u => u.LastActive, clock.GetCurrentInstant())); |                 s.SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant()) | ||||||
|  |                     .SetProperty(u => u.LastActive, clock.GetCurrentInstant()) | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|         // Re-fetch member to fetch the new sid |         // Re-fetch member to fetch the new sid | ||||||
|         var updatedMember = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef); |         var updatedMember = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef); | ||||||
|  |  | ||||||
|  | @ -12,8 +12,12 @@ public class MetaController : ApiControllerBase | ||||||
|     [ProducesResponseType<MetaResponse>(StatusCodes.Status200OK)] |     [ProducesResponseType<MetaResponse>(StatusCodes.Status200OK)] | ||||||
|     public IActionResult GetMeta() |     public IActionResult GetMeta() | ||||||
|     { |     { | ||||||
|         return Ok(new MetaResponse( |         return Ok( | ||||||
|             Repository, BuildInfo.Version, BuildInfo.Hash, (int)FoxnounsMetrics.MemberCount.Value, |             new MetaResponse( | ||||||
|  |                 Repository, | ||||||
|  |                 BuildInfo.Version, | ||||||
|  |                 BuildInfo.Hash, | ||||||
|  |                 (int)FoxnounsMetrics.MemberCount.Value, | ||||||
|                 new UserInfo( |                 new UserInfo( | ||||||
|                     (int)FoxnounsMetrics.UsersCount.Value, |                     (int)FoxnounsMetrics.UsersCount.Value, | ||||||
|                     (int)FoxnounsMetrics.UsersActiveMonthCount.Value, |                     (int)FoxnounsMetrics.UsersActiveMonthCount.Value, | ||||||
|  | @ -23,12 +27,15 @@ public class MetaController : ApiControllerBase | ||||||
|                 new Limits( |                 new Limits( | ||||||
|                     MemberCount: MembersController.MaxMemberCount, |                     MemberCount: MembersController.MaxMemberCount, | ||||||
|                     BioLength: ValidationUtils.MaxBioLength, |                     BioLength: ValidationUtils.MaxBioLength, | ||||||
|                 CustomPreferences: UsersController.MaxCustomPreferences)) |                     CustomPreferences: UsersController.MaxCustomPreferences | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     [HttpGet("/api/v2/coffee")] |     [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( |     private record MetaResponse( | ||||||
|         string Repository, |         string Repository, | ||||||
|  | @ -36,13 +43,11 @@ public class MetaController : ApiControllerBase | ||||||
|         string Hash, |         string Hash, | ||||||
|         int Members, |         int Members, | ||||||
|         UserInfo Users, |         UserInfo Users, | ||||||
|         Limits Limits); |         Limits Limits | ||||||
|  |     ); | ||||||
| 
 | 
 | ||||||
|     private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay); |     private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay); | ||||||
| 
 | 
 | ||||||
|     // All limits that the frontend should know about (for UI purposes) |     // All limits that the frontend should know about (for UI purposes) | ||||||
|     private record Limits( |     private record Limits(int MemberCount, int BioLength, int CustomPreferences); | ||||||
|         int MemberCount, |  | ||||||
|         int BioLength, |  | ||||||
|         int CustomPreferences); |  | ||||||
| } | } | ||||||
|  | @ -20,7 +20,8 @@ public class UsersController( | ||||||
|     UserRendererService userRenderer, |     UserRendererService userRenderer, | ||||||
|     ISnowflakeGenerator snowflakeGenerator, |     ISnowflakeGenerator snowflakeGenerator, | ||||||
|     IQueue queue, |     IQueue queue, | ||||||
|     IClock clock) : ApiControllerBase |     IClock clock | ||||||
|  | ) : ApiControllerBase | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<UsersController>(); |     private readonly ILogger _logger = logger.ForContext<UsersController>(); | ||||||
| 
 | 
 | ||||||
|  | @ -29,20 +30,25 @@ public class UsersController( | ||||||
|     public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default) |     public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default) | ||||||
|     { |     { | ||||||
|         var user = await db.ResolveUserAsync(userRef, CurrentToken, ct); |         var user = await db.ResolveUserAsync(userRef, CurrentToken, ct); | ||||||
|         return Ok(await userRenderer.RenderUserAsync( |         return Ok( | ||||||
|  |             await userRenderer.RenderUserAsync( | ||||||
|                 user, |                 user, | ||||||
|                 selfUser: CurrentUser, |                 selfUser: CurrentUser, | ||||||
|                 token: CurrentToken, |                 token: CurrentToken, | ||||||
|                 renderMembers: true, |                 renderMembers: true, | ||||||
|                 renderAuthMethods: true, |                 renderAuthMethods: true, | ||||||
|                 ct: ct |                 ct: ct | ||||||
|         )); |             ) | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     [HttpPatch("@me")] |     [HttpPatch("@me")] | ||||||
|     [Authorize("user.update")] |     [Authorize("user.update")] | ||||||
|     [ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)] |     [ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)] | ||||||
|     public async Task<IActionResult> UpdateUserAsync([FromBody] UpdateUserRequest req, CancellationToken ct = default) |     public async Task<IActionResult> UpdateUserAsync( | ||||||
|  |         [FromBody] UpdateUserRequest req, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         await using var tx = await db.Database.BeginTransactionAsync(ct); |         await using var tx = await db.Database.BeginTransactionAsync(ct); | ||||||
|         var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct); |         var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct); | ||||||
|  | @ -74,26 +80,37 @@ public class UsersController( | ||||||
| 
 | 
 | ||||||
|         if (req.Names != null) |         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(); |             user.Names = req.Names.ToList(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.Pronouns != null) |         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(); |             user.Pronouns = req.Pronouns.ToList(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.Fields != null) |         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(); |             user.Fields = req.Fields.ToList(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (req.Flags != null) |         if (req.Flags != null) | ||||||
|         { |         { | ||||||
|             var flagError = await db.SetUserFlagsAsync(CurrentUser!.Id, req.Flags); |             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))) |         if (req.HasProperty(nameof(req.Avatar))) | ||||||
|  | @ -105,7 +122,8 @@ public class UsersController( | ||||||
|         // so it's in a separate block to the validation above. |         // so it's in a separate block to the validation above. | ||||||
|         if (req.HasProperty(nameof(req.Avatar))) |         if (req.HasProperty(nameof(req.Avatar))) | ||||||
|             queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>( |             queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>( | ||||||
|                 new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)); |                 new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar) | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|  | @ -113,26 +131,45 @@ public class UsersController( | ||||||
|         } |         } | ||||||
|         catch (UniqueConstraintException) |         catch (UniqueConstraintException) | ||||||
|         { |         { | ||||||
|             _logger.Debug("Could not update user {Id} due to name conflict ({CurrentName} / {NewName})", user.Id, |             _logger.Debug( | ||||||
|                 user.Username, req.Username); |                 "Could not update user {Id} due to name conflict ({CurrentName} / {NewName})", | ||||||
|             throw new ApiError.BadRequest("That username is already taken.", "username", req.Username!); |                 user.Id, | ||||||
|  |                 user.Username, | ||||||
|  |                 req.Username | ||||||
|  |             ); | ||||||
|  |             throw new ApiError.BadRequest( | ||||||
|  |                 "That username is already taken.", | ||||||
|  |                 "username", | ||||||
|  |                 req.Username! | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         await tx.CommitAsync(ct); |         await tx.CommitAsync(ct); | ||||||
|         return Ok(await userRenderer.RenderUserAsync(user, CurrentUser, renderMembers: false, |         return Ok( | ||||||
|             renderAuthMethods: false, ct: ct)); |             await userRenderer.RenderUserAsync( | ||||||
|  |                 user, | ||||||
|  |                 CurrentUser, | ||||||
|  |                 renderMembers: false, | ||||||
|  |                 renderAuthMethods: false, | ||||||
|  |                 ct: ct | ||||||
|  |             ) | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     [HttpPatch("@me/custom-preferences")] |     [HttpPatch("@me/custom-preferences")] | ||||||
|     [Authorize("user.update")] |     [Authorize("user.update")] | ||||||
|     [ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)] |     [ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)] | ||||||
|     public async Task<IActionResult> UpdateCustomPreferencesAsync([FromBody] List<CustomPreferencesUpdateRequest> req, |     public async Task<IActionResult> UpdateCustomPreferencesAsync( | ||||||
|         CancellationToken ct = default) |         [FromBody] List<CustomPreferencesUpdateRequest> req, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         ValidationUtils.Validate(ValidateCustomPreferences(req)); |         ValidationUtils.Validate(ValidateCustomPreferences(req)); | ||||||
| 
 | 
 | ||||||
|         var user = await db.ResolveUserAsync(CurrentUser!.Id, ct); |         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) |         foreach (var r in req) | ||||||
|         { |         { | ||||||
|  | @ -144,7 +181,7 @@ public class UsersController( | ||||||
|                     Icon = r.Icon, |                     Icon = r.Icon, | ||||||
|                     Muted = r.Muted, |                     Muted = r.Muted, | ||||||
|                     Size = r.Size, |                     Size = r.Size, | ||||||
|                     Tooltip = r.Tooltip |                     Tooltip = r.Tooltip, | ||||||
|                 }; |                 }; | ||||||
|             } |             } | ||||||
|             else |             else | ||||||
|  | @ -155,7 +192,7 @@ public class UsersController( | ||||||
|                     Icon = r.Icon, |                     Icon = r.Icon, | ||||||
|                     Muted = r.Muted, |                     Muted = r.Muted, | ||||||
|                     Size = r.Size, |                     Size = r.Size, | ||||||
|                     Tooltip = r.Tooltip |                     Tooltip = r.Tooltip, | ||||||
|                 }; |                 }; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  | @ -180,15 +217,25 @@ public class UsersController( | ||||||
|     public const int MaxCustomPreferences = 25; |     public const int MaxCustomPreferences = 25; | ||||||
| 
 | 
 | ||||||
|     private static List<(string, ValidationError?)> ValidateCustomPreferences( |     private static List<(string, ValidationError?)> ValidateCustomPreferences( | ||||||
|         List<CustomPreferencesUpdateRequest> preferences) |         List<CustomPreferencesUpdateRequest> preferences | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var errors = new List<(string, ValidationError?)>(); |         var errors = new List<(string, ValidationError?)>(); | ||||||
| 
 | 
 | ||||||
|         if (preferences.Count > MaxCustomPreferences) |         if (preferences.Count > MaxCustomPreferences) | ||||||
|             errors.Add(("custom_preferences", |             errors.Add( | ||||||
|                 ValidationError.LengthError("Too many custom preferences", 0, MaxCustomPreferences, |                 ( | ||||||
|                     preferences.Count))); |                     "custom_preferences", | ||||||
|         if (preferences.Count > 50) return errors; |                     ValidationError.LengthError( | ||||||
|  |                         "Too many custom preferences", | ||||||
|  |                         0, | ||||||
|  |                         MaxCustomPreferences, | ||||||
|  |                         preferences.Count | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             ); | ||||||
|  |         if (preferences.Count > 50) | ||||||
|  |             return errors; | ||||||
| 
 | 
 | ||||||
|         // TODO: validate individual preferences |         // TODO: validate individual preferences | ||||||
| 
 | 
 | ||||||
|  | @ -208,7 +255,6 @@ public class UsersController( | ||||||
|         public Snowflake[]? Flags { get; init; } |         public Snowflake[]? Flags { get; init; } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|     [HttpGet("@me/settings")] |     [HttpGet("@me/settings")] | ||||||
|     [Authorize("user.read_hidden")] |     [Authorize("user.read_hidden")] | ||||||
|     [ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)] |     [ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)] | ||||||
|  | @ -221,8 +267,10 @@ public class UsersController( | ||||||
|     [HttpPatch("@me/settings")] |     [HttpPatch("@me/settings")] | ||||||
|     [Authorize("user.read_hidden", "user.update")] |     [Authorize("user.read_hidden", "user.update")] | ||||||
|     [ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)] |     [ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)] | ||||||
|     public async Task<IActionResult> UpdateUserSettingsAsync([FromBody] UpdateUserSettingsRequest req, |     public async Task<IActionResult> UpdateUserSettingsAsync( | ||||||
|         CancellationToken ct = default) |         [FromBody] UpdateUserSettingsRequest req, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct); |         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"); |             throw new ApiError.BadRequest("Cannot reroll short ID yet"); | ||||||
| 
 | 
 | ||||||
|         // Using ExecuteUpdateAsync here as the new short ID is generated by the database |         // Using ExecuteUpdateAsync here as the new short ID is generated by the database | ||||||
|         await db.Users.Where(u => u.Id == CurrentUser.Id) |         await db | ||||||
|             .ExecuteUpdateAsync(s => s |             .Users.Where(u => u.Id == CurrentUser.Id) | ||||||
|                 .SetProperty(u => u.Sid, _ => db.FindFreeUserSid()) |             .ExecuteUpdateAsync(s => | ||||||
|  |                 s.SetProperty(u => u.Sid, _ => db.FindFreeUserSid()) | ||||||
|                     .SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant()) |                     .SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant()) | ||||||
|                 .SetProperty(u => u.LastActive, clock.GetCurrentInstant())); |                     .SetProperty(u => u.LastActive, clock.GetCurrentInstant()) | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|         var user = await db.ResolveUserAsync(CurrentUser.Id); |         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 | ||||||
|  |             ) | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -45,11 +45,12 @@ public class DatabaseContext : DbContext | ||||||
|         _loggerFactory = loggerFactory; |         _loggerFactory = loggerFactory; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) |     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => | ||||||
|         => optionsBuilder |         optionsBuilder | ||||||
|             .ConfigureWarnings(c => |             .ConfigureWarnings(c => | ||||||
|                 c.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning) |                 c.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning) | ||||||
|                     .Ignore(CoreEventId.SaveChangesFailed)) |                     .Ignore(CoreEventId.SaveChangesFailed) | ||||||
|  |             ) | ||||||
|             .UseNpgsql(_dataSource, o => o.UseNodaTime()) |             .UseNpgsql(_dataSource, o => o.UseNodaTime()) | ||||||
|             .UseSnakeCaseNamingConvention() |             .UseSnakeCaseNamingConvention() | ||||||
|             .UseLoggerFactory(_loggerFactory) |             .UseLoggerFactory(_loggerFactory) | ||||||
|  | @ -76,7 +77,10 @@ public class DatabaseContext : DbContext | ||||||
|         modelBuilder.Entity<User>().Property(u => u.CustomPreferences).HasColumnType("jsonb"); |         modelBuilder.Entity<User>().Property(u => u.CustomPreferences).HasColumnType("jsonb"); | ||||||
|         modelBuilder.Entity<User>().Property(u => u.Settings).HasColumnType("jsonb"); |         modelBuilder.Entity<User>().Property(u => u.Settings).HasColumnType("jsonb"); | ||||||
| 
 | 
 | ||||||
|         modelBuilder.Entity<Member>().Property(m => m.Sid).HasDefaultValueSql("find_free_member_sid()"); |         modelBuilder | ||||||
|  |             .Entity<Member>() | ||||||
|  |             .Property(m => m.Sid) | ||||||
|  |             .HasDefaultValueSql("find_free_member_sid()"); | ||||||
|         modelBuilder.Entity<Member>().Property(m => m.Fields).HasColumnType("jsonb"); |         modelBuilder.Entity<Member>().Property(m => m.Fields).HasColumnType("jsonb"); | ||||||
|         modelBuilder.Entity<Member>().Property(m => m.Names).HasColumnType("jsonb"); |         modelBuilder.Entity<Member>().Property(m => m.Names).HasColumnType("jsonb"); | ||||||
|         modelBuilder.Entity<Member>().Property(m => m.Pronouns).HasColumnType("jsonb"); |         modelBuilder.Entity<Member>().Property(m => m.Pronouns).HasColumnType("jsonb"); | ||||||
|  | @ -84,10 +88,12 @@ public class DatabaseContext : DbContext | ||||||
|         modelBuilder.Entity<UserFlag>().Navigation(f => f.PrideFlag).AutoInclude(); |         modelBuilder.Entity<UserFlag>().Navigation(f => f.PrideFlag).AutoInclude(); | ||||||
|         modelBuilder.Entity<MemberFlag>().Navigation(f => f.PrideFlag).AutoInclude(); |         modelBuilder.Entity<MemberFlag>().Navigation(f => f.PrideFlag).AutoInclude(); | ||||||
| 
 | 
 | ||||||
|         modelBuilder.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeUserSid))!) |         modelBuilder | ||||||
|  |             .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeUserSid))!) | ||||||
|             .HasName("find_free_user_sid"); |             .HasName("find_free_user_sid"); | ||||||
| 
 | 
 | ||||||
|         modelBuilder.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!) |         modelBuilder | ||||||
|  |             .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!) | ||||||
|             .HasName("find_free_member_sid"); |             .HasName("find_free_member_sid"); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -102,13 +108,18 @@ public class DatabaseContext : DbContext | ||||||
|     public string FindFreeMemberSid() => throw new NotSupportedException(); |     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<DatabaseContext> | public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory<DatabaseContext> | ||||||
| { | { | ||||||
|     public DatabaseContext CreateDbContext(string[] args) |     public DatabaseContext CreateDbContext(string[] args) | ||||||
|     { |     { | ||||||
|         // Read the configuration file |         // Read the configuration file | ||||||
|         var config = new ConfigurationBuilder() |         var config = | ||||||
|  |             new ConfigurationBuilder() | ||||||
|                 .AddConfiguration() |                 .AddConfiguration() | ||||||
|                 .Build() |                 .Build() | ||||||
|                 // Get the configuration as our config class |                 // Get the configuration as our config class | ||||||
|  |  | ||||||
|  | @ -8,89 +8,128 @@ namespace Foxnouns.Backend.Database; | ||||||
| 
 | 
 | ||||||
| public static class DatabaseQueryExtensions | public static class DatabaseQueryExtensions | ||||||
| { | { | ||||||
|     public static async Task<User> ResolveUserAsync(this DatabaseContext context, string userRef, Token? token, |     public static async Task<User> ResolveUserAsync( | ||||||
|         CancellationToken ct = default) |         this DatabaseContext context, | ||||||
|  |         string userRef, | ||||||
|  |         Token? token, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         if (userRef == "@me") |         if (userRef == "@me") | ||||||
|         { |         { | ||||||
|             return token != null |             return token != null | ||||||
|                 ? await context.Users.FirstAsync(u => u.Id == token.UserId, ct) |                 ? await context.Users.FirstAsync(u => u.Id == token.UserId, ct) | ||||||
|                 : throw new ApiError.Unauthorized("This endpoint requires an authenticated user.", |                 : throw new ApiError.Unauthorized( | ||||||
|                     ErrorCode.AuthenticationRequired); |                     "This endpoint requires an authenticated user.", | ||||||
|  |                     ErrorCode.AuthenticationRequired | ||||||
|  |                 ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         User? user; |         User? user; | ||||||
|         if (Snowflake.TryParse(userRef, out var snowflake)) |         if (Snowflake.TryParse(userRef, out var snowflake)) | ||||||
|         { |         { | ||||||
|             user = await context.Users |             user = await context | ||||||
|                 .Where(u => !u.Deleted) |                 .Users.Where(u => !u.Deleted) | ||||||
|                 .FirstOrDefaultAsync(u => u.Id == snowflake, ct); |                 .FirstOrDefaultAsync(u => u.Id == snowflake, ct); | ||||||
|             if (user != null) return user; |             if (user != null) | ||||||
|  |                 return user; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         user = await context.Users |         user = await context | ||||||
|             .Where(u => !u.Deleted) |             .Users.Where(u => !u.Deleted) | ||||||
|             .FirstOrDefaultAsync(u => u.Username == userRef, ct); |             .FirstOrDefaultAsync(u => u.Username == userRef, ct); | ||||||
|         if (user != null) return user; |         if (user != null) | ||||||
|         throw new ApiError.NotFound("No user with that ID or username found.", code: ErrorCode.UserNotFound); |             return user; | ||||||
|  |         throw new ApiError.NotFound( | ||||||
|  |             "No user with that ID or username found.", | ||||||
|  |             code: ErrorCode.UserNotFound | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static async Task<User> ResolveUserAsync(this DatabaseContext context, Snowflake id, |     public static async Task<User> ResolveUserAsync( | ||||||
|         CancellationToken ct = default) |         this DatabaseContext context, | ||||||
|  |         Snowflake id, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var user = await context.Users |         var user = await context | ||||||
|             .Where(u => !u.Deleted) |             .Users.Where(u => !u.Deleted) | ||||||
|             .FirstOrDefaultAsync(u => u.Id == id, ct); |             .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); |         throw new ApiError.NotFound("No user with that ID found.", code: ErrorCode.UserNotFound); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, Snowflake id, |     public static async Task<Member> ResolveMemberAsync( | ||||||
|         CancellationToken ct = default) |         this DatabaseContext context, | ||||||
|  |         Snowflake id, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var member = await context.Members |         var member = await context | ||||||
|             .Include(m => m.User) |             .Members.Include(m => m.User) | ||||||
|             .Where(m => !m.User.Deleted) |             .Where(m => !m.User.Deleted) | ||||||
|             .FirstOrDefaultAsync(m => m.Id == id, ct); |             .FirstOrDefaultAsync(m => m.Id == id, ct); | ||||||
|         if (member != null) return member; |         if (member != null) | ||||||
|         throw new ApiError.NotFound("No member with that ID found.", code: ErrorCode.MemberNotFound); |             return member; | ||||||
|  |         throw new ApiError.NotFound( | ||||||
|  |             "No member with that ID found.", | ||||||
|  |             code: ErrorCode.MemberNotFound | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, string userRef, string memberRef, |     public static async Task<Member> ResolveMemberAsync( | ||||||
|         Token? token, CancellationToken ct = default) |         this DatabaseContext context, | ||||||
|  |         string userRef, | ||||||
|  |         string memberRef, | ||||||
|  |         Token? token, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var user = await context.ResolveUserAsync(userRef, token, ct); |         var user = await context.ResolveUserAsync(userRef, token, ct); | ||||||
|         return await context.ResolveMemberAsync(user.Id, memberRef, ct); |         return await context.ResolveMemberAsync(user.Id, memberRef, ct); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, Snowflake userId, |     public static async Task<Member> ResolveMemberAsync( | ||||||
|         string memberRef, CancellationToken ct = default) |         this DatabaseContext context, | ||||||
|  |         Snowflake userId, | ||||||
|  |         string memberRef, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         Member? member; |         Member? member; | ||||||
|         if (Snowflake.TryParse(memberRef, out var snowflake)) |         if (Snowflake.TryParse(memberRef, out var snowflake)) | ||||||
|         { |         { | ||||||
|             member = await context.Members |             member = await context | ||||||
|                 .Include(m => m.User) |                 .Members.Include(m => m.User) | ||||||
|                 .Include(m => m.ProfileFlags) |                 .Include(m => m.ProfileFlags) | ||||||
|                 .Where(m => !m.User.Deleted) |                 .Where(m => !m.User.Deleted) | ||||||
|                 .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct); |                 .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct); | ||||||
|             if (member != null) return member; |             if (member != null) | ||||||
|  |                 return member; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         member = await context.Members |         member = await context | ||||||
|             .Include(m => m.User) |             .Members.Include(m => m.User) | ||||||
|             .Include(m => m.ProfileFlags) |             .Include(m => m.ProfileFlags) | ||||||
|             .Where(m => !m.User.Deleted) |             .Where(m => !m.User.Deleted) | ||||||
|             .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct); |             .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct); | ||||||
|         if (member != null) return member; |         if (member != null) | ||||||
|         throw new ApiError.NotFound("No member with that ID or name found.", code: ErrorCode.MemberNotFound); |             return member; | ||||||
|  |         throw new ApiError.NotFound( | ||||||
|  |             "No member with that ID or name found.", | ||||||
|  |             code: ErrorCode.MemberNotFound | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static async Task<Application> GetFrontendApplicationAsync(this DatabaseContext context, |     public static async Task<Application> GetFrontendApplicationAsync( | ||||||
|         CancellationToken ct = default) |         this DatabaseContext context, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var app = await context.Applications.FirstOrDefaultAsync(a => a.Id == new Snowflake(0), ct); |         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 |         app = new Application | ||||||
|         { |         { | ||||||
|  | @ -107,27 +146,42 @@ public static class DatabaseQueryExtensions | ||||||
|         return app; |         return app; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static async Task<Token?> GetToken(this DatabaseContext context, byte[] rawToken, |     public static async Task<Token?> GetToken( | ||||||
|         CancellationToken ct = default) |         this DatabaseContext context, | ||||||
|  |         byte[] rawToken, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var hash = SHA512.HashData(rawToken); |         var hash = SHA512.HashData(rawToken); | ||||||
| 
 | 
 | ||||||
|         var oauthToken = await context.Tokens |         var oauthToken = await context | ||||||
|             .Include(t => t.Application) |             .Tokens.Include(t => t.Application) | ||||||
|             .Include(t => t.User) |             .Include(t => t.User) | ||||||
|             .FirstOrDefaultAsync( |             .FirstOrDefaultAsync( | ||||||
|                 t => t.Hash == hash && t.ExpiresAt > SystemClock.Instance.GetCurrentInstant() && !t.ManuallyExpired, |                 t => | ||||||
|                 ct); |                     t.Hash == hash | ||||||
|  |                     && t.ExpiresAt > SystemClock.Instance.GetCurrentInstant() | ||||||
|  |                     && !t.ManuallyExpired, | ||||||
|  |                 ct | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|         return oauthToken; |         return oauthToken; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static async Task<Snowflake?> GetTokenUserId(this DatabaseContext context, byte[] rawToken, |     public static async Task<Snowflake?> GetTokenUserId( | ||||||
|         CancellationToken ct = default) |         this DatabaseContext context, | ||||||
|  |         byte[] rawToken, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var hash = SHA512.HashData(rawToken); |         var hash = SHA512.HashData(rawToken); | ||||||
|         return await context.Tokens |         return await context | ||||||
|             .Where(t => t.Hash == hash && t.ExpiresAt > SystemClock.Instance.GetCurrentInstant() && !t.ManuallyExpired) |             .Tokens.Where(t => | ||||||
|             .Select(t => t.UserId).FirstOrDefaultAsync(ct); |                 t.Hash == hash | ||||||
|  |                 && t.ExpiresAt > SystemClock.Instance.GetCurrentInstant() | ||||||
|  |                 && !t.ManuallyExpired | ||||||
|  |             ) | ||||||
|  |             .Select(t => t.UserId) | ||||||
|  |             .FirstOrDefaultAsync(ct); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -5,23 +5,30 @@ namespace Foxnouns.Backend.Database; | ||||||
| 
 | 
 | ||||||
| public static class FlagQueryExtensions | public static class FlagQueryExtensions | ||||||
| { | { | ||||||
|     private static async Task<List<PrideFlag>> GetFlagsAsync(this DatabaseContext db, Snowflake userId) => |     private static async Task<List<PrideFlag>> GetFlagsAsync( | ||||||
|         await db.PrideFlags.Where(f => f.UserId == userId).OrderBy(f => f.Id).ToListAsync(); |         this DatabaseContext db, | ||||||
|  |         Snowflake userId | ||||||
|  |     ) => await db.PrideFlags.Where(f => f.UserId == userId).OrderBy(f => f.Id).ToListAsync(); | ||||||
| 
 | 
 | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Sets the user's profile flags to the given IDs. Returns a validation error if any of the flag IDs are unknown |     /// 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. |     /// or if too many IDs are given. Duplicates are allowed. | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     public static async Task<ValidationError?> SetUserFlagsAsync(this DatabaseContext db, Snowflake userId, |     public static async Task<ValidationError?> SetUserFlagsAsync( | ||||||
|         Snowflake[] flagIds) |         this DatabaseContext db, | ||||||
|  |         Snowflake userId, | ||||||
|  |         Snowflake[] flagIds | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var currentFlags = await db.UserFlags.Where(f => f.UserId == userId).ToListAsync(); |         var currentFlags = await db.UserFlags.Where(f => f.UserId == userId).ToListAsync(); | ||||||
|         foreach (var flag in currentFlags) |         foreach (var flag in currentFlags) | ||||||
|             db.UserFlags.Remove(flag); |             db.UserFlags.Remove(flag); | ||||||
| 
 | 
 | ||||||
|         // If there's no new flags to set, we're done |         // If there's no new flags to set, we're done | ||||||
|         if (flagIds.Length == 0) return null; |         if (flagIds.Length == 0) | ||||||
|         if (flagIds.Length > 100) return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length); |             return null; | ||||||
|  |         if (flagIds.Length > 100) | ||||||
|  |             return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length); | ||||||
| 
 | 
 | ||||||
|         var flags = await db.GetFlagsAsync(userId); |         var flags = await db.GetFlagsAsync(userId); | ||||||
|         var unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray(); |         var unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray(); | ||||||
|  | @ -34,22 +41,32 @@ public static class FlagQueryExtensions | ||||||
|         return null; |         return null; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static async Task<ValidationError?> SetMemberFlagsAsync(this DatabaseContext db, Snowflake userId, |     public static async Task<ValidationError?> SetMemberFlagsAsync( | ||||||
|         Snowflake memberId, Snowflake[] flagIds) |         this DatabaseContext db, | ||||||
|  |         Snowflake userId, | ||||||
|  |         Snowflake memberId, | ||||||
|  |         Snowflake[] flagIds | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var currentFlags = await db.MemberFlags.Where(f => f.MemberId == memberId).ToListAsync(); |         var currentFlags = await db.MemberFlags.Where(f => f.MemberId == memberId).ToListAsync(); | ||||||
|         foreach (var flag in currentFlags) |         foreach (var flag in currentFlags) | ||||||
|             db.MemberFlags.Remove(flag); |             db.MemberFlags.Remove(flag); | ||||||
| 
 | 
 | ||||||
|         if (flagIds.Length == 0) return null; |         if (flagIds.Length == 0) | ||||||
|         if (flagIds.Length > 100) return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length); |             return null; | ||||||
|  |         if (flagIds.Length > 100) | ||||||
|  |             return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length); | ||||||
| 
 | 
 | ||||||
|         var flags = await db.GetFlagsAsync(userId); |         var flags = await db.GetFlagsAsync(userId); | ||||||
|         var unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray(); |         var unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray(); | ||||||
|         if (unknownFlagIds.Length != 0) |         if (unknownFlagIds.Length != 0) | ||||||
|             return ValidationError.GenericValidationError("Unknown flag IDs", unknownFlagIds); |             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); |         db.MemberFlags.AddRange(memberFlags); | ||||||
| 
 | 
 | ||||||
|         return null; |         return null; | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| 
 | 
 | ||||||
| #nullable disable | #nullable disable | ||||||
|  | @ -22,12 +22,13 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                     domain = table.Column<string>(type: "text", nullable: false), |                     domain = table.Column<string>(type: "text", nullable: false), | ||||||
|                     client_id = table.Column<string>(type: "text", nullable: false), |                     client_id = table.Column<string>(type: "text", nullable: false), | ||||||
|                     client_secret = table.Column<string>(type: "text", nullable: false), |                     client_secret = table.Column<string>(type: "text", nullable: false), | ||||||
|                     instance_type = table.Column<int>(type: "integer", nullable: false) |                     instance_type = table.Column<int>(type: "integer", nullable: false), | ||||||
|                 }, |                 }, | ||||||
|                 constraints: table => |                 constraints: table => | ||||||
|                 { |                 { | ||||||
|                     table.PrimaryKey("pk_fediverse_applications", x => x.id); |                     table.PrimaryKey("pk_fediverse_applications", x => x.id); | ||||||
|                 }); |                 } | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateTable( |             migrationBuilder.CreateTable( | ||||||
|                 name: "users", |                 name: "users", | ||||||
|  | @ -43,12 +44,13 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                     role = table.Column<int>(type: "integer", nullable: false), |                     role = table.Column<int>(type: "integer", nullable: false), | ||||||
|                     fields = table.Column<string>(type: "jsonb", nullable: false), |                     fields = table.Column<string>(type: "jsonb", nullable: false), | ||||||
|                     names = table.Column<string>(type: "jsonb", nullable: false), |                     names = table.Column<string>(type: "jsonb", nullable: false), | ||||||
|                     pronouns = table.Column<string>(type: "jsonb", nullable: false) |                     pronouns = table.Column<string>(type: "jsonb", nullable: false), | ||||||
|                 }, |                 }, | ||||||
|                 constraints: table => |                 constraints: table => | ||||||
|                 { |                 { | ||||||
|                     table.PrimaryKey("pk_users", x => x.id); |                     table.PrimaryKey("pk_users", x => x.id); | ||||||
|                 }); |                 } | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateTable( |             migrationBuilder.CreateTable( | ||||||
|                 name: "auth_methods", |                 name: "auth_methods", | ||||||
|  | @ -59,7 +61,7 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                     remote_id = table.Column<string>(type: "text", nullable: false), |                     remote_id = table.Column<string>(type: "text", nullable: false), | ||||||
|                     remote_username = table.Column<string>(type: "text", nullable: true), |                     remote_username = table.Column<string>(type: "text", nullable: true), | ||||||
|                     user_id = table.Column<long>(type: "bigint", nullable: false), |                     user_id = table.Column<long>(type: "bigint", nullable: false), | ||||||
|                     fediverse_application_id = table.Column<long>(type: "bigint", nullable: true) |                     fediverse_application_id = table.Column<long>(type: "bigint", nullable: true), | ||||||
|                 }, |                 }, | ||||||
|                 constraints: table => |                 constraints: table => | ||||||
|                 { |                 { | ||||||
|  | @ -68,14 +70,17 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                         name: "fk_auth_methods_fediverse_applications_fediverse_application_id", |                         name: "fk_auth_methods_fediverse_applications_fediverse_application_id", | ||||||
|                         column: x => x.fediverse_application_id, |                         column: x => x.fediverse_application_id, | ||||||
|                         principalTable: "fediverse_applications", |                         principalTable: "fediverse_applications", | ||||||
|                         principalColumn: "id"); |                         principalColumn: "id" | ||||||
|  |                     ); | ||||||
|                     table.ForeignKey( |                     table.ForeignKey( | ||||||
|                         name: "fk_auth_methods_users_user_id", |                         name: "fk_auth_methods_users_user_id", | ||||||
|                         column: x => x.user_id, |                         column: x => x.user_id, | ||||||
|                         principalTable: "users", |                         principalTable: "users", | ||||||
|                         principalColumn: "id", |                         principalColumn: "id", | ||||||
|                         onDelete: ReferentialAction.Cascade); |                         onDelete: ReferentialAction.Cascade | ||||||
|                 }); |                     ); | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateTable( |             migrationBuilder.CreateTable( | ||||||
|                 name: "members", |                 name: "members", | ||||||
|  | @ -91,7 +96,7 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                     user_id = table.Column<long>(type: "bigint", nullable: false), |                     user_id = table.Column<long>(type: "bigint", nullable: false), | ||||||
|                     fields = table.Column<string>(type: "jsonb", nullable: false), |                     fields = table.Column<string>(type: "jsonb", nullable: false), | ||||||
|                     names = table.Column<string>(type: "jsonb", nullable: false), |                     names = table.Column<string>(type: "jsonb", nullable: false), | ||||||
|                     pronouns = table.Column<string>(type: "jsonb", nullable: false) |                     pronouns = table.Column<string>(type: "jsonb", nullable: false), | ||||||
|                 }, |                 }, | ||||||
|                 constraints: table => |                 constraints: table => | ||||||
|                 { |                 { | ||||||
|  | @ -101,18 +106,23 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                         column: x => x.user_id, |                         column: x => x.user_id, | ||||||
|                         principalTable: "users", |                         principalTable: "users", | ||||||
|                         principalColumn: "id", |                         principalColumn: "id", | ||||||
|                         onDelete: ReferentialAction.Cascade); |                         onDelete: ReferentialAction.Cascade | ||||||
|                 }); |                     ); | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateTable( |             migrationBuilder.CreateTable( | ||||||
|                 name: "tokens", |                 name: "tokens", | ||||||
|                 columns: table => new |                 columns: table => new | ||||||
|                 { |                 { | ||||||
|                     id = table.Column<long>(type: "bigint", nullable: false), |                     id = table.Column<long>(type: "bigint", nullable: false), | ||||||
|                     expires_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), |                     expires_at = table.Column<Instant>( | ||||||
|  |                         type: "timestamp with time zone", | ||||||
|  |                         nullable: false | ||||||
|  |                     ), | ||||||
|                     scopes = table.Column<string[]>(type: "text[]", nullable: false), |                     scopes = table.Column<string[]>(type: "text[]", nullable: false), | ||||||
|                     manually_expired = table.Column<bool>(type: "boolean", nullable: false), |                     manually_expired = table.Column<bool>(type: "boolean", nullable: false), | ||||||
|                     user_id = table.Column<long>(type: "bigint", nullable: false) |                     user_id = table.Column<long>(type: "bigint", nullable: false), | ||||||
|                 }, |                 }, | ||||||
|                 constraints: table => |                 constraints: table => | ||||||
|                 { |                 { | ||||||
|  | @ -122,53 +132,56 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                         column: x => x.user_id, |                         column: x => x.user_id, | ||||||
|                         principalTable: "users", |                         principalTable: "users", | ||||||
|                         principalColumn: "id", |                         principalColumn: "id", | ||||||
|                         onDelete: ReferentialAction.Cascade); |                         onDelete: ReferentialAction.Cascade | ||||||
|                 }); |                     ); | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateIndex( |             migrationBuilder.CreateIndex( | ||||||
|                 name: "ix_auth_methods_fediverse_application_id", |                 name: "ix_auth_methods_fediverse_application_id", | ||||||
|                 table: "auth_methods", |                 table: "auth_methods", | ||||||
|                 column: "fediverse_application_id"); |                 column: "fediverse_application_id" | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateIndex( |             migrationBuilder.CreateIndex( | ||||||
|                 name: "ix_auth_methods_user_id", |                 name: "ix_auth_methods_user_id", | ||||||
|                 table: "auth_methods", |                 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. |             // 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) |             // Due to historical reasons (I made a mistake while writing the initial migration for the Go version) | ||||||
|             // only members have case-insensitive names. |             // 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( |             migrationBuilder.CreateIndex( | ||||||
|                 name: "ix_tokens_user_id", |                 name: "ix_tokens_user_id", | ||||||
|                 table: "tokens", |                 table: "tokens", | ||||||
|                 column: "user_id"); |                 column: "user_id" | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateIndex( |             migrationBuilder.CreateIndex( | ||||||
|                 name: "ix_users_username", |                 name: "ix_users_username", | ||||||
|                 table: "users", |                 table: "users", | ||||||
|                 column: "username", |                 column: "username", | ||||||
|                 unique: true); |                 unique: true | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|         protected override void Down(MigrationBuilder migrationBuilder) |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|         { |         { | ||||||
|             migrationBuilder.DropTable( |             migrationBuilder.DropTable(name: "auth_methods"); | ||||||
|                 name: "auth_methods"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropTable( |             migrationBuilder.DropTable(name: "members"); | ||||||
|                 name: "members"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropTable( |             migrationBuilder.DropTable(name: "tokens"); | ||||||
|                 name: "tokens"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropTable( |             migrationBuilder.DropTable(name: "fediverse_applications"); | ||||||
|                 name: "fediverse_applications"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropTable( |             migrationBuilder.DropTable(name: "users"); | ||||||
|                 name: "users"); |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| 
 | 
 | ||||||
| #nullable disable | #nullable disable | ||||||
| 
 | 
 | ||||||
|  | @ -18,14 +18,16 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 table: "tokens", |                 table: "tokens", | ||||||
|                 type: "bigint", |                 type: "bigint", | ||||||
|                 nullable: false, |                 nullable: false, | ||||||
|                 defaultValue: 0L); |                 defaultValue: 0L | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.AddColumn<byte[]>( |             migrationBuilder.AddColumn<byte[]>( | ||||||
|                 name: "hash", |                 name: "hash", | ||||||
|                 table: "tokens", |                 table: "tokens", | ||||||
|                 type: "bytea", |                 type: "bytea", | ||||||
|                 nullable: false, |                 nullable: false, | ||||||
|                 defaultValue: new byte[0]); |                 defaultValue: new byte[0] | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateTable( |             migrationBuilder.CreateTable( | ||||||
|                 name: "applications", |                 name: "applications", | ||||||
|  | @ -36,17 +38,19 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                     client_secret = table.Column<string>(type: "text", nullable: false), |                     client_secret = table.Column<string>(type: "text", nullable: false), | ||||||
|                     name = table.Column<string>(type: "text", nullable: false), |                     name = table.Column<string>(type: "text", nullable: false), | ||||||
|                     scopes = table.Column<string[]>(type: "text[]", nullable: false), |                     scopes = table.Column<string[]>(type: "text[]", nullable: false), | ||||||
|                     redirect_uris = table.Column<string[]>(type: "text[]", nullable: false) |                     redirect_uris = table.Column<string[]>(type: "text[]", nullable: false), | ||||||
|                 }, |                 }, | ||||||
|                 constraints: table => |                 constraints: table => | ||||||
|                 { |                 { | ||||||
|                     table.PrimaryKey("pk_applications", x => x.id); |                     table.PrimaryKey("pk_applications", x => x.id); | ||||||
|                 }); |                 } | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateIndex( |             migrationBuilder.CreateIndex( | ||||||
|                 name: "ix_tokens_application_id", |                 name: "ix_tokens_application_id", | ||||||
|                 table: "tokens", |                 table: "tokens", | ||||||
|                 column: "application_id"); |                 column: "application_id" | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.AddForeignKey( |             migrationBuilder.AddForeignKey( | ||||||
|                 name: "fk_tokens_applications_application_id", |                 name: "fk_tokens_applications_application_id", | ||||||
|  | @ -54,7 +58,8 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 column: "application_id", |                 column: "application_id", | ||||||
|                 principalTable: "applications", |                 principalTable: "applications", | ||||||
|                 principalColumn: "id", |                 principalColumn: "id", | ||||||
|                 onDelete: ReferentialAction.Cascade); |                 onDelete: ReferentialAction.Cascade | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|  | @ -62,22 +67,16 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|         { |         { | ||||||
|             migrationBuilder.DropForeignKey( |             migrationBuilder.DropForeignKey( | ||||||
|                 name: "fk_tokens_applications_application_id", |                 name: "fk_tokens_applications_application_id", | ||||||
|                 table: "tokens"); |                 table: "tokens" | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropTable( |             migrationBuilder.DropTable(name: "applications"); | ||||||
|                 name: "applications"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropIndex( |             migrationBuilder.DropIndex(name: "ix_tokens_application_id", table: "tokens"); | ||||||
|                 name: "ix_tokens_application_id", |  | ||||||
|                 table: "tokens"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.DropColumn(name: "application_id", table: "tokens"); | ||||||
|                 name: "application_id", |  | ||||||
|                 table: "tokens"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.DropColumn(name: "hash", table: "tokens"); | ||||||
|                 name: "hash", |  | ||||||
|                 table: "tokens"); |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| 
 | 
 | ||||||
| #nullable disable | #nullable disable | ||||||
| 
 | 
 | ||||||
|  | @ -18,15 +18,14 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 table: "users", |                 table: "users", | ||||||
|                 type: "boolean", |                 type: "boolean", | ||||||
|                 nullable: false, |                 nullable: false, | ||||||
|                 defaultValue: false); |                 defaultValue: false | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|         protected override void Down(MigrationBuilder migrationBuilder) |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|         { |         { | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.DropColumn(name: "list_hidden", table: "users"); | ||||||
|                 name: "list_hidden", |  | ||||||
|                 table: "users"); |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| 
 | 
 | ||||||
| #nullable disable | #nullable disable | ||||||
| 
 | 
 | ||||||
|  | @ -17,15 +17,14 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 name: "password", |                 name: "password", | ||||||
|                 table: "users", |                 table: "users", | ||||||
|                 type: "text", |                 type: "text", | ||||||
|                 nullable: true); |                 nullable: true | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|         protected override void Down(MigrationBuilder migrationBuilder) |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|         { |         { | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.DropColumn(name: "password", table: "users"); | ||||||
|                 name: "password", |  | ||||||
|                 table: "users"); |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
| 
 | 
 | ||||||
|  | @ -19,29 +19,37 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 name: "temporary_keys", |                 name: "temporary_keys", | ||||||
|                 columns: table => new |                 columns: table => new | ||||||
|                 { |                 { | ||||||
|                     id = table.Column<long>(type: "bigint", nullable: false) |                     id = table | ||||||
|                         .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), |                         .Column<long>(type: "bigint", nullable: false) | ||||||
|  |                         .Annotation( | ||||||
|  |                             "Npgsql:ValueGenerationStrategy", | ||||||
|  |                             NpgsqlValueGenerationStrategy.IdentityByDefaultColumn | ||||||
|  |                         ), | ||||||
|                     key = table.Column<string>(type: "text", nullable: false), |                     key = table.Column<string>(type: "text", nullable: false), | ||||||
|                     value = table.Column<string>(type: "text", nullable: false), |                     value = table.Column<string>(type: "text", nullable: false), | ||||||
|                     expires = table.Column<Instant>(type: "timestamp with time zone", nullable: false) |                     expires = table.Column<Instant>( | ||||||
|  |                         type: "timestamp with time zone", | ||||||
|  |                         nullable: false | ||||||
|  |                     ), | ||||||
|                 }, |                 }, | ||||||
|                 constraints: table => |                 constraints: table => | ||||||
|                 { |                 { | ||||||
|                     table.PrimaryKey("pk_temporary_keys", x => x.id); |                     table.PrimaryKey("pk_temporary_keys", x => x.id); | ||||||
|                 }); |                 } | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateIndex( |             migrationBuilder.CreateIndex( | ||||||
|                 name: "ix_temporary_keys_key", |                 name: "ix_temporary_keys_key", | ||||||
|                 table: "temporary_keys", |                 table: "temporary_keys", | ||||||
|                 column: "key", |                 column: "key", | ||||||
|                 unique: true); |                 unique: true | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|         protected override void Down(MigrationBuilder migrationBuilder) |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|         { |         { | ||||||
|             migrationBuilder.DropTable( |             migrationBuilder.DropTable(name: "temporary_keys"); | ||||||
|                 name: "temporary_keys"); |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| 
 | 
 | ||||||
| #nullable disable | #nullable disable | ||||||
|  | @ -19,15 +19,14 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 table: "users", |                 table: "users", | ||||||
|                 type: "timestamp with time zone", |                 type: "timestamp with time zone", | ||||||
|                 nullable: false, |                 nullable: false, | ||||||
|                 defaultValueSql: "now()"); |                 defaultValueSql: "now()" | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|         protected override void Down(MigrationBuilder migrationBuilder) |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|         { |         { | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.DropColumn(name: "last_active", table: "users"); | ||||||
|                 name: "last_active", |  | ||||||
|                 table: "users"); |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| 
 | 
 | ||||||
| #nullable disable | #nullable disable | ||||||
|  | @ -19,35 +19,32 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 table: "users", |                 table: "users", | ||||||
|                 type: "boolean", |                 type: "boolean", | ||||||
|                 nullable: false, |                 nullable: false, | ||||||
|                 defaultValue: false); |                 defaultValue: false | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.AddColumn<Instant>( |             migrationBuilder.AddColumn<Instant>( | ||||||
|                 name: "deleted_at", |                 name: "deleted_at", | ||||||
|                 table: "users", |                 table: "users", | ||||||
|                 type: "timestamp with time zone", |                 type: "timestamp with time zone", | ||||||
|                 nullable: true); |                 nullable: true | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.AddColumn<long>( |             migrationBuilder.AddColumn<long>( | ||||||
|                 name: "deleted_by", |                 name: "deleted_by", | ||||||
|                 table: "users", |                 table: "users", | ||||||
|                 type: "bigint", |                 type: "bigint", | ||||||
|                 nullable: true); |                 nullable: true | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|         protected override void Down(MigrationBuilder migrationBuilder) |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|         { |         { | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.DropColumn(name: "deleted", table: "users"); | ||||||
|                 name: "deleted", |  | ||||||
|                 table: "users"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.DropColumn(name: "deleted_at", table: "users"); | ||||||
|                 name: "deleted_at", |  | ||||||
|                 table: "users"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.DropColumn(name: "deleted_by", table: "users"); | ||||||
|                 name: "deleted_by", |  | ||||||
|                 table: "users"); |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| using System; | using System; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; |  | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using Foxnouns.Backend.Database.Models; | using Foxnouns.Backend.Database.Models; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| 
 | 
 | ||||||
| #nullable disable | #nullable disable | ||||||
|  | @ -21,15 +21,14 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 table: "users", |                 table: "users", | ||||||
|                 type: "jsonb", |                 type: "jsonb", | ||||||
|                 nullable: false, |                 nullable: false, | ||||||
|                 defaultValueSql: "'{}'"); |                 defaultValueSql: "'{}'" | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|         protected override void Down(MigrationBuilder migrationBuilder) |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|         { |         { | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.DropColumn(name: "custom_preferences", table: "users"); | ||||||
|                 name: "custom_preferences", |  | ||||||
|                 table: "users"); |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -19,15 +19,14 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 table: "users", |                 table: "users", | ||||||
|                 type: "jsonb", |                 type: "jsonb", | ||||||
|                 nullable: false, |                 nullable: false, | ||||||
|                 defaultValueSql: "'{}'"); |                 defaultValueSql: "'{}'" | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|         protected override void Down(MigrationBuilder migrationBuilder) |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|         { |         { | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.DropColumn(name: "settings", table: "users"); | ||||||
|                 name: "settings", |  | ||||||
|                 table: "users"); |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| 
 | 
 | ||||||
| #nullable disable | #nullable disable | ||||||
|  | @ -18,38 +18,46 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 name: "sid", |                 name: "sid", | ||||||
|                 table: "users", |                 table: "users", | ||||||
|                 type: "text", |                 type: "text", | ||||||
|                 nullable: true); |                 nullable: true | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.AddColumn<Instant>( |             migrationBuilder.AddColumn<Instant>( | ||||||
|                 name: "last_sid_reroll", |                 name: "last_sid_reroll", | ||||||
|                 table: "users", |                 table: "users", | ||||||
|                 type: "timestamp with time zone", |                 type: "timestamp with time zone", | ||||||
|                 nullable: false, |                 nullable: false, | ||||||
|                 defaultValueSql: "now() - '1 hour'::interval"); |                 defaultValueSql: "now() - '1 hour'::interval" | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.AddColumn<string>( |             migrationBuilder.AddColumn<string>( | ||||||
|                 name: "sid", |                 name: "sid", | ||||||
|                 table: "members", |                 table: "members", | ||||||
|                 type: "text", |                 type: "text", | ||||||
|                 nullable: true); |                 nullable: true | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateIndex( |             migrationBuilder.CreateIndex( | ||||||
|                 name: "ix_users_sid", |                 name: "ix_users_sid", | ||||||
|                 table: "users", |                 table: "users", | ||||||
|                 column: "sid", |                 column: "sid", | ||||||
|                 unique: true); |                 unique: true | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateIndex( |             migrationBuilder.CreateIndex( | ||||||
|                 name: "ix_members_sid", |                 name: "ix_members_sid", | ||||||
|                 table: "members", |                 table: "members", | ||||||
|                 column: "sid", |                 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) |     select string_agg(substr('abcdefghijklmnopqrstuvwxyz', ceil(random() * 26)::integer, 1), '') from generate_series(1, len) | ||||||
| $$ language sql volatile; | $$ 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; | declare new_sid text; | ||||||
| begin | begin | ||||||
|     loop |     loop | ||||||
|  | @ -58,8 +66,10 @@ begin | ||||||
|     end loop; |     end loop; | ||||||
| end | end | ||||||
| $$ language plpgsql volatile; | $$ 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; | declare new_sid text; | ||||||
| begin | begin | ||||||
|     loop |     loop | ||||||
|  | @ -67,7 +77,8 @@ begin | ||||||
|         if not exists (select 1 from members where sid = new_sid) then return new_sid; end if; |         if not exists (select 1 from members where sid = new_sid) then return new_sid; end if; | ||||||
|     end loop; |     end loop; | ||||||
| end | end | ||||||
| $$ language plpgsql volatile;");
 | $$ language plpgsql volatile;"
 | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|  | @ -77,25 +88,15 @@ $$ language plpgsql volatile;"); | ||||||
|             migrationBuilder.Sql("drop function find_free_user_sid;"); |             migrationBuilder.Sql("drop function find_free_user_sid;"); | ||||||
|             migrationBuilder.Sql("drop function generate_sid;"); |             migrationBuilder.Sql("drop function generate_sid;"); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropIndex( |             migrationBuilder.DropIndex(name: "ix_users_sid", table: "users"); | ||||||
|                 name: "ix_users_sid", |  | ||||||
|                 table: "users"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropIndex( |             migrationBuilder.DropIndex(name: "ix_members_sid", table: "members"); | ||||||
|                 name: "ix_members_sid", |  | ||||||
|                 table: "members"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.DropColumn(name: "sid", table: "users"); | ||||||
|                 name: "sid", |  | ||||||
|                 table: "users"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.DropColumn(name: "last_sid_reroll", table: "users"); | ||||||
|                 name: "last_sid_reroll", |  | ||||||
|                 table: "users"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropColumn( |             migrationBuilder.DropColumn(name: "sid", table: "members"); | ||||||
|                 name: "sid", |  | ||||||
|                 table: "members"); |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| 
 | 
 | ||||||
| #nullable disable | #nullable disable | ||||||
|  | @ -22,7 +22,8 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 defaultValueSql: "find_free_user_sid()", |                 defaultValueSql: "find_free_user_sid()", | ||||||
|                 oldClrType: typeof(string), |                 oldClrType: typeof(string), | ||||||
|                 oldType: "text", |                 oldType: "text", | ||||||
|                 oldNullable: true); |                 oldNullable: true | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.AlterColumn<string>( |             migrationBuilder.AlterColumn<string>( | ||||||
|                 name: "sid", |                 name: "sid", | ||||||
|  | @ -32,7 +33,8 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 defaultValueSql: "find_free_member_sid()", |                 defaultValueSql: "find_free_member_sid()", | ||||||
|                 oldClrType: typeof(string), |                 oldClrType: typeof(string), | ||||||
|                 oldType: "text", |                 oldType: "text", | ||||||
|                 oldNullable: true); |                 oldNullable: true | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|  | @ -45,7 +47,8 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 nullable: true, |                 nullable: true, | ||||||
|                 oldClrType: typeof(string), |                 oldClrType: typeof(string), | ||||||
|                 oldType: "text", |                 oldType: "text", | ||||||
|                 oldDefaultValueSql: "find_free_user_sid()"); |                 oldDefaultValueSql: "find_free_user_sid()" | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.AlterColumn<string>( |             migrationBuilder.AlterColumn<string>( | ||||||
|                 name: "sid", |                 name: "sid", | ||||||
|  | @ -54,7 +57,8 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                 nullable: true, |                 nullable: true, | ||||||
|                 oldClrType: typeof(string), |                 oldClrType: typeof(string), | ||||||
|                 oldType: "text", |                 oldType: "text", | ||||||
|                 oldDefaultValueSql: "find_free_member_sid()"); |                 oldDefaultValueSql: "find_free_member_sid()" | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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; | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
| 
 | 
 | ||||||
| #nullable disable | #nullable disable | ||||||
|  | @ -22,7 +22,7 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                     user_id = table.Column<long>(type: "bigint", nullable: false), |                     user_id = table.Column<long>(type: "bigint", nullable: false), | ||||||
|                     hash = table.Column<string>(type: "text", nullable: false), |                     hash = table.Column<string>(type: "text", nullable: false), | ||||||
|                     name = table.Column<string>(type: "text", nullable: false), |                     name = table.Column<string>(type: "text", nullable: false), | ||||||
|                     description = table.Column<string>(type: "text", nullable: true) |                     description = table.Column<string>(type: "text", nullable: true), | ||||||
|                 }, |                 }, | ||||||
|                 constraints: table => |                 constraints: table => | ||||||
|                 { |                 { | ||||||
|  | @ -32,17 +32,23 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                         column: x => x.user_id, |                         column: x => x.user_id, | ||||||
|                         principalTable: "users", |                         principalTable: "users", | ||||||
|                         principalColumn: "id", |                         principalColumn: "id", | ||||||
|                         onDelete: ReferentialAction.Cascade); |                         onDelete: ReferentialAction.Cascade | ||||||
|                 }); |                     ); | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateTable( |             migrationBuilder.CreateTable( | ||||||
|                 name: "member_flags", |                 name: "member_flags", | ||||||
|                 columns: table => new |                 columns: table => new | ||||||
|                 { |                 { | ||||||
|                     id = table.Column<long>(type: "bigint", nullable: false) |                     id = table | ||||||
|                         .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), |                         .Column<long>(type: "bigint", nullable: false) | ||||||
|  |                         .Annotation( | ||||||
|  |                             "Npgsql:ValueGenerationStrategy", | ||||||
|  |                             NpgsqlValueGenerationStrategy.IdentityByDefaultColumn | ||||||
|  |                         ), | ||||||
|                     member_id = table.Column<long>(type: "bigint", nullable: false), |                     member_id = table.Column<long>(type: "bigint", nullable: false), | ||||||
|                     pride_flag_id = table.Column<long>(type: "bigint", nullable: false) |                     pride_flag_id = table.Column<long>(type: "bigint", nullable: false), | ||||||
|                 }, |                 }, | ||||||
|                 constraints: table => |                 constraints: table => | ||||||
|                 { |                 { | ||||||
|  | @ -52,23 +58,30 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                         column: x => x.member_id, |                         column: x => x.member_id, | ||||||
|                         principalTable: "members", |                         principalTable: "members", | ||||||
|                         principalColumn: "id", |                         principalColumn: "id", | ||||||
|                         onDelete: ReferentialAction.Cascade); |                         onDelete: ReferentialAction.Cascade | ||||||
|  |                     ); | ||||||
|                     table.ForeignKey( |                     table.ForeignKey( | ||||||
|                         name: "fk_member_flags_pride_flags_pride_flag_id", |                         name: "fk_member_flags_pride_flags_pride_flag_id", | ||||||
|                         column: x => x.pride_flag_id, |                         column: x => x.pride_flag_id, | ||||||
|                         principalTable: "pride_flags", |                         principalTable: "pride_flags", | ||||||
|                         principalColumn: "id", |                         principalColumn: "id", | ||||||
|                         onDelete: ReferentialAction.Cascade); |                         onDelete: ReferentialAction.Cascade | ||||||
|                 }); |                     ); | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateTable( |             migrationBuilder.CreateTable( | ||||||
|                 name: "user_flags", |                 name: "user_flags", | ||||||
|                 columns: table => new |                 columns: table => new | ||||||
|                 { |                 { | ||||||
|                     id = table.Column<long>(type: "bigint", nullable: false) |                     id = table | ||||||
|                         .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), |                         .Column<long>(type: "bigint", nullable: false) | ||||||
|  |                         .Annotation( | ||||||
|  |                             "Npgsql:ValueGenerationStrategy", | ||||||
|  |                             NpgsqlValueGenerationStrategy.IdentityByDefaultColumn | ||||||
|  |                         ), | ||||||
|                     user_id = table.Column<long>(type: "bigint", nullable: false), |                     user_id = table.Column<long>(type: "bigint", nullable: false), | ||||||
|                     pride_flag_id = table.Column<long>(type: "bigint", nullable: false) |                     pride_flag_id = table.Column<long>(type: "bigint", nullable: false), | ||||||
|                 }, |                 }, | ||||||
|                 constraints: table => |                 constraints: table => | ||||||
|                 { |                 { | ||||||
|  | @ -78,52 +91,57 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                         column: x => x.pride_flag_id, |                         column: x => x.pride_flag_id, | ||||||
|                         principalTable: "pride_flags", |                         principalTable: "pride_flags", | ||||||
|                         principalColumn: "id", |                         principalColumn: "id", | ||||||
|                         onDelete: ReferentialAction.Cascade); |                         onDelete: ReferentialAction.Cascade | ||||||
|  |                     ); | ||||||
|                     table.ForeignKey( |                     table.ForeignKey( | ||||||
|                         name: "fk_user_flags_users_user_id", |                         name: "fk_user_flags_users_user_id", | ||||||
|                         column: x => x.user_id, |                         column: x => x.user_id, | ||||||
|                         principalTable: "users", |                         principalTable: "users", | ||||||
|                         principalColumn: "id", |                         principalColumn: "id", | ||||||
|                         onDelete: ReferentialAction.Cascade); |                         onDelete: ReferentialAction.Cascade | ||||||
|                 }); |                     ); | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateIndex( |             migrationBuilder.CreateIndex( | ||||||
|                 name: "ix_member_flags_member_id", |                 name: "ix_member_flags_member_id", | ||||||
|                 table: "member_flags", |                 table: "member_flags", | ||||||
|                 column: "member_id"); |                 column: "member_id" | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateIndex( |             migrationBuilder.CreateIndex( | ||||||
|                 name: "ix_member_flags_pride_flag_id", |                 name: "ix_member_flags_pride_flag_id", | ||||||
|                 table: "member_flags", |                 table: "member_flags", | ||||||
|                 column: "pride_flag_id"); |                 column: "pride_flag_id" | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateIndex( |             migrationBuilder.CreateIndex( | ||||||
|                 name: "ix_pride_flags_user_id", |                 name: "ix_pride_flags_user_id", | ||||||
|                 table: "pride_flags", |                 table: "pride_flags", | ||||||
|                 column: "user_id"); |                 column: "user_id" | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateIndex( |             migrationBuilder.CreateIndex( | ||||||
|                 name: "ix_user_flags_pride_flag_id", |                 name: "ix_user_flags_pride_flag_id", | ||||||
|                 table: "user_flags", |                 table: "user_flags", | ||||||
|                 column: "pride_flag_id"); |                 column: "pride_flag_id" | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.CreateIndex( |             migrationBuilder.CreateIndex( | ||||||
|                 name: "ix_user_flags_user_id", |                 name: "ix_user_flags_user_id", | ||||||
|                 table: "user_flags", |                 table: "user_flags", | ||||||
|                 column: "user_id"); |                 column: "user_id" | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         /// <inheritdoc /> |         /// <inheritdoc /> | ||||||
|         protected override void Down(MigrationBuilder migrationBuilder) |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|         { |         { | ||||||
|             migrationBuilder.DropTable( |             migrationBuilder.DropTable(name: "member_flags"); | ||||||
|                 name: "member_flags"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropTable( |             migrationBuilder.DropTable(name: "user_flags"); | ||||||
|                 name: "user_flags"); |  | ||||||
| 
 | 
 | ||||||
|             migrationBuilder.DropTable( |             migrationBuilder.DropTable(name: "pride_flags"); | ||||||
|                 name: "pride_flags"); |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -11,20 +11,30 @@ public class Application : BaseModel | ||||||
|     public required string[] Scopes { get; init; } |     public required string[] Scopes { get; init; } | ||||||
|     public required string[] RedirectUris { get; init; } |     public required string[] RedirectUris { get; init; } | ||||||
| 
 | 
 | ||||||
|     public static Application Create(ISnowflakeGenerator snowflakeGenerator, string name, string[] scopes, |     public static Application Create( | ||||||
|         string[] redirectUrls) |         ISnowflakeGenerator snowflakeGenerator, | ||||||
|  |         string name, | ||||||
|  |         string[] scopes, | ||||||
|  |         string[] redirectUrls | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var clientId = RandomNumberGenerator.GetHexString(32, true); |         var clientId = RandomNumberGenerator.GetHexString(32, true); | ||||||
|         var clientSecret = AuthUtils.RandomToken(); |         var clientSecret = AuthUtils.RandomToken(); | ||||||
| 
 | 
 | ||||||
|         if (scopes.Except(AuthUtils.ApplicationScopes).Any()) |         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))) |         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 |         return new Application | ||||||
|  | @ -34,7 +44,7 @@ public class Application : BaseModel | ||||||
|             ClientSecret = clientSecret, |             ClientSecret = clientSecret, | ||||||
|             Name = name, |             Name = name, | ||||||
|             Scopes = scopes, |             Scopes = scopes, | ||||||
|             RedirectUris = redirectUrls |             RedirectUris = redirectUrls, | ||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -11,5 +11,5 @@ public class FediverseApplication : BaseModel | ||||||
| public enum FediverseInstanceType | public enum FediverseInstanceType | ||||||
| { | { | ||||||
|     MastodonApi, |     MastodonApi, | ||||||
|     MisskeyApi |     MisskeyApi, | ||||||
| } | } | ||||||
|  | @ -37,7 +37,9 @@ public class User : BaseModel | ||||||
|     public bool Deleted { get; set; } |     public bool Deleted { get; set; } | ||||||
|     public Instant? DeletedAt { get; set; } |     public Instant? DeletedAt { get; set; } | ||||||
|     public Snowflake? DeletedBy { 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 |     public class CustomPreference | ||||||
|     { |     { | ||||||
|  |  | ||||||
|  | @ -41,19 +41,26 @@ public readonly struct Snowflake(ulong value) : IEquatable<Snowflake> | ||||||
|     public short Increment => (short)(Value & 0xFFF); |     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 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 ulong(Snowflake s) => s.Value; | ||||||
|  | 
 | ||||||
|     public static implicit operator long(Snowflake s) => (long)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(ulong n) => new(n); | ||||||
|  | 
 | ||||||
|     public static implicit operator Snowflake(long n) => new((ulong)n); |     public static implicit operator Snowflake(long n) => new((ulong)n); | ||||||
| 
 | 
 | ||||||
|     public static bool TryParse(string input, [NotNullWhen(true)] out Snowflake? snowflake) |     public static bool TryParse(string input, [NotNullWhen(true)] out Snowflake? snowflake) | ||||||
|     { |     { | ||||||
|         snowflake = null; |         snowflake = null; | ||||||
|         if (!ulong.TryParse(input, out var res)) return false; |         if (!ulong.TryParse(input, out var res)) | ||||||
|  |             return false; | ||||||
|         snowflake = new Snowflake(res); |         snowflake = new Snowflake(res); | ||||||
|         return true; |         return true; | ||||||
|     } |     } | ||||||
|  | @ -66,27 +73,37 @@ public readonly struct Snowflake(ulong value) : IEquatable<Snowflake> | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public override int GetHashCode() => Value.GetHashCode(); |     public override int GetHashCode() => Value.GetHashCode(); | ||||||
|  | 
 | ||||||
|     public override string ToString() => Value.ToString(); |     public override string ToString() => Value.ToString(); | ||||||
| 
 | 
 | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// An Entity Framework ValueConverter for Snowflakes to longs. |     /// An Entity Framework ValueConverter for Snowflakes to longs. | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     // ReSharper disable once ClassNeverInstantiated.Global |     // ReSharper disable once ClassNeverInstantiated.Global | ||||||
|     public class ValueConverter() : ValueConverter<Snowflake, long>( |     public class ValueConverter() | ||||||
|  |         : ValueConverter<Snowflake, long>( | ||||||
|             convertToProviderExpression: x => x, |             convertToProviderExpression: x => x, | ||||||
|             convertFromProviderExpression: x => x |             convertFromProviderExpression: x => x | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|     private class JsonConverter : JsonConverter<Snowflake> |     private class JsonConverter : JsonConverter<Snowflake> | ||||||
|     { |     { | ||||||
|         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()); |             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, |             bool hasExistingValue, | ||||||
|             JsonSerializer serializer) |             JsonSerializer serializer | ||||||
|  |         ) | ||||||
|         { |         { | ||||||
|             return ulong.Parse((string)reader.Value!); |             return ulong.Parse((string)reader.Value!); | ||||||
|         } |         } | ||||||
|  | @ -97,10 +114,16 @@ public readonly struct Snowflake(ulong value) : IEquatable<Snowflake> | ||||||
|         public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => |         public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => | ||||||
|             sourceType == typeof(string); |             sourceType == typeof(string); | ||||||
| 
 | 
 | ||||||
|         public override bool CanConvertTo(ITypeDescriptorContext? context, [NotNullWhen(true)] Type? destinationType) => |         public override bool CanConvertTo( | ||||||
|             destinationType == typeof(Snowflake); |             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; |             return TryParse((string)value, out var snowflake) ? snowflake : null; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | @ -32,13 +32,19 @@ public class SnowflakeGenerator : ISnowflakeGenerator | ||||||
|         var threadId = Environment.CurrentManagedThreadId % 32; |         var threadId = Environment.CurrentManagedThreadId % 32; | ||||||
|         var timestamp = time.Value.ToUnixTimeMilliseconds() - Snowflake.Epoch; |         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 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<ISnowflakeGenerator>(new SnowflakeGenerator(processId)); |         return services.AddSingleton<ISnowflakeGenerator>(new SnowflakeGenerator(processId)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -9,39 +9,47 @@ public class FoxnounsError(string message, Exception? inner = null) : Exception( | ||||||
| { | { | ||||||
|     public Exception? Inner => inner; |     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) |     public class UnknownEntityError(Type entityType, Exception? inner = null) | ||||||
|         : DatabaseError($"Entity of type {entityType.Name} not found", inner); |         : DatabaseError($"Entity of type {entityType.Name} not found", inner); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCode? errorCode = null) | public class ApiError( | ||||||
|     : FoxnounsError(message) |     string message, | ||||||
|  |     HttpStatusCode? statusCode = null, | ||||||
|  |     ErrorCode? errorCode = null | ||||||
|  | ) : FoxnounsError(message) | ||||||
| { | { | ||||||
|     public readonly HttpStatusCode StatusCode = statusCode ?? HttpStatusCode.InternalServerError; |     public readonly HttpStatusCode StatusCode = statusCode ?? HttpStatusCode.InternalServerError; | ||||||
|     public readonly ErrorCode ErrorCode = errorCode ?? ErrorCode.InternalServerError; |     public readonly ErrorCode ErrorCode = errorCode ?? ErrorCode.InternalServerError; | ||||||
| 
 | 
 | ||||||
|     public class Unauthorized(string message, ErrorCode errorCode = ErrorCode.AuthenticationError) : ApiError(message, |     public class Unauthorized(string message, ErrorCode errorCode = ErrorCode.AuthenticationError) | ||||||
|         statusCode: HttpStatusCode.Unauthorized, |         : ApiError(message, statusCode: HttpStatusCode.Unauthorized, errorCode: errorCode); | ||||||
|         errorCode: errorCode); |  | ||||||
| 
 | 
 | ||||||
|     public class Forbidden( |     public class Forbidden( | ||||||
|         string message, |         string message, | ||||||
|         IEnumerable<string>? scopes = null, |         IEnumerable<string>? scopes = null, | ||||||
|         ErrorCode errorCode = ErrorCode.Forbidden) |         ErrorCode errorCode = ErrorCode.Forbidden | ||||||
|         : ApiError(message, statusCode: HttpStatusCode.Forbidden, errorCode: errorCode) |     ) : ApiError(message, statusCode: HttpStatusCode.Forbidden, errorCode: errorCode) | ||||||
|     { |     { | ||||||
|         public readonly string[] Scopes = scopes?.ToArray() ?? []; |         public readonly string[] Scopes = scopes?.ToArray() ?? []; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public class BadRequest(string message, IReadOnlyDictionary<string, IEnumerable<ValidationError>>? errors = null) |     public class BadRequest( | ||||||
|         : ApiError(message, statusCode: HttpStatusCode.BadRequest) |         string message, | ||||||
|  |         IReadOnlyDictionary<string, IEnumerable<ValidationError>>? errors = null | ||||||
|  |     ) : ApiError(message, statusCode: HttpStatusCode.BadRequest) | ||||||
|     { |     { | ||||||
|         public BadRequest(string message, string field, object actualValue) : this("Error validating input", |         public BadRequest(string message, string field, object actualValue) | ||||||
|  |             : this( | ||||||
|  |                 "Error validating input", | ||||||
|                 new Dictionary<string, IEnumerable<ValidationError>> |                 new Dictionary<string, IEnumerable<ValidationError>> | ||||||
|                 { { field, [ValidationError.GenericValidationError(message, actualValue)] } }) |  | ||||||
|                 { |                 { | ||||||
|  |                     { field, [ValidationError.GenericValidationError(message, actualValue)] }, | ||||||
|                 } |                 } | ||||||
|  |             ) { } | ||||||
| 
 | 
 | ||||||
|         public JObject ToJson() |         public JObject ToJson() | ||||||
|         { |         { | ||||||
|  | @ -49,9 +57,10 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo | ||||||
|             { |             { | ||||||
|                 { "status", (int)HttpStatusCode.BadRequest }, |                 { "status", (int)HttpStatusCode.BadRequest }, | ||||||
|                 { "message", Message }, |                 { "message", Message }, | ||||||
|                 { "code", "BAD_REQUEST" } |                 { "code", "BAD_REQUEST" }, | ||||||
|             }; |             }; | ||||||
|             if (errors == null) return o; |             if (errors == null) | ||||||
|  |                 return o; | ||||||
| 
 | 
 | ||||||
|             var a = new JArray(); |             var a = new JArray(); | ||||||
|             foreach (var error in errors) |             foreach (var error in errors) | ||||||
|  | @ -59,7 +68,7 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo | ||||||
|                 var errorObj = new JObject |                 var errorObj = new JObject | ||||||
|                 { |                 { | ||||||
|                     { "key", error.Key }, |                     { "key", error.Key }, | ||||||
|                     { "errors", JArray.FromObject(error.Value) } |                     { "errors", JArray.FromObject(error.Value) }, | ||||||
|                 }; |                 }; | ||||||
|                 a.Add(errorObj); |                 a.Add(errorObj); | ||||||
|             } |             } | ||||||
|  | @ -82,9 +91,10 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo | ||||||
|             { |             { | ||||||
|                 { "status", (int)HttpStatusCode.BadRequest }, |                 { "status", (int)HttpStatusCode.BadRequest }, | ||||||
|                 { "message", Message }, |                 { "message", Message }, | ||||||
|                 { "code", "BAD_REQUEST" } |                 { "code", "BAD_REQUEST" }, | ||||||
|             }; |             }; | ||||||
|             if (modelState == null) return o; |             if (modelState == null) | ||||||
|  |                 return o; | ||||||
| 
 | 
 | ||||||
|             var a = new JArray(); |             var a = new JArray(); | ||||||
|             foreach (var error in modelState.Where(e => e.Value is { Errors.Count: > 0 })) |             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 }, |                     { "key", error.Key }, | ||||||
|                     { |                     { | ||||||
|                         "errors", |                         "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); |                 a.Add(errorObj); | ||||||
|             } |             } | ||||||
|  | @ -108,7 +123,8 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo | ||||||
|     public class NotFound(string message, ErrorCode? code = null) |     public class NotFound(string message, ErrorCode? code = null) | ||||||
|         : ApiError(message, statusCode: HttpStatusCode.NotFound, errorCode: code); |         : 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 | public enum ErrorCode | ||||||
|  | @ -143,34 +159,38 @@ public class ValidationError | ||||||
|     [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |     [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||||
|     public object? ActualValue { get; init; } |     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 |         return new ValidationError | ||||||
|         { |         { | ||||||
|             Message = message, |             Message = message, | ||||||
|             MinLength = minLength, |             MinLength = minLength, | ||||||
|             MaxLength = maxLength, |             MaxLength = maxLength, | ||||||
|             ActualLength = actualLength |             ActualLength = actualLength, | ||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static ValidationError DisallowedValueError(string message, IEnumerable<object> allowedValues, |     public static ValidationError DisallowedValueError( | ||||||
|         object actualValue) |         string message, | ||||||
|  |         IEnumerable<object> allowedValues, | ||||||
|  |         object actualValue | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         return new ValidationError |         return new ValidationError | ||||||
|         { |         { | ||||||
|             Message = message, |             Message = message, | ||||||
|             AllowedValues = allowedValues, |             AllowedValues = allowedValues, | ||||||
|             ActualValue = actualValue |             ActualValue = actualValue, | ||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static ValidationError GenericValidationError(string message, object? actualValue) |     public static ValidationError GenericValidationError(string message, object? actualValue) | ||||||
|     { |     { | ||||||
|         return new ValidationError |         return new ValidationError { Message = message, ActualValue = actualValue }; | ||||||
|         { |  | ||||||
|             Message = message, |  | ||||||
|             ActualValue = actualValue |  | ||||||
|         }; |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -14,21 +14,35 @@ public static class AvatarObjectExtensions | ||||||
| { | { | ||||||
|     private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"]; |     private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"]; | ||||||
| 
 | 
 | ||||||
|     public static async Task |     public static async Task DeleteMemberAvatarAsync( | ||||||
|         DeleteMemberAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash, |         this ObjectStorageService objectStorageService, | ||||||
|             CancellationToken ct = default) => |         Snowflake id, | ||||||
|         await objectStorageService.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash), ct); |         string hash, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) => | ||||||
|  |         await objectStorageService.RemoveObjectAsync( | ||||||
|  |             MemberAvatarUpdateInvocable.Path(id, hash), | ||||||
|  |             ct | ||||||
|  |         ); | ||||||
| 
 | 
 | ||||||
|     public static async Task |     public static async Task DeleteUserAvatarAsync( | ||||||
|         DeleteUserAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash, |         this ObjectStorageService objectStorageService, | ||||||
|             CancellationToken ct = default) => |         Snowflake id, | ||||||
|         await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); |         string hash, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) => await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); | ||||||
| 
 | 
 | ||||||
|     public static async Task DeleteFlagAsync(this ObjectStorageService objectStorageService, string hash, |     public static async Task DeleteFlagAsync( | ||||||
|         CancellationToken ct = default) => |         this ObjectStorageService objectStorageService, | ||||||
|         await objectStorageService.RemoveObjectAsync(CreateFlagInvocable.Path(hash), ct); |         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/")) |         if (!uri.StartsWith("data:image/")) | ||||||
|             throw new ArgumentException("Not a data URI", nameof(uri)); |             throw new ArgumentException("Not a data URI", nameof(uri)); | ||||||
|  | @ -49,7 +63,7 @@ public static class AvatarObjectExtensions | ||||||
|             { |             { | ||||||
|                 Size = new Size(size), |                 Size = new Size(size), | ||||||
|                 Mode = crop ? ResizeMode.Crop : ResizeMode.Max, |                 Mode = crop ? ResizeMode.Crop : ResizeMode.Max, | ||||||
|                 Position = AnchorPositionMode.Center |                 Position = AnchorPositionMode.Center, | ||||||
|             }, |             }, | ||||||
|             image.Size |             image.Size | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
|  | @ -8,37 +8,58 @@ namespace Foxnouns.Backend.Extensions; | ||||||
| 
 | 
 | ||||||
| public static class KeyCacheExtensions | public static class KeyCacheExtensions | ||||||
| { | { | ||||||
|     public static async Task<string> GenerateAuthStateAsync(this KeyCacheService keyCacheService, |     public static async Task<string> GenerateAuthStateAsync( | ||||||
|         CancellationToken ct = default) |         this KeyCacheService keyCacheService, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); |         var state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); | ||||||
|         await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); |         await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); | ||||||
|         return state; |         return state; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static async Task ValidateAuthStateAsync(this KeyCacheService keyCacheService, string state, |     public static async Task ValidateAuthStateAsync( | ||||||
|         CancellationToken ct = default) |         this KeyCacheService keyCacheService, | ||||||
|  |         string state, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", delete: true, ct); |         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<string> GenerateRegisterEmailStateAsync(this KeyCacheService keyCacheService, string email, |     public static async Task<string> GenerateRegisterEmailStateAsync( | ||||||
|         Snowflake? userId = null, CancellationToken ct = default) |         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 |         // This state is used in links, not just as JSON values, so make it URL-safe | ||||||
|         var state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); |         var state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); | ||||||
|         await keyCacheService.SetKeyAsync($"email_state:{state}", new RegisterEmailState(email, userId), |         await keyCacheService.SetKeyAsync( | ||||||
|             Duration.FromDays(1), ct); |             $"email_state:{state}", | ||||||
|  |             new RegisterEmailState(email, userId), | ||||||
|  |             Duration.FromDays(1), | ||||||
|  |             ct | ||||||
|  |         ); | ||||||
|         return state; |         return state; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static async Task<RegisterEmailState?> GetRegisterEmailStateAsync(this KeyCacheService keyCacheService, |     public static async Task<RegisterEmailState?> GetRegisterEmailStateAsync( | ||||||
|         string state, CancellationToken ct = default) => |         this KeyCacheService keyCacheService, | ||||||
|         await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}", delete: true, ct); |         string state, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) => | ||||||
|  |         await keyCacheService.GetKeyAsync<RegisterEmailState>( | ||||||
|  |             $"email_state:{state}", | ||||||
|  |             delete: true, | ||||||
|  |             ct | ||||||
|  |         ); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| public record RegisterEmailState( | public record RegisterEmailState( | ||||||
|     string Email, |     string Email, | ||||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId | ||||||
|     Snowflake? ExistingUserId); | ); | ||||||
|  |  | ||||||
|  | @ -29,8 +29,10 @@ public static class WebApplicationExtensions | ||||||
|             // ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead. |             // 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. |             // Serilog doesn't disable the built-in logs, so we do it here. | ||||||
|             .MinimumLevel.Override("Microsoft", LogEventLevel.Information) |             .MinimumLevel.Override("Microsoft", LogEventLevel.Information) | ||||||
|             .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", |             .MinimumLevel.Override( | ||||||
|                 config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal) |                 "Microsoft.EntityFrameworkCore.Database.Command", | ||||||
|  |                 config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal | ||||||
|  |             ) | ||||||
|             .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) |             .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) | ||||||
|             .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) |             .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) | ||||||
|             .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) |             .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) | ||||||
|  | @ -38,7 +40,10 @@ public static class WebApplicationExtensions | ||||||
| 
 | 
 | ||||||
|         if (config.Logging.SeqLogUrl != null) |         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. |         // AddSerilog doesn't seem to add an ILogger to the service collection, so add that manually. | ||||||
|  | @ -74,7 +79,8 @@ public static class WebApplicationExtensions | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     public static IServiceCollection AddServices(this WebApplicationBuilder builder, Config config) |     public static IServiceCollection AddServices(this WebApplicationBuilder builder, Config config) | ||||||
|     { |     { | ||||||
|         builder.Host.ConfigureServices((ctx, services) => |         builder.Host.ConfigureServices( | ||||||
|  |             (ctx, services) => | ||||||
|             { |             { | ||||||
|                 services |                 services | ||||||
|                     .AddQueue() |                     .AddQueue() | ||||||
|  | @ -84,7 +90,8 @@ public static class WebApplicationExtensions | ||||||
|                     .AddMinio(c => |                     .AddMinio(c => | ||||||
|                         c.WithEndpoint(config.Storage.Endpoint) |                         c.WithEndpoint(config.Storage.Endpoint) | ||||||
|                             .WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey) |                             .WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey) | ||||||
|                         .Build()) |                             .Build() | ||||||
|  |                     ) | ||||||
|                     .AddSingleton<MetricsCollectionService>() |                     .AddSingleton<MetricsCollectionService>() | ||||||
|                     .AddSingleton<IClock>(SystemClock.Instance) |                     .AddSingleton<IClock>(SystemClock.Instance) | ||||||
|                     .AddSnowflakeGenerator() |                     .AddSnowflakeGenerator() | ||||||
|  | @ -104,18 +111,20 @@ public static class WebApplicationExtensions | ||||||
| 
 | 
 | ||||||
|                 if (!config.Logging.EnableMetrics) |                 if (!config.Logging.EnableMetrics) | ||||||
|                     services.AddHostedService<BackgroundMetricsCollectionService>(); |                     services.AddHostedService<BackgroundMetricsCollectionService>(); | ||||||
|         }); |             } | ||||||
|  |         ); | ||||||
| 
 | 
 | ||||||
|         return builder.Services; |         return builder.Services; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services |     public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => | ||||||
|  |         services | ||||||
|             .AddScoped<ErrorHandlerMiddleware>() |             .AddScoped<ErrorHandlerMiddleware>() | ||||||
|             .AddScoped<AuthenticationMiddleware>() |             .AddScoped<AuthenticationMiddleware>() | ||||||
|             .AddScoped<AuthorizationMiddleware>(); |             .AddScoped<AuthorizationMiddleware>(); | ||||||
| 
 | 
 | ||||||
|     public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) => app |     public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) => | ||||||
|         .UseMiddleware<ErrorHandlerMiddleware>() |         app.UseMiddleware<ErrorHandlerMiddleware>() | ||||||
|             .UseMiddleware<AuthenticationMiddleware>() |             .UseMiddleware<AuthenticationMiddleware>() | ||||||
|             .UseMiddleware<AuthorizationMiddleware>(); |             .UseMiddleware<AuthorizationMiddleware>(); | ||||||
| 
 | 
 | ||||||
|  | @ -124,13 +133,20 @@ public static class WebApplicationExtensions | ||||||
|         // Read version information from .version in the repository root |         // Read version information from .version in the repository root | ||||||
|         await BuildInfo.ReadBuildInfo(); |         await BuildInfo.ReadBuildInfo(); | ||||||
| 
 | 
 | ||||||
|         app.Services.ConfigureQueue().LogQueuedTaskProgress(app.Services.GetRequiredService<ILogger<IQueue>>()); |         app.Services.ConfigureQueue() | ||||||
|  |             .LogQueuedTaskProgress(app.Services.GetRequiredService<ILogger<IQueue>>()); | ||||||
| 
 | 
 | ||||||
|         await using var scope = app.Services.CreateAsyncScope(); |         await using var scope = app.Services.CreateAsyncScope(); | ||||||
|         var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<WebApplication>(); |         var logger = scope | ||||||
|  |             .ServiceProvider.GetRequiredService<ILogger>() | ||||||
|  |             .ForContext<WebApplication>(); | ||||||
|         var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>(); |         var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>(); | ||||||
| 
 | 
 | ||||||
|         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(); |         var pendingMigrations = (await db.Database.GetPendingMigrationsAsync()).ToList(); | ||||||
|         if (args.Contains("--migrate") || args.Contains("--migrate-and-start")) |         if (args.Contains("--migrate") || args.Contains("--migrate-and-start")) | ||||||
|  | @ -146,13 +162,15 @@ public static class WebApplicationExtensions | ||||||
|                 logger.Information("Successfully migrated database"); |                 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) |         else if (pendingMigrations.Count > 0) | ||||||
|         { |         { | ||||||
|             logger.Fatal( |             logger.Fatal( | ||||||
|                 "There are {Count} pending migrations, run server with --migrate or --migrate-and-start to run migrations.", |                 "There are {Count} pending migrations, run server with --migrate or --migrate-and-start to run migrations.", | ||||||
|                 pendingMigrations.Count); |                 pendingMigrations.Count | ||||||
|  |             ); | ||||||
|             Environment.Exit(1); |             Environment.Exit(1); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -4,23 +4,35 @@ namespace Foxnouns.Backend; | ||||||
| 
 | 
 | ||||||
| public static class FoxnounsMetrics | public static class FoxnounsMetrics | ||||||
| { | { | ||||||
|     public static readonly Gauge UsersCount = |     public static readonly Gauge UsersCount = Metrics.CreateGauge( | ||||||
|         Metrics.CreateGauge("foxnouns_user_count", "Number of total users"); |         "foxnouns_user_count", | ||||||
|  |         "Number of total users" | ||||||
|  |     ); | ||||||
| 
 | 
 | ||||||
|     public static readonly Gauge UsersActiveMonthCount = |     public static readonly Gauge UsersActiveMonthCount = Metrics.CreateGauge( | ||||||
|         Metrics.CreateGauge("foxnouns_user_count_active_month", "Number of users active in the last month"); |         "foxnouns_user_count_active_month", | ||||||
|  |         "Number of users active in the last month" | ||||||
|  |     ); | ||||||
| 
 | 
 | ||||||
|     public static readonly Gauge UsersActiveWeekCount = |     public static readonly Gauge UsersActiveWeekCount = Metrics.CreateGauge( | ||||||
|         Metrics.CreateGauge("foxnouns_user_count_active_week", "Number of users active in the last week"); |         "foxnouns_user_count_active_week", | ||||||
|  |         "Number of users active in the last week" | ||||||
|  |     ); | ||||||
| 
 | 
 | ||||||
|     public static readonly Gauge UsersActiveDayCount = |     public static readonly Gauge UsersActiveDayCount = Metrics.CreateGauge( | ||||||
|         Metrics.CreateGauge("foxnouns_user_count_active_day", "Number of users active in the last day"); |         "foxnouns_user_count_active_day", | ||||||
|  |         "Number of users active in the last day" | ||||||
|  |     ); | ||||||
| 
 | 
 | ||||||
|     public static readonly Gauge MemberCount = |     public static readonly Gauge MemberCount = Metrics.CreateGauge( | ||||||
|         Metrics.CreateGauge("foxnouns_member_count", "Number of total members"); |         "foxnouns_member_count", | ||||||
|  |         "Number of total members" | ||||||
|  |     ); | ||||||
| 
 | 
 | ||||||
|     public static readonly Summary MetricsCollectionTime = |     public static readonly Summary MetricsCollectionTime = Metrics.CreateSummary( | ||||||
|         Metrics.CreateSummary("foxnouns_time_metrics", "Time it took to collect metrics"); |         "foxnouns_time_metrics", | ||||||
|  |         "Time it took to collect metrics" | ||||||
|  |     ); | ||||||
| 
 | 
 | ||||||
|     public static Gauge ProcessPhysicalMemory => |     public static Gauge ProcessPhysicalMemory => | ||||||
|         Metrics.CreateGauge("foxnouns_process_physical_memory", "Process physical memory"); |         Metrics.CreateGauge("foxnouns_process_physical_memory", "Process physical memory"); | ||||||
|  | @ -31,7 +43,9 @@ public static class FoxnounsMetrics | ||||||
|     public static Gauge ProcessPrivateMemory => |     public static Gauge ProcessPrivateMemory => | ||||||
|         Metrics.CreateGauge("foxnouns_process_private_memory", "Process private memory"); |         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"); |     public static Gauge ProcessHandles => | ||||||
|  |         Metrics.CreateGauge("foxnouns_process_handles", "Process handle count"); | ||||||
| } | } | ||||||
|  | @ -6,20 +6,30 @@ using Foxnouns.Backend.Services; | ||||||
| 
 | 
 | ||||||
| namespace Foxnouns.Backend.Jobs; | namespace Foxnouns.Backend.Jobs; | ||||||
| 
 | 
 | ||||||
| public class CreateFlagInvocable(DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger) | public class CreateFlagInvocable( | ||||||
|     : IInvocable, IInvocableWithPayload<CreateFlagPayload> |     DatabaseContext db, | ||||||
|  |     ObjectStorageService objectStorageService, | ||||||
|  |     ILogger logger | ||||||
|  | ) : IInvocable, IInvocableWithPayload<CreateFlagPayload> | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<CreateFlagInvocable>(); |     private readonly ILogger _logger = logger.ForContext<CreateFlagInvocable>(); | ||||||
|     public required CreateFlagPayload Payload { get; set; } |     public required CreateFlagPayload Payload { get; set; } | ||||||
| 
 | 
 | ||||||
|     public async Task Invoke() |     public async Task Invoke() | ||||||
|     { |     { | ||||||
|         _logger.Information("Creating flag {FlagId} for user {UserId} with image data length {DataLength}", Payload.Id, |         _logger.Information( | ||||||
|             Payload.UserId, Payload.ImageData.Length); |             "Creating flag {FlagId} for user {UserId} with image data length {DataLength}", | ||||||
|  |             Payload.Id, | ||||||
|  |             Payload.UserId, | ||||||
|  |             Payload.ImageData.Length | ||||||
|  |         ); | ||||||
| 
 | 
 | ||||||
|         try |         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"); |             await objectStorageService.PutObjectAsync(Path(hash), image, "image/webp"); | ||||||
| 
 | 
 | ||||||
|             var flag = new PrideFlag |             var flag = new PrideFlag | ||||||
|  | @ -28,7 +38,7 @@ public class CreateFlagInvocable(DatabaseContext db, ObjectStorageService object | ||||||
|                 UserId = Payload.UserId, |                 UserId = Payload.UserId, | ||||||
|                 Hash = hash, |                 Hash = hash, | ||||||
|                 Name = Payload.Name, |                 Name = Payload.Name, | ||||||
|                 Description = Payload.Description |                 Description = Payload.Description, | ||||||
|             }; |             }; | ||||||
|             db.Add(flag); |             db.Add(flag); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -6,16 +6,21 @@ using Foxnouns.Backend.Services; | ||||||
| 
 | 
 | ||||||
| namespace Foxnouns.Backend.Jobs; | namespace Foxnouns.Backend.Jobs; | ||||||
| 
 | 
 | ||||||
| public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger) | public class MemberAvatarUpdateInvocable( | ||||||
|     : IInvocable, IInvocableWithPayload<AvatarUpdatePayload> |     DatabaseContext db, | ||||||
|  |     ObjectStorageService objectStorageService, | ||||||
|  |     ILogger logger | ||||||
|  | ) : IInvocable, IInvocableWithPayload<AvatarUpdatePayload> | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>(); |     private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>(); | ||||||
|     public required AvatarUpdatePayload Payload { get; set; } |     public required AvatarUpdatePayload Payload { get; set; } | ||||||
| 
 | 
 | ||||||
|     public async Task Invoke() |     public async Task Invoke() | ||||||
|     { |     { | ||||||
|         if (Payload.NewAvatar != null) await UpdateMemberAvatarAsync(Payload.Id, Payload.NewAvatar); |         if (Payload.NewAvatar != null) | ||||||
|         else await ClearMemberAvatarAsync(Payload.Id); |             await UpdateMemberAvatarAsync(Payload.Id, Payload.NewAvatar); | ||||||
|  |         else | ||||||
|  |             await ClearMemberAvatarAsync(Payload.Id); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     private async Task UpdateMemberAvatarAsync(Snowflake id, string newAvatar) |     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); |         var member = await db.Members.FindAsync(id); | ||||||
|         if (member == null) |         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; |             return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | @ -46,7 +54,11 @@ public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageServic | ||||||
|         } |         } | ||||||
|         catch (ArgumentException ae) |         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); |         var member = await db.Members.FindAsync(id); | ||||||
|         if (member == null) |         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; |             return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -4,4 +4,10 @@ namespace Foxnouns.Backend.Jobs; | ||||||
| 
 | 
 | ||||||
| public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar); | public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar); | ||||||
| 
 | 
 | ||||||
| public record CreateFlagPayload(Snowflake Id, Snowflake UserId, string Name, string ImageData, string? Description); | public record CreateFlagPayload( | ||||||
|  |     Snowflake Id, | ||||||
|  |     Snowflake UserId, | ||||||
|  |     string Name, | ||||||
|  |     string ImageData, | ||||||
|  |     string? Description | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | @ -6,16 +6,21 @@ using Foxnouns.Backend.Services; | ||||||
| 
 | 
 | ||||||
| namespace Foxnouns.Backend.Jobs; | namespace Foxnouns.Backend.Jobs; | ||||||
| 
 | 
 | ||||||
| public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger) | public class UserAvatarUpdateInvocable( | ||||||
|     : IInvocable, IInvocableWithPayload<AvatarUpdatePayload> |     DatabaseContext db, | ||||||
|  |     ObjectStorageService objectStorageService, | ||||||
|  |     ILogger logger | ||||||
|  | ) : IInvocable, IInvocableWithPayload<AvatarUpdatePayload> | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>(); |     private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>(); | ||||||
|     public required AvatarUpdatePayload Payload { get; set; } |     public required AvatarUpdatePayload Payload { get; set; } | ||||||
| 
 | 
 | ||||||
|     public async Task Invoke() |     public async Task Invoke() | ||||||
|     { |     { | ||||||
|         if (Payload.NewAvatar != null) await UpdateUserAvatarAsync(Payload.Id, Payload.NewAvatar); |         if (Payload.NewAvatar != null) | ||||||
|         else await ClearUserAvatarAsync(Payload.Id); |             await UpdateUserAvatarAsync(Payload.Id, Payload.NewAvatar); | ||||||
|  |         else | ||||||
|  |             await ClearUserAvatarAsync(Payload.Id); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     private async Task UpdateUserAvatarAsync(Snowflake id, string newAvatar) |     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); |         var user = await db.Users.FindAsync(id); | ||||||
|         if (user == null) |         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; |             return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | @ -47,7 +55,11 @@ public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService | ||||||
|         } |         } | ||||||
|         catch (ArgumentException ae) |         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); |         var user = await db.Users.FindAsync(id); | ||||||
|         if (user == null) |         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; |             return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -7,9 +7,7 @@ public class AccountCreationMailable(Config config, AccountCreationMailableView | ||||||
| { | { | ||||||
|     public override void Build() |     public override void Build() | ||||||
|     { |     { | ||||||
|         To(view.To) |         To(view.To).From(config.EmailAuth.From!).View("~/Views/Mail/AccountCreation.cshtml", view); | ||||||
|             .From(config.EmailAuth.From!) |  | ||||||
|             .View("~/Views/Mail/AccountCreation.cshtml", view); |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -17,7 +17,9 @@ public class AuthenticationMiddleware(DatabaseContext db) : IMiddleware | ||||||
|             return; |             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); |             await next(ctx); | ||||||
|             return; |             return; | ||||||
|  | @ -40,6 +42,7 @@ public static class HttpContextExtensions | ||||||
|     private const string Key = "token"; |     private const string Key = "token"; | ||||||
| 
 | 
 | ||||||
|     public static void SetToken(this HttpContext ctx, Token token) => ctx.Items.Add(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? GetUser(this HttpContext ctx) => ctx.GetToken()?.User; | ||||||
| 
 | 
 | ||||||
|     public static User GetUserOrThrow(this HttpContext ctx) => |     public static User GetUserOrThrow(this HttpContext ctx) => | ||||||
|  |  | ||||||
|  | @ -18,14 +18,26 @@ public class AuthorizationMiddleware : IMiddleware | ||||||
| 
 | 
 | ||||||
|         var token = ctx.GetToken(); |         var token = ctx.GetToken(); | ||||||
|         if (token == null) |         if (token == null) | ||||||
|             throw new ApiError.Unauthorized("This endpoint requires an authenticated user.", |             throw new ApiError.Unauthorized( | ||||||
|                 ErrorCode.AuthenticationRequired); |                 "This endpoint requires an authenticated user.", | ||||||
|         if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()) |                 ErrorCode.AuthenticationRequired | ||||||
|             throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", |             ); | ||||||
|                 attribute.Scopes.Except(token.Scopes.ExpandScopes()), ErrorCode.MissingScopes); |         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) |         if (attribute.RequireAdmin && token.User.Role != UserRole.Admin) | ||||||
|             throw new ApiError.Forbidden("This endpoint can only be used by admins."); |             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."); |             throw new ApiError.Forbidden("This endpoint can only be used by moderators."); | ||||||
| 
 | 
 | ||||||
|         await next(ctx); |         await next(ctx); | ||||||
|  |  | ||||||
|  | @ -21,19 +21,26 @@ public class ErrorHandlerMiddleware(ILogger baseLogger, IHub sentry) : IMiddlewa | ||||||
| 
 | 
 | ||||||
|             if (ctx.Response.HasStarted) |             if (ctx.Response.HasStarted) | ||||||
|             { |             { | ||||||
|                 logger.Error(e, "Error in {ClassName} ({Path}) after response started being sent", typeName, |                 logger.Error( | ||||||
|                     ctx.Request.Path); |                     e, | ||||||
|  |                     "Error in {ClassName} ({Path}) after response started being sent", | ||||||
|  |                     typeName, | ||||||
|  |                     ctx.Request.Path | ||||||
|  |                 ); | ||||||
| 
 | 
 | ||||||
|                 sentry.CaptureException(e, scope => |                 sentry.CaptureException( | ||||||
|  |                     e, | ||||||
|  |                     scope => | ||||||
|                     { |                     { | ||||||
|                         var user = ctx.GetUser(); |                         var user = ctx.GetUser(); | ||||||
|                         if (user != null) |                         if (user != null) | ||||||
|                             scope.User = new SentryUser |                             scope.User = new SentryUser | ||||||
|                             { |                             { | ||||||
|                                 Id = user.Id.ToString(), |                                 Id = user.Id.ToString(), | ||||||
|                             Username = user.Username |                                 Username = user.Username, | ||||||
|                             }; |                             }; | ||||||
|                 }); |                     } | ||||||
|  |                 ); | ||||||
| 
 | 
 | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|  | @ -45,13 +52,17 @@ public class ErrorHandlerMiddleware(ILogger baseLogger, IHub sentry) : IMiddlewa | ||||||
|                 ctx.Response.ContentType = "application/json; charset=utf-8"; |                 ctx.Response.ContentType = "application/json; charset=utf-8"; | ||||||
|                 if (ae is ApiError.Forbidden fe) |                 if (ae is ApiError.Forbidden fe) | ||||||
|                 { |                 { | ||||||
|                     await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError |                     await ctx.Response.WriteAsync( | ||||||
|  |                         JsonConvert.SerializeObject( | ||||||
|  |                             new HttpApiError | ||||||
|                             { |                             { | ||||||
|                                 Status = (int)fe.StatusCode, |                                 Status = (int)fe.StatusCode, | ||||||
|                                 Code = ErrorCode.Forbidden, |                                 Code = ErrorCode.Forbidden, | ||||||
|                                 Message = fe.Message, |                                 Message = fe.Message, | ||||||
|                         Scopes = fe.Scopes.Length > 0 ? fe.Scopes : null |                                 Scopes = fe.Scopes.Length > 0 ? fe.Scopes : null, | ||||||
|                     })); |                             } | ||||||
|  |                         ) | ||||||
|  |                     ); | ||||||
|                     return; |                     return; | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|  | @ -61,45 +72,61 @@ public class ErrorHandlerMiddleware(ILogger baseLogger, IHub sentry) : IMiddlewa | ||||||
|                     return; |                     return; | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|                 await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError |                 await ctx.Response.WriteAsync( | ||||||
|  |                     JsonConvert.SerializeObject( | ||||||
|  |                         new HttpApiError | ||||||
|                         { |                         { | ||||||
|                             Status = (int)ae.StatusCode, |                             Status = (int)ae.StatusCode, | ||||||
|                             Code = ae.ErrorCode, |                             Code = ae.ErrorCode, | ||||||
|                             Message = ae.Message, |                             Message = ae.Message, | ||||||
|                 })); |                         } | ||||||
|  |                     ) | ||||||
|  |                 ); | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             if (e is FoxnounsError fce) |             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 |             else | ||||||
|             { |             { | ||||||
|                 logger.Error(e, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path); |                 logger.Error(e, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path); | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             var errorId = sentry.CaptureException(e, scope => |             var errorId = sentry.CaptureException( | ||||||
|  |                 e, | ||||||
|  |                 scope => | ||||||
|                 { |                 { | ||||||
|                     var user = ctx.GetUser(); |                     var user = ctx.GetUser(); | ||||||
|                     if (user != null) |                     if (user != null) | ||||||
|                         scope.User = new SentryUser |                         scope.User = new SentryUser | ||||||
|                         { |                         { | ||||||
|                             Id = user.Id.ToString(), |                             Id = user.Id.ToString(), | ||||||
|                         Username = user.Username |                             Username = user.Username, | ||||||
|                         }; |                         }; | ||||||
|             }); |                 } | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|             ctx.Response.StatusCode = (int)HttpStatusCode.InternalServerError; |             ctx.Response.StatusCode = (int)HttpStatusCode.InternalServerError; | ||||||
|             ctx.Response.Headers.RequestId = ctx.TraceIdentifier; |             ctx.Response.Headers.RequestId = ctx.TraceIdentifier; | ||||||
|             ctx.Response.ContentType = "application/json; charset=utf-8"; |             ctx.Response.ContentType = "application/json; charset=utf-8"; | ||||||
|             await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError |             await ctx.Response.WriteAsync( | ||||||
|  |                 JsonConvert.SerializeObject( | ||||||
|  |                     new HttpApiError | ||||||
|                     { |                     { | ||||||
|                         Status = (int)HttpStatusCode.InternalServerError, |                         Status = (int)HttpStatusCode.InternalServerError, | ||||||
|                         Code = ErrorCode.InternalServerError, |                         Code = ErrorCode.InternalServerError, | ||||||
|                         ErrorId = errorId.ToString(), |                         ErrorId = errorId.ToString(), | ||||||
|                         Message = "Internal server error", |                         Message = "Internal server error", | ||||||
|             })); |                     } | ||||||
|  |                 ) | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,5 +1,4 @@ | ||||||
| using Foxnouns.Backend; | using Foxnouns.Backend; | ||||||
| using Serilog; |  | ||||||
| using Foxnouns.Backend.Extensions; | using Foxnouns.Backend.Extensions; | ||||||
| using Foxnouns.Backend.Services; | using Foxnouns.Backend.Services; | ||||||
| using Foxnouns.Backend.Utils; | using Foxnouns.Backend.Utils; | ||||||
|  | @ -8,6 +7,7 @@ using Newtonsoft.Json; | ||||||
| using Newtonsoft.Json.Serialization; | using Newtonsoft.Json.Serialization; | ||||||
| using Prometheus; | using Prometheus; | ||||||
| using Sentry.Extensibility; | using Sentry.Extensibility; | ||||||
|  | using Serilog; | ||||||
| 
 | 
 | ||||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||||
| 
 | 
 | ||||||
|  | @ -15,8 +15,8 @@ var config = builder.AddConfiguration(); | ||||||
| 
 | 
 | ||||||
| builder.AddSerilog(); | builder.AddSerilog(); | ||||||
| 
 | 
 | ||||||
| builder.WebHost | builder | ||||||
|     .UseSentry(opts => |     .WebHost.UseSentry(opts => | ||||||
|     { |     { | ||||||
|         opts.Dsn = config.Logging.SentryUrl; |         opts.Dsn = config.Logging.SentryUrl; | ||||||
|         opts.TracesSampleRate = config.Logging.SentryTracesSampleRate; |         opts.TracesSampleRate = config.Logging.SentryTracesSampleRate; | ||||||
|  | @ -30,13 +30,13 @@ builder.WebHost | ||||||
|         opts.Limits.MaxRequestBodySize = 2 * 1024 * 1024; |         opts.Limits.MaxRequestBodySize = 2 * 1024 * 1024; | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
| builder.Services | builder | ||||||
|     .AddControllers() |     .Services.AddControllers() | ||||||
|     .AddNewtonsoftJson(options => |     .AddNewtonsoftJson(options => | ||||||
|     { |     { | ||||||
|         options.SerializerSettings.ContractResolver = new PatchRequestContractResolver |         options.SerializerSettings.ContractResolver = new PatchRequestContractResolver | ||||||
|         { |         { | ||||||
|             NamingStrategy = new SnakeCaseNamingStrategy() |             NamingStrategy = new SnakeCaseNamingStrategy(), | ||||||
|         }; |         }; | ||||||
|     }) |     }) | ||||||
|     .ConfigureApiBehaviorOptions(options => |     .ConfigureApiBehaviorOptions(options => | ||||||
|  | @ -47,18 +47,16 @@ builder.Services | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
| // Set the default converter to snake case as we use it in a couple places. | // Set the default converter to snake case as we use it in a couple places. | ||||||
| JsonConvert.DefaultSettings = () => new JsonSerializerSettings | JsonConvert.DefaultSettings = () => | ||||||
|  |     new JsonSerializerSettings | ||||||
|     { |     { | ||||||
|         ContractResolver = new DefaultContractResolver |         ContractResolver = new DefaultContractResolver | ||||||
|         { |         { | ||||||
|         NamingStrategy = new SnakeCaseNamingStrategy() |             NamingStrategy = new SnakeCaseNamingStrategy(), | ||||||
|     } |         }, | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
| builder.AddServices(config) | builder.AddServices(config).AddCustomMiddleware().AddEndpointsApiExplorer().AddSwaggerGen(); | ||||||
|     .AddCustomMiddleware() |  | ||||||
|     .AddEndpointsApiExplorer() |  | ||||||
|     .AddSwaggerGen(); |  | ||||||
| 
 | 
 | ||||||
| var app = builder.Build(); | var app = builder.Build(); | ||||||
| 
 | 
 | ||||||
|  | @ -66,9 +64,11 @@ await app.Initialize(args); | ||||||
| 
 | 
 | ||||||
| app.UseSerilogRequestLogging(); | app.UseSerilogRequestLogging(); | ||||||
| app.UseRouting(); | app.UseRouting(); | ||||||
|  | 
 | ||||||
| // Not all environments will want tracing (from experience, it's expensive to use in production, even with a low sample rate), | // 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. | // so it's locked behind a config option. | ||||||
| if (config.Logging.SentryTracing) app.UseSentryTracing(); | if (config.Logging.SentryTracing) | ||||||
|  |     app.UseSentryTracing(); | ||||||
| app.UseSwagger(); | app.UseSwagger(); | ||||||
| app.UseSwaggerUI(); | app.UseSwaggerUI(); | ||||||
| app.UseCors(); | app.UseCors(); | ||||||
|  | @ -80,7 +80,8 @@ app.Urls.Add(config.Address); | ||||||
| 
 | 
 | ||||||
| // Make sure metrics are updated whenever Prometheus scrapes them | // Make sure metrics are updated whenever Prometheus scrapes them | ||||||
| Metrics.DefaultRegistry.AddBeforeCollectCallback(async ct => | Metrics.DefaultRegistry.AddBeforeCollectCallback(async ct => | ||||||
|     await app.Services.GetRequiredService<MetricsCollectionService>().CollectMetricsAsync(ct)); |     await app.Services.GetRequiredService<MetricsCollectionService>().CollectMetricsAsync(ct) | ||||||
|  | ); | ||||||
| 
 | 
 | ||||||
| app.Run(); | app.Run(); | ||||||
| Log.CloseAndFlush(); | Log.CloseAndFlush(); | ||||||
|  | @ -16,8 +16,12 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s | ||||||
|     /// Creates a new user with the given email address and password. |     /// Creates a new user with the given email address and password. | ||||||
|     /// This method does <i>not</i> save the resulting user, the caller must still call <see cref="M:Microsoft.EntityFrameworkCore.DbContext.SaveChanges" />. |     /// This method does <i>not</i> save the resulting user, the caller must still call <see cref="M:Microsoft.EntityFrameworkCore.DbContext.SaveChanges" />. | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     public async Task<User> CreateUserWithPasswordAsync(string username, string email, string password, |     public async Task<User> CreateUserWithPasswordAsync( | ||||||
|         CancellationToken ct = default) |         string username, | ||||||
|  |         string email, | ||||||
|  |         string password, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var user = new User |         var user = new User | ||||||
|         { |         { | ||||||
|  | @ -26,9 +30,13 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s | ||||||
|             AuthMethods = |             AuthMethods = | ||||||
|             { |             { | ||||||
|                 new AuthMethod |                 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); |         db.Add(user); | ||||||
|  | @ -42,8 +50,14 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s | ||||||
|     /// To create a user with email authentication, use <see cref="CreateUserWithPasswordAsync" /> |     /// To create a user with email authentication, use <see cref="CreateUserWithPasswordAsync" /> | ||||||
|     /// This method does <i>not</i> save the resulting user, the caller must still call <see cref="M:Microsoft.EntityFrameworkCore.DbContext.SaveChanges" />. |     /// This method does <i>not</i> save the resulting user, the caller must still call <see cref="M:Microsoft.EntityFrameworkCore.DbContext.SaveChanges" />. | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     public async Task<User> CreateUserWithRemoteAuthAsync(string username, AuthType authType, string remoteId, |     public async Task<User> CreateUserWithRemoteAuthAsync( | ||||||
|         string remoteUsername, FediverseApplication? instance = null, CancellationToken ct = default) |         string username, | ||||||
|  |         AuthType authType, | ||||||
|  |         string remoteId, | ||||||
|  |         string remoteUsername, | ||||||
|  |         FediverseApplication? instance = null, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         AssertValidAuthType(authType, instance); |         AssertValidAuthType(authType, instance); | ||||||
| 
 | 
 | ||||||
|  | @ -58,11 +72,14 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s | ||||||
|             { |             { | ||||||
|                 new AuthMethod |                 new AuthMethod | ||||||
|                 { |                 { | ||||||
|                     Id = snowflakeGenerator.GenerateSnowflake(), AuthType = authType, RemoteId = remoteId, |                     Id = snowflakeGenerator.GenerateSnowflake(), | ||||||
|                     RemoteUsername = remoteUsername, FediverseApplication = instance |                     AuthType = authType, | ||||||
|                 } |                     RemoteId = remoteId, | ||||||
|  |                     RemoteUsername = remoteUsername, | ||||||
|  |                     FediverseApplication = instance, | ||||||
|                 }, |                 }, | ||||||
|             LastActive = clock.GetCurrentInstant() |             }, | ||||||
|  |             LastActive = clock.GetCurrentInstant(), | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         db.Add(user); |         db.Add(user); | ||||||
|  | @ -78,19 +95,31 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s | ||||||
|     /// <returns>A tuple of the authenticated user and whether multi-factor authentication is required</returns> |     /// <returns>A tuple of the authenticated user and whether multi-factor authentication is required</returns> | ||||||
|     /// <exception cref="ApiError.NotFound">Thrown if the email address is not associated with any user |     /// <exception cref="ApiError.NotFound">Thrown if the email address is not associated with any user | ||||||
|     /// or if the password is incorrect</exception> |     /// or if the password is incorrect</exception> | ||||||
|     public async Task<(User, EmailAuthenticationResult)> AuthenticateUserAsync(string email, string password, |     public async Task<(User, EmailAuthenticationResult)> AuthenticateUserAsync( | ||||||
|         CancellationToken ct = default) |         string email, | ||||||
|  |         string password, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var user = await db.Users.FirstOrDefaultAsync(u => |         var user = await db.Users.FirstOrDefaultAsync( | ||||||
|             u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email), ct); |             u => u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email), | ||||||
|  |             ct | ||||||
|  |         ); | ||||||
|         if (user == null) |         if (user == null) | ||||||
|             throw new ApiError.NotFound("No user with that email address found, or password is incorrect", |             throw new ApiError.NotFound( | ||||||
|                 ErrorCode.UserNotFound); |                 "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? |         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", |             throw new ApiError.NotFound( | ||||||
|                 ErrorCode.UserNotFound); |                 "No user with that email address found, or password is incorrect", | ||||||
|  |                 ErrorCode.UserNotFound | ||||||
|  |             ); | ||||||
|         if (pwResult == PasswordVerificationResult.SuccessRehashNeeded) |         if (pwResult == PasswordVerificationResult.SuccessRehashNeeded) | ||||||
|         { |         { | ||||||
|             user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); |             user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); | ||||||
|  | @ -117,19 +146,33 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s | ||||||
|     /// <returns>A user object, or null if the remote account isn't linked to any user.</returns> |     /// <returns>A user object, or null if the remote account isn't linked to any user.</returns> | ||||||
|     /// <exception cref="FoxnounsError">Thrown if <c>instance</c> is passed when not required, |     /// <exception cref="FoxnounsError">Thrown if <c>instance</c> is passed when not required, | ||||||
|     /// or not passed when required</exception> |     /// or not passed when required</exception> | ||||||
|     public async Task<User?> AuthenticateUserAsync(AuthType authType, string remoteId, |     public async Task<User?> AuthenticateUserAsync( | ||||||
|         FediverseApplication? instance = null, CancellationToken ct = default) |         AuthType authType, | ||||||
|  |         string remoteId, | ||||||
|  |         FediverseApplication? instance = null, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         AssertValidAuthType(authType, instance); |         AssertValidAuthType(authType, instance); | ||||||
| 
 | 
 | ||||||
|         return await db.Users.FirstOrDefaultAsync(u => |         return await db.Users.FirstOrDefaultAsync( | ||||||
|  |             u => | ||||||
|                 u.AuthMethods.Any(a => |                 u.AuthMethods.Any(a => | ||||||
|                 a.AuthType == authType && a.RemoteId == remoteId && a.FediverseApplication == instance), ct); |                     a.AuthType == authType | ||||||
|  |                     && a.RemoteId == remoteId | ||||||
|  |                     && a.FediverseApplication == instance | ||||||
|  |                 ), | ||||||
|  |             ct | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public async Task<AuthMethod> AddAuthMethodAsync(Snowflake userId, AuthType authType, string remoteId, |     public async Task<AuthMethod> AddAuthMethodAsync( | ||||||
|  |         Snowflake userId, | ||||||
|  |         AuthType authType, | ||||||
|  |         string remoteId, | ||||||
|         string? remoteUsername = null, |         string? remoteUsername = null, | ||||||
|         CancellationToken ct = default) |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         AssertValidAuthType(authType, null); |         AssertValidAuthType(authType, null); | ||||||
| 
 | 
 | ||||||
|  | @ -139,7 +182,7 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s | ||||||
|             AuthType = authType, |             AuthType = authType, | ||||||
|             RemoteId = remoteId, |             RemoteId = remoteId, | ||||||
|             RemoteUsername = remoteUsername, |             RemoteUsername = remoteUsername, | ||||||
|             UserId = userId |             UserId = userId, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         db.Add(authMethod); |         db.Add(authMethod); | ||||||
|  | @ -147,21 +190,33 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s | ||||||
|         return authMethod; |         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)) |         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(); |         var (token, hash) = GenerateToken(); | ||||||
|         return (token, new Token |         return ( | ||||||
|  |             token, | ||||||
|  |             new Token | ||||||
|             { |             { | ||||||
|                 Id = snowflakeGenerator.GenerateSnowflake(), |                 Id = snowflakeGenerator.GenerateSnowflake(), | ||||||
|                 Hash = hash, |                 Hash = hash, | ||||||
|                 Application = application, |                 Application = application, | ||||||
|                 User = user, |                 User = user, | ||||||
|                 ExpiresAt = expires, |                 ExpiresAt = expires, | ||||||
|             Scopes = scopes |                 Scopes = scopes, | ||||||
|         }); |             } | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     private static (string, byte[]) GenerateToken() |     private static (string, byte[]) GenerateToken() | ||||||
|  |  | ||||||
|  | @ -10,26 +10,43 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<KeyCacheService>(); |     private readonly ILogger _logger = logger.ForContext<KeyCacheService>(); | ||||||
| 
 | 
 | ||||||
|     public Task SetKeyAsync(string key, string value, Duration expireAfter, CancellationToken ct = default) => |     public Task SetKeyAsync( | ||||||
|         SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter, ct); |         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 |         db.TemporaryKeys.Add( | ||||||
|  |             new TemporaryKey | ||||||
|             { |             { | ||||||
|                 Expires = expires, |                 Expires = expires, | ||||||
|                 Key = key, |                 Key = key, | ||||||
|                 Value = value, |                 Value = value, | ||||||
|         }); |             } | ||||||
|  |         ); | ||||||
|         await db.SaveChangesAsync(ct); |         await db.SaveChangesAsync(ct); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public async Task<string?> GetKeyAsync(string key, bool delete = false, CancellationToken ct = default) |     public async Task<string?> GetKeyAsync( | ||||||
|  |         string key, | ||||||
|  |         bool delete = false, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key, ct); |         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; |         return value.Value; | ||||||
|     } |     } | ||||||
|  | @ -39,20 +56,38 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) | ||||||
| 
 | 
 | ||||||
|     public async Task DeleteExpiredKeysAsync(CancellationToken ct) |     public async Task DeleteExpiredKeysAsync(CancellationToken ct) | ||||||
|     { |     { | ||||||
|         var count = await db.TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant()).ExecuteDeleteAsync(ct); |         var count = await db | ||||||
|         if (count != 0) _logger.Information("Removed {Count} expired keys from the database", count); |             .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<T>(string key, T obj, Duration expiresAt, CancellationToken ct = default) where T : class => |     public Task SetKeyAsync<T>( | ||||||
|         SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct); |         string key, | ||||||
|  |         T obj, | ||||||
|  |         Duration expiresAt, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|  |         where T : class => SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct); | ||||||
| 
 | 
 | ||||||
|     public async Task SetKeyAsync<T>(string key, T obj, Instant expires, CancellationToken ct = default) where T : class |     public async Task SetKeyAsync<T>( | ||||||
|  |         string key, | ||||||
|  |         T obj, | ||||||
|  |         Instant expires, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|  |         where T : class | ||||||
|     { |     { | ||||||
|         var value = JsonConvert.SerializeObject(obj); |         var value = JsonConvert.SerializeObject(obj); | ||||||
|         await SetKeyAsync(key, value, expires, ct); |         await SetKeyAsync(key, value, expires, ct); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public async Task<T?> GetKeyAsync<T>(string key, bool delete = false, CancellationToken ct = default) |     public async Task<T?> GetKeyAsync<T>( | ||||||
|  |         string key, | ||||||
|  |         bool delete = false, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|         where T : class |         where T : class | ||||||
|     { |     { | ||||||
|         var value = await GetKeyAsync(key, delete, ct); |         var value = await GetKeyAsync(key, delete, ct); | ||||||
|  |  | ||||||
|  | @ -15,12 +15,17 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co | ||||||
|             _logger.Debug("Sending account creation email to {ToEmail}", to); |             _logger.Debug("Sending account creation email to {ToEmail}", to); | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 await mailer.SendAsync(new AccountCreationMailable(config, new AccountCreationMailableView |                 await mailer.SendAsync( | ||||||
|  |                     new AccountCreationMailable( | ||||||
|  |                         config, | ||||||
|  |                         new AccountCreationMailableView | ||||||
|                         { |                         { | ||||||
|                             BaseUrl = config.BaseUrl, |                             BaseUrl = config.BaseUrl, | ||||||
|                             To = to, |                             To = to, | ||||||
|                     Code = code |                             Code = code, | ||||||
|                 })); |                         } | ||||||
|  |                     ) | ||||||
|  |                 ); | ||||||
|             } |             } | ||||||
|             catch (Exception exc) |             catch (Exception exc) | ||||||
|             { |             { | ||||||
|  |  | ||||||
|  | @ -10,17 +10,17 @@ public class MemberRendererService(DatabaseContext db, Config config) | ||||||
| { | { | ||||||
|     public async Task<IEnumerable<PartialMember>> RenderUserMembersAsync(User user, Token? token) |     public async Task<IEnumerable<PartialMember>> RenderUserMembersAsync(User user, Token? token) | ||||||
|     { |     { | ||||||
|         var canReadHiddenMembers = token != null && token.UserId == user.Id && token.HasScope("member.read"); |         var canReadHiddenMembers = | ||||||
|         var renderUnlisted = token != null && token.UserId == user.Id && token.HasScope("user.read_hidden"); |             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; |         var canReadMemberList = !user.ListHidden || canReadHiddenMembers; | ||||||
| 
 | 
 | ||||||
|         IEnumerable<Member> members = canReadMemberList |         IEnumerable<Member> members = canReadMemberList | ||||||
|             ? await db.Members |             ? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync() | ||||||
|                 .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)); |         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"); |         var renderUnlisted = token?.UserId == member.UserId && token.HasScope("user.read_hidden"); | ||||||
| 
 | 
 | ||||||
|         return new MemberResponse( |         return new MemberResponse( | ||||||
|             member.Id, member.Sid, member.Name, member.DisplayName, member.Bio, |             member.Id, | ||||||
|             AvatarUrlFor(member), member.Links, member.Names, member.Pronouns, member.Fields, |             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)), |             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) => |     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, |     public PartialMember RenderPartialMember(Member member, bool renderUnlisted = false) => | ||||||
|  |         new( | ||||||
|  |             member.Id, | ||||||
|  |             member.Sid, | ||||||
|             member.Name, |             member.Name, | ||||||
|         member.DisplayName, member.Bio, AvatarUrlFor(member), member.Names, member.Pronouns, |             member.DisplayName, | ||||||
|         renderUnlisted ? member.Unlisted : null); |             member.Bio, | ||||||
|  |             AvatarUrlFor(member), | ||||||
|  |             member.Names, | ||||||
|  |             member.Pronouns, | ||||||
|  |             renderUnlisted ? member.Unlisted : null | ||||||
|  |         ); | ||||||
| 
 | 
 | ||||||
|     private string? AvatarUrlFor(Member member) => |     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) => |     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"; |     private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp"; | ||||||
| 
 | 
 | ||||||
|  | @ -63,8 +92,8 @@ public class MemberRendererService(DatabaseContext db, Config config) | ||||||
|         string? AvatarUrl, |         string? AvatarUrl, | ||||||
|         IEnumerable<FieldEntry> Names, |         IEnumerable<FieldEntry> Names, | ||||||
|         IEnumerable<Pronoun> Pronouns, |         IEnumerable<Pronoun> Pronouns, | ||||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted | ||||||
|         bool? Unlisted); |     ); | ||||||
| 
 | 
 | ||||||
|     public record MemberResponse( |     public record MemberResponse( | ||||||
|         Snowflake Id, |         Snowflake Id, | ||||||
|  | @ -79,6 +108,6 @@ public class MemberRendererService(DatabaseContext db, Config config) | ||||||
|         IEnumerable<Field> Fields, |         IEnumerable<Field> Fields, | ||||||
|         IEnumerable<UserRendererService.PrideFlagResponse> Flags, |         IEnumerable<UserRendererService.PrideFlagResponse> Flags, | ||||||
|         UserRendererService.PartialUser User, |         UserRendererService.PartialUser User, | ||||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted | ||||||
|         bool? Unlisted); |     ); | ||||||
| } | } | ||||||
|  | @ -6,10 +6,7 @@ using Prometheus; | ||||||
| 
 | 
 | ||||||
| namespace Foxnouns.Backend.Services; | namespace Foxnouns.Backend.Services; | ||||||
| 
 | 
 | ||||||
| public class MetricsCollectionService( | public class MetricsCollectionService(ILogger logger, IServiceProvider services, IClock clock) | ||||||
|     ILogger logger, |  | ||||||
|     IServiceProvider services, |  | ||||||
|     IClock clock) |  | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<MetricsCollectionService>(); |     private readonly ILogger _logger = logger.ForContext<MetricsCollectionService>(); | ||||||
| 
 | 
 | ||||||
|  | @ -31,8 +28,10 @@ public class MetricsCollectionService( | ||||||
|         FoxnounsMetrics.UsersActiveWeekCount.Set(users.Count(i => i > now - Week)); |         FoxnounsMetrics.UsersActiveWeekCount.Set(users.Count(i => i > now - Week)); | ||||||
|         FoxnounsMetrics.UsersActiveDayCount.Set(users.Count(i => i > now - Day)); |         FoxnounsMetrics.UsersActiveDayCount.Set(users.Count(i => i > now - Day)); | ||||||
| 
 | 
 | ||||||
|         var memberCount = await db.Members.Include(m => m.User) |         var memberCount = await db | ||||||
|             .Where(m => !m.Unlisted && !m.User.ListHidden && !m.User.Deleted).CountAsync(ct); |             .Members.Include(m => m.User) | ||||||
|  |             .Where(m => !m.Unlisted && !m.User.ListHidden && !m.User.Deleted) | ||||||
|  |             .CountAsync(ct); | ||||||
|         FoxnounsMetrics.MemberCount.Set(memberCount); |         FoxnounsMetrics.MemberCount.Set(memberCount); | ||||||
| 
 | 
 | ||||||
|         var process = Process.GetCurrentProcess(); |         var process = Process.GetCurrentProcess(); | ||||||
|  | @ -42,13 +41,17 @@ public class MetricsCollectionService( | ||||||
|         FoxnounsMetrics.ProcessThreads.Set(process.Threads.Count); |         FoxnounsMetrics.ProcessThreads.Set(process.Threads.Count); | ||||||
|         FoxnounsMetrics.ProcessHandles.Set(process.HandleCount); |         FoxnounsMetrics.ProcessHandles.Set(process.HandleCount); | ||||||
| 
 | 
 | ||||||
|         _logger.Information("Collected metrics in {DurationMilliseconds} ms", |         _logger.Information( | ||||||
|             timer.ObserveDuration().TotalMilliseconds); |             "Collected metrics in {DurationMilliseconds} ms", | ||||||
|  |             timer.ObserveDuration().TotalMilliseconds | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| public class BackgroundMetricsCollectionService(ILogger logger, MetricsCollectionService metricsCollectionService) | public class BackgroundMetricsCollectionService( | ||||||
|     : BackgroundService |     ILogger logger, | ||||||
|  |     MetricsCollectionService metricsCollectionService | ||||||
|  | ) : BackgroundService | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<BackgroundMetricsCollectionService>(); |     private readonly ILogger _logger = logger.ForContext<BackgroundMetricsCollectionService>(); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -15,7 +15,8 @@ public class ObjectStorageService(ILogger logger, Config config, IMinioClient mi | ||||||
|         { |         { | ||||||
|             await minioClient.RemoveObjectAsync( |             await minioClient.RemoveObjectAsync( | ||||||
|                 new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path), |                 new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path), | ||||||
|                 ct); |                 ct | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
|         catch (InvalidObjectNameException) |         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, |         _logger.Debug( | ||||||
|             data.Length, contentType); |             "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) |                 .WithBucket(config.Storage.Bucket) | ||||||
|                 .WithObject(path) |                 .WithObject(path) | ||||||
|                 .WithObjectSize(data.Length) |                 .WithObjectSize(data.Length) | ||||||
|                 .WithStreamData(data) |                 .WithStreamData(data) | ||||||
|                 .WithContentType(contentType), ct |                 .WithContentType(contentType), | ||||||
|  |             ct | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -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 _discordTokenUri = new("https://discord.com/api/oauth2/token"); | ||||||
|     private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me"); |     private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me"); | ||||||
| 
 | 
 | ||||||
|     public async Task<RemoteUser> RequestDiscordTokenAsync(string code, string state, CancellationToken ct = default) |     public async Task<RemoteUser> RequestDiscordTokenAsync( | ||||||
|  |         string code, | ||||||
|  |         string state, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var redirectUri = $"{config.BaseUrl}/auth/callback/discord"; |         var redirectUri = $"{config.BaseUrl}/auth/callback/discord"; | ||||||
|         var resp = await _httpClient.PostAsync(_discordTokenUri, new FormUrlEncodedContent( |         var resp = await _httpClient.PostAsync( | ||||||
|  |             _discordTokenUri, | ||||||
|  |             new FormUrlEncodedContent( | ||||||
|                 new Dictionary<string, string> |                 new Dictionary<string, string> | ||||||
|                 { |                 { | ||||||
|                     { "client_id", config.DiscordAuth.ClientId! }, |                     { "client_id", config.DiscordAuth.ClientId! }, | ||||||
|                     { "client_secret", config.DiscordAuth.ClientSecret! }, |                     { "client_secret", config.DiscordAuth.ClientSecret! }, | ||||||
|                     { "grant_type", "authorization_code" }, |                     { "grant_type", "authorization_code" }, | ||||||
|                     { "code", code }, |                     { "code", code }, | ||||||
|                 { "redirect_uri", redirectUri } |                     { "redirect_uri", redirectUri }, | ||||||
|                 } |                 } | ||||||
|         ), ct); |             ), | ||||||
|  |             ct | ||||||
|  |         ); | ||||||
|         if (!resp.IsSuccessStatusCode) |         if (!resp.IsSuccessStatusCode) | ||||||
|         { |         { | ||||||
|             var respBody = await resp.Content.ReadAsStringAsync(ct); |             var respBody = await resp.Content.ReadAsStringAsync(ct); | ||||||
|             _logger.Error("Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}", |             _logger.Error( | ||||||
|                 (int)resp.StatusCode, respBody); |                 "Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}", | ||||||
|  |                 (int)resp.StatusCode, | ||||||
|  |                 respBody | ||||||
|  |             ); | ||||||
|             throw new FoxnounsError("Invalid Discord OAuth response"); |             throw new FoxnounsError("Invalid Discord OAuth response"); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         resp.EnsureSuccessStatusCode(); |         resp.EnsureSuccessStatusCode(); | ||||||
|         var token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(ct); |         var token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(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); |         var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri); | ||||||
|         req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}"); |         req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}"); | ||||||
|  | @ -42,18 +54,25 @@ public class RemoteAuthService(Config config, ILogger logger) | ||||||
|         var resp2 = await _httpClient.SendAsync(req, ct); |         var resp2 = await _httpClient.SendAsync(req, ct); | ||||||
|         resp2.EnsureSuccessStatusCode(); |         resp2.EnsureSuccessStatusCode(); | ||||||
|         var user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(ct); |         var user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(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); |         return new RemoteUser(user.id, user.username); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     [SuppressMessage("ReSharper", "InconsistentNaming", |     [SuppressMessage( | ||||||
|         Justification = "Easier to use snake_case here, rather than passing in JSON converter options")] |         "ReSharper", | ||||||
|  |         "InconsistentNaming", | ||||||
|  |         Justification = "Easier to use snake_case here, rather than passing in JSON converter options" | ||||||
|  |     )] | ||||||
|     [UsedImplicitly] |     [UsedImplicitly] | ||||||
|     private record DiscordTokenResponse(string access_token, string token_type); |     private record DiscordTokenResponse(string access_token, string token_type); | ||||||
| 
 | 
 | ||||||
|     [SuppressMessage("ReSharper", "InconsistentNaming", |     [SuppressMessage( | ||||||
|         Justification = "Easier to use snake_case here, rather than passing in JSON converter options")] |         "ReSharper", | ||||||
|  |         "InconsistentNaming", | ||||||
|  |         Justification = "Easier to use snake_case here, rather than passing in JSON converter options" | ||||||
|  |     )] | ||||||
|     [UsedImplicitly] |     [UsedImplicitly] | ||||||
|     private record DiscordUserResponse(string id, string username); |     private record DiscordUserResponse(string id, string username); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -7,48 +7,73 @@ using NodaTime; | ||||||
| 
 | 
 | ||||||
| namespace Foxnouns.Backend.Services; | 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<UserResponse> RenderUserAsync(User user, User? selfUser = null, |     public async Task<UserResponse> RenderUserAsync( | ||||||
|  |         User user, | ||||||
|  |         User? selfUser = null, | ||||||
|         Token? token = null, |         Token? token = null, | ||||||
|         bool renderMembers = true, |         bool renderMembers = true, | ||||||
|         bool renderAuthMethods = false, |         bool renderAuthMethods = false, | ||||||
|         CancellationToken ct = default) |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var isSelfUser = selfUser?.Id == user.Id; |         var isSelfUser = selfUser?.Id == user.Id; | ||||||
|         var tokenCanReadHiddenMembers = token.HasScope("member.read") && isSelfUser; |         var tokenCanReadHiddenMembers = token.HasScope("member.read") && isSelfUser; | ||||||
|         var tokenHidden = token.HasScope("user.read_hidden") && isSelfUser; |         var tokenHidden = token.HasScope("user.read_hidden") && isSelfUser; | ||||||
|         var tokenPrivileged = token.HasScope("user.read_privileged") && isSelfUser; |         var tokenPrivileged = token.HasScope("user.read_privileged") && isSelfUser; | ||||||
| 
 | 
 | ||||||
|         renderMembers = renderMembers && |         renderMembers = renderMembers && (!user.ListHidden || tokenCanReadHiddenMembers); | ||||||
|                         (!user.ListHidden || tokenCanReadHiddenMembers); |  | ||||||
|         renderAuthMethods = renderAuthMethods && tokenPrivileged; |         renderAuthMethods = renderAuthMethods && tokenPrivileged; | ||||||
| 
 | 
 | ||||||
|         IEnumerable<Member> members = |         IEnumerable<Member> members = renderMembers | ||||||
|             renderMembers ? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync(ct) : []; |             ? 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. |         // 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 |         var authMethods = renderAuthMethods | ||||||
|             ? await db.AuthMethods |             ? await db | ||||||
|                 .Where(a => a.UserId == user.Id) |                 .AuthMethods.Where(a => a.UserId == user.Id) | ||||||
|                 .Include(a => a.FediverseApplication) |                 .Include(a => a.FediverseApplication) | ||||||
|                 .ToListAsync(ct) |                 .ToListAsync(ct) | ||||||
|             : []; |             : []; | ||||||
| 
 | 
 | ||||||
|         return new UserResponse( |         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.Links, | ||||||
|             user.Names, user.Pronouns, user.Fields, user.CustomPreferences, |             user.Names, | ||||||
|  |             user.Pronouns, | ||||||
|  |             user.Fields, | ||||||
|  |             user.CustomPreferences, | ||||||
|             flags.Select(f => RenderPrideFlag(f.PrideFlag)), |             flags.Select(f => RenderPrideFlag(f.PrideFlag)), | ||||||
|             user.Role, |             user.Role, | ||||||
|             renderMembers ? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden)) : null, |             renderMembers | ||||||
|  |                 ? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden)) | ||||||
|  |                 : null, | ||||||
|             renderAuthMethods |             renderAuthMethods | ||||||
|                 ? authMethods.Select(a => new AuthenticationMethodResponse( |                 ? authMethods.Select(a => new AuthenticationMethodResponse( | ||||||
|                     a.Id, a.AuthType, a.RemoteId, |                     a.Id, | ||||||
|                     a.RemoteUsername, a.FediverseApplication?.Domain |                     a.AuthType, | ||||||
|  |                     a.RemoteId, | ||||||
|  |                     a.RemoteUsername, | ||||||
|  |                     a.FediverseApplication?.Domain | ||||||
|                 )) |                 )) | ||||||
|                 : null, |                 : null, | ||||||
|             tokenHidden ? user.ListHidden : null, |             tokenHidden ? user.ListHidden : null, | ||||||
|  | @ -58,10 +83,19 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public PartialUser RenderPartialUser(User user) => |     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) => |     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"; |     public string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp"; | ||||||
| 
 | 
 | ||||||
|  | @ -79,24 +113,21 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe | ||||||
|         IEnumerable<Field> Fields, |         IEnumerable<Field> Fields, | ||||||
|         Dictionary<Snowflake, User.CustomPreference> CustomPreferences, |         Dictionary<Snowflake, User.CustomPreference> CustomPreferences, | ||||||
|         IEnumerable<PrideFlagResponse> Flags, |         IEnumerable<PrideFlagResponse> Flags, | ||||||
|         [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] |         [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role, | ||||||
|         UserRole Role, |  | ||||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||||
|             IEnumerable<MemberRendererService.PartialMember>? Members, |             IEnumerable<MemberRendererService.PartialMember>? Members, | ||||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||||
|             IEnumerable<AuthenticationMethodResponse>? AuthMethods, |             IEnumerable<AuthenticationMethodResponse>? AuthMethods, | ||||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||||
|             bool? MemberListHidden, |             bool? MemberListHidden, | ||||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive, | ||||||
|         Instant? LastActive, |  | ||||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||||
|             Instant? LastSidReroll |             Instant? LastSidReroll | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     public record AuthenticationMethodResponse( |     public record AuthenticationMethodResponse( | ||||||
|         Snowflake Id, |         Snowflake Id, | ||||||
|         [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] |         [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type, | ||||||
|         AuthType Type, |  | ||||||
|         string RemoteId, |         string RemoteId, | ||||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||||
|             string? RemoteUsername, |             string? RemoteUsername, | ||||||
|  | @ -120,5 +151,6 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe | ||||||
|         Snowflake Id, |         Snowflake Id, | ||||||
|         string ImageUrl, |         string ImageUrl, | ||||||
|         string Name, |         string Name, | ||||||
|         string? Description); |         string? Description | ||||||
|  |     ); | ||||||
| } | } | ||||||
|  | @ -7,12 +7,28 @@ public static class AuthUtils | ||||||
| { | { | ||||||
|     public const string ClientCredentials = "client_credentials"; |     public const string ClientCredentials = "client_credentials"; | ||||||
|     public const string AuthorizationCode = "authorization_code"; |     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 = |     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", | ||||||
|  |     ]; | ||||||
| 
 | 
 | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// All scopes endpoints can be secured by. This does *not* include the catch-all token scopes. |     /// 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) |     public static string[] ExpandScopes(this string[] scopes) | ||||||
|     { |     { | ||||||
|         if (scopes.Contains("*")) return ["*", ..Scopes]; |         if (scopes.Contains("*")) | ||||||
|  |             return ["*", .. Scopes]; | ||||||
|         List<string> expandedScopes = ["identify"]; |         List<string> expandedScopes = ["identify"]; | ||||||
|         if (scopes.Contains("user")) expandedScopes.AddRange(UserScopes); |         if (scopes.Contains("user")) | ||||||
|         if (scopes.Contains("member")) expandedScopes.AddRange(MemberScopes); |             expandedScopes.AddRange(UserScopes); | ||||||
|  |         if (scopes.Contains("member")) | ||||||
|  |             expandedScopes.AddRange(MemberScopes); | ||||||
| 
 | 
 | ||||||
|         return expandedScopes.ToArray(); |         return expandedScopes.ToArray(); | ||||||
|     } |     } | ||||||
|  | @ -41,8 +60,10 @@ public static class AuthUtils | ||||||
|     private static string[] ExpandAppScopes(this string[] scopes) |     private static string[] ExpandAppScopes(this string[] scopes) | ||||||
|     { |     { | ||||||
|         var expandedScopes = scopes.ExpandScopes().ToList(); |         var expandedScopes = scopes.ExpandScopes().ToList(); | ||||||
|         if (scopes.Contains("user")) expandedScopes.Add("user"); |         if (scopes.Contains("user")) | ||||||
|         if (scopes.Contains("member")) expandedScopes.Add("member"); |             expandedScopes.Add("user"); | ||||||
|  |         if (scopes.Contains("member")) | ||||||
|  |             expandedScopes.Add("member"); | ||||||
|         return expandedScopes.ToArray(); |         return expandedScopes.ToArray(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -84,7 +105,8 @@ public static class AuthUtils | ||||||
|     { |     { | ||||||
|         rawToken = []; |         rawToken = []; | ||||||
| 
 | 
 | ||||||
|         if (string.IsNullOrWhiteSpace(input)) return false; |         if (string.IsNullOrWhiteSpace(input)) | ||||||
|  |             return false; | ||||||
|         if (input.StartsWith("bearer ", StringComparison.InvariantCultureIgnoreCase)) |         if (input.StartsWith("bearer ", StringComparison.InvariantCultureIgnoreCase)) | ||||||
|             input = input["bearer ".Length..]; |             input = input["bearer ".Length..]; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -13,7 +13,9 @@ namespace Foxnouns.Backend.Utils; | ||||||
| public abstract class PatchRequest | public abstract class PatchRequest | ||||||
| { | { | ||||||
|     private readonly HashSet<string> _properties = []; |     private readonly HashSet<string> _properties = []; | ||||||
|  | 
 | ||||||
|     public bool HasProperty(string propertyName) => _properties.Contains(propertyName); |     public bool HasProperty(string propertyName) => _properties.Contains(propertyName); | ||||||
|  | 
 | ||||||
|     public void SetHasProperty(string propertyName) => _properties.Add(propertyName); |     public void SetHasProperty(string propertyName) => _properties.Add(propertyName); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -23,13 +25,17 @@ public abstract class PatchRequest | ||||||
| /// </summary> | /// </summary> | ||||||
| public class PatchRequestContractResolver : DefaultContractResolver | 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); |         var prop = base.CreateProperty(member, memberSerialization); | ||||||
| 
 | 
 | ||||||
|         prop.SetIsSpecified += (o, _) => |         prop.SetIsSpecified += (o, _) => | ||||||
|         { |         { | ||||||
|             if (o is not PatchRequest patchRequest) return; |             if (o is not PatchRequest patchRequest) | ||||||
|  |                 return; | ||||||
|             patchRequest.SetHasProperty(prop.UnderlyingName!); |             patchRequest.SetHasProperty(prop.UnderlyingName!); | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -8,7 +8,8 @@ namespace Foxnouns.Backend.Utils; | ||||||
| /// A custom StringEnumConverter that converts enum members to SCREAMING_SNAKE_CASE, rather than CamelCase as is the default. | /// 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. | /// 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. | ||||||
| /// </summary> | /// </summary> | ||||||
| public class ScreamingSnakeCaseEnumConverter() : StringEnumConverter(new ScreamingSnakeCaseNamingStrategy(), false) | public class ScreamingSnakeCaseEnumConverter() | ||||||
|  |     : StringEnumConverter(new ScreamingSnakeCaseNamingStrategy(), false) | ||||||
| { | { | ||||||
|     private class ScreamingSnakeCaseNamingStrategy : SnakeCaseNamingStrategy |     private class ScreamingSnakeCaseNamingStrategy : SnakeCaseNamingStrategy | ||||||
|     { |     { | ||||||
|  |  | ||||||
|  | @ -21,7 +21,7 @@ public static partial class ValidationUtils | ||||||
|         "pronouns", |         "pronouns", | ||||||
|         "settings", |         "settings", | ||||||
|         "pronouns.cc", |         "pronouns.cc", | ||||||
|         "pronounscc" |         "pronounscc", | ||||||
|     ]; |     ]; | ||||||
| 
 | 
 | ||||||
|     private static readonly string[] InvalidMemberNames = |     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 |         // the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible | ||||||
|         "edit" |         "edit", | ||||||
|     ]; |     ]; | ||||||
| 
 | 
 | ||||||
|     public static ValidationError? ValidateUsername(string username) |     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), |                 > 40 => ValidationError.LengthError("Username is too long", 2, 40, username.Length), | ||||||
|                 _ => ValidationError.GenericValidationError( |                 _ => ValidationError.GenericValidationError( | ||||||
|                     "Username is invalid, can only contain alphanumeric characters, dashes, underscores, and periods", |                     "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 ValidationError.GenericValidationError("Username is not allowed", username); | ||||||
|         return null; |         return null; | ||||||
|     } |     } | ||||||
|  | @ -58,13 +63,18 @@ public static partial class ValidationUtils | ||||||
|                 < 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length), |                 < 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length), | ||||||
|                 > 100 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length), |                 > 100 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length), | ||||||
|                 _ => ValidationError.GenericValidationError( |                 _ => ValidationError.GenericValidationError( | ||||||
|                     "Member name cannot contain any of the following: " + |                     "Member name cannot contain any of the following: " | ||||||
|                     " @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , " + |                         + " @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , " | ||||||
|                     "and cannot be one or two periods", |                         + "and cannot be one or two periods", | ||||||
|                     memberName) |                     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 ValidationError.GenericValidationError("Name is not allowed", memberName); | ||||||
|         return null; |         return null; | ||||||
|     } |     } | ||||||
|  | @ -72,12 +82,14 @@ public static partial class ValidationUtils | ||||||
|     public static void Validate(IEnumerable<(string, ValidationError?)> errors) |     public static void Validate(IEnumerable<(string, ValidationError?)> errors) | ||||||
|     { |     { | ||||||
|         errors = errors.Where(e => e.Item2 != null).ToList(); |         errors = errors.Where(e => e.Item2 != null).ToList(); | ||||||
|         if (!errors.Any()) return; |         if (!errors.Any()) | ||||||
|  |             return; | ||||||
| 
 | 
 | ||||||
|         var errorDict = new Dictionary<string, IEnumerable<ValidationError>>(); |         var errorDict = new Dictionary<string, IEnumerable<ValidationError>>(); | ||||||
|         foreach (var error in errors) |         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!]); |             errorDict.Add(error.Item1, [error.Item2!]); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | @ -88,9 +100,19 @@ public static partial class ValidationUtils | ||||||
|     { |     { | ||||||
|         return displayName?.Length switch |         return displayName?.Length switch | ||||||
|         { |         { | ||||||
|             0 => ValidationError.LengthError("Display name is too short", 1, 100, displayName.Length), |             0 => ValidationError.LengthError( | ||||||
|             > 100 => ValidationError.LengthError("Display name is too long", 1, 100, displayName.Length), |                 "Display name is too short", | ||||||
|             _ => null |                 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) |     public static IEnumerable<(string, ValidationError?)> ValidateLinks(string[]? links) | ||||||
|     { |     { | ||||||
|         if (links == null) return []; |         if (links == null) | ||||||
|  |             return []; | ||||||
|         if (links.Length > MaxLinks) |         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?)>(); |         var errors = new List<(string, ValidationError?)>(); | ||||||
|         foreach (var (link, idx) in links.Select((l, i) => (l, i))) |         foreach (var (link, idx) in links.Select((l, i) => (l, i))) | ||||||
|  | @ -109,12 +135,25 @@ public static partial class ValidationUtils | ||||||
|             switch (link.Length) |             switch (link.Length) | ||||||
|             { |             { | ||||||
|                 case 0: |                 case 0: | ||||||
|                     errors.Add(($"links.{idx}", |                     errors.Add( | ||||||
|                         ValidationError.LengthError("Link cannot be empty", 1, 256, 0))); |                         ( | ||||||
|  |                             $"links.{idx}", | ||||||
|  |                             ValidationError.LengthError("Link cannot be empty", 1, 256, 0) | ||||||
|  |                         ) | ||||||
|  |                     ); | ||||||
|                     break; |                     break; | ||||||
|                 case > MaxLinkLength: |                 case > MaxLinkLength: | ||||||
|                     errors.Add(($"links.{idx}", |                     errors.Add( | ||||||
|                         ValidationError.LengthError("Link is too long", 1, MaxLinkLength, link.Length))); |                         ( | ||||||
|  |                             $"links.{idx}", | ||||||
|  |                             ValidationError.LengthError( | ||||||
|  |                                 "Link is too long", | ||||||
|  |                                 1, | ||||||
|  |                                 MaxLinkLength, | ||||||
|  |                                 link.Length | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     ); | ||||||
|                     break; |                     break; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  | @ -129,8 +168,13 @@ public static partial class ValidationUtils | ||||||
|         return bio?.Length switch |         return bio?.Length switch | ||||||
|         { |         { | ||||||
|             0 => ValidationError.LengthError("Bio is too short", 1, MaxBioLength, bio.Length), |             0 => ValidationError.LengthError("Bio is too short", 1, MaxBioLength, bio.Length), | ||||||
|             > MaxBioLength => ValidationError.LengthError("Bio is too long", 1, MaxBioLength, bio.Length), |             > MaxBioLength => ValidationError.LengthError( | ||||||
|             _ => null |                 "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), |             0 => ValidationError.GenericValidationError("Avatar cannot be empty", null), | ||||||
|             > 1_500_000 => ValidationError.GenericValidationError("Avatar is too large", null), |             > 1_500_000 => ValidationError.GenericValidationError("Avatar is too large", null), | ||||||
|             _ => null |             _ => null, | ||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|     private static readonly string[] DefaultStatusOptions = |     private static readonly string[] DefaultStatusOptions = | ||||||
|     [ |     [ | ||||||
|         "favourite", |         "favourite", | ||||||
|         "okay", |         "okay", | ||||||
|         "jokingly", |         "jokingly", | ||||||
|         "friends_only", |         "friends_only", | ||||||
|         "avoid" |         "avoid", | ||||||
|     ]; |     ]; | ||||||
| 
 | 
 | ||||||
|     public static IEnumerable<(string, ValidationError?)> ValidateFields(List<Field>? fields, |     public static IEnumerable<(string, ValidationError?)> ValidateFields( | ||||||
|         IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences) |         List<Field>? fields, | ||||||
|  |         IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         if (fields == null) return []; |         if (fields == null) | ||||||
|  |             return []; | ||||||
| 
 | 
 | ||||||
|         var errors = new List<(string, ValidationError?)>(); |         var errors = new List<(string, ValidationError?)>(); | ||||||
|         if (fields.Count > 25) |         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 |         // 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))) |         foreach (var (field, index) in fields.Select((field, index) => (field, index))) | ||||||
|         { |         { | ||||||
|             switch (field.Name.Length) |             switch (field.Name.Length) | ||||||
|             { |             { | ||||||
|                 case > Limits.FieldNameLimit: |                 case > Limits.FieldNameLimit: | ||||||
|                     errors.Add(($"fields.{index}.name", |                     errors.Add( | ||||||
|                         ValidationError.LengthError("Field name is too long", 1, Limits.FieldNameLimit, |                         ( | ||||||
|                             field.Name.Length))); |                             $"fields.{index}.name", | ||||||
|  |                             ValidationError.LengthError( | ||||||
|  |                                 "Field name is too long", | ||||||
|  |                                 1, | ||||||
|  |                                 Limits.FieldNameLimit, | ||||||
|  |                                 field.Name.Length | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     ); | ||||||
|                     break; |                     break; | ||||||
|                 case < 1: |                 case < 1: | ||||||
|                     errors.Add(($"fields.{index}.name", |                     errors.Add( | ||||||
|                         ValidationError.LengthError("Field name is too short", 1, Limits.FieldNameLimit, |                         ( | ||||||
|                             field.Name.Length))); |                             $"fields.{index}.name", | ||||||
|  |                             ValidationError.LengthError( | ||||||
|  |                                 "Field name is too short", | ||||||
|  |                                 1, | ||||||
|  |                                 Limits.FieldNameLimit, | ||||||
|  |                                 field.Name.Length | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     ); | ||||||
|                     break; |                     break; | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             errors = errors.Concat(ValidateFieldEntries(field.Entries, customPreferences, $"fields.{index}.entries")) |             errors = errors | ||||||
|  |                 .Concat( | ||||||
|  |                     ValidateFieldEntries( | ||||||
|  |                         field.Entries, | ||||||
|  |                         customPreferences, | ||||||
|  |                         $"fields.{index}.entries" | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|                 .ToList(); |                 .ToList(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         return errors; |         return errors; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static IEnumerable<(string, ValidationError?)> ValidateFieldEntries(FieldEntry[]? entries, |     public static IEnumerable<(string, ValidationError?)> ValidateFieldEntries( | ||||||
|         IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences, string errorPrefix = "fields") |         FieldEntry[]? entries, | ||||||
|  |         IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences, | ||||||
|  |         string errorPrefix = "fields" | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         if (entries == null || entries.Length == 0) return []; |         if (entries == null || entries.Length == 0) | ||||||
|  |             return []; | ||||||
|         var errors = new List<(string, ValidationError?)>(); |         var errors = new List<(string, ValidationError?)>(); | ||||||
| 
 | 
 | ||||||
|         if (entries.Length > Limits.FieldEntriesLimit) |         if (entries.Length > Limits.FieldEntriesLimit) | ||||||
|             errors.Add((errorPrefix, |             errors.Add( | ||||||
|                 ValidationError.LengthError("Field has too many entries", 0, Limits.FieldEntriesLimit, |                 ( | ||||||
|                     entries.Length))); |                     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 |         // 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))) |         foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) | ||||||
|         { |         { | ||||||
|             switch (entry.Value.Length) |             switch (entry.Value.Length) | ||||||
|             { |             { | ||||||
|                 case > Limits.FieldEntryTextLimit: |                 case > Limits.FieldEntryTextLimit: | ||||||
|                     errors.Add(($"{errorPrefix}.{entryIdx}.value", |                     errors.Add( | ||||||
|                         ValidationError.LengthError("Field value is too long", 1, Limits.FieldEntryTextLimit, |                         ( | ||||||
|                             entry.Value.Length))); |                             $"{errorPrefix}.{entryIdx}.value", | ||||||
|  |                             ValidationError.LengthError( | ||||||
|  |                                 "Field value is too long", | ||||||
|  |                                 1, | ||||||
|  |                                 Limits.FieldEntryTextLimit, | ||||||
|  |                                 entry.Value.Length | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     ); | ||||||
|                     break; |                     break; | ||||||
|                 case < 1: |                 case < 1: | ||||||
|                     errors.Add(($"{errorPrefix}.{entryIdx}.value", |                     errors.Add( | ||||||
|                         ValidationError.LengthError("Field value is too short", 1, Limits.FieldEntryTextLimit, |                         ( | ||||||
|                             entry.Value.Length))); |                             $"{errorPrefix}.{entryIdx}.value", | ||||||
|  |                             ValidationError.LengthError( | ||||||
|  |                                 "Field value is too short", | ||||||
|  |                                 1, | ||||||
|  |                                 Limits.FieldEntryTextLimit, | ||||||
|  |                                 entry.Value.Length | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     ); | ||||||
|                     break; |                     break; | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? []; |             var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? []; | ||||||
| 
 | 
 | ||||||
|             if (!DefaultStatusOptions.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status)) |             if ( | ||||||
|                 errors.Add(($"{errorPrefix}.{entryIdx}.status", |                 !DefaultStatusOptions.Contains(entry.Status) | ||||||
|                     ValidationError.GenericValidationError("Invalid status", entry.Status))); |                 && !customPreferenceIds.Contains(entry.Status) | ||||||
|  |             ) | ||||||
|  |                 errors.Add( | ||||||
|  |                     ( | ||||||
|  |                         $"{errorPrefix}.{entryIdx}.status", | ||||||
|  |                         ValidationError.GenericValidationError("Invalid status", entry.Status) | ||||||
|  |                     ) | ||||||
|  |                 ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         return errors; |         return errors; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public static IEnumerable<(string, ValidationError?)> ValidatePronouns(Pronoun[]? entries, |     public static IEnumerable<(string, ValidationError?)> ValidatePronouns( | ||||||
|         IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences, string errorPrefix = "pronouns") |         Pronoun[]? entries, | ||||||
|  |         IReadOnlyDictionary<Snowflake, User.CustomPreference> customPreferences, | ||||||
|  |         string errorPrefix = "pronouns" | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         if (entries == null || entries.Length == 0) return []; |         if (entries == null || entries.Length == 0) | ||||||
|  |             return []; | ||||||
|         var errors = new List<(string, ValidationError?)>(); |         var errors = new List<(string, ValidationError?)>(); | ||||||
| 
 | 
 | ||||||
|         if (entries.Length > Limits.FieldEntriesLimit) |         if (entries.Length > Limits.FieldEntriesLimit) | ||||||
|             errors.Add((errorPrefix, |             errors.Add( | ||||||
|                 ValidationError.LengthError("Too many pronouns", 0, Limits.FieldEntriesLimit, |                 ( | ||||||
|                     entries.Length))); |                     errorPrefix, | ||||||
|  |                     ValidationError.LengthError( | ||||||
|  |                         "Too many pronouns", | ||||||
|  |                         0, | ||||||
|  |                         Limits.FieldEntriesLimit, | ||||||
|  |                         entries.Length | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             ); | ||||||
| 
 | 
 | ||||||
|         // Same as above, no overwhelming this function with a ridiculous amount of entries |         // 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))) |         foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) | ||||||
|         { |         { | ||||||
|             switch (entry.Value.Length) |             switch (entry.Value.Length) | ||||||
|             { |             { | ||||||
|                 case > Limits.FieldEntryTextLimit: |                 case > Limits.FieldEntryTextLimit: | ||||||
|                     errors.Add(($"{errorPrefix}.{entryIdx}.value", |                     errors.Add( | ||||||
|                         ValidationError.LengthError("Pronoun value is too long", 1, Limits.FieldEntryTextLimit, |                         ( | ||||||
|                             entry.Value.Length))); |                             $"{errorPrefix}.{entryIdx}.value", | ||||||
|  |                             ValidationError.LengthError( | ||||||
|  |                                 "Pronoun value is too long", | ||||||
|  |                                 1, | ||||||
|  |                                 Limits.FieldEntryTextLimit, | ||||||
|  |                                 entry.Value.Length | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     ); | ||||||
|                     break; |                     break; | ||||||
|                 case < 1: |                 case < 1: | ||||||
|                     errors.Add(($"{errorPrefix}.{entryIdx}.value", |                     errors.Add( | ||||||
|                         ValidationError.LengthError("Pronoun value is too short", 1, Limits.FieldEntryTextLimit, |                         ( | ||||||
|                             entry.Value.Length))); |                             $"{errorPrefix}.{entryIdx}.value", | ||||||
|  |                             ValidationError.LengthError( | ||||||
|  |                                 "Pronoun value is too short", | ||||||
|  |                                 1, | ||||||
|  |                                 Limits.FieldEntryTextLimit, | ||||||
|  |                                 entry.Value.Length | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     ); | ||||||
|                     break; |                     break; | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|  | @ -263,25 +408,46 @@ public static partial class ValidationUtils | ||||||
|                 switch (entry.DisplayText.Length) |                 switch (entry.DisplayText.Length) | ||||||
|                 { |                 { | ||||||
|                     case > Limits.FieldEntryTextLimit: |                     case > Limits.FieldEntryTextLimit: | ||||||
|                         errors.Add(($"{errorPrefix}.{entryIdx}.value", |                         errors.Add( | ||||||
|                             ValidationError.LengthError("Pronoun display text is too long", 1, |                             ( | ||||||
|  |                                 $"{errorPrefix}.{entryIdx}.value", | ||||||
|  |                                 ValidationError.LengthError( | ||||||
|  |                                     "Pronoun display text is too long", | ||||||
|  |                                     1, | ||||||
|                                     Limits.FieldEntryTextLimit, |                                     Limits.FieldEntryTextLimit, | ||||||
|                                 entry.Value.Length))); |                                     entry.Value.Length | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                         ); | ||||||
|                         break; |                         break; | ||||||
|                     case < 1: |                     case < 1: | ||||||
|                         errors.Add(($"{errorPrefix}.{entryIdx}.value", |                         errors.Add( | ||||||
|                             ValidationError.LengthError("Pronoun display text is too short", 1, |                             ( | ||||||
|  |                                 $"{errorPrefix}.{entryIdx}.value", | ||||||
|  |                                 ValidationError.LengthError( | ||||||
|  |                                     "Pronoun display text is too short", | ||||||
|  |                                     1, | ||||||
|                                     Limits.FieldEntryTextLimit, |                                     Limits.FieldEntryTextLimit, | ||||||
|                                 entry.Value.Length))); |                                     entry.Value.Length | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                         ); | ||||||
|                         break; |                         break; | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? []; |             var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? []; | ||||||
| 
 | 
 | ||||||
|             if (!DefaultStatusOptions.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status)) |             if ( | ||||||
|                 errors.Add(($"{errorPrefix}.{entryIdx}.status", |                 !DefaultStatusOptions.Contains(entry.Status) | ||||||
|                     ValidationError.GenericValidationError("Invalid status", entry.Status))); |                 && !customPreferenceIds.Contains(entry.Status) | ||||||
|  |             ) | ||||||
|  |                 errors.Add( | ||||||
|  |                     ( | ||||||
|  |                         $"{errorPrefix}.{entryIdx}.status", | ||||||
|  |                         ValidationError.GenericValidationError("Invalid status", entry.Status) | ||||||
|  |                     ) | ||||||
|  |                 ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         return errors; |         return errors; | ||||||
|  | @ -290,6 +456,10 @@ public static partial class ValidationUtils | ||||||
|     [GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")]
 |     [GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")]
 | ||||||
|     private static partial Regex UsernameRegex(); |     private static partial Regex UsernameRegex(); | ||||||
| 
 | 
 | ||||||
|     [GeneratedRegex("""^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""", RegexOptions.IgnoreCase, "en-NL")]
 |     [GeneratedRegex( | ||||||
|  |         """^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""", | ||||||
|  |         RegexOptions.IgnoreCase, | ||||||
|  |         "en-NL" | ||||||
|  |     )] | ||||||
|     private static partial Regex MemberRegex(); |     private static partial Regex MemberRegex(); | ||||||
| } | } | ||||||
|  | @ -19,12 +19,19 @@ public static class Users | ||||||
|         var stopwatch = new Stopwatch(); |         var stopwatch = new Stopwatch(); | ||||||
|         stopwatch.Start(); |         stopwatch.Start(); | ||||||
| 
 | 
 | ||||||
|         var users = NetImporter.ReadFromFile<ImportUser>(filename).Output.Select(ConvertUser).ToList(); |         var users = NetImporter | ||||||
|  |             .ReadFromFile<ImportUser>(filename) | ||||||
|  |             .Output.Select(ConvertUser) | ||||||
|  |             .ToList(); | ||||||
|         db.AddRange(users); |         db.AddRange(users); | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
| 
 | 
 | ||||||
|         stopwatch.Stop(); |         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) |     private static User ConvertUser(ImportUser oldUser) | ||||||
|  | @ -43,40 +50,46 @@ public static class Users | ||||||
|             Role = oldUser.ParseRole(), |             Role = oldUser.ParseRole(), | ||||||
|             Deleted = oldUser.Deleted, |             Deleted = oldUser.Deleted, | ||||||
|             DeletedAt = oldUser.DeletedAt?.ToInstant(), |             DeletedAt = oldUser.DeletedAt?.ToInstant(), | ||||||
|             DeletedBy = null |             DeletedBy = null, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         if (oldUser is { DiscordId: not null, DiscordUsername: not null }) |         if (oldUser is { DiscordId: not null, DiscordUsername: not null }) | ||||||
|         { |         { | ||||||
|             user.AuthMethods.Add(new AuthMethod |             user.AuthMethods.Add( | ||||||
|  |                 new AuthMethod | ||||||
|                 { |                 { | ||||||
|                     Id = SnowflakeGenerator.Instance.GenerateSnowflake(), |                     Id = SnowflakeGenerator.Instance.GenerateSnowflake(), | ||||||
|                     AuthType = AuthType.Discord, |                     AuthType = AuthType.Discord, | ||||||
|                     RemoteId = oldUser.DiscordId, |                     RemoteId = oldUser.DiscordId, | ||||||
|                 RemoteUsername = oldUser.DiscordUsername |                     RemoteUsername = oldUser.DiscordUsername, | ||||||
|             }); |                 } | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (oldUser is { TumblrId: not null, TumblrUsername: not null }) |         if (oldUser is { TumblrId: not null, TumblrUsername: not null }) | ||||||
|         { |         { | ||||||
|             user.AuthMethods.Add(new AuthMethod |             user.AuthMethods.Add( | ||||||
|  |                 new AuthMethod | ||||||
|                 { |                 { | ||||||
|                     Id = SnowflakeGenerator.Instance.GenerateSnowflake(), |                     Id = SnowflakeGenerator.Instance.GenerateSnowflake(), | ||||||
|                     AuthType = AuthType.Tumblr, |                     AuthType = AuthType.Tumblr, | ||||||
|                     RemoteId = oldUser.TumblrId, |                     RemoteId = oldUser.TumblrId, | ||||||
|                 RemoteUsername = oldUser.TumblrUsername |                     RemoteUsername = oldUser.TumblrUsername, | ||||||
|             }); |                 } | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (oldUser is { GoogleId: not null, GoogleUsername: not null }) |         if (oldUser is { GoogleId: not null, GoogleUsername: not null }) | ||||||
|         { |         { | ||||||
|             user.AuthMethods.Add(new AuthMethod |             user.AuthMethods.Add( | ||||||
|  |                 new AuthMethod | ||||||
|                 { |                 { | ||||||
|                     Id = SnowflakeGenerator.Instance.GenerateSnowflake(), |                     Id = SnowflakeGenerator.Instance.GenerateSnowflake(), | ||||||
|                     AuthType = AuthType.Google, |                     AuthType = AuthType.Google, | ||||||
|                     RemoteId = oldUser.GoogleId, |                     RemoteId = oldUser.GoogleId, | ||||||
|                 RemoteUsername = oldUser.GoogleUsername |                     RemoteUsername = oldUser.GoogleUsername, | ||||||
|             }); |                 } | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         // Convert all custom preference UUIDs to snowflakes |         // Convert all custom preference UUIDs to snowflakes | ||||||
|  | @ -90,28 +103,35 @@ public static class Users | ||||||
| 
 | 
 | ||||||
|         foreach (var name in oldUser.Names ?? []) |         foreach (var name in oldUser.Names ?? []) | ||||||
|         { |         { | ||||||
|             user.Names.Add(new FieldEntry |             user.Names.Add( | ||||||
|  |                 new FieldEntry | ||||||
|                 { |                 { | ||||||
|                     Value = name.Value, |                     Value = name.Value, | ||||||
|                 Status = prefMapping.TryGetValue(name.Status, out var newStatus) ? newStatus.ToString() : name.Status, |                     Status = prefMapping.TryGetValue(name.Status, out var newStatus) | ||||||
|             }); |                         ? newStatus.ToString() | ||||||
|  |                         : name.Status, | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         foreach (var pronoun in oldUser.Pronouns ?? []) |         foreach (var pronoun in oldUser.Pronouns ?? []) | ||||||
|         { |         { | ||||||
|             user.Pronouns.Add(new Pronoun |             user.Pronouns.Add( | ||||||
|  |                 new Pronoun | ||||||
|                 { |                 { | ||||||
|                     Value = pronoun.Value, |                     Value = pronoun.Value, | ||||||
|                     DisplayText = pronoun.DisplayText, |                     DisplayText = pronoun.DisplayText, | ||||||
|                     Status = prefMapping.TryGetValue(pronoun.Status, out var newStatus) |                     Status = prefMapping.TryGetValue(pronoun.Status, out var newStatus) | ||||||
|                         ? newStatus.ToString() |                         ? newStatus.ToString() | ||||||
|                         : pronoun.Status, |                         : pronoun.Status, | ||||||
|             }); |                 } | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         foreach (var field in oldUser.Fields ?? []) |         foreach (var field in oldUser.Fields ?? []) | ||||||
|         { |         { | ||||||
|             var entries = field.Entries.Select(entry => new FieldEntry |             var entries = field | ||||||
|  |                 .Entries.Select(entry => new FieldEntry | ||||||
|                 { |                 { | ||||||
|                     Value = entry.Value, |                     Value = entry.Value, | ||||||
|                     Status = prefMapping.TryGetValue(entry.Status, out var newStatus) |                     Status = prefMapping.TryGetValue(entry.Status, out var newStatus) | ||||||
|  | @ -120,11 +140,7 @@ public static class Users | ||||||
|                 }) |                 }) | ||||||
|                 .ToList(); |                 .ToList(); | ||||||
| 
 | 
 | ||||||
|             user.Fields.Add(new Field |             user.Fields.Add(new Field { Name = field.Name, Entries = entries.ToArray() }); | ||||||
|             { |  | ||||||
|                 Name = field.Name, |  | ||||||
|                 Entries = entries.ToArray() |  | ||||||
|             }); |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         Log.Debug("Converted user {UserId}", oldUser.Id); |         Log.Debug("Converted user {UserId}", oldUser.Id); | ||||||
|  | @ -161,14 +177,16 @@ public static class Users | ||||||
|         bool Deleted, |         bool Deleted, | ||||||
|         OffsetDateTime? DeletedAt, |         OffsetDateTime? DeletedAt, | ||||||
|         string? DeleteReason, |         string? DeleteReason, | ||||||
|         Dictionary<string, User.CustomPreference> CustomPreferences) |         Dictionary<string, User.CustomPreference> CustomPreferences | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         public UserRole ParseRole() => Role switch |         public UserRole ParseRole() => | ||||||
|  |             Role switch | ||||||
|             { |             { | ||||||
|                 "USER" => UserRole.User, |                 "USER" => UserRole.User, | ||||||
|                 "MODERATOR" => UserRole.Moderator, |                 "MODERATOR" => UserRole.Moderator, | ||||||
|                 "ADMIN" => UserRole.Admin, |                 "ADMIN" => UserRole.Admin, | ||||||
|             _ => UserRole.User |                 _ => UserRole.User, | ||||||
|             }; |             }; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -19,7 +19,10 @@ internal static class NetImporter | ||||||
|             .Enrich.FromLogContext() |             .Enrich.FromLogContext() | ||||||
|             .MinimumLevel.Debug() |             .MinimumLevel.Debug() | ||||||
|             .MinimumLevel.Override("Microsoft", LogEventLevel.Information) |             .MinimumLevel.Override("Microsoft", LogEventLevel.Information) | ||||||
|             .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", LogEventLevel.Information) |             .MinimumLevel.Override( | ||||||
|  |                 "Microsoft.EntityFrameworkCore.Database.Command", | ||||||
|  |                 LogEventLevel.Information | ||||||
|  |             ) | ||||||
|             .WriteTo.Console() |             .WriteTo.Console() | ||||||
|             .CreateLogger(); |             .CreateLogger(); | ||||||
| 
 | 
 | ||||||
|  | @ -47,16 +50,11 @@ internal static class NetImporter | ||||||
|     internal static async Task<DatabaseContext> GetContextAsync() |     internal static async Task<DatabaseContext> GetContextAsync() | ||||||
|     { |     { | ||||||
|         var connString = Environment.GetEnvironmentVariable("DATABASE"); |         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 loggerFactory = new LoggerFactory().AddSerilog(Log.Logger); | ||||||
|         var config = new Config |         var config = new Config { Database = new Config.DatabaseConfig { Url = connString } }; | ||||||
|         { |  | ||||||
|             Database = new Config.DatabaseConfig |  | ||||||
|             { |  | ||||||
|                 Url = connString |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
| 
 | 
 | ||||||
|         var db = new DatabaseContext(config, loggerFactory); |         var db = new DatabaseContext(config, loggerFactory); | ||||||
| 
 | 
 | ||||||
|  | @ -70,13 +68,17 @@ internal static class NetImporter | ||||||
| 
 | 
 | ||||||
|     private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings |     private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings | ||||||
|     { |     { | ||||||
|         ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy() } |         ContractResolver = new DefaultContractResolver | ||||||
|  |         { | ||||||
|  |             NamingStrategy = new SnakeCaseNamingStrategy(), | ||||||
|  |         }, | ||||||
|     }.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); |     }.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); | ||||||
| 
 | 
 | ||||||
|     internal static Input<T> ReadFromFile<T>(string path) |     internal static Input<T> ReadFromFile<T>(string path) | ||||||
|     { |     { | ||||||
|         var data = File.ReadAllText(path); |         var data = File.ReadAllText(path); | ||||||
|         return JsonConvert.DeserializeObject<Input<T>>(data, Settings) ?? throw new Exception("Invalid input file"); |         return JsonConvert.DeserializeObject<Input<T>>(data, Settings) | ||||||
|  |             ?? throw new Exception("Invalid input file"); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue