Compare commits
5 commits
71d3b42330
...
57e1ec09c0
Author | SHA1 | Date | |
---|---|---|---|
57e1ec09c0 | |||
03209e4028 | |||
9966656c0c | |||
c20831f20d | |||
74222ead45 |
40 changed files with 476 additions and 123 deletions
|
@ -104,21 +104,9 @@ public class DiscordAuthController(
|
||||||
{
|
{
|
||||||
CheckRequirements();
|
CheckRequirements();
|
||||||
|
|
||||||
var existingAccounts = await db
|
var state = await remoteAuthService.ValidateAddAccountRequestAsync(
|
||||||
.AuthMethods.Where(m => m.UserId == CurrentUser!.Id && m.AuthType == AuthType.Discord)
|
CurrentUser!.Id,
|
||||||
.CountAsync();
|
AuthType.Discord
|
||||||
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 url =
|
var url =
|
||||||
|
@ -138,12 +126,11 @@ public class DiscordAuthController(
|
||||||
{
|
{
|
||||||
CheckRequirements();
|
CheckRequirements();
|
||||||
|
|
||||||
var accountState = await keyCacheService.GetAddExtraAccountStateAsync(req.State);
|
await remoteAuthService.ValidateAddAccountStateAsync(
|
||||||
if (
|
req.State,
|
||||||
accountState is not { AuthType: AuthType.Discord }
|
CurrentUser!.Id,
|
||||||
|| accountState.UserId != CurrentUser!.Id
|
AuthType.Discord
|
||||||
)
|
);
|
||||||
throw new ApiError.BadRequest("Invalid state", "state", req.State);
|
|
||||||
|
|
||||||
var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code);
|
var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code);
|
||||||
try
|
try
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
|
using System.Net;
|
||||||
|
using EntityFramework.Exceptions.Common;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.Backend.Services.Auth;
|
using Foxnouns.Backend.Services.Auth;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
|
@ -15,13 +18,14 @@ public class FediverseAuthController(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
FediverseAuthService fediverseAuthService,
|
FediverseAuthService fediverseAuthService,
|
||||||
AuthService authService,
|
AuthService authService,
|
||||||
|
RemoteAuthService remoteAuthService,
|
||||||
KeyCacheService keyCacheService
|
KeyCacheService keyCacheService
|
||||||
) : ApiControllerBase
|
) : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<FediverseAuthController>();
|
private readonly ILogger _logger = logger.ForContext<FediverseAuthController>();
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<AuthController.SingleUrlResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetFediverseUrlAsync(
|
public async Task<IActionResult> GetFediverseUrlAsync(
|
||||||
[FromQuery] string instance,
|
[FromQuery] string instance,
|
||||||
[FromQuery] bool forceRefresh = false
|
[FromQuery] bool forceRefresh = false
|
||||||
|
@ -31,7 +35,7 @@ public class FediverseAuthController(
|
||||||
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
|
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
|
||||||
|
|
||||||
var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh);
|
var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh);
|
||||||
return Ok(new FediverseUrlResponse(url));
|
return Ok(new AuthController.SingleUrlResponse(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("callback")]
|
[HttpPost("callback")]
|
||||||
|
@ -118,9 +122,74 @@ public class FediverseAuthController(
|
||||||
return Ok(await authService.GenerateUserTokenAsync(user));
|
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(
|
private record FediverseTicketData(
|
||||||
Snowflake ApplicationId,
|
Snowflake ApplicationId,
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
using Coravel.Queuing.Interfaces;
|
using Coravel.Queuing.Interfaces;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Coravel.Mailer.Mail.Helpers;
|
|
||||||
using Coravel.Queuing.Interfaces;
|
using Coravel.Queuing.Interfaces;
|
||||||
using EntityFramework.Exceptions.Common;
|
using EntityFramework.Exceptions.Common;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Database.Models;
|
namespace Foxnouns.Backend.Database.Models;
|
||||||
|
@ -9,6 +8,5 @@ public class DataExport : BaseModel
|
||||||
public User User { get; init; } = null!;
|
public User User { get; init; } = null!;
|
||||||
public required string Filename { get; init; }
|
public required string Filename { get; init; }
|
||||||
|
|
||||||
[NotMapped]
|
|
||||||
public static readonly Duration Expiration = Duration.FromDays(15);
|
public static readonly Duration Expiration = Duration.FromDays(15);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
using NodaTime;
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Database.Models;
|
namespace Foxnouns.Backend.Database.Models;
|
||||||
|
|
||||||
public class FediverseApplication : BaseModel
|
public class FediverseApplication : BaseModel
|
||||||
|
|
|
@ -55,10 +55,7 @@ public class User : BaseModel
|
||||||
public PreferenceSize Size { get; set; }
|
public PreferenceSize Size { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[NotMapped]
|
|
||||||
public static readonly Duration DeleteAfter = Duration.FromDays(30);
|
public static readonly Duration DeleteAfter = Duration.FromDays(30);
|
||||||
|
|
||||||
[NotMapped]
|
|
||||||
public static readonly Duration DeleteSuspendedAfter = Duration.FromDays(180);
|
public static readonly Duration DeleteSuspendedAfter = Duration.FromDays(180);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -63,13 +63,14 @@ public static class KeyCacheExtensions
|
||||||
this KeyCacheService keyCacheService,
|
this KeyCacheService keyCacheService,
|
||||||
AuthType authType,
|
AuthType authType,
|
||||||
Snowflake userId,
|
Snowflake userId,
|
||||||
|
string? instance = null,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var state = AuthUtils.RandomToken();
|
var state = AuthUtils.RandomToken();
|
||||||
await keyCacheService.SetKeyAsync(
|
await keyCacheService.SetKeyAsync(
|
||||||
$"add_account:{state}",
|
$"add_account:{state}",
|
||||||
new AddExtraAccountState(authType, userId),
|
new AddExtraAccountState(authType, userId, instance),
|
||||||
Duration.FromDays(1),
|
Duration.FromDays(1),
|
||||||
ct
|
ct
|
||||||
);
|
);
|
||||||
|
@ -93,4 +94,4 @@ public record RegisterEmailState(
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId
|
||||||
);
|
);
|
||||||
|
|
||||||
public record AddExtraAccountState(AuthType AuthType, Snowflake UserId);
|
public record AddExtraAccountState(AuthType AuthType, Snowflake UserId, string? Instance = null);
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
<PackageReference Include="Coravel.Mailer" Version="5.0.1"/>
|
<PackageReference Include="Coravel.Mailer" Version="5.0.1"/>
|
||||||
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
|
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
|
||||||
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2"/>
|
<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="JetBrains.Annotations" Version="2024.2.0"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7"/>
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/>
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
using System.Security.Cryptography;
|
|
||||||
using Coravel.Invocable;
|
using Coravel.Invocable;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
using System.Security.Cryptography;
|
|
||||||
using Coravel.Invocable;
|
using Coravel.Invocable;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Middleware;
|
namespace Foxnouns.Backend.Middleware;
|
||||||
|
|
|
@ -218,10 +218,11 @@ public class AuthService(
|
||||||
AuthType authType,
|
AuthType authType,
|
||||||
string remoteId,
|
string remoteId,
|
||||||
string? remoteUsername = null,
|
string? remoteUsername = null,
|
||||||
|
FediverseApplication? app = null,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
AssertValidAuthType(authType, null);
|
AssertValidAuthType(authType, app);
|
||||||
|
|
||||||
// This is already checked when
|
// This is already checked when
|
||||||
var currentCount = await db
|
var currentCount = await db
|
||||||
|
@ -237,6 +238,7 @@ public class AuthService(
|
||||||
Id = snowflakeGenerator.GenerateSnowflake(),
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
AuthType = authType,
|
AuthType = authType,
|
||||||
RemoteId = remoteId,
|
RemoteId = remoteId,
|
||||||
|
FediverseApplicationId = app?.Id,
|
||||||
RemoteUsername = remoteUsername,
|
RemoteUsername = remoteUsername,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
};
|
};
|
||||||
|
|
|
@ -69,9 +69,10 @@ public partial class FediverseAuthService
|
||||||
private async Task<FediverseUser> GetMastodonUserAsync(
|
private async Task<FediverseUser> GetMastodonUserAsync(
|
||||||
FediverseApplication app,
|
FediverseApplication app,
|
||||||
string code,
|
string code,
|
||||||
string state
|
string? state = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
if (state != null)
|
||||||
await _keyCacheService.ValidateAuthStateAsync(state);
|
await _keyCacheService.ValidateAuthStateAsync(state);
|
||||||
|
|
||||||
var tokenResp = await _client.PostAsync(
|
var tokenResp = await _client.PostAsync(
|
||||||
|
@ -120,7 +121,8 @@ public partial class FediverseAuthService
|
||||||
|
|
||||||
private async Task<string> GenerateMastodonAuthUrlAsync(
|
private async Task<string> GenerateMastodonAuthUrlAsync(
|
||||||
FediverseApplication app,
|
FediverseApplication app,
|
||||||
bool forceRefresh
|
bool forceRefresh,
|
||||||
|
string? state = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (forceRefresh)
|
if (forceRefresh)
|
||||||
|
@ -132,7 +134,7 @@ public partial class FediverseAuthService
|
||||||
app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id);
|
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"
|
return $"https://{app.Domain}/oauth/authorize?response_type=code"
|
||||||
+ $"&client_id={app.ClientId}"
|
+ $"&client_id={app.ClientId}"
|
||||||
|
|
|
@ -37,10 +37,14 @@ public partial class FediverseAuthService
|
||||||
_client.DefaultRequestHeaders.Add("Accept", "application/json");
|
_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);
|
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,
|
// 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
|
app.InstanceType switch
|
||||||
{
|
{
|
||||||
FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync(
|
FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync(
|
||||||
app,
|
app,
|
||||||
forceRefresh
|
forceRefresh,
|
||||||
|
state
|
||||||
),
|
),
|
||||||
FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
|
FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
|
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
|
||||||
|
@ -110,7 +119,7 @@ public partial class FediverseAuthService
|
||||||
public async Task<FediverseUser> GetRemoteFediverseUserAsync(
|
public async Task<FediverseUser> GetRemoteFediverseUserAsync(
|
||||||
FediverseApplication app,
|
FediverseApplication app,
|
||||||
string code,
|
string code,
|
||||||
string state
|
string? state = null
|
||||||
) =>
|
) =>
|
||||||
app.InstanceType switch
|
app.InstanceType switch
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,9 +1,21 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
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 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 ILogger _logger = logger.ForContext<RemoteAuthService>();
|
||||||
private readonly HttpClient _httpClient = new();
|
private readonly HttpClient _httpClient = new();
|
||||||
|
@ -76,4 +88,56 @@ public class RemoteAuthService(Config config, ILogger logger)
|
||||||
private record DiscordUserResponse(string id, string username);
|
private record DiscordUserResponse(string id, string username);
|
||||||
|
|
||||||
public record RemoteUser(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);
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -4,7 +4,6 @@ using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Org.BouncyCastle.Ocsp;
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services;
|
namespace Foxnouns.Backend.Services;
|
||||||
|
|
||||||
|
|
|
@ -127,6 +127,9 @@ public static partial class ValidationUtils
|
||||||
if (entries.Length > Limits.FieldEntriesLimit + 50)
|
if (entries.Length > Limits.FieldEntriesLimit + 50)
|
||||||
return errors;
|
return errors;
|
||||||
|
|
||||||
|
var customPreferenceIds =
|
||||||
|
customPreferences?.Keys.Select(id => id.ToString()).ToArray() ?? [];
|
||||||
|
|
||||||
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
|
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
|
||||||
{
|
{
|
||||||
switch (entry.Value.Length)
|
switch (entry.Value.Length)
|
||||||
|
@ -159,8 +162,6 @@ public static partial class ValidationUtils
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? [];
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!DefaultStatusOptions.Contains(entry.Status)
|
!DefaultStatusOptions.Contains(entry.Status)
|
||||||
&& !customPreferenceIds.Contains(entry.Status)
|
&& !customPreferenceIds.Contains(entry.Status)
|
||||||
|
@ -203,6 +204,9 @@ public static partial class ValidationUtils
|
||||||
if (entries.Length > Limits.FieldEntriesLimit + 50)
|
if (entries.Length > Limits.FieldEntriesLimit + 50)
|
||||||
return errors;
|
return errors;
|
||||||
|
|
||||||
|
var customPreferenceIds =
|
||||||
|
customPreferences?.Keys.Select(id => id.ToString()).ToList() ?? [];
|
||||||
|
|
||||||
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
|
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
|
||||||
{
|
{
|
||||||
switch (entry.Value.Length)
|
switch (entry.Value.Length)
|
||||||
|
@ -268,8 +272,6 @@ public static partial class ValidationUtils
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? [];
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!DefaultStatusOptions.Contains(entry.Status)
|
!DefaultStatusOptions.Contains(entry.Status)
|
||||||
&& !customPreferenceIds.Contains(entry.Status)
|
&& !customPreferenceIds.Contains(entry.Status)
|
||||||
|
|
|
@ -45,6 +45,12 @@
|
||||||
"Npgsql": "8.0.1"
|
"Npgsql": "8.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Humanizer.Core": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[2.14.1, )",
|
||||||
|
"resolved": "2.14.1",
|
||||||
|
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
|
||||||
|
},
|
||||||
"JetBrains.Annotations": {
|
"JetBrains.Annotations": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[2024.2.0, )",
|
"requested": "[2024.2.0, )",
|
||||||
|
@ -291,11 +297,6 @@
|
||||||
"Microsoft.EntityFrameworkCore.Relational": "8.0.0"
|
"Microsoft.EntityFrameworkCore.Relational": "8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Humanizer.Core": {
|
|
||||||
"type": "Transitive",
|
|
||||||
"resolved": "2.14.1",
|
|
||||||
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
|
|
||||||
},
|
|
||||||
"MailKit": {
|
"MailKit": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "2.5.1",
|
"resolved": "2.5.1",
|
||||||
|
|
|
@ -42,6 +42,7 @@
|
||||||
"bootstrap-icons": "^1.11.3",
|
"bootstrap-icons": "^1.11.3",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
|
"minidenticons": "^4.2.1",
|
||||||
"pretty-bytes": "^6.1.1",
|
"pretty-bytes": "^6.1.1",
|
||||||
"sanitize-html": "^2.13.1",
|
"sanitize-html": "^2.13.1",
|
||||||
"svelte-tippy": "^1.3.2",
|
"svelte-tippy": "^1.3.2",
|
||||||
|
|
|
@ -23,6 +23,9 @@ importers:
|
||||||
markdown-it:
|
markdown-it:
|
||||||
specifier: ^14.1.0
|
specifier: ^14.1.0
|
||||||
version: 14.1.0
|
version: 14.1.0
|
||||||
|
minidenticons:
|
||||||
|
specifier: ^4.2.1
|
||||||
|
version: 4.2.1
|
||||||
pretty-bytes:
|
pretty-bytes:
|
||||||
specifier: ^6.1.1
|
specifier: ^6.1.1
|
||||||
version: 6.1.1
|
version: 6.1.1
|
||||||
|
@ -1110,6 +1113,10 @@ packages:
|
||||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
engines: {node: '>=8.6'}
|
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:
|
minimatch@3.1.2:
|
||||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||||
|
|
||||||
|
@ -2369,6 +2376,8 @@ snapshots:
|
||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
|
|
||||||
|
minidenticons@4.2.1: {}
|
||||||
|
|
||||||
minimatch@3.1.2:
|
minimatch@3.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 1.1.11
|
brace-expansion: 1.1.11
|
||||||
|
|
|
@ -1,16 +1,22 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { DEFAULT_AVATAR } from "$lib";
|
import { minidenticon } from "minidenticons";
|
||||||
|
|
||||||
type Props = { url: string | null; alt: string; lazyLoad?: boolean; size?: number };
|
type Props = { url: string | null; alt: string; lazyLoad?: boolean; size?: number; name: string };
|
||||||
let { url, alt, lazyLoad, size }: Props = $props();
|
let { url, alt, lazyLoad, size, name }: Props = $props();
|
||||||
|
|
||||||
let width = $derived(size || 200);
|
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>
|
</script>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
class="rounded-circle img-fluid"
|
class="rounded-circle img-fluid"
|
||||||
style="height: {width}px; width: {width}px"
|
style="height: {width}px; width: {width}px"
|
||||||
src={url || DEFAULT_AVATAR}
|
class:identicon={!url}
|
||||||
|
src={url || identicon}
|
||||||
{alt}
|
{alt}
|
||||||
{width}
|
{width}
|
||||||
loading={lazyLoad ? "lazy" : "eager"}
|
loading={lazyLoad ? "lazy" : "eager"}
|
||||||
|
@ -20,4 +26,12 @@
|
||||||
img {
|
img {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.identicon {
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background-color: var(--bs-secondary-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
background-color: var(--bs-light-border-subtle);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -7,12 +7,13 @@
|
||||||
import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte";
|
import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
name: string;
|
||||||
current: string | null;
|
current: string | null;
|
||||||
alt: string;
|
alt: string;
|
||||||
update: (avatar: string) => Promise<void>;
|
update: (avatar: string) => Promise<void>;
|
||||||
updated: boolean;
|
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;
|
const MAX_AVATAR_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
@ -40,7 +41,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p class="text-center">
|
<p class="text-center">
|
||||||
<Avatar url={avatarExists ? avatar : current} {alt} />
|
<Avatar {name} url={avatarExists ? avatar : current} {alt} />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<InputGroup class="mb-2">
|
<InputGroup class="mb-2">
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 text-center">
|
<div class="col-md-4 text-center">
|
||||||
<Avatar
|
<Avatar
|
||||||
|
name={"name" in profile ? profile.name : profile.username}
|
||||||
url={profile.avatar_url}
|
url={profile.avatar_url}
|
||||||
alt={$t("avatar-tooltip", { name })}
|
alt={$t("avatar-tooltip", { name })}
|
||||||
lazyLoad={lazyLoadAvatar}
|
lazyLoad={lazyLoadAvatar}
|
||||||
|
|
|
@ -36,6 +36,7 @@
|
||||||
<div>
|
<div>
|
||||||
<a href="/@{username}/{member.name}">
|
<a href="/@{username}/{member.name}">
|
||||||
<Avatar
|
<Avatar
|
||||||
|
name={member.name}
|
||||||
url={member.avatar_url}
|
url={member.avatar_url}
|
||||||
lazyLoad
|
lazyLoad
|
||||||
alt={$t("avatar-tooltip", { name: member.display_name })}
|
alt={$t("avatar-tooltip", { name: member.display_name })}
|
||||||
|
|
|
@ -47,7 +47,8 @@
|
||||||
"successful-link-fedi": "Your account has successfully been linked to the following fediverse account:",
|
"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-hint": "You now can close this page, or go back to your profile:",
|
||||||
"successful-link-profile-link": "Go 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": {
|
"error": {
|
||||||
"bad-request-header": "Something was wrong with your input",
|
"bad-request-header": "Something was wrong with your input",
|
||||||
|
@ -112,7 +113,13 @@
|
||||||
"create-member-name-label": "Member name",
|
"create-member-name-label": "Member name",
|
||||||
"auth-remove-method": "Remove",
|
"auth-remove-method": "Remove",
|
||||||
"force-log-out-warning": "Make sure you're still able to log in before using this!",
|
"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",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
|
|
|
@ -1,18 +1,39 @@
|
||||||
import { apiRequest } from "$api";
|
import { apiRequest } from "$api";
|
||||||
import ApiError, { ErrorCode } from "$api/error";
|
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 { setToken } from "$lib";
|
||||||
import createRegisterAction from "$lib/actions/register.js";
|
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 }) => {
|
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 code = url.searchParams.get("code") as string | null;
|
||||||
const state = url.searchParams.get("state") as string | null;
|
const state = url.searchParams.get("state") as string | null;
|
||||||
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
|
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const resp = await apiRequest<CallbackResponse>("POST", "/auth/fediverse/callback", {
|
const resp = await apiRequest<CallbackResponse>("POST", "/auth/fediverse/callback", {
|
||||||
body: { code, state, instance: params.instance },
|
body: { code, state, instance: params.instance },
|
||||||
isInternal: true,
|
isInternal: true,
|
||||||
|
@ -26,10 +47,16 @@ export const load = async ({ parent, params, url, fetch, cookies }) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasAccount: false,
|
hasAccount: false,
|
||||||
instance: params.instance,
|
isLinkRequest: false,
|
||||||
ticket: resp.ticket!,
|
ticket: resp.ticket!,
|
||||||
remoteUser: resp.remote_username!,
|
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 = {
|
export const actions = {
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ActionData, PageData } from "./$types";
|
import type { ActionData, PageData } from "./$types";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
|
import Error from "$components/Error.svelte";
|
||||||
import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte";
|
import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte";
|
||||||
|
import NewAuthMethod from "$components/settings/NewAuthMethod.svelte";
|
||||||
|
|
||||||
type Props = { data: PageData; form: ActionData };
|
type Props = { data: PageData; form: ActionData };
|
||||||
let { data, form }: Props = $props();
|
let { data, form }: Props = $props();
|
||||||
|
@ -12,11 +14,18 @@
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
{#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
|
<OauthRegistrationForm
|
||||||
title={$t("auth.register-with-mastodon")}
|
title={$t("auth.register-with-mastodon")}
|
||||||
remoteLabel={$t("auth.remote-fediverse-account-label")}
|
remoteLabel={$t("auth.remote-fediverse-account-label")}
|
||||||
remoteUser={data.remoteUser}
|
remoteUser={data.remoteUser!}
|
||||||
ticket={data.ticket}
|
ticket={data.ticket!}
|
||||||
error={form?.error}
|
error={form?.error}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -72,7 +72,11 @@
|
||||||
<h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4>
|
<h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4>
|
||||||
<form method="POST" action="?/fedi" use:enhance>
|
<form method="POST" action="?/fedi" use:enhance>
|
||||||
<InputGroup>
|
<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>
|
<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<p>
|
<p>
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
import Error from "$components/Error.svelte";
|
import Error from "$components/Error.svelte";
|
||||||
import { idTimestamp } from "$lib";
|
import { idTimestamp } from "$lib";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
|
||||||
type Props = { data: PageData; form: ActionData };
|
type Props = { data: PageData; form: ActionData };
|
||||||
let { data, form }: Props = $props();
|
let { data, form }: Props = $props();
|
||||||
|
@ -20,7 +21,7 @@
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-9">
|
<div class="col-md-9">
|
||||||
<h5>Change your username</h5>
|
<h5>Change your username</h5>
|
||||||
<form method="POST" action="?/changeUsername">
|
<form method="POST" action="?/changeUsername" use:enhance>
|
||||||
<FormGroup class="mb-3">
|
<FormGroup class="mb-3">
|
||||||
<InputGroup class="m-1 mt-3 w-md-75">
|
<InputGroup class="m-1 mt-3 w-md-75">
|
||||||
<Input
|
<Input
|
||||||
|
@ -55,6 +56,7 @@
|
||||||
<div class="col-md-3 text-center">
|
<div class="col-md-3 text-center">
|
||||||
<h5>{$t("settings.avatar")}</h5>
|
<h5>{$t("settings.avatar")}</h5>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
name={data.user.username}
|
||||||
url={data.user.avatar_url}
|
url={data.user.avatar_url}
|
||||||
alt={$t("avatar-tooltip", { name: "@" + data.user.username })}
|
alt={$t("avatar-tooltip", { name: "@" + data.user.username })}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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);
|
||||||
|
},
|
||||||
|
};
|
|
@ -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>
|
35
Foxnouns.Frontend/src/routes/settings/export/+page.server.ts
Normal file
35
Foxnouns.Frontend/src/routes/settings/export/+page.server.ts
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
49
Foxnouns.Frontend/src/routes/settings/export/+page.svelte
Normal file
49
Foxnouns.Frontend/src/routes/settings/export/+page.svelte
Normal 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>
|
|
@ -30,6 +30,7 @@
|
||||||
{#each data.members as member (member.id)}
|
{#each data.members as member (member.id)}
|
||||||
<ListGroupItem tag="a" href="/settings/members/{member.id}" data-sveltekit-preload-data="tap">
|
<ListGroupItem tag="a" href="/settings/members/{member.id}" data-sveltekit-preload-data="tap">
|
||||||
<Avatar
|
<Avatar
|
||||||
|
name={member.name}
|
||||||
url={member.avatar_url}
|
url={member.avatar_url}
|
||||||
alt={$t("avatar-tooltip", { name: member.display_name })}
|
alt={$t("avatar-tooltip", { name: member.display_name })}
|
||||||
size={20}
|
size={20}
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
import SidEditor from "$components/editor/SidEditor.svelte";
|
import SidEditor from "$components/editor/SidEditor.svelte";
|
||||||
import BioEditor from "$components/editor/BioEditor.svelte";
|
import BioEditor from "$components/editor/BioEditor.svelte";
|
||||||
import { PUBLIC_BASE_URL } from "$env/static/public";
|
import { PUBLIC_BASE_URL } from "$env/static/public";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
|
||||||
type Props = { data: PageData; form: ActionData };
|
type Props = { data: PageData; form: ActionData };
|
||||||
let { data, form }: Props = $props();
|
let { data, form }: Props = $props();
|
||||||
|
@ -74,6 +75,7 @@
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<h4>{$t("settings.avatar")}</h4>
|
<h4>{$t("settings.avatar")}</h4>
|
||||||
<AvatarEditor
|
<AvatarEditor
|
||||||
|
name={data.member.name}
|
||||||
current={data.member.avatar_url}
|
current={data.member.avatar_url}
|
||||||
alt={$t("avatar-tooltip", { name: data.member.name })}
|
alt={$t("avatar-tooltip", { name: data.member.name })}
|
||||||
update={updateAvatar}
|
update={updateAvatar}
|
||||||
|
@ -82,7 +84,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<h4>{$t("edit-profile.member-name")}</h4>
|
<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>
|
<InputGroup>
|
||||||
<input
|
<input
|
||||||
name="name"
|
name="name"
|
||||||
|
@ -98,7 +100,7 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h4>{$t("edit-profile.display-name")}</h4>
|
<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>
|
<InputGroup>
|
||||||
<input
|
<input
|
||||||
class="form-control"
|
class="form-control"
|
||||||
|
@ -116,7 +118,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<h4>{$t("edit-profile.profile-options-header")}</h4>
|
<h4>{$t("edit-profile.profile-options-header")}</h4>
|
||||||
<form method="POST" action="?/options">
|
<form method="POST" action="?/options" use:enhance>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input
|
<input
|
||||||
class="form-check-input"
|
class="form-check-input"
|
||||||
|
@ -145,7 +147,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<h4>{$t("edit-profile.bio-tab")}</h4>
|
<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} />
|
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
import ErrorAlert from "$components/ErrorAlert.svelte";
|
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import type { ActionData } from "./$types";
|
import type { ActionData } from "./$types";
|
||||||
|
@ -13,7 +14,7 @@
|
||||||
<ErrorAlert error={form.error} />
|
<ErrorAlert error={form.error} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST" use:enhance>
|
||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
<label class="form-label" for="name">{$t("settings.create-member-name-label")}</label>
|
<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" />
|
<input class="form-control" type="text" id="name" name="name" required autocomplete="off" />
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
import { DateTime, FixedOffsetZone } from "luxon";
|
import { DateTime, FixedOffsetZone } from "luxon";
|
||||||
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||||
import SidEditor from "$components/editor/SidEditor.svelte";
|
import SidEditor from "$components/editor/SidEditor.svelte";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
|
||||||
type Props = { data: PageData; form: ActionData };
|
type Props = { data: PageData; form: ActionData };
|
||||||
let { data, form }: Props = $props();
|
let { data, form }: Props = $props();
|
||||||
|
@ -91,6 +92,7 @@
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<h4>{$t("settings.avatar")}</h4>
|
<h4>{$t("settings.avatar")}</h4>
|
||||||
<AvatarEditor
|
<AvatarEditor
|
||||||
|
name={data.user.username}
|
||||||
current={data.user.avatar_url}
|
current={data.user.avatar_url}
|
||||||
alt={$t("avatar-tooltip", { name: "@" + data.user.username })}
|
alt={$t("avatar-tooltip", { name: "@" + data.user.username })}
|
||||||
update={updateAvatar}
|
update={updateAvatar}
|
||||||
|
@ -127,7 +129,7 @@
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<h4>{$t("edit-profile.profile-options-header")}</h4>
|
<h4>{$t("edit-profile.profile-options-header")}</h4>
|
||||||
<form method="POST" action="?/options">
|
<form method="POST" action="?/options" use:enhance>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label" for="member-title">{$t("edit-profile.member-header-label")}</label>
|
<label class="form-label" for="member-title">{$t("edit-profile.member-header-label")}</label>
|
||||||
<input
|
<input
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
import type { ActionData, PageData } from "./$types";
|
import type { ActionData, PageData } from "./$types";
|
||||||
import BioEditor from "$components/editor/BioEditor.svelte";
|
import BioEditor from "$components/editor/BioEditor.svelte";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
|
||||||
type Props = { data: PageData; form: ActionData };
|
type Props = { data: PageData; form: ActionData };
|
||||||
let { data, form }: Props = $props();
|
let { data, form }: Props = $props();
|
||||||
|
@ -12,6 +13,6 @@
|
||||||
|
|
||||||
<h4>{$t("edit-profile.bio-tab")}</h4>
|
<h4>{$t("edit-profile.bio-tab")}</h4>
|
||||||
<FormStatusMarker {form} />
|
<FormStatusMarker {form} />
|
||||||
<form method="POST">
|
<form method="POST" use:enhance>
|
||||||
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />
|
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
|
||||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0"/>
|
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0"/>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7"/>
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7"/>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
|
||||||
|
|
Loading…
Reference in a new issue