Compare commits

..

5 commits

Author SHA1 Message Date
sam
57e1ec09c0
feat: link fediverse account to existing user 2024-12-04 01:49:03 +01:00
sam
03209e4028
chore(backend): clean imports 2024-12-03 20:05:24 +01:00
sam
9966656c0c
fix(backend): don't need [NotMapped] for these actually 2024-12-03 20:04:28 +01:00
sam
c20831f20d
feat(frontend): export ui 2024-12-03 20:02:09 +01:00
sam
74222ead45
feat(frontend): replace placeholder avatar with identicons
i don't actually know what the license on the kitten image is, and while
it's very unlikely, i don't want to get into legal trouble. it was only
ever supposed to be a temporary image, too.

identicons aren't the prettiest but at least they have a clear license
:3
2024-12-03 15:19:52 +01:00
40 changed files with 476 additions and 123 deletions

View file

@ -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

View file

@ -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<FediverseAuthController>();
[HttpGet]
[ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)]
[ProducesResponseType<AuthController.SingleUrlResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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,

View file

@ -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;

View file

@ -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;

View file

@ -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);
}

View file

@ -1,5 +1,3 @@
using NodaTime;
namespace Foxnouns.Backend.Database.Models;
public class FediverseApplication : BaseModel

View file

@ -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);
}

View file

@ -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);

View file

@ -12,6 +12,7 @@
<PackageReference Include="Coravel.Mailer" Version="5.0.1"/>
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2"/>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="JetBrains.Annotations" Version="2024.2.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/>

View file

@ -1,4 +1,3 @@
using System.Security.Cryptography;
using Coravel.Invocable;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Extensions;

View file

@ -1,4 +1,3 @@
using System.Security.Cryptography;
using Coravel.Invocable;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Extensions;

View file

@ -1,6 +1,5 @@
using System.Net;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Newtonsoft.Json;
namespace Foxnouns.Backend.Middleware;

View file

@ -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,
};

View file

@ -69,10 +69,11 @@ public partial class FediverseAuthService
private async Task<FediverseUser> 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<string> 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}"

View file

@ -37,10 +37,14 @@ public partial class FediverseAuthService
_client.DefaultRequestHeaders.Add("Accept", "application/json");
}
public async Task<string> GenerateAuthUrlAsync(string instance, bool forceRefresh)
public async Task<string> 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<string> GenerateAuthUrlAsync(FediverseApplication app, bool forceRefresh) =>
private async Task<string> 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<FediverseUser> GetRemoteFediverseUserAsync(
FediverseApplication app,
string code,
string state
string? state = null
) =>
app.InstanceType switch
{

View file

@ -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<RemoteAuthService>();
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);
/// <summary>
/// Validates whether a user can still add a new account of the given AuthType, and throws an error if they can't.
/// </summary>
/// <param name="userId">The user to check.</param>
/// <param name="authType">The auth type to check.</param>
/// <param name="instance">The optional fediverse instance to generate a state for.</param>
/// <returns>A state for the given auth type and user ID.</returns>
/// <exception cref="ApiError.BadRequest">The given user can't add another account of this type.
/// This exception should not be caught by controller code.</exception>
public async Task<string> 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)
);
}
/// <summary>
/// Checks whether the given state is correct for the given user/auth type combination.
/// </summary>
/// <exception cref="ApiError.BadRequest">The state doesn't match.
/// This exception should not be caught by controller code.</exception>
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);
}
}

View file

@ -4,7 +4,6 @@ using Foxnouns.Backend.Utils;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using NodaTime;
using Org.BouncyCastle.Ocsp;
namespace Foxnouns.Backend.Services;

View file

@ -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)

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -1,16 +1,22 @@
<script lang="ts">
import { DEFAULT_AVATAR } from "$lib";
import { minidenticon } from "minidenticons";
type Props = { url: string | null; alt: string; lazyLoad?: boolean; size?: number };
let { url, alt, lazyLoad, size }: Props = $props();
type Props = { url: string | null; alt: string; lazyLoad?: boolean; size?: number; name: string };
let { url, alt, lazyLoad, size, name }: Props = $props();
let width = $derived(size || 200);
let identicon: string | null = $derived.by(() => {
if (url) return null;
return "data:image/svg+xml;utf8," + encodeURIComponent(minidenticon(name, 50, 80));
});
</script>
<img
class="rounded-circle img-fluid"
style="height: {width}px; width: {width}px"
src={url || DEFAULT_AVATAR}
class:identicon={!url}
src={url || identicon}
{alt}
{width}
loading={lazyLoad ? "lazy" : "eager"}
@ -20,4 +26,12 @@
img {
object-fit: cover;
}
.identicon {
@media (prefers-color-scheme: dark) {
background-color: var(--bs-secondary-border-subtle);
}
background-color: var(--bs-light-border-subtle);
}
</style>

View file

@ -7,12 +7,13 @@
import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte";
type Props = {
name: string;
current: string | null;
alt: string;
update: (avatar: string) => Promise<void>;
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 @@
</script>
<p class="text-center">
<Avatar url={avatarExists ? avatar : current} {alt} />
<Avatar {name} url={avatarExists ? avatar : current} {alt} />
</p>
<InputGroup class="mb-2">

View file

@ -22,6 +22,7 @@
<div class="row">
<div class="col-md-4 text-center">
<Avatar
name={"name" in profile ? profile.name : profile.username}
url={profile.avatar_url}
alt={$t("avatar-tooltip", { name })}
lazyLoad={lazyLoadAvatar}

View file

@ -36,6 +36,7 @@
<div>
<a href="/@{username}/{member.name}">
<Avatar
name={member.name}
url={member.avatar_url}
lazyLoad
alt={$t("avatar-tooltip", { name: member.display_name })}

View file

@ -47,7 +47,8 @@
"successful-link-fedi": "Your account has successfully been linked to the following fediverse account:",
"successful-link-profile-hint": "You now can close this page, or go back to your profile:",
"successful-link-profile-link": "Go to your profile",
"remote-discord-account-label": "Your Discord account"
"remote-discord-account-label": "Your Discord account",
"log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)"
},
"error": {
"bad-request-header": "Something was wrong with your input",
@ -112,7 +113,13 @@
"create-member-name-label": "Member name",
"auth-remove-method": "Remove",
"force-log-out-warning": "Make sure you're still able to log in before using this!",
"force-log-out-confirmation": "Are you sure you want to log out from all devices? If you just want to log out from this device, click the \"Log out\" button on your settings page."
"force-log-out-confirmation": "Are you sure you want to log out from all devices? If you just want to log out from this device, click the \"Log out\" button on your settings page.",
"export-request-success": "Successfully requested a new export! Please note that it may take a few minutes to complete, especially if you have a lot of members.",
"export-title": "Request a copy of your data",
"export-info": "You can request a copy of your data once every 24 hours. Exports are stored for 15 days (a little over two weeks) and then deleted.",
"export-expires-at": "(expires {{expiresAt}})",
"export-download": "Download export",
"export-request-button": "Request a new export"
},
"yes": "Yes",
"no": "No",

View file

@ -1,35 +1,62 @@
import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error";
import type { CallbackResponse } from "$api/models/auth.js";
import type { AddAccountResponse, CallbackResponse } from "$api/models/auth.js";
import { setToken } from "$lib";
import createRegisterAction from "$lib/actions/register.js";
import { redirect } from "@sveltejs/kit";
import log from "$lib/log";
import { isRedirect, redirect } from "@sveltejs/kit";
export const load = async ({ parent, params, url, fetch, cookies }) => {
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<CallbackResponse>("POST", "/auth/fediverse/callback", {
body: { code, state, instance: params.instance },
isInternal: true,
fetch,
});
const { meUser } = await parent();
if (meUser) {
try {
const resp = await apiRequest<AddAccountResponse>(
"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<CallbackResponse>("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 = {

View file

@ -1,7 +1,9 @@
<script lang="ts">
import type { ActionData, PageData } from "./$types";
import { t } from "$lib/i18n";
import Error from "$components/Error.svelte";
import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte";
import NewAuthMethod from "$components/settings/NewAuthMethod.svelte";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
@ -12,11 +14,18 @@
</svelte:head>
<div class="container">
<OauthRegistrationForm
title={$t("auth.register-with-mastodon")}
remoteLabel={$t("auth.remote-fediverse-account-label")}
remoteUser={data.remoteUser}
ticket={data.ticket}
error={form?.error}
/>
{#if data.error}
<h1>{$t("auth.register-with-mastodon")}</h1>
<Error error={data.error} />
{:else if data.isLinkRequest}
<NewAuthMethod method={data.newAuthMethod!} user={data.meUser!} />
{:else}
<OauthRegistrationForm
title={$t("auth.register-with-mastodon")}
remoteLabel={$t("auth.remote-fediverse-account-label")}
remoteUser={data.remoteUser!}
ticket={data.ticket!}
error={form?.error}
/>
{/if}
</div>

View file

@ -72,7 +72,11 @@
<h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4>
<form method="POST" action="?/fedi" use:enhance>
<InputGroup>
<Input name="instance" type="text" placeholder="Your instance (i.e. mastodon.social)" />
<Input
name="instance"
type="text"
placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")}
/>
<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button>
</InputGroup>
<p>

View file

@ -7,6 +7,7 @@
import Error from "$components/Error.svelte";
import { idTimestamp } from "$lib";
import { DateTime } from "luxon";
import { enhance } from "$app/forms";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
@ -20,7 +21,7 @@
<div class="row mb-3">
<div class="col-md-9">
<h5>Change your username</h5>
<form method="POST" action="?/changeUsername">
<form method="POST" action="?/changeUsername" use:enhance>
<FormGroup class="mb-3">
<InputGroup class="m-1 mt-3 w-md-75">
<Input
@ -55,6 +56,7 @@
<div class="col-md-3 text-center">
<h5>{$t("settings.avatar")}</h5>
<Avatar
name={data.user.username}
url={data.user.avatar_url}
alt={$t("avatar-tooltip", { name: "@" + data.user.username })}
/>

View file

@ -0,0 +1,37 @@
import { apiRequest } from "$api";
import { redirect } from "@sveltejs/kit";
export const actions = {
add: async ({ request, fetch, cookies }) => {
const body = await request.formData();
const instance = body.get("instance") as string;
const { url } = await apiRequest<{ url: string }>(
"GET",
`/auth/fediverse/add-account?instance=${encodeURIComponent(instance)}`,
{
isInternal: true,
fetch,
cookies,
},
);
redirect(303, url);
},
forceRefresh: async ({ request, fetch, cookies }) => {
const body = await request.formData();
const instance = body.get("instance") as string;
const { url } = await apiRequest<{ url: string }>(
"GET",
`/auth/fediverse/add-account?instance=${encodeURIComponent(instance)}&forceRefresh=true`,
{
isInternal: true,
fetch,
cookies,
},
);
redirect(303, url);
},
};

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { t } from "$lib/i18n";
import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap";
</script>
<h3>Link a new Fediverse account</h3>
<form method="POST" action="?/add">
<InputGroup>
<Input
name="instance"
type="text"
placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")}
/>
<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button>
</InputGroup>
<p>
{$t("auth.log-in-with-fediverse-error-blurb")}
<Button formaction="?/forceRefresh" type="submit" color="link">
{$t("auth.log-in-with-fediverse-force-refresh-button")}
</Button>
</p>
</form>

View file

@ -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<Export>("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;
}
},
};

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { DateTime } from "luxon";
import type { ActionData, PageData } from "./$types";
import ErrorAlert from "$components/ErrorAlert.svelte";
import { Icon } from "@sveltestrap/sveltestrap";
import { t } from "$lib/i18n";
import { enhance } from "$app/forms";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
let expiresAt = $derived.by(() => {
if (!data.expiresAt) return null;
return DateTime.fromISO(data.expiresAt);
});
</script>
<div class="mx-auto w-lg-75">
<h3>{$t("settings.export-title")}</h3>
{#if form?.ok}
<p class="text-success-emphasis">
<Icon name="check-circle-fill" />
{$t("settings.export-request-success")}
</p>
{:else if form?.error}
<ErrorAlert error={form.error} />
{/if}
<p>
{$t("settings.export-info")}
</p>
<form method="POST" use:enhance>
<div class="btn-group">
<button type="submit" class="btn btn-primary" disabled={!data.canExport}>
{$t("settings.export-request-button")}
</button>
{#if data.url}
<a href={data.url} target="_blank" class="btn btn-success">
{$t("settings.export-download")}
{#if expiresAt}
{$t("settings.export-expires-at", { expiresAt: expiresAt.toRelative() })}
{/if}
</a>
{/if}
</div>
</form>
</div>

View file

@ -30,6 +30,7 @@
{#each data.members as member (member.id)}
<ListGroupItem tag="a" href="/settings/members/{member.id}" data-sveltekit-preload-data="tap">
<Avatar
name={member.name}
url={member.avatar_url}
alt={$t("avatar-tooltip", { name: member.display_name })}
size={20}

View file

@ -14,6 +14,7 @@
import SidEditor from "$components/editor/SidEditor.svelte";
import BioEditor from "$components/editor/BioEditor.svelte";
import { PUBLIC_BASE_URL } from "$env/static/public";
import { enhance } from "$app/forms";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
@ -74,6 +75,7 @@
<div class="col-md">
<h4>{$t("settings.avatar")}</h4>
<AvatarEditor
name={data.member.name}
current={data.member.avatar_url}
alt={$t("avatar-tooltip", { name: data.member.name })}
update={updateAvatar}
@ -82,7 +84,7 @@
</div>
<div class="col-md">
<h4>{$t("edit-profile.member-name")}</h4>
<form method="POST" action="?/changeName" class="mb-3">
<form method="POST" action="?/changeName" class="mb-3" use:enhance>
<InputGroup>
<input
name="name"
@ -98,7 +100,7 @@
</form>
<h4>{$t("edit-profile.display-name")}</h4>
<form class="mb-3" method="POST" action="?/changeDisplayName">
<form class="mb-3" method="POST" action="?/changeDisplayName" use:enhance>
<InputGroup>
<input
class="form-control"
@ -116,7 +118,7 @@
</div>
<div class="row mb-3">
<h4>{$t("edit-profile.profile-options-header")}</h4>
<form method="POST" action="?/options">
<form method="POST" action="?/options" use:enhance>
<div class="form-check">
<input
class="form-check-input"
@ -145,7 +147,7 @@
</div>
<div class="row mb-3">
<h4>{$t("edit-profile.bio-tab")}</h4>
<form method="POST" action="?/bio">
<form method="POST" action="?/bio" use:enhance>
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />
</form>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { enhance } from "$app/forms";
import ErrorAlert from "$components/ErrorAlert.svelte";
import { t } from "$lib/i18n";
import type { ActionData } from "./$types";
@ -13,7 +14,7 @@
<ErrorAlert error={form.error} />
{/if}
<form method="POST">
<form method="POST" use:enhance>
<div class="my-3">
<label class="form-label" for="name">{$t("settings.create-member-name-label")}</label>
<input class="form-control" type="text" id="name" name="name" required autocomplete="off" />

View file

@ -11,6 +11,7 @@
import { DateTime, FixedOffsetZone } from "luxon";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import SidEditor from "$components/editor/SidEditor.svelte";
import { enhance } from "$app/forms";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
@ -91,6 +92,7 @@
<div class="col-md">
<h4>{$t("settings.avatar")}</h4>
<AvatarEditor
name={data.user.username}
current={data.user.avatar_url}
alt={$t("avatar-tooltip", { name: "@" + data.user.username })}
update={updateAvatar}
@ -127,7 +129,7 @@
<div class="mt-3">
<h4>{$t("edit-profile.profile-options-header")}</h4>
<form method="POST" action="?/options">
<form method="POST" action="?/options" use:enhance>
<div class="mb-3">
<label class="form-label" for="member-title">{$t("edit-profile.member-header-label")}</label>
<input

View file

@ -3,6 +3,7 @@
import type { ActionData, PageData } from "./$types";
import BioEditor from "$components/editor/BioEditor.svelte";
import { t } from "$lib/i18n";
import { enhance } from "$app/forms";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
@ -12,6 +13,6 @@
<h4>{$t("edit-profile.bio-tab")}</h4>
<FormStatusMarker {form} />
<form method="POST">
<form method="POST" use:enhance>
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />
</form>

View file

@ -1,25 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Foxnouns.Backend\Foxnouns.Backend.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Foxnouns.Backend\Foxnouns.Backend.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/>
</ItemGroup>
</Project>