diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index ee22804..118caa8 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -104,21 +104,9 @@ public class DiscordAuthController( { CheckRequirements(); - var existingAccounts = await db - .AuthMethods.Where(m => m.UserId == CurrentUser!.Id && m.AuthType == AuthType.Discord) - .CountAsync(); - if (existingAccounts > AuthUtils.MaxAuthMethodsPerType) - { - throw new ApiError.BadRequest( - "Too many linked Discord accounts, maximum of 3 per account." - ); - } - - var state = HttpUtility.UrlEncode( - await keyCacheService.GenerateAddExtraAccountStateAsync( - AuthType.Discord, - CurrentUser!.Id - ) + var state = await remoteAuthService.ValidateAddAccountRequestAsync( + CurrentUser!.Id, + AuthType.Discord ); var url = @@ -138,12 +126,11 @@ public class DiscordAuthController( { CheckRequirements(); - var accountState = await keyCacheService.GetAddExtraAccountStateAsync(req.State); - if ( - accountState is not { AuthType: AuthType.Discord } - || accountState.UserId != CurrentUser!.Id - ) - throw new ApiError.BadRequest("Invalid state", "state", req.State); + await remoteAuthService.ValidateAddAccountStateAsync( + req.State, + CurrentUser!.Id, + AuthType.Discord + ); var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code); try diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs index 7cb52c8..103061b 100644 --- a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs @@ -1,5 +1,8 @@ +using System.Net; +using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; using Foxnouns.Backend.Services.Auth; using Foxnouns.Backend.Utils; @@ -15,13 +18,14 @@ public class FediverseAuthController( DatabaseContext db, FediverseAuthService fediverseAuthService, AuthService authService, + RemoteAuthService remoteAuthService, KeyCacheService keyCacheService ) : ApiControllerBase { 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 @@ -31,7 +35,7 @@ public class FediverseAuthController( throw new ApiError.BadRequest("Not a valid domain.", "instance", instance); var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh); - return Ok(new FediverseUrlResponse(url)); + return Ok(new AuthController.SingleUrlResponse(url)); } [HttpPost("callback")] @@ -118,9 +122,74 @@ public class FediverseAuthController( return Ok(await authService.GenerateUserTokenAsync(user)); } - public record CallbackRequest(string Instance, string Code, string State); + [HttpGet("add-account")] + [Authorize("*")] + public async Task AddFediverseAccountAsync( + [FromQuery] string instance, + [FromQuery] bool forceRefresh = false + ) + { + if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.')) + throw new ApiError.BadRequest("Not a valid domain.", "instance", instance); - private record FediverseUrlResponse(string Url); + var state = await remoteAuthService.ValidateAddAccountRequestAsync( + CurrentUser!.Id, + AuthType.Fediverse, + instance + ); + + var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state); + return Ok(new AuthController.SingleUrlResponse(url)); + } + + [HttpPost("add-account/callback")] + [Authorize("*")] + public async Task AddAccountCallbackAsync([FromBody] CallbackRequest req) + { + await remoteAuthService.ValidateAddAccountStateAsync( + req.State, + CurrentUser!.Id, + AuthType.Fediverse, + req.Instance + ); + + var app = await fediverseAuthService.GetApplicationAsync(req.Instance); + var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code); + try + { + var authMethod = await authService.AddAuthMethodAsync( + CurrentUser.Id, + AuthType.Fediverse, + remoteUser.Id, + remoteUser.Username, + app + ); + _logger.Debug( + "Added new Fediverse auth method {AuthMethodId} to user {UserId}", + authMethod.Id, + CurrentUser.Id + ); + + return Ok( + new AuthController.AddOauthAccountResponse( + authMethod.Id, + AuthType.Fediverse, + authMethod.RemoteId, + $"{authMethod.RemoteUsername}@{app.Domain}" + ) + ); + } + catch (UniqueConstraintException) + { + throw new ApiError( + "That account is already linked.", + HttpStatusCode.BadRequest, + ErrorCode.AccountAlreadyLinked + ); + } + } + + public record CallbackRequest(string Instance, string Code, string State); private record FediverseTicketData( Snowflake ApplicationId, diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs index d414dba..e7a17e9 100644 --- a/Foxnouns.Backend/Controllers/FlagsController.cs +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -1,6 +1,5 @@ using Coravel.Queuing.Interfaces; using Foxnouns.Backend.Database; -using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Middleware; diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index aa8d02d..b133954 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using Coravel.Mailer.Mail.Helpers; using Coravel.Queuing.Interfaces; using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; diff --git a/Foxnouns.Backend/Database/Models/DataExport.cs b/Foxnouns.Backend/Database/Models/DataExport.cs index 6e5a719..582ffd8 100644 --- a/Foxnouns.Backend/Database/Models/DataExport.cs +++ b/Foxnouns.Backend/Database/Models/DataExport.cs @@ -1,4 +1,3 @@ -using System.ComponentModel.DataAnnotations.Schema; using NodaTime; namespace Foxnouns.Backend.Database.Models; @@ -9,6 +8,5 @@ public class DataExport : BaseModel public User User { get; init; } = null!; public required string Filename { get; init; } - [NotMapped] public static readonly Duration Expiration = Duration.FromDays(15); } diff --git a/Foxnouns.Backend/Database/Models/FediverseApplication.cs b/Foxnouns.Backend/Database/Models/FediverseApplication.cs index 882a377..6dc813d 100644 --- a/Foxnouns.Backend/Database/Models/FediverseApplication.cs +++ b/Foxnouns.Backend/Database/Models/FediverseApplication.cs @@ -1,5 +1,3 @@ -using NodaTime; - namespace Foxnouns.Backend.Database.Models; public class FediverseApplication : BaseModel diff --git a/Foxnouns.Backend/Database/Models/User.cs b/Foxnouns.Backend/Database/Models/User.cs index 7eda12d..f75acde 100644 --- a/Foxnouns.Backend/Database/Models/User.cs +++ b/Foxnouns.Backend/Database/Models/User.cs @@ -55,10 +55,7 @@ public class User : BaseModel public PreferenceSize Size { get; set; } } - [NotMapped] public static readonly Duration DeleteAfter = Duration.FromDays(30); - - [NotMapped] public static readonly Duration DeleteSuspendedAfter = Duration.FromDays(180); } diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index 522c8d6..f3e1467 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -63,13 +63,14 @@ public static class KeyCacheExtensions this KeyCacheService keyCacheService, AuthType authType, Snowflake userId, + string? instance = null, CancellationToken ct = default ) { var state = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync( $"add_account:{state}", - new AddExtraAccountState(authType, userId), + new AddExtraAccountState(authType, userId, instance), Duration.FromDays(1), ct ); @@ -93,4 +94,4 @@ public record RegisterEmailState( [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId ); -public record AddExtraAccountState(AuthType AuthType, Snowflake UserId); +public record AddExtraAccountState(AuthType AuthType, Snowflake UserId, string? Instance = null); diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index dbc46d3..d604012 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -12,6 +12,7 @@ + diff --git a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs index d97e1a7..d2fd9ec 100644 --- a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs @@ -1,4 +1,3 @@ -using System.Security.Cryptography; using Coravel.Invocable; using Foxnouns.Backend.Database; using Foxnouns.Backend.Extensions; diff --git a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs index 8147424..f212cc3 100644 --- a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs @@ -1,4 +1,3 @@ -using System.Security.Cryptography; using Coravel.Invocable; using Foxnouns.Backend.Database; using Foxnouns.Backend.Extensions; diff --git a/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs b/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs index 70fa6ff..4c14e5f 100644 --- a/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs +++ b/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs @@ -1,6 +1,5 @@ using System.Net; using Foxnouns.Backend.Utils; -using Microsoft.AspNetCore.Mvc.ModelBinding; using Newtonsoft.Json; namespace Foxnouns.Backend.Middleware; diff --git a/Foxnouns.Backend/Services/Auth/AuthService.cs b/Foxnouns.Backend/Services/Auth/AuthService.cs index 4eca66e..e3ec4c4 100644 --- a/Foxnouns.Backend/Services/Auth/AuthService.cs +++ b/Foxnouns.Backend/Services/Auth/AuthService.cs @@ -218,10 +218,11 @@ public class AuthService( AuthType authType, string remoteId, string? remoteUsername = null, + FediverseApplication? app = null, CancellationToken ct = default ) { - AssertValidAuthType(authType, null); + AssertValidAuthType(authType, app); // This is already checked when var currentCount = await db @@ -237,6 +238,7 @@ public class AuthService( Id = snowflakeGenerator.GenerateSnowflake(), AuthType = authType, RemoteId = remoteId, + FediverseApplicationId = app?.Id, RemoteUsername = remoteUsername, UserId = userId, }; diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs index 97e411a..ba232bf 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs @@ -69,10 +69,11 @@ public partial class FediverseAuthService private async Task GetMastodonUserAsync( FediverseApplication app, string code, - string state + string? state = null ) { - await _keyCacheService.ValidateAuthStateAsync(state); + if (state != null) + await _keyCacheService.ValidateAuthStateAsync(state); var tokenResp = await _client.PostAsync( MastodonTokenUri(app.Domain), @@ -120,7 +121,8 @@ public partial class FediverseAuthService private async Task GenerateMastodonAuthUrlAsync( FediverseApplication app, - bool forceRefresh + bool forceRefresh, + string? state = null ) { if (forceRefresh) @@ -132,7 +134,7 @@ public partial class FediverseAuthService app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id); } - var state = HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync()); + state ??= HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync()); return $"https://{app.Domain}/oauth/authorize?response_type=code" + $"&client_id={app.ClientId}" diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs index f78fbde..224c0a3 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs @@ -37,10 +37,14 @@ public partial class FediverseAuthService _client.DefaultRequestHeaders.Add("Accept", "application/json"); } - public async Task GenerateAuthUrlAsync(string instance, bool forceRefresh) + public async Task GenerateAuthUrlAsync( + string instance, + bool forceRefresh, + string? state = null + ) { var app = await GetApplicationAsync(instance); - return await GenerateAuthUrlAsync(app, forceRefresh); + return await GenerateAuthUrlAsync(app, forceRefresh, state); } // thank you, gargron and syuilo, for agreeing on a name for *once* in your lives, @@ -96,12 +100,17 @@ public partial class FediverseAuthService ); } - private async Task GenerateAuthUrlAsync(FediverseApplication app, bool forceRefresh) => + private async Task GenerateAuthUrlAsync( + FediverseApplication app, + bool forceRefresh, + string? state = null + ) => app.InstanceType switch { FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync( app, - forceRefresh + forceRefresh, + state ), FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), @@ -110,7 +119,7 @@ public partial class FediverseAuthService public async Task GetRemoteFediverseUserAsync( FediverseApplication app, string code, - string state + string? state = null ) => app.InstanceType switch { diff --git a/Foxnouns.Backend/Services/RemoteAuthService.cs b/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs similarity index 52% rename from Foxnouns.Backend/Services/RemoteAuthService.cs rename to Foxnouns.Backend/Services/Auth/RemoteAuthService.cs index 91a2dc5..9b62a70 100644 --- a/Foxnouns.Backend/Services/RemoteAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs @@ -1,9 +1,21 @@ using System.Diagnostics.CodeAnalysis; +using System.Web; +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Extensions; +using Foxnouns.Backend.Utils; +using Humanizer; using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; -namespace Foxnouns.Backend.Services; +namespace Foxnouns.Backend.Services.Auth; -public class RemoteAuthService(Config config, ILogger logger) +public class RemoteAuthService( + Config config, + ILogger logger, + DatabaseContext db, + KeyCacheService keyCacheService +) { private readonly ILogger _logger = logger.ForContext(); private readonly HttpClient _httpClient = new(); @@ -76,4 +88,56 @@ public class RemoteAuthService(Config config, ILogger logger) private record DiscordUserResponse(string id, string username); public record RemoteUser(string Id, string Username); + + /// + /// Validates whether a user can still add a new account of the given AuthType, and throws an error if they can't. + /// + /// The user to check. + /// The auth type to check. + /// The optional fediverse instance to generate a state for. + /// A state for the given auth type and user ID. + /// The given user can't add another account of this type. + /// This exception should not be caught by controller code. + public async Task ValidateAddAccountRequestAsync( + Snowflake userId, + AuthType authType, + string? instance = null + ) + { + var existingAccounts = await db + .AuthMethods.Where(m => m.UserId == userId && m.AuthType == authType) + .CountAsync(); + if (existingAccounts > AuthUtils.MaxAuthMethodsPerType) + { + throw new ApiError.BadRequest( + $"Too many linked {authType.Humanize()} accounts, maximum of {AuthUtils.MaxAuthMethodsPerType} per account." + ); + } + + return HttpUtility.UrlEncode( + await keyCacheService.GenerateAddExtraAccountStateAsync(authType, userId, instance) + ); + } + + /// + /// Checks whether the given state is correct for the given user/auth type combination. + /// + /// The state doesn't match. + /// This exception should not be caught by controller code. + public async Task ValidateAddAccountStateAsync( + string state, + Snowflake userId, + AuthType authType, + string? instance = null + ) + { + var accountState = await keyCacheService.GetAddExtraAccountStateAsync(state); + if ( + accountState == null + || accountState.AuthType != authType + || accountState.UserId != userId + || (instance != null && accountState.Instance != instance) + ) + throw new ApiError.BadRequest("Invalid state", "state", state); + } } diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index f073cda..cb728d1 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -4,7 +4,6 @@ using Foxnouns.Backend.Utils; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using NodaTime; -using Org.BouncyCastle.Ocsp; namespace Foxnouns.Backend.Services; diff --git a/Foxnouns.Backend/Utils/ValidationUtils.Fields.cs b/Foxnouns.Backend/Utils/ValidationUtils.Fields.cs index 1ed083c..4271459 100644 --- a/Foxnouns.Backend/Utils/ValidationUtils.Fields.cs +++ b/Foxnouns.Backend/Utils/ValidationUtils.Fields.cs @@ -127,6 +127,9 @@ public static partial class ValidationUtils if (entries.Length > Limits.FieldEntriesLimit + 50) return errors; + var customPreferenceIds = + customPreferences?.Keys.Select(id => id.ToString()).ToArray() ?? []; + foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) { switch (entry.Value.Length) @@ -159,8 +162,6 @@ public static partial class ValidationUtils break; } - var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? []; - if ( !DefaultStatusOptions.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status) @@ -203,6 +204,9 @@ public static partial class ValidationUtils if (entries.Length > Limits.FieldEntriesLimit + 50) return errors; + var customPreferenceIds = + customPreferences?.Keys.Select(id => id.ToString()).ToList() ?? []; + foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) { switch (entry.Value.Length) @@ -268,8 +272,6 @@ public static partial class ValidationUtils } } - var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? []; - if ( !DefaultStatusOptions.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status) diff --git a/Foxnouns.Backend/packages.lock.json b/Foxnouns.Backend/packages.lock.json index 02ca7ca..5a5a32b 100644 --- a/Foxnouns.Backend/packages.lock.json +++ b/Foxnouns.Backend/packages.lock.json @@ -45,6 +45,12 @@ "Npgsql": "8.0.1" } }, + "Humanizer.Core": { + "type": "Direct", + "requested": "[2.14.1, )", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, "JetBrains.Annotations": { "type": "Direct", "requested": "[2024.2.0, )", @@ -291,11 +297,6 @@ "Microsoft.EntityFrameworkCore.Relational": "8.0.0" } }, - "Humanizer.Core": { - "type": "Transitive", - "resolved": "2.14.1", - "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" - }, "MailKit": { "type": "Transitive", "resolved": "2.5.1", diff --git a/Foxnouns.Frontend/package.json b/Foxnouns.Frontend/package.json index 3fc70d1..0e74736 100644 --- a/Foxnouns.Frontend/package.json +++ b/Foxnouns.Frontend/package.json @@ -42,6 +42,7 @@ "bootstrap-icons": "^1.11.3", "luxon": "^3.5.0", "markdown-it": "^14.1.0", + "minidenticons": "^4.2.1", "pretty-bytes": "^6.1.1", "sanitize-html": "^2.13.1", "svelte-tippy": "^1.3.2", diff --git a/Foxnouns.Frontend/pnpm-lock.yaml b/Foxnouns.Frontend/pnpm-lock.yaml index 9b78513..bb1a839 100644 --- a/Foxnouns.Frontend/pnpm-lock.yaml +++ b/Foxnouns.Frontend/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: markdown-it: specifier: ^14.1.0 version: 14.1.0 + minidenticons: + specifier: ^4.2.1 + version: 4.2.1 pretty-bytes: specifier: ^6.1.1 version: 6.1.1 @@ -1110,6 +1113,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + minidenticons@4.2.1: + resolution: {integrity: sha512-oWfFivA0lOx/V/bO/YIJbthB26lV8JXYvhnv9zM2hNd3fzsHTXQ6c6bWZPcvhD3nnOB+lQk/D9lF43BXixrN8g==} + engines: {node: '>=15.14.0'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2369,6 +2376,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + minidenticons@4.2.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 diff --git a/Foxnouns.Frontend/src/lib/components/Avatar.svelte b/Foxnouns.Frontend/src/lib/components/Avatar.svelte index 99dd8f3..d9f4eec 100644 --- a/Foxnouns.Frontend/src/lib/components/Avatar.svelte +++ b/Foxnouns.Frontend/src/lib/components/Avatar.svelte @@ -1,16 +1,22 @@ diff --git a/Foxnouns.Frontend/src/lib/components/editor/AvatarEditor.svelte b/Foxnouns.Frontend/src/lib/components/editor/AvatarEditor.svelte index 05a9a62..e18c6b6 100644 --- a/Foxnouns.Frontend/src/lib/components/editor/AvatarEditor.svelte +++ b/Foxnouns.Frontend/src/lib/components/editor/AvatarEditor.svelte @@ -7,12 +7,13 @@ import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte"; type Props = { + name: string; current: string | null; alt: string; update: (avatar: string) => Promise; updated: boolean; }; - let { current, alt, update: onclick, updated }: Props = $props(); + let { name, current, alt, update: onclick, updated }: Props = $props(); const MAX_AVATAR_BYTES = 1_000_000; @@ -40,7 +41,7 @@

- +

diff --git a/Foxnouns.Frontend/src/lib/components/profile/ProfileHeader.svelte b/Foxnouns.Frontend/src/lib/components/profile/ProfileHeader.svelte index d28a001..0fd1960 100644 --- a/Foxnouns.Frontend/src/lib/components/profile/ProfileHeader.svelte +++ b/Foxnouns.Frontend/src/lib/components/profile/ProfileHeader.svelte @@ -22,6 +22,7 @@
{ - const { meUser } = await parent(); - if (meUser) redirect(303, `/@${meUser.username}`); - const code = url.searchParams.get("code") as string | null; const state = url.searchParams.get("state") as string | null; if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj; - const resp = await apiRequest("POST", "/auth/fediverse/callback", { - body: { code, state, instance: params.instance }, - isInternal: true, - fetch, - }); + const { meUser } = await parent(); + if (meUser) { + try { + const resp = await apiRequest( + "POST", + "/auth/fediverse/add-account/callback", + { + isInternal: true, + body: { code, state, instance: params.instance }, + fetch, + cookies, + }, + ); - if (resp.has_account) { - setToken(cookies, resp.token!); - redirect(303, `/@${resp.user!.username}`); + return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp }; + } catch (e) { + if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj }; + log.error("error linking new fediverse account to user %s:", meUser.id, e); + throw e; + } } - return { - hasAccount: false, - instance: params.instance, - ticket: resp.ticket!, - remoteUser: resp.remote_username!, - }; + try { + const resp = await apiRequest("POST", "/auth/fediverse/callback", { + body: { code, state, instance: params.instance }, + isInternal: true, + fetch, + }); + + if (resp.has_account) { + setToken(cookies, resp.token!); + redirect(303, `/@${resp.user!.username}`); + } + + return { + hasAccount: false, + isLinkRequest: false, + ticket: resp.ticket!, + remoteUser: resp.remote_username!, + }; + } catch (e) { + if (isRedirect(e)) throw e; + if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj }; + log.error("error while requesting fediverse callback:", e); + throw e; + } }; export const actions = { diff --git a/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.svelte b/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.svelte index 5d02eeb..99bd00c 100644 --- a/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.svelte +++ b/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.svelte @@ -1,7 +1,9 @@ + +

Link a new Fediverse account

+ +
+ + + + +

+ {$t("auth.log-in-with-fediverse-error-blurb")} + +

+
diff --git a/Foxnouns.Frontend/src/routes/settings/export/+page.server.ts b/Foxnouns.Frontend/src/routes/settings/export/+page.server.ts new file mode 100644 index 0000000..136af01 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/export/+page.server.ts @@ -0,0 +1,35 @@ +import { apiRequest, fastRequest } from "$api"; +import ApiError from "$api/error.js"; +import log from "$lib/log.js"; +import { DateTime, Duration } from "luxon"; + +type Export = { url: string | null; expires_at: string | null }; + +export const load = async ({ fetch, cookies }) => { + const resp = await apiRequest("GET", "/data-exports", { + fetch, + cookies, + isInternal: true, + }); + + let canExport = true; + if (resp.expires_at) { + const created = DateTime.fromISO(resp.expires_at).minus(Duration.fromObject({ days: 15 })); + canExport = DateTime.now().diff(created, "seconds").seconds >= 86400; + } + + return { url: resp.url, expiresAt: resp.expires_at, canExport }; +}; + +export const actions = { + default: async ({ fetch, cookies }) => { + try { + fastRequest("POST", "/data-exports", { fetch, cookies, isInternal: true }); + return { ok: true, error: null }; + } catch (e) { + if (e instanceof ApiError) return { ok: false, error: e.obj }; + log.error("Error requesting data export:", e); + throw e; + } + }, +}; diff --git a/Foxnouns.Frontend/src/routes/settings/export/+page.svelte b/Foxnouns.Frontend/src/routes/settings/export/+page.svelte new file mode 100644 index 0000000..874f8e8 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/export/+page.svelte @@ -0,0 +1,49 @@ + + +
diff --git a/Foxnouns.Frontend/src/routes/settings/members/+page.svelte b/Foxnouns.Frontend/src/routes/settings/members/+page.svelte index 563cf90..6963c0c 100644 --- a/Foxnouns.Frontend/src/routes/settings/members/+page.svelte +++ b/Foxnouns.Frontend/src/routes/settings/members/+page.svelte @@ -30,6 +30,7 @@ {#each data.members as member (member.id)}

{$t("settings.avatar")}

{$t("edit-profile.member-name")}

-
+

{$t("edit-profile.display-name")}

- +

{$t("edit-profile.profile-options-header")}

- +

{$t("edit-profile.bio-tab")}

- +
diff --git a/Foxnouns.Frontend/src/routes/settings/members/new/+page.svelte b/Foxnouns.Frontend/src/routes/settings/members/new/+page.svelte index 7999be2..2c1fc0b 100644 --- a/Foxnouns.Frontend/src/routes/settings/members/new/+page.svelte +++ b/Foxnouns.Frontend/src/routes/settings/members/new/+page.svelte @@ -1,4 +1,5 @@