From 8bd44498046cb8a08bbe84758d889dd3144d7aae Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 8 Dec 2024 20:17:30 +0100 Subject: [PATCH] refactor(backend): move all request/response types to a new Dto namespace --- .../Authentication/AuthController.cs | 40 +-------- .../Authentication/DiscordAuthController.cs | 17 ++-- .../Authentication/EmailAuthController.cs | 29 +++---- .../Authentication/FediverseAuthController.cs | 23 +++--- .../Controllers/ExportsController.cs | 3 +- .../Controllers/FlagsController.cs | 62 +++----------- .../Controllers/InternalController.cs | 5 +- .../Controllers/MembersController.cs | 37 ++------- .../Controllers/MetaController.cs | 27 ++---- .../Controllers/UsersController.cs | 44 ++-------- Foxnouns.Backend/Dto/Auth.cs | 47 +++++++++++ Foxnouns.Backend/Dto/DataExport.cs | 6 ++ Foxnouns.Backend/Dto/Flag.cs | 17 ++++ Foxnouns.Backend/Dto/Internal.cs | 8 ++ Foxnouns.Backend/Dto/Member.cs | 61 ++++++++++++++ Foxnouns.Backend/Dto/Meta.cs | 21 +++++ Foxnouns.Backend/Dto/User.cs | 82 +++++++++++++++++++ Foxnouns.Backend/Services/Auth/AuthService.cs | 2 +- .../Services/MemberRendererService.cs | 33 +------- .../Services/UserRendererService.cs | 53 +----------- .../Utils/ValidationUtils.Preferences.cs | 9 +- 21 files changed, 310 insertions(+), 316 deletions(-) create mode 100644 Foxnouns.Backend/Dto/Auth.cs create mode 100644 Foxnouns.Backend/Dto/DataExport.cs create mode 100644 Foxnouns.Backend/Dto/Flag.cs create mode 100644 Foxnouns.Backend/Dto/Internal.cs create mode 100644 Foxnouns.Backend/Dto/Member.cs create mode 100644 Foxnouns.Backend/Dto/Meta.cs create mode 100644 Foxnouns.Backend/Dto/User.cs 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..14705fb 100644 --- a/Foxnouns.Backend/Controllers/FlagsController.cs +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -1,50 +1,49 @@ 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(); @@ -56,10 +55,6 @@ public class FlagsController( return Accepted(new CreateFlagResponse(id, req.Name, req.Description)); } - 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 +79,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/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..649154a --- /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/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..8661088 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, @@ -84,34 +85,6 @@ public class MemberRendererService(DatabaseContext db, Config config) private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp"; - 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..3d4ca51 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; @@ -132,58 +133,6 @@ public class UserRendererService( 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 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)) {