diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index ced8c3d..8016a1f 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -2,14 +2,12 @@ using System.Net; using System.Web; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; -using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; -using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; @@ -47,28 +45,6 @@ public class AuthController( return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, null, null)); } - private record UrlsResponse(bool EmailEnabled, string? Discord, string? Google, string? Tumblr); - - public record AuthResponse( - UserRendererService.UserResponse User, - string Token, - Instant ExpiresAt - ); - - public record SingleUrlResponse(string Url); - - public record AddOauthAccountResponse( - Snowflake Id, - [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type, - string RemoteId, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - string? RemoteUsername - ); - - public record OauthRegisterRequest(string Ticket, string Username); - - public record CallbackRequest(string Code, string State); - [HttpPost("force-log-out")] [Authorize("identify")] public async Task ForceLogoutAsync() @@ -83,9 +59,7 @@ public class AuthController( [HttpGet("methods/{id}")] [Authorize("*")] - [ProducesResponseType( - statusCode: StatusCodes.Status200OK - )] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetAuthMethodAsync(Snowflake id) { AuthMethod? authMethod = await db @@ -143,13 +117,3 @@ public class AuthController( return NoContent(); } } - -public record CallbackResponse( - bool HasAccount, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - UserRendererService.UserResponse? User, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt -); diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index f7f9c3d..2d66ede 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -3,6 +3,7 @@ using System.Web; using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; @@ -29,7 +30,7 @@ public class DiscordAuthController( [HttpPost("callback")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task CallbackAsync([FromBody] AuthController.CallbackRequest req) + public async Task CallbackAsync([FromBody] CallbackRequest req) { CheckRequirements(); await keyCacheService.ValidateAuthStateAsync(req.State); @@ -58,10 +59,8 @@ public class DiscordAuthController( } [HttpPost("register")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task RegisterAsync( - [FromBody] AuthController.OauthRegisterRequest req - ) + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task RegisterAsync([FromBody] OauthRegisterRequest req) { RemoteAuthService.RemoteUser? remoteUser = await keyCacheService.GetKeyAsync( @@ -109,14 +108,12 @@ public class DiscordAuthController( + $"&prompt=none&state={state}" + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}"; - return Ok(new AuthController.SingleUrlResponse(url)); + return Ok(new SingleUrlResponse(url)); } [HttpPost("add-account/callback")] [Authorize("*")] - public async Task AddAccountCallbackAsync( - [FromBody] AuthController.CallbackRequest req - ) + public async Task AddAccountCallbackAsync([FromBody] CallbackRequest req) { CheckRequirements(); @@ -144,7 +141,7 @@ public class DiscordAuthController( ); return Ok( - new AuthController.AddOauthAccountResponse( + new AddOauthAccountResponse( authMethod.Id, AuthType.Discord, authMethod.RemoteId, diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index 4162f4c..f8d78b0 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -2,6 +2,7 @@ using System.Net; using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; @@ -30,7 +31,7 @@ public class EmailAuthController( [HttpPost("register/init")] public async Task RegisterInitAsync( - [FromBody] RegisterRequest req, + [FromBody] EmailRegisterRequest req, CancellationToken ct = default ) { @@ -73,7 +74,7 @@ public class EmailAuthController( [HttpPost("register")] public async Task CompleteRegistrationAsync( - [FromBody] CompleteRegistrationRequest req + [FromBody] EmailCompleteRegistrationRequest req ) { CheckRequirements(); @@ -102,7 +103,7 @@ public class EmailAuthController( await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}"); return Ok( - new AuthController.AuthResponse( + new AuthResponse( await userRenderer.RenderUserAsync(user, user, renderMembers: false), tokenStr, token.ExpiresAt @@ -111,9 +112,9 @@ public class EmailAuthController( } [HttpPost("login")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task LoginAsync( - [FromBody] LoginRequest req, + [FromBody] EmailLoginRequest req, CancellationToken ct = default ) { @@ -141,7 +142,7 @@ public class EmailAuthController( await db.SaveChangesAsync(ct); return Ok( - new AuthController.AuthResponse( + new AuthResponse( await userRenderer.RenderUserAsync(user, user, renderMembers: false, ct: ct), tokenStr, token.ExpiresAt @@ -151,7 +152,7 @@ public class EmailAuthController( [HttpPost("change-password")] [Authorize("*")] - public async Task UpdatePasswordAsync([FromBody] ChangePasswordRequest req) + public async Task UpdatePasswordAsync([FromBody] EmailChangePasswordRequest req) { if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Current)) throw new ApiError.Forbidden("Invalid password"); @@ -211,7 +212,7 @@ public class EmailAuthController( [HttpPost("add-email/callback")] [Authorize("*")] - public async Task AddEmailCallbackAsync([FromBody] CallbackRequest req) + public async Task AddEmailCallbackAsync([FromBody] EmailCallbackRequest req) { CheckRequirements(); @@ -233,7 +234,7 @@ public class EmailAuthController( ); return Ok( - new AuthController.AddOauthAccountResponse( + new AddOauthAccountResponse( authMethod.Id, AuthType.Email, authMethod.RemoteId, @@ -258,14 +259,4 @@ public class EmailAuthController( if (!config.EmailAuth.Enabled) throw new ApiError.BadRequest("Email authentication is not enabled on this instance."); } - - public record LoginRequest(string Email, string Password); - - public record RegisterRequest(string Email); - - public record CompleteRegistrationRequest(string Ticket, string Username, string Password); - - public record CallbackRequest(string State); - - public record ChangePasswordRequest(string Current, string New); } diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs index f47eb43..2587621 100644 --- a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs @@ -2,6 +2,7 @@ using System.Net; using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; using Foxnouns.Backend.Services.Auth; @@ -25,7 +26,7 @@ public class FediverseAuthController( private readonly ILogger _logger = logger.ForContext(); [HttpGet] - [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetFediverseUrlAsync( [FromQuery] string instance, [FromQuery] bool forceRefresh = false @@ -35,12 +36,12 @@ public class FediverseAuthController( throw new ApiError.BadRequest("Not a valid domain.", "instance", instance); string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh); - return Ok(new AuthController.SingleUrlResponse(url)); + return Ok(new SingleUrlResponse(url)); } [HttpPost("callback")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public async Task FediverseCallbackAsync([FromBody] CallbackRequest req) + public async Task FediverseCallbackAsync([FromBody] FediverseCallbackRequest req) { FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance); FediverseAuthService.FediverseUser remoteUser = @@ -74,10 +75,8 @@ public class FediverseAuthController( } [HttpPost("register")] - [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public async Task RegisterAsync( - [FromBody] AuthController.OauthRegisterRequest req - ) + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + public async Task RegisterAsync([FromBody] OauthRegisterRequest req) { FediverseTicketData? ticketData = await keyCacheService.GetKeyAsync( $"fediverse:{req.Ticket}", @@ -138,12 +137,14 @@ public class FediverseAuthController( ); string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state); - return Ok(new AuthController.SingleUrlResponse(url)); + return Ok(new SingleUrlResponse(url)); } [HttpPost("add-account/callback")] [Authorize("*")] - public async Task AddAccountCallbackAsync([FromBody] CallbackRequest req) + public async Task AddAccountCallbackAsync( + [FromBody] FediverseCallbackRequest req + ) { await remoteAuthService.ValidateAddAccountStateAsync( req.State, @@ -171,7 +172,7 @@ public class FediverseAuthController( ); return Ok( - new AuthController.AddOauthAccountResponse( + new AddOauthAccountResponse( authMethod.Id, AuthType.Fediverse, authMethod.RemoteId, @@ -189,8 +190,6 @@ public class FediverseAuthController( } } - public record CallbackRequest(string Instance, string Code, string State); - private record FediverseTicketData( Snowflake ApplicationId, FediverseAuthService.FediverseUser User diff --git a/Foxnouns.Backend/Controllers/ExportsController.cs b/Foxnouns.Backend/Controllers/ExportsController.cs index feb8d91..06844a1 100644 --- a/Foxnouns.Backend/Controllers/ExportsController.cs +++ b/Foxnouns.Backend/Controllers/ExportsController.cs @@ -1,6 +1,7 @@ using Coravel.Queuing.Interfaces; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto; using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Middleware; using Microsoft.AspNetCore.Mvc; @@ -43,8 +44,6 @@ public class ExportsController( private string ExportUrl(Snowflake userId, string filename) => $"{config.MediaBaseUrl}/data-exports/{userId}/{filename}.zip"; - private record DataExportResponse(string? Url, Instant? ExpiresAt); - [HttpPost] public async Task QueueDataExportAsync() { diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs index 9bb9f91..4bc947b 100644 --- a/Foxnouns.Backend/Controllers/FlagsController.cs +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -1,65 +1,69 @@ using Coravel.Queuing.Interfaces; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; -using Foxnouns.Backend.Extensions; +using Foxnouns.Backend.Dto; using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; namespace Foxnouns.Backend.Controllers; [Route("/api/v2/users/@me/flags")] public class FlagsController( - ILogger logger, DatabaseContext db, UserRendererService userRenderer, - ObjectStorageService objectStorageService, ISnowflakeGenerator snowflakeGenerator, IQueue queue ) : ApiControllerBase { - private readonly ILogger _logger = logger.ForContext(); - [HttpGet] [Authorize("identify")] - [ProducesResponseType>( - statusCode: StatusCodes.Status200OK - )] + [ProducesResponseType>(statusCode: StatusCodes.Status200OK)] public async Task GetFlagsAsync(CancellationToken ct = default) { List flags = await db .PrideFlags.Where(f => f.UserId == CurrentUser!.Id) + .OrderBy(f => f.Name) + .ThenBy(f => f.Id) .ToListAsync(ct); return Ok(flags.Select(userRenderer.RenderPrideFlag)); } + public const int MaxFlagCount = 500; + [HttpPost] [Authorize("user.update")] - [ProducesResponseType( - statusCode: StatusCodes.Status202Accepted - )] - public IActionResult CreateFlag([FromBody] CreateFlagRequest req) + [ProducesResponseType(statusCode: StatusCodes.Status202Accepted)] + public async Task CreateFlagAsync([FromBody] CreateFlagRequest req) { + int flagCount = await db.PrideFlags.Where(f => f.UserId == CurrentUser!.Id).CountAsync(); + if (flagCount >= MaxFlagCount) + throw new ApiError.BadRequest("Maximum number of flags reached"); + ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image)); - Snowflake id = snowflakeGenerator.GenerateSnowflake(); + var flag = new PrideFlag + { + Id = snowflakeGenerator.GenerateSnowflake(), + UserId = CurrentUser!.Id, + Name = req.Name, + Description = req.Description, + }; + + db.Add(flag); + await db.SaveChangesAsync(); queue.QueueInvocableWithPayload( - new CreateFlagPayload(id, CurrentUser!.Id, req.Name, req.Image, req.Description) + new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Name, req.Image, req.Description) ); - return Accepted(new CreateFlagResponse(id, req.Name, req.Description)); + return Accepted(userRenderer.RenderPrideFlag(flag)); } - public record CreateFlagRequest(string Name, string Image, string? Description); - - private record CreateFlagResponse(Snowflake Id, string Name, string? Description); - [HttpPatch("{id}")] [Authorize("user.update")] public async Task UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req) @@ -84,52 +88,19 @@ public class FlagsController( return Ok(userRenderer.RenderPrideFlag(flag)); } - public class UpdateFlagRequest : PatchRequest - { - public string? Name { get; init; } - public string? Description { get; init; } - } - [HttpDelete("{id}")] [Authorize("user.update")] public async Task DeleteFlagAsync(Snowflake id) { - await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync(); - PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f => f.Id == id && f.UserId == CurrentUser!.Id ); if (flag == null) throw new ApiError.NotFound("Unknown flag ID, or it's not your flag."); - string hash = flag.Hash; - db.PrideFlags.Remove(flag); await db.SaveChangesAsync(); - int flagCount = await db.PrideFlags.CountAsync(f => f.Hash == flag.Hash); - if (flagCount == 0) - { - try - { - _logger.Information( - "Deleting flag file {Hash} as it is no longer used by any flags", - hash - ); - await objectStorageService.DeleteFlagAsync(hash); - } - catch (Exception e) - { - _logger.Error(e, "Error deleting flag file {Hash}", hash); - } - } - else - { - _logger.Debug("Flag file {Hash} is used by other flags, not deleting", hash); - } - - await tx.CommitAsync(); - return NoContent(); } diff --git a/Foxnouns.Backend/Controllers/InternalController.cs b/Foxnouns.Backend/Controllers/InternalController.cs index 9e94022..2f4baf7 100644 --- a/Foxnouns.Backend/Controllers/InternalController.cs +++ b/Foxnouns.Backend/Controllers/InternalController.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using Foxnouns.Backend.Database; +using Foxnouns.Backend.Dto; using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; @@ -63,10 +64,6 @@ public partial class InternalController(DatabaseContext db) : ControllerBase return Ok(new RequestDataResponse(userId, template)); } - public record RequestDataRequest(string? Token, string Method, string Path); - - public record RequestDataResponse(Snowflake? UserId, string Template); - private static RouteEndpoint? GetEndpoint( HttpContext httpContext, string url, diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index a9021b9..269817e 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -2,6 +2,7 @@ using Coravel.Queuing.Interfaces; using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Middleware; @@ -28,9 +29,7 @@ public class MembersController( private readonly ILogger _logger = logger.ForContext(); [HttpGet] - [ProducesResponseType>( - StatusCodes.Status200OK - )] + [ProducesResponseType>(StatusCodes.Status200OK)] public async Task GetMembersAsync(string userRef, CancellationToken ct = default) { User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); @@ -38,7 +37,7 @@ public class MembersController( } [HttpGet("{memberRef}")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task GetMemberAsync( string userRef, string memberRef, @@ -52,7 +51,7 @@ public class MembersController( public const int MaxMemberCount = 500; [HttpPost("/api/v2/users/@me/members")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [Authorize("member.create")] public async Task CreateMemberAsync( [FromBody] CreateMemberRequest req, @@ -246,20 +245,6 @@ public class MembersController( return Ok(memberRenderer.RenderMember(member, CurrentToken)); } - public class UpdateMemberRequest : PatchRequest - { - public string? Name { get; init; } - public string? DisplayName { get; init; } - public string? Bio { get; init; } - public string? Avatar { get; init; } - public string[]? Links { get; init; } - public FieldEntry[]? Names { get; init; } - public Pronoun[]? Pronouns { get; init; } - public Field[]? Fields { get; init; } - public Snowflake[]? Flags { get; init; } - public bool? Unlisted { get; init; } - } - [HttpDelete("/api/v2/users/@me/members/{memberRef}")] [Authorize("member.update")] public async Task DeleteMemberAsync(string memberRef) @@ -282,21 +267,9 @@ public class MembersController( return NoContent(); } - public record CreateMemberRequest( - string Name, - string? DisplayName, - string? Bio, - string? Avatar, - bool? Unlisted, - string[]? Links, - List? Names, - List? Pronouns, - List? Fields - ); - [HttpPost("/api/v2/users/@me/members/{memberRef}/reroll-sid")] [Authorize("member.update")] - [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task RerollSidAsync(string memberRef) { Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef); diff --git a/Foxnouns.Backend/Controllers/MetaController.cs b/Foxnouns.Backend/Controllers/MetaController.cs index 2a9466e..2c5bc45 100644 --- a/Foxnouns.Backend/Controllers/MetaController.cs +++ b/Foxnouns.Backend/Controllers/MetaController.cs @@ -1,3 +1,4 @@ +using Foxnouns.Backend.Dto; using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; @@ -17,17 +18,18 @@ public class MetaController : ApiControllerBase BuildInfo.Version, BuildInfo.Hash, (int)FoxnounsMetrics.MemberCount.Value, - new UserInfo( + new UserInfoResponse( (int)FoxnounsMetrics.UsersCount.Value, (int)FoxnounsMetrics.UsersActiveMonthCount.Value, (int)FoxnounsMetrics.UsersActiveWeekCount.Value, (int)FoxnounsMetrics.UsersActiveDayCount.Value ), - new Limits( + new LimitsResponse( MembersController.MaxMemberCount, ValidationUtils.MaxBioLength, ValidationUtils.MaxCustomPreferences, - AuthUtils.MaxAuthMethodsPerType + AuthUtils.MaxAuthMethodsPerType, + FlagsController.MaxFlagCount ) ) ); @@ -35,23 +37,4 @@ public class MetaController : ApiControllerBase [HttpGet("/api/v2/coffee")] public IActionResult BrewCoffee() => Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot); - - private record MetaResponse( - string Repository, - string Version, - string Hash, - int Members, - UserInfo Users, - Limits Limits - ); - - private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay); - - // All limits that the frontend should know about (for UI purposes) - private record Limits( - int MemberCount, - int BioLength, - int CustomPreferences, - int MaxAuthMethods - ); } diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index 98d5645..74149b7 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -1,8 +1,8 @@ -using System.Diagnostics.CodeAnalysis; using Coravel.Queuing.Interfaces; using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto; using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; @@ -27,7 +27,7 @@ public class UsersController( private readonly ILogger _logger = logger.ForContext(); [HttpGet("{userRef}")] - [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetUserAsync(string userRef, CancellationToken ct = default) { User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); @@ -38,7 +38,7 @@ public class UsersController( [HttpPatch("@me")] [Authorize("user.update")] - [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task UpdateUserAsync( [FromBody] UpdateUserRequest req, CancellationToken ct = default @@ -196,7 +196,7 @@ public class UsersController( [Authorize("user.update")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task UpdateCustomPreferencesAsync( - [FromBody] List req, + [FromBody] List req, CancellationToken ct = default ) { @@ -207,7 +207,7 @@ public class UsersController( .CustomPreferences.Where(x => req.Any(r => r.Id == x.Key)) .ToDictionary(); - foreach (CustomPreferenceUpdate? r in req) + foreach (CustomPreferenceUpdateRequest? r in req) { if (r.Id != null && preferences.ContainsKey(r.Id.Value)) { @@ -239,33 +239,6 @@ public class UsersController( return Ok(user.CustomPreferences); } - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] - public class CustomPreferenceUpdate - { - public Snowflake? Id { get; init; } - public required string Icon { get; set; } - public required string Tooltip { get; set; } - public PreferenceSize Size { get; set; } - public bool Muted { get; set; } - public bool Favourite { get; set; } - } - - public class UpdateUserRequest : PatchRequest - { - public string? Username { get; init; } - public string? DisplayName { get; init; } - public string? Bio { get; init; } - public string? Avatar { get; init; } - public string[]? Links { get; init; } - public FieldEntry[]? Names { get; init; } - public Pronoun[]? Pronouns { get; init; } - public Field[]? Fields { get; init; } - public Snowflake[]? Flags { get; init; } - public string? MemberTitle { get; init; } - public bool? MemberListHidden { get; init; } - public string? Timezone { get; init; } - } - [HttpGet("@me/settings")] [Authorize("user.read_hidden")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] @@ -294,14 +267,9 @@ public class UsersController( return Ok(user.Settings); } - public class UpdateUserSettingsRequest : PatchRequest - { - public bool? DarkMode { get; init; } - } - [HttpPost("@me/reroll-sid")] [Authorize("user.update")] - [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task RerollSidAsync() { Instant minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1); diff --git a/Foxnouns.Backend/Database/Migrations/20241209134148_NullableFlagHash.cs b/Foxnouns.Backend/Database/Migrations/20241209134148_NullableFlagHash.cs new file mode 100644 index 0000000..12d84ff --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20241209134148_NullableFlagHash.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20241209134148_NullableFlagHash")] + public partial class NullableFlagHash : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "hash", + table: "pride_flags", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "hash", + table: "pride_flags", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true + ); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index 79a9855..4bd1ede 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -282,7 +282,6 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnName("description"); b.Property("Hash") - .IsRequired() .HasColumnType("text") .HasColumnName("hash"); @@ -546,7 +545,7 @@ namespace Foxnouns.Backend.Database.Migrations modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b => { b.HasOne("Foxnouns.Backend.Database.Models.User", "User") - .WithMany() + .WithMany("DataExports") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() @@ -645,6 +644,8 @@ namespace Foxnouns.Backend.Database.Migrations { b.Navigation("AuthMethods"); + b.Navigation("DataExports"); + b.Navigation("Flags"); b.Navigation("Members"); diff --git a/Foxnouns.Backend/Database/Models/PrideFlag.cs b/Foxnouns.Backend/Database/Models/PrideFlag.cs index 45a3a7c..d5437f8 100644 --- a/Foxnouns.Backend/Database/Models/PrideFlag.cs +++ b/Foxnouns.Backend/Database/Models/PrideFlag.cs @@ -3,7 +3,9 @@ namespace Foxnouns.Backend.Database.Models; public class PrideFlag : BaseModel { public required Snowflake UserId { get; init; } - public required string Hash { get; init; } + + // A null hash means the flag hasn't been processed yet. + public string? Hash { get; set; } public required string Name { get; set; } public string? Description { get; set; } } diff --git a/Foxnouns.Backend/Dto/Auth.cs b/Foxnouns.Backend/Dto/Auth.cs new file mode 100644 index 0000000..71ebd91 --- /dev/null +++ b/Foxnouns.Backend/Dto/Auth.cs @@ -0,0 +1,47 @@ +// ReSharper disable NotAccessedPositionalProperty.Global +// ReSharper disable ClassNeverInstantiated.Global +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Utils; +using Newtonsoft.Json; +using NodaTime; + +namespace Foxnouns.Backend.Dto; + +public record CallbackResponse( + bool HasAccount, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] UserResponse? User, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt +); + +public record UrlsResponse(bool EmailEnabled, string? Discord, string? Google, string? Tumblr); + +public record AuthResponse(UserResponse User, string Token, Instant ExpiresAt); + +public record SingleUrlResponse(string Url); + +public record AddOauthAccountResponse( + Snowflake Id, + [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type, + string RemoteId, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername +); + +public record OauthRegisterRequest(string Ticket, string Username); + +public record CallbackRequest(string Code, string State); + +public record EmailLoginRequest(string Email, string Password); + +public record EmailRegisterRequest(string Email); + +public record EmailCompleteRegistrationRequest(string Ticket, string Username, string Password); + +public record EmailCallbackRequest(string State); + +public record EmailChangePasswordRequest(string Current, string New); + +public record FediverseCallbackRequest(string Instance, string Code, string State); diff --git a/Foxnouns.Backend/Dto/DataExport.cs b/Foxnouns.Backend/Dto/DataExport.cs new file mode 100644 index 0000000..91413ab --- /dev/null +++ b/Foxnouns.Backend/Dto/DataExport.cs @@ -0,0 +1,6 @@ +// ReSharper disable NotAccessedPositionalProperty.Global +using NodaTime; + +namespace Foxnouns.Backend.Dto; + +public record DataExportResponse(string? Url, Instant? ExpiresAt); diff --git a/Foxnouns.Backend/Dto/Flag.cs b/Foxnouns.Backend/Dto/Flag.cs new file mode 100644 index 0000000..882613a --- /dev/null +++ b/Foxnouns.Backend/Dto/Flag.cs @@ -0,0 +1,17 @@ +// ReSharper disable NotAccessedPositionalProperty.Global +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Utils; + +namespace Foxnouns.Backend.Dto; + +public record PrideFlagResponse(Snowflake Id, string? ImageUrl, string Name, string? Description); + +public record CreateFlagRequest(string Name, string Image, string? Description); + +public record CreateFlagResponse(Snowflake Id, string Name, string? Description); + +public class UpdateFlagRequest : PatchRequest +{ + public string? Name { get; init; } + public string? Description { get; init; } +} diff --git a/Foxnouns.Backend/Dto/Internal.cs b/Foxnouns.Backend/Dto/Internal.cs new file mode 100644 index 0000000..aed0411 --- /dev/null +++ b/Foxnouns.Backend/Dto/Internal.cs @@ -0,0 +1,8 @@ +// ReSharper disable NotAccessedPositionalProperty.Global +using Foxnouns.Backend.Database; + +namespace Foxnouns.Backend.Dto; + +public record RequestDataRequest(string? Token, string Method, string Path); + +public record RequestDataResponse(Snowflake? UserId, string Template); diff --git a/Foxnouns.Backend/Dto/Member.cs b/Foxnouns.Backend/Dto/Member.cs new file mode 100644 index 0000000..2925efe --- /dev/null +++ b/Foxnouns.Backend/Dto/Member.cs @@ -0,0 +1,61 @@ +// ReSharper disable NotAccessedPositionalProperty.Global +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Utils; +using Newtonsoft.Json; + +namespace Foxnouns.Backend.Dto; + +public record CreateMemberRequest( + string Name, + string? DisplayName, + string? Bio, + string? Avatar, + bool? Unlisted, + string[]? Links, + List? Names, + List? Pronouns, + List? Fields +); + +public class UpdateMemberRequest : PatchRequest +{ + public string? Name { get; init; } + public string? DisplayName { get; init; } + public string? Bio { get; init; } + public string? Avatar { get; init; } + public string[]? Links { get; init; } + public FieldEntry[]? Names { get; init; } + public Pronoun[]? Pronouns { get; init; } + public Field[]? Fields { get; init; } + public Snowflake[]? Flags { get; init; } + public bool? Unlisted { get; init; } +} + +public record PartialMember( + Snowflake Id, + string Sid, + string Name, + string DisplayName, + string? Bio, + string? AvatarUrl, + IEnumerable Names, + IEnumerable Pronouns, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted +); + +public record MemberResponse( + Snowflake Id, + string Sid, + string Name, + string DisplayName, + string? Bio, + string? AvatarUrl, + string[] Links, + IEnumerable Names, + IEnumerable Pronouns, + IEnumerable Fields, + IEnumerable Flags, + PartialUser User, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted +); diff --git a/Foxnouns.Backend/Dto/Meta.cs b/Foxnouns.Backend/Dto/Meta.cs new file mode 100644 index 0000000..80826ca --- /dev/null +++ b/Foxnouns.Backend/Dto/Meta.cs @@ -0,0 +1,21 @@ +// ReSharper disable NotAccessedPositionalProperty.Global +namespace Foxnouns.Backend.Dto; + +public record MetaResponse( + string Repository, + string Version, + string Hash, + int Members, + UserInfoResponse Users, + LimitsResponse Limits +); + +public record UserInfoResponse(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay); + +public record LimitsResponse( + int MemberCount, + int BioLength, + int CustomPreferences, + int MaxAuthMethods, + int MaxFlags +); diff --git a/Foxnouns.Backend/Dto/User.cs b/Foxnouns.Backend/Dto/User.cs new file mode 100644 index 0000000..c0604fb --- /dev/null +++ b/Foxnouns.Backend/Dto/User.cs @@ -0,0 +1,82 @@ +// ReSharper disable NotAccessedPositionalProperty.Global +// ReSharper disable ClassNeverInstantiated.Global +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Utils; +using Newtonsoft.Json; +using NodaTime; + +namespace Foxnouns.Backend.Dto; + +public record UserResponse( + Snowflake Id, + string Sid, + string Username, + string? DisplayName, + string? Bio, + string? MemberTitle, + string? AvatarUrl, + string[] Links, + IEnumerable Names, + IEnumerable Pronouns, + IEnumerable Fields, + Dictionary CustomPreferences, + IEnumerable Flags, + int? UtcOffset, + [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + IEnumerable? Members, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + IEnumerable? AuthMethods, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? MemberListHidden, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone +); + +public record AuthMethodResponse( + Snowflake Id, + [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type, + string RemoteId, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername +); + +public record PartialUser( + Snowflake Id, + string Sid, + string Username, + string? DisplayName, + string? AvatarUrl, + Dictionary CustomPreferences +); + +public class UpdateUserSettingsRequest : PatchRequest +{ + public bool? DarkMode { get; init; } +} + +public class CustomPreferenceUpdateRequest +{ + public Snowflake? Id { get; init; } + public required string Icon { get; set; } + public required string Tooltip { get; set; } + public PreferenceSize Size { get; set; } + public bool Muted { get; set; } + public bool Favourite { get; set; } +} + +public class UpdateUserRequest : PatchRequest +{ + public string? Username { get; init; } + public string? DisplayName { get; init; } + public string? Bio { get; init; } + public string? Avatar { get; init; } + public string[]? Links { get; init; } + public FieldEntry[]? Names { get; init; } + public Pronoun[]? Pronouns { get; init; } + public Field[]? Fields { get; init; } + public Snowflake[]? Flags { get; init; } + public string? MemberTitle { get; init; } + public bool? MemberListHidden { get; init; } + public string? Timezone { get; init; } +} diff --git a/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs b/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs index 8e34d01..c07ea7f 100644 --- a/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs +++ b/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs @@ -108,6 +108,12 @@ public class CreateDataExportInvocable( private async Task WritePrideFlag(ZipArchive zip, PrideFlag flag) { + if (flag.Hash == null) + { + _logger.Debug("Flag {FlagId} has a null hash, ignoring it", flag.Id); + return; + } + _logger.Debug("Writing flag {FlagId}", flag.Id); var flagData = $""" diff --git a/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs index 5c5df2d..93a4e0c 100644 --- a/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs +++ b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs @@ -3,6 +3,7 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; +using Microsoft.EntityFrameworkCore; namespace Foxnouns.Backend.Jobs; @@ -26,6 +27,18 @@ public class CreateFlagInvocable( try { + PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f => + f.Id == Payload.Id && f.UserId == Payload.UserId + ); + if (flag == null) + { + _logger.Warning( + "Got a flag create job for {FlagId} but it doesn't exist, aborting", + Payload.Id + ); + return; + } + (string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage( Payload.ImageData, 256, @@ -33,16 +46,8 @@ public class CreateFlagInvocable( ); await objectStorageService.PutObjectAsync(Path(hash), image, "image/webp"); - var flag = new PrideFlag - { - Id = Payload.Id, - UserId = Payload.UserId, - Hash = hash, - Name = Payload.Name, - Description = Payload.Description, - }; - db.Add(flag); - + flag.Hash = hash; + db.Update(flag); await db.SaveChangesAsync(); _logger.Information("Uploaded flag {FlagId} with hash {Hash}", flag.Id, flag.Hash); diff --git a/Foxnouns.Backend/Services/Auth/AuthService.cs b/Foxnouns.Backend/Services/Auth/AuthService.cs index 221bb58..0317c4a 100644 --- a/Foxnouns.Backend/Services/Auth/AuthService.cs +++ b/Foxnouns.Backend/Services/Auth/AuthService.cs @@ -1,7 +1,7 @@ using System.Security.Cryptography; -using Foxnouns.Backend.Controllers.Authentication; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto; using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; diff --git a/Foxnouns.Backend/Services/MemberRendererService.cs b/Foxnouns.Backend/Services/MemberRendererService.cs index ea93e4d..06de060 100644 --- a/Foxnouns.Backend/Services/MemberRendererService.cs +++ b/Foxnouns.Backend/Services/MemberRendererService.cs @@ -1,5 +1,6 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto; using Foxnouns.Backend.Utils; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; @@ -49,7 +50,7 @@ public class MemberRendererService(DatabaseContext db, Config config) ); } - private UserRendererService.PartialUser RenderPartialUser(User user) => + private PartialUser RenderPartialUser(User user) => new( user.Id, user.Sid, @@ -82,36 +83,9 @@ public class MemberRendererService(DatabaseContext db, Config config) ? $"{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) => + flag.Hash != null ? $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp" : null; - private UserRendererService.PrideFlagResponse RenderPrideFlag(PrideFlag flag) => + private PrideFlagResponse RenderPrideFlag(PrideFlag flag) => new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description); - - public record PartialMember( - Snowflake Id, - string Sid, - string Name, - string DisplayName, - string? Bio, - string? AvatarUrl, - IEnumerable Names, - IEnumerable Pronouns, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted - ); - - public record MemberResponse( - Snowflake Id, - string Sid, - string Name, - string DisplayName, - string? Bio, - string? AvatarUrl, - string[] Links, - IEnumerable Names, - IEnumerable Pronouns, - IEnumerable Fields, - IEnumerable Flags, - UserRendererService.PartialUser User, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted - ); } diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index 9f6da8b..6f33583 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -1,5 +1,6 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto; using Foxnouns.Backend.Utils; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; @@ -130,60 +131,9 @@ public class UserRendererService( ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null; - public string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp"; - - public record UserResponse( - Snowflake Id, - string Sid, - string Username, - string? DisplayName, - string? Bio, - string? MemberTitle, - string? AvatarUrl, - string[] Links, - IEnumerable Names, - IEnumerable Pronouns, - IEnumerable Fields, - Dictionary CustomPreferences, - IEnumerable Flags, - int? UtcOffset, - [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - IEnumerable? Members, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - IEnumerable? AuthMethods, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - bool? MemberListHidden, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - Instant? LastSidReroll, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone - ); - - public record AuthMethodResponse( - Snowflake Id, - [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type, - string RemoteId, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - string? RemoteUsername - ); - - public record PartialUser( - Snowflake Id, - string Sid, - string Username, - string? DisplayName, - string? AvatarUrl, - Dictionary CustomPreferences - ); + public string? ImageUrlFor(PrideFlag flag) => + flag.Hash != null ? $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp" : null; public PrideFlagResponse RenderPrideFlag(PrideFlag flag) => new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description); - - public record PrideFlagResponse( - Snowflake Id, - string ImageUrl, - string Name, - string? Description - ); } diff --git a/Foxnouns.Backend/Utils/ValidationUtils.Preferences.cs b/Foxnouns.Backend/Utils/ValidationUtils.Preferences.cs index a4109f2..a6ec90e 100644 --- a/Foxnouns.Backend/Utils/ValidationUtils.Preferences.cs +++ b/Foxnouns.Backend/Utils/ValidationUtils.Preferences.cs @@ -14,6 +14,7 @@ // along with this program. If not, see . using Foxnouns.Backend.Controllers; +using Foxnouns.Backend.Dto; namespace Foxnouns.Backend.Utils; @@ -23,7 +24,7 @@ public static partial class ValidationUtils public const int MaxPreferenceTooltipLength = 128; public static List<(string, ValidationError?)> ValidateCustomPreferences( - List preferences + List preferences ) { var errors = new List<(string, ValidationError?)>(); @@ -46,11 +47,7 @@ public static partial class ValidationUtils if (preferences.Count > 50) return errors; - foreach ( - (UsersController.CustomPreferenceUpdate? p, int i) in preferences.Select( - (p, i) => (p, i) - ) - ) + foreach ((CustomPreferenceUpdateRequest? p, int i) in preferences.Select((p, i) => (p, i))) { if (!BootstrapIcons.IsValid(p.Icon)) { diff --git a/Foxnouns.Frontend/package.json b/Foxnouns.Frontend/package.json index 0e74736..bd5ed64 100644 --- a/Foxnouns.Frontend/package.json +++ b/Foxnouns.Frontend/package.json @@ -29,6 +29,7 @@ "prettier-plugin-svelte": "^3.2.6", "sass": "^1.81.0", "svelte": "^5.0.0", + "svelte-bootstrap-icons": "^3.1.1", "svelte-check": "^4.0.0", "sveltekit-i18n": "^2.4.2", "typescript": "^5.0.0", diff --git a/Foxnouns.Frontend/pnpm-lock.yaml b/Foxnouns.Frontend/pnpm-lock.yaml index bb1a839..d2289ec 100644 --- a/Foxnouns.Frontend/pnpm-lock.yaml +++ b/Foxnouns.Frontend/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: svelte: specifier: ^5.0.0 version: 5.2.2 + svelte-bootstrap-icons: + specifier: ^3.1.1 + version: 3.1.1 svelte-check: specifier: ^4.0.0 version: 4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3) @@ -1321,6 +1324,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svelte-bootstrap-icons@3.1.1: + resolution: {integrity: sha512-ghJlt6TX3IX35M7wSvGyrmVgXeT5GMRF+7+q6L4OUT2RJWF09mQIvZTZ04Ii3FBfg10KdzFdvVuoB8M0cVHfzw==} + svelte-check@4.0.9: resolution: {integrity: sha512-SVNCz2L+9ZELGli7G0n3B3QE5kdf0u27RtKr2ZivWQhcWIXatZxwM4VrQ6AiA2k9zKp2mk5AxkEhdjbpjv7rEw==} engines: {node: '>= 18.0.0'} @@ -2564,6 +2570,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svelte-bootstrap-icons@3.1.1: {} + svelte-check@4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3): dependencies: '@jridgewell/trace-mapping': 0.3.25 diff --git a/Foxnouns.Frontend/src/lib/api/models/meta.ts b/Foxnouns.Frontend/src/lib/api/models/meta.ts index eb77a31..56f31c9 100644 --- a/Foxnouns.Frontend/src/lib/api/models/meta.ts +++ b/Foxnouns.Frontend/src/lib/api/models/meta.ts @@ -17,4 +17,5 @@ export type Limits = { bio_length: number; custom_preferences: number; max_auth_methods: number; + max_flags: number; }; diff --git a/Foxnouns.Frontend/src/lib/api/models/user.ts b/Foxnouns.Frontend/src/lib/api/models/user.ts index d2deb8f..f830983 100644 --- a/Foxnouns.Frontend/src/lib/api/models/user.ts +++ b/Foxnouns.Frontend/src/lib/api/models/user.ts @@ -66,7 +66,7 @@ export type Field = { export type PrideFlag = { id: string; - image_url: string; + image_url: string | null; name: string; description: string | null; }; diff --git a/Foxnouns.Frontend/src/lib/components/ClientPaginator.svelte b/Foxnouns.Frontend/src/lib/components/ClientPaginator.svelte new file mode 100644 index 0000000..7f72ca2 --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/ClientPaginator.svelte @@ -0,0 +1,37 @@ + + +{#if pageCount > 1} +
+ + + (currentPage = 0)} /> + + + (currentPage = prevPage)} /> + + {#each new Array(pageCount) as _, page} + + (currentPage = page)}>{page + 1} + + {/each} + + (currentPage = nextPage)} /> + + + (currentPage = pageCount - 1)} /> + + +
+{/if} diff --git a/Foxnouns.Frontend/src/lib/components/IconButton.svelte b/Foxnouns.Frontend/src/lib/components/IconButton.svelte index 4633dd1..1feedd9 100644 --- a/Foxnouns.Frontend/src/lib/components/IconButton.svelte +++ b/Foxnouns.Frontend/src/lib/components/IconButton.svelte @@ -10,11 +10,18 @@ type?: "submit" | "reset" | "button"; id?: string; onclick?: MouseEventHandler; + outline?: boolean; }; - let { icon, tooltip, color = "primary", type, id, onclick }: Props = $props(); + let { icon, tooltip, color = "primary", type, id, onclick, outline }: Props = $props(); - diff --git a/Foxnouns.Frontend/src/lib/components/editor/EditorFlagImage.svelte b/Foxnouns.Frontend/src/lib/components/editor/EditorFlagImage.svelte new file mode 100644 index 0000000..45875b8 --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/editor/EditorFlagImage.svelte @@ -0,0 +1,17 @@ + + +{flag.description + + diff --git a/Foxnouns.Frontend/src/lib/components/editor/FlagButton.svelte b/Foxnouns.Frontend/src/lib/components/editor/FlagButton.svelte new file mode 100644 index 0000000..51b87c4 --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/editor/FlagButton.svelte @@ -0,0 +1,35 @@ + + + + + diff --git a/Foxnouns.Frontend/src/lib/components/editor/FlagEditor.svelte b/Foxnouns.Frontend/src/lib/components/editor/FlagEditor.svelte new file mode 100644 index 0000000..8cb994c --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/editor/FlagEditor.svelte @@ -0,0 +1,57 @@ + + +
+ + {flag.description + +
+ + +
+ + +
+
+
+ + diff --git a/Foxnouns.Frontend/src/lib/components/editor/FlagSearch.svelte b/Foxnouns.Frontend/src/lib/components/editor/FlagSearch.svelte new file mode 100644 index 0000000..78a6c0f --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/editor/FlagSearch.svelte @@ -0,0 +1,48 @@ + + + + +
+ {#each filteredFlags as flag (flag.id)} + select(flag)} padding /> + {:else} +
+

+ +

+

+ {#if query} + {$t("editor.flag-search-no-flags")} + {:else} + {$t("editor.flag-search-no-account-flags")} + {/if} +

+
+ {/each} + {#if flags.length > 0} +

+ + {$t("editor.flag-search-hint")} + {$t("editor.flag-manage-your-flags")} +

+ {:else} +

{$t("editor.flag-manage-your-flags")}

+ {/if} +
diff --git a/Foxnouns.Frontend/src/lib/components/editor/ProfileFlagsEditor.svelte b/Foxnouns.Frontend/src/lib/components/editor/ProfileFlagsEditor.svelte new file mode 100644 index 0000000..5bd62fd --- /dev/null +++ b/Foxnouns.Frontend/src/lib/components/editor/ProfileFlagsEditor.svelte @@ -0,0 +1,95 @@ + + +
+
+

+ {$t("settings.flag-title")} + +

+ + {#each flags as flag, i} +
+
+ moveFlag(i, true)} + /> + moveFlag(i, false)} + /> + removeFlag(flag)} + tooltip={$t("editor.remove-this-flag")} + /> +
+
+ {:else} +

+ {$t("editor.no-flags-hint")} +

+ {/each} +
+
+

{$t("editor.add-flags-header")}

+ +
+
+ + diff --git a/Foxnouns.Frontend/src/lib/components/profile/ProfileFlag.svelte b/Foxnouns.Frontend/src/lib/components/profile/ProfileFlag.svelte index bf171cd..b3d1611 100644 --- a/Foxnouns.Frontend/src/lib/components/profile/ProfileFlag.svelte +++ b/Foxnouns.Frontend/src/lib/components/profile/ProfileFlag.svelte @@ -1,5 +1,6 @@ + +

{$t("settings.flag-title")}

+ + + +
+ +

{$t("settings.flag-upload-title")}

+ + + + + + +

+ {$t("settings.flag-current-flags-title", { + count: data.flags.length, + max: data.meta.limits.max_flags, + })} +

+ + + + + {#each arr as flag (flag.id)} + + + + {flag.name} + + + {#if lastEditedFlag === flag.id}{/if} + + + {/each} + + + diff --git a/Foxnouns.Frontend/src/routes/settings/members/+page.server.ts b/Foxnouns.Frontend/src/routes/settings/members/+page.server.ts index 220865b..715610b 100644 --- a/Foxnouns.Frontend/src/routes/settings/members/+page.server.ts +++ b/Foxnouns.Frontend/src/routes/settings/members/+page.server.ts @@ -1,18 +1,15 @@ +import paginate from "$lib/paginate"; + const MEMBERS_PER_PAGE = 15; export const load = async ({ url, parent }) => { const { user } = await parent(); - let currentPage = Number(url.searchParams.get("page") || "0"); - let pageCount = Math.ceil(user.members.length / MEMBERS_PER_PAGE); - let members = user.members.slice( - currentPage * MEMBERS_PER_PAGE, - (currentPage + 1) * MEMBERS_PER_PAGE, + const { data, currentPage, pageCount } = paginate( + user.members, + url.searchParams.get("page"), + MEMBERS_PER_PAGE, ); - if (members.length === 0) { - members = user.members.slice(0, MEMBERS_PER_PAGE); - currentPage = 0; - } - return { members, currentPage, pageCount }; + return { members: data, currentPage, pageCount }; }; diff --git a/Foxnouns.Frontend/src/routes/settings/members/[id]/+page.svelte b/Foxnouns.Frontend/src/routes/settings/members/[id]/+page.svelte index b4b38e2..c286c38 100644 --- a/Foxnouns.Frontend/src/routes/settings/members/[id]/+page.svelte +++ b/Foxnouns.Frontend/src/routes/settings/members/[id]/+page.svelte @@ -6,6 +6,7 @@ import ApiError from "$api/error"; import log from "$lib/log"; import { Icon, InputGroup } from "@sveltestrap/sveltestrap"; + import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte"; import { t } from "$lib/i18n"; import AvatarEditor from "$components/editor/AvatarEditor.svelte"; import ErrorAlert from "$components/ErrorAlert.svelte"; @@ -133,7 +134,7 @@

- + {$t("edit-profile.unlisted-note")} {PUBLIC_BASE_URL.substring("https://".length)}/@{data.member.user.username}/{data.member diff --git a/Foxnouns.Frontend/src/routes/settings/members/[id]/fields/+page.svelte b/Foxnouns.Frontend/src/routes/settings/members/[id]/fields/+page.svelte new file mode 100644 index 0000000..491a45f --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/members/[id]/fields/+page.svelte @@ -0,0 +1,32 @@ + + + diff --git a/Foxnouns.Frontend/src/routes/settings/members/[id]/flags-links/+page.server.ts b/Foxnouns.Frontend/src/routes/settings/members/[id]/flags-links/+page.server.ts new file mode 100644 index 0000000..0b3a452 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/members/[id]/flags-links/+page.server.ts @@ -0,0 +1,7 @@ +import { apiRequest } from "$api"; +import type { PrideFlag } from "$api/models/user"; + +export const load = async ({ fetch, cookies }) => { + const flags = await apiRequest("GET", "/users/@me/flags", { fetch, cookies }); + return { flags }; +}; diff --git a/Foxnouns.Frontend/src/routes/settings/members/[id]/flags-links/+page.svelte b/Foxnouns.Frontend/src/routes/settings/members/[id]/flags-links/+page.svelte new file mode 100644 index 0000000..db35e18 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/members/[id]/flags-links/+page.svelte @@ -0,0 +1,28 @@ + + + diff --git a/Foxnouns.Frontend/src/routes/settings/profile/fields/+page.svelte b/Foxnouns.Frontend/src/routes/settings/profile/fields/+page.svelte index 8bab783..4c61a58 100644 --- a/Foxnouns.Frontend/src/routes/settings/profile/fields/+page.svelte +++ b/Foxnouns.Frontend/src/routes/settings/profile/fields/+page.svelte @@ -29,4 +29,4 @@ }; - + diff --git a/Foxnouns.Frontend/src/routes/settings/profile/flags-links/+page.server.ts b/Foxnouns.Frontend/src/routes/settings/profile/flags-links/+page.server.ts new file mode 100644 index 0000000..0b3a452 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/profile/flags-links/+page.server.ts @@ -0,0 +1,7 @@ +import { apiRequest } from "$api"; +import type { PrideFlag } from "$api/models/user"; + +export const load = async ({ fetch, cookies }) => { + const flags = await apiRequest("GET", "/users/@me/flags", { fetch, cookies }); + return { flags }; +}; diff --git a/Foxnouns.Frontend/src/routes/settings/profile/flags-links/+page.svelte b/Foxnouns.Frontend/src/routes/settings/profile/flags-links/+page.svelte new file mode 100644 index 0000000..0273e6b --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/profile/flags-links/+page.svelte @@ -0,0 +1,28 @@ + + + diff --git a/Foxnouns.Frontend/static/unknown_flag.svg b/Foxnouns.Frontend/static/unknown_flag.svg new file mode 100644 index 0000000..bbf82ce --- /dev/null +++ b/Foxnouns.Frontend/static/unknown_flag.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index ea28fc8..146591f 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,8 @@ Code taken entirely or almost entirely from external sources: taken from [PluralKit](https://github.com/PluralKit/PluralKit/blob/32a6e97342acc3b35e6f9e7b4dd169e21d888770/PluralKit.Core/Database/Functions/functions.sql) - `Foxnouns.Backend/Database/prune-designer-cs-files.sh`, taken from [Iceshrimp.NET](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/commit/7c93dcf79dda54fc1a4ea9772e3f80874e6bcefb/Iceshrimp.Backend/Core/Database/prune-designer-cs-files.sh) + +Files under a different license: + +- `Foxnouns.Frontend/static/unknown_flag.svg` is https://commons.wikimedia.org/wiki/File:Unknown_flag.svg, + by 8938e on Wikimedia Commons, licensed as CC BY-SA 4.0. \ No newline at end of file