Compare commits
4 commits
f8e6032449
...
b0a286dd9f
Author | SHA1 | Date | |
---|---|---|---|
b0a286dd9f | |||
2a0df335bc | |||
d9d48c3cbf | |||
8bd4449804 |
54 changed files with 1252 additions and 554 deletions
|
@ -2,14 +2,12 @@ using System.Net;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.Backend.Utils;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
|
||||||
using NodaTime;
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers.Authentication;
|
namespace Foxnouns.Backend.Controllers.Authentication;
|
||||||
|
|
||||||
|
@ -47,28 +45,6 @@ public class AuthController(
|
||||||
return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, null, null));
|
return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
private record UrlsResponse(bool EmailEnabled, string? Discord, string? Google, string? Tumblr);
|
|
||||||
|
|
||||||
public record AuthResponse(
|
|
||||||
UserRendererService.UserResponse User,
|
|
||||||
string Token,
|
|
||||||
Instant ExpiresAt
|
|
||||||
);
|
|
||||||
|
|
||||||
public record SingleUrlResponse(string Url);
|
|
||||||
|
|
||||||
public record AddOauthAccountResponse(
|
|
||||||
Snowflake Id,
|
|
||||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
|
|
||||||
string RemoteId,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
string? RemoteUsername
|
|
||||||
);
|
|
||||||
|
|
||||||
public record OauthRegisterRequest(string Ticket, string Username);
|
|
||||||
|
|
||||||
public record CallbackRequest(string Code, string State);
|
|
||||||
|
|
||||||
[HttpPost("force-log-out")]
|
[HttpPost("force-log-out")]
|
||||||
[Authorize("identify")]
|
[Authorize("identify")]
|
||||||
public async Task<IActionResult> ForceLogoutAsync()
|
public async Task<IActionResult> ForceLogoutAsync()
|
||||||
|
@ -83,9 +59,7 @@ public class AuthController(
|
||||||
|
|
||||||
[HttpGet("methods/{id}")]
|
[HttpGet("methods/{id}")]
|
||||||
[Authorize("*")]
|
[Authorize("*")]
|
||||||
[ProducesResponseType<UserRendererService.AuthMethodResponse>(
|
[ProducesResponseType<AuthMethodResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
statusCode: StatusCodes.Status200OK
|
|
||||||
)]
|
|
||||||
public async Task<IActionResult> GetAuthMethodAsync(Snowflake id)
|
public async Task<IActionResult> GetAuthMethodAsync(Snowflake id)
|
||||||
{
|
{
|
||||||
AuthMethod? authMethod = await db
|
AuthMethod? authMethod = await db
|
||||||
|
@ -143,13 +117,3 @@ public class AuthController(
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CallbackResponse(
|
|
||||||
bool HasAccount,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
UserRendererService.UserResponse? User,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt
|
|
||||||
);
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ using System.Web;
|
||||||
using EntityFramework.Exceptions.Common;
|
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.Dto;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
@ -29,7 +30,7 @@ public class DiscordAuthController(
|
||||||
|
|
||||||
[HttpPost("callback")]
|
[HttpPost("callback")]
|
||||||
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> CallbackAsync([FromBody] AuthController.CallbackRequest req)
|
public async Task<IActionResult> CallbackAsync([FromBody] CallbackRequest req)
|
||||||
{
|
{
|
||||||
CheckRequirements();
|
CheckRequirements();
|
||||||
await keyCacheService.ValidateAuthStateAsync(req.State);
|
await keyCacheService.ValidateAuthStateAsync(req.State);
|
||||||
|
@ -58,10 +59,8 @@ public class DiscordAuthController(
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<AuthResponse>(StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> RegisterAsync(
|
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
|
||||||
[FromBody] AuthController.OauthRegisterRequest req
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
RemoteAuthService.RemoteUser? remoteUser =
|
RemoteAuthService.RemoteUser? remoteUser =
|
||||||
await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>(
|
await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>(
|
||||||
|
@ -109,14 +108,12 @@ public class DiscordAuthController(
|
||||||
+ $"&prompt=none&state={state}"
|
+ $"&prompt=none&state={state}"
|
||||||
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
|
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
|
||||||
|
|
||||||
return Ok(new AuthController.SingleUrlResponse(url));
|
return Ok(new SingleUrlResponse(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("add-account/callback")]
|
[HttpPost("add-account/callback")]
|
||||||
[Authorize("*")]
|
[Authorize("*")]
|
||||||
public async Task<IActionResult> AddAccountCallbackAsync(
|
public async Task<IActionResult> AddAccountCallbackAsync([FromBody] CallbackRequest req)
|
||||||
[FromBody] AuthController.CallbackRequest req
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
CheckRequirements();
|
CheckRequirements();
|
||||||
|
|
||||||
|
@ -144,7 +141,7 @@ public class DiscordAuthController(
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new AuthController.AddOauthAccountResponse(
|
new AddOauthAccountResponse(
|
||||||
authMethod.Id,
|
authMethod.Id,
|
||||||
AuthType.Discord,
|
AuthType.Discord,
|
||||||
authMethod.RemoteId,
|
authMethod.RemoteId,
|
||||||
|
|
|
@ -2,6 +2,7 @@ using System.Net;
|
||||||
using EntityFramework.Exceptions.Common;
|
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.Dto;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
@ -30,7 +31,7 @@ public class EmailAuthController(
|
||||||
|
|
||||||
[HttpPost("register/init")]
|
[HttpPost("register/init")]
|
||||||
public async Task<IActionResult> RegisterInitAsync(
|
public async Task<IActionResult> RegisterInitAsync(
|
||||||
[FromBody] RegisterRequest req,
|
[FromBody] EmailRegisterRequest req,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
@ -73,7 +74,7 @@ public class EmailAuthController(
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> CompleteRegistrationAsync(
|
public async Task<IActionResult> CompleteRegistrationAsync(
|
||||||
[FromBody] CompleteRegistrationRequest req
|
[FromBody] EmailCompleteRegistrationRequest req
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
CheckRequirements();
|
CheckRequirements();
|
||||||
|
@ -102,7 +103,7 @@ public class EmailAuthController(
|
||||||
await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}");
|
await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}");
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new AuthController.AuthResponse(
|
new AuthResponse(
|
||||||
await userRenderer.RenderUserAsync(user, user, renderMembers: false),
|
await userRenderer.RenderUserAsync(user, user, renderMembers: false),
|
||||||
tokenStr,
|
tokenStr,
|
||||||
token.ExpiresAt
|
token.ExpiresAt
|
||||||
|
@ -111,9 +112,9 @@ public class EmailAuthController(
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<AuthResponse>(StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> LoginAsync(
|
public async Task<IActionResult> LoginAsync(
|
||||||
[FromBody] LoginRequest req,
|
[FromBody] EmailLoginRequest req,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
@ -141,7 +142,7 @@ public class EmailAuthController(
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new AuthController.AuthResponse(
|
new AuthResponse(
|
||||||
await userRenderer.RenderUserAsync(user, user, renderMembers: false, ct: ct),
|
await userRenderer.RenderUserAsync(user, user, renderMembers: false, ct: ct),
|
||||||
tokenStr,
|
tokenStr,
|
||||||
token.ExpiresAt
|
token.ExpiresAt
|
||||||
|
@ -151,7 +152,7 @@ public class EmailAuthController(
|
||||||
|
|
||||||
[HttpPost("change-password")]
|
[HttpPost("change-password")]
|
||||||
[Authorize("*")]
|
[Authorize("*")]
|
||||||
public async Task<IActionResult> UpdatePasswordAsync([FromBody] ChangePasswordRequest req)
|
public async Task<IActionResult> UpdatePasswordAsync([FromBody] EmailChangePasswordRequest req)
|
||||||
{
|
{
|
||||||
if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Current))
|
if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Current))
|
||||||
throw new ApiError.Forbidden("Invalid password");
|
throw new ApiError.Forbidden("Invalid password");
|
||||||
|
@ -211,7 +212,7 @@ public class EmailAuthController(
|
||||||
|
|
||||||
[HttpPost("add-email/callback")]
|
[HttpPost("add-email/callback")]
|
||||||
[Authorize("*")]
|
[Authorize("*")]
|
||||||
public async Task<IActionResult> AddEmailCallbackAsync([FromBody] CallbackRequest req)
|
public async Task<IActionResult> AddEmailCallbackAsync([FromBody] EmailCallbackRequest req)
|
||||||
{
|
{
|
||||||
CheckRequirements();
|
CheckRequirements();
|
||||||
|
|
||||||
|
@ -233,7 +234,7 @@ public class EmailAuthController(
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new AuthController.AddOauthAccountResponse(
|
new AddOauthAccountResponse(
|
||||||
authMethod.Id,
|
authMethod.Id,
|
||||||
AuthType.Email,
|
AuthType.Email,
|
||||||
authMethod.RemoteId,
|
authMethod.RemoteId,
|
||||||
|
@ -258,14 +259,4 @@ public class EmailAuthController(
|
||||||
if (!config.EmailAuth.Enabled)
|
if (!config.EmailAuth.Enabled)
|
||||||
throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
|
throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public record LoginRequest(string Email, string Password);
|
|
||||||
|
|
||||||
public record RegisterRequest(string Email);
|
|
||||||
|
|
||||||
public record CompleteRegistrationRequest(string Ticket, string Username, string Password);
|
|
||||||
|
|
||||||
public record CallbackRequest(string State);
|
|
||||||
|
|
||||||
public record ChangePasswordRequest(string Current, string New);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ using System.Net;
|
||||||
using EntityFramework.Exceptions.Common;
|
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.Dto;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.Backend.Services.Auth;
|
using Foxnouns.Backend.Services.Auth;
|
||||||
|
@ -25,7 +26,7 @@ public class FediverseAuthController(
|
||||||
private readonly ILogger _logger = logger.ForContext<FediverseAuthController>();
|
private readonly ILogger _logger = logger.ForContext<FediverseAuthController>();
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType<AuthController.SingleUrlResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<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
|
||||||
|
@ -35,12 +36,12 @@ public class FediverseAuthController(
|
||||||
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
|
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
|
||||||
|
|
||||||
string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh);
|
string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh);
|
||||||
return Ok(new AuthController.SingleUrlResponse(url));
|
return Ok(new SingleUrlResponse(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("callback")]
|
[HttpPost("callback")]
|
||||||
[ProducesResponseType<CallbackResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<CallbackResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> FediverseCallbackAsync([FromBody] CallbackRequest req)
|
public async Task<IActionResult> FediverseCallbackAsync([FromBody] FediverseCallbackRequest req)
|
||||||
{
|
{
|
||||||
FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
|
FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
|
||||||
FediverseAuthService.FediverseUser remoteUser =
|
FediverseAuthService.FediverseUser remoteUser =
|
||||||
|
@ -74,10 +75,8 @@ public class FediverseAuthController(
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
[ProducesResponseType<AuthController.AuthResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<AuthResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> RegisterAsync(
|
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
|
||||||
[FromBody] AuthController.OauthRegisterRequest req
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
FediverseTicketData? ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
|
FediverseTicketData? ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
|
||||||
$"fediverse:{req.Ticket}",
|
$"fediverse:{req.Ticket}",
|
||||||
|
@ -138,12 +137,14 @@ public class FediverseAuthController(
|
||||||
);
|
);
|
||||||
|
|
||||||
string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state);
|
string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state);
|
||||||
return Ok(new AuthController.SingleUrlResponse(url));
|
return Ok(new SingleUrlResponse(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("add-account/callback")]
|
[HttpPost("add-account/callback")]
|
||||||
[Authorize("*")]
|
[Authorize("*")]
|
||||||
public async Task<IActionResult> AddAccountCallbackAsync([FromBody] CallbackRequest req)
|
public async Task<IActionResult> AddAccountCallbackAsync(
|
||||||
|
[FromBody] FediverseCallbackRequest req
|
||||||
|
)
|
||||||
{
|
{
|
||||||
await remoteAuthService.ValidateAddAccountStateAsync(
|
await remoteAuthService.ValidateAddAccountStateAsync(
|
||||||
req.State,
|
req.State,
|
||||||
|
@ -171,7 +172,7 @@ public class FediverseAuthController(
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new AuthController.AddOauthAccountResponse(
|
new AddOauthAccountResponse(
|
||||||
authMethod.Id,
|
authMethod.Id,
|
||||||
AuthType.Fediverse,
|
AuthType.Fediverse,
|
||||||
authMethod.RemoteId,
|
authMethod.RemoteId,
|
||||||
|
@ -189,8 +190,6 @@ public class FediverseAuthController(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CallbackRequest(string Instance, string Code, string State);
|
|
||||||
|
|
||||||
private record FediverseTicketData(
|
private record FediverseTicketData(
|
||||||
Snowflake ApplicationId,
|
Snowflake ApplicationId,
|
||||||
FediverseAuthService.FediverseUser User
|
FediverseAuthService.FediverseUser User
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
using Coravel.Queuing.Interfaces;
|
using Coravel.Queuing.Interfaces;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
@ -43,8 +44,6 @@ public class ExportsController(
|
||||||
private string ExportUrl(Snowflake userId, string filename) =>
|
private string ExportUrl(Snowflake userId, string filename) =>
|
||||||
$"{config.MediaBaseUrl}/data-exports/{userId}/{filename}.zip";
|
$"{config.MediaBaseUrl}/data-exports/{userId}/{filename}.zip";
|
||||||
|
|
||||||
private record DataExportResponse(string? Url, Instant? ExpiresAt);
|
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> QueueDataExportAsync()
|
public async Task<IActionResult> QueueDataExportAsync()
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,65 +1,69 @@
|
||||||
using Coravel.Queuing.Interfaces;
|
using Coravel.Queuing.Interfaces;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers;
|
namespace Foxnouns.Backend.Controllers;
|
||||||
|
|
||||||
[Route("/api/v2/users/@me/flags")]
|
[Route("/api/v2/users/@me/flags")]
|
||||||
public class FlagsController(
|
public class FlagsController(
|
||||||
ILogger logger,
|
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
UserRendererService userRenderer,
|
UserRendererService userRenderer,
|
||||||
ObjectStorageService objectStorageService,
|
|
||||||
ISnowflakeGenerator snowflakeGenerator,
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
IQueue queue
|
IQueue queue
|
||||||
) : ApiControllerBase
|
) : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<FlagsController>();
|
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize("identify")]
|
[Authorize("identify")]
|
||||||
[ProducesResponseType<IEnumerable<UserRendererService.PrideFlagResponse>>(
|
[ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
|
||||||
statusCode: StatusCodes.Status200OK
|
|
||||||
)]
|
|
||||||
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
|
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
List<PrideFlag> flags = await db
|
List<PrideFlag> flags = await db
|
||||||
.PrideFlags.Where(f => f.UserId == CurrentUser!.Id)
|
.PrideFlags.Where(f => f.UserId == CurrentUser!.Id)
|
||||||
|
.OrderBy(f => f.Name)
|
||||||
|
.ThenBy(f => f.Id)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return Ok(flags.Select(userRenderer.RenderPrideFlag));
|
return Ok(flags.Select(userRenderer.RenderPrideFlag));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public const int MaxFlagCount = 500;
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize("user.update")]
|
[Authorize("user.update")]
|
||||||
[ProducesResponseType<UserRendererService.PrideFlagResponse>(
|
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)]
|
||||||
statusCode: StatusCodes.Status202Accepted
|
public async Task<IActionResult> CreateFlagAsync([FromBody] CreateFlagRequest req)
|
||||||
)]
|
|
||||||
public IActionResult CreateFlag([FromBody] CreateFlagRequest req)
|
|
||||||
{
|
{
|
||||||
|
int flagCount = await db.PrideFlags.Where(f => f.UserId == CurrentUser!.Id).CountAsync();
|
||||||
|
if (flagCount >= MaxFlagCount)
|
||||||
|
throw new ApiError.BadRequest("Maximum number of flags reached");
|
||||||
|
|
||||||
ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image));
|
ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image));
|
||||||
|
|
||||||
Snowflake id = snowflakeGenerator.GenerateSnowflake();
|
var flag = new PrideFlag
|
||||||
|
{
|
||||||
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
UserId = CurrentUser!.Id,
|
||||||
|
Name = req.Name,
|
||||||
|
Description = req.Description,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Add(flag);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
queue.QueueInvocableWithPayload<CreateFlagInvocable, CreateFlagPayload>(
|
queue.QueueInvocableWithPayload<CreateFlagInvocable, CreateFlagPayload>(
|
||||||
new CreateFlagPayload(id, CurrentUser!.Id, req.Name, req.Image, req.Description)
|
new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Name, req.Image, req.Description)
|
||||||
);
|
);
|
||||||
|
|
||||||
return Accepted(new CreateFlagResponse(id, req.Name, req.Description));
|
return Accepted(userRenderer.RenderPrideFlag(flag));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CreateFlagRequest(string Name, string Image, string? Description);
|
|
||||||
|
|
||||||
private record CreateFlagResponse(Snowflake Id, string Name, string? Description);
|
|
||||||
|
|
||||||
[HttpPatch("{id}")]
|
[HttpPatch("{id}")]
|
||||||
[Authorize("user.update")]
|
[Authorize("user.update")]
|
||||||
public async Task<IActionResult> UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req)
|
public async Task<IActionResult> UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req)
|
||||||
|
@ -84,52 +88,19 @@ public class FlagsController(
|
||||||
return Ok(userRenderer.RenderPrideFlag(flag));
|
return Ok(userRenderer.RenderPrideFlag(flag));
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateFlagRequest : PatchRequest
|
|
||||||
{
|
|
||||||
public string? Name { get; init; }
|
|
||||||
public string? Description { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
[Authorize("user.update")]
|
[Authorize("user.update")]
|
||||||
public async Task<IActionResult> DeleteFlagAsync(Snowflake id)
|
public async Task<IActionResult> DeleteFlagAsync(Snowflake id)
|
||||||
{
|
{
|
||||||
await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync();
|
|
||||||
|
|
||||||
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
||||||
f.Id == id && f.UserId == CurrentUser!.Id
|
f.Id == id && f.UserId == CurrentUser!.Id
|
||||||
);
|
);
|
||||||
if (flag == null)
|
if (flag == null)
|
||||||
throw new ApiError.NotFound("Unknown flag ID, or it's not your flag.");
|
throw new ApiError.NotFound("Unknown flag ID, or it's not your flag.");
|
||||||
|
|
||||||
string hash = flag.Hash;
|
|
||||||
|
|
||||||
db.PrideFlags.Remove(flag);
|
db.PrideFlags.Remove(flag);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
int flagCount = await db.PrideFlags.CountAsync(f => f.Hash == flag.Hash);
|
|
||||||
if (flagCount == 0)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.Information(
|
|
||||||
"Deleting flag file {Hash} as it is no longer used by any flags",
|
|
||||||
hash
|
|
||||||
);
|
|
||||||
await objectStorageService.DeleteFlagAsync(hash);
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
_logger.Error(e, "Error deleting flag file {Hash}", hash);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.Debug("Flag file {Hash} is used by other flags, not deleting", hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.CommitAsync();
|
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
|
@ -63,10 +64,6 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
|
||||||
return Ok(new RequestDataResponse(userId, template));
|
return Ok(new RequestDataResponse(userId, template));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RequestDataRequest(string? Token, string Method, string Path);
|
|
||||||
|
|
||||||
public record RequestDataResponse(Snowflake? UserId, string Template);
|
|
||||||
|
|
||||||
private static RouteEndpoint? GetEndpoint(
|
private static RouteEndpoint? GetEndpoint(
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
string url,
|
string url,
|
||||||
|
|
|
@ -2,6 +2,7 @@ using Coravel.Queuing.Interfaces;
|
||||||
using EntityFramework.Exceptions.Common;
|
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.Dto;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
|
@ -28,9 +29,7 @@ public class MembersController(
|
||||||
private readonly ILogger _logger = logger.ForContext<MembersController>();
|
private readonly ILogger _logger = logger.ForContext<MembersController>();
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType<IEnumerable<MemberRendererService.PartialMember>>(
|
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
|
||||||
StatusCodes.Status200OK
|
|
||||||
)]
|
|
||||||
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||||
|
@ -38,7 +37,7 @@ public class MembersController(
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{memberRef}")]
|
[HttpGet("{memberRef}")]
|
||||||
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetMemberAsync(
|
public async Task<IActionResult> GetMemberAsync(
|
||||||
string userRef,
|
string userRef,
|
||||||
string memberRef,
|
string memberRef,
|
||||||
|
@ -52,7 +51,7 @@ public class MembersController(
|
||||||
public const int MaxMemberCount = 500;
|
public const int MaxMemberCount = 500;
|
||||||
|
|
||||||
[HttpPost("/api/v2/users/@me/members")]
|
[HttpPost("/api/v2/users/@me/members")]
|
||||||
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
||||||
[Authorize("member.create")]
|
[Authorize("member.create")]
|
||||||
public async Task<IActionResult> CreateMemberAsync(
|
public async Task<IActionResult> CreateMemberAsync(
|
||||||
[FromBody] CreateMemberRequest req,
|
[FromBody] CreateMemberRequest req,
|
||||||
|
@ -246,20 +245,6 @@ public class MembersController(
|
||||||
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateMemberRequest : PatchRequest
|
|
||||||
{
|
|
||||||
public string? Name { get; init; }
|
|
||||||
public string? DisplayName { get; init; }
|
|
||||||
public string? Bio { get; init; }
|
|
||||||
public string? Avatar { get; init; }
|
|
||||||
public string[]? Links { get; init; }
|
|
||||||
public FieldEntry[]? Names { get; init; }
|
|
||||||
public Pronoun[]? Pronouns { get; init; }
|
|
||||||
public Field[]? Fields { get; init; }
|
|
||||||
public Snowflake[]? Flags { get; init; }
|
|
||||||
public bool? Unlisted { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("/api/v2/users/@me/members/{memberRef}")]
|
[HttpDelete("/api/v2/users/@me/members/{memberRef}")]
|
||||||
[Authorize("member.update")]
|
[Authorize("member.update")]
|
||||||
public async Task<IActionResult> DeleteMemberAsync(string memberRef)
|
public async Task<IActionResult> DeleteMemberAsync(string memberRef)
|
||||||
|
@ -282,21 +267,9 @@ public class MembersController(
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CreateMemberRequest(
|
|
||||||
string Name,
|
|
||||||
string? DisplayName,
|
|
||||||
string? Bio,
|
|
||||||
string? Avatar,
|
|
||||||
bool? Unlisted,
|
|
||||||
string[]? Links,
|
|
||||||
List<FieldEntry>? Names,
|
|
||||||
List<Pronoun>? Pronouns,
|
|
||||||
List<Field>? Fields
|
|
||||||
);
|
|
||||||
|
|
||||||
[HttpPost("/api/v2/users/@me/members/{memberRef}/reroll-sid")]
|
[HttpPost("/api/v2/users/@me/members/{memberRef}/reroll-sid")]
|
||||||
[Authorize("member.update")]
|
[Authorize("member.update")]
|
||||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<MemberResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> RerollSidAsync(string memberRef)
|
public async Task<IActionResult> RerollSidAsync(string memberRef)
|
||||||
{
|
{
|
||||||
Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
@ -17,17 +18,18 @@ public class MetaController : ApiControllerBase
|
||||||
BuildInfo.Version,
|
BuildInfo.Version,
|
||||||
BuildInfo.Hash,
|
BuildInfo.Hash,
|
||||||
(int)FoxnounsMetrics.MemberCount.Value,
|
(int)FoxnounsMetrics.MemberCount.Value,
|
||||||
new UserInfo(
|
new UserInfoResponse(
|
||||||
(int)FoxnounsMetrics.UsersCount.Value,
|
(int)FoxnounsMetrics.UsersCount.Value,
|
||||||
(int)FoxnounsMetrics.UsersActiveMonthCount.Value,
|
(int)FoxnounsMetrics.UsersActiveMonthCount.Value,
|
||||||
(int)FoxnounsMetrics.UsersActiveWeekCount.Value,
|
(int)FoxnounsMetrics.UsersActiveWeekCount.Value,
|
||||||
(int)FoxnounsMetrics.UsersActiveDayCount.Value
|
(int)FoxnounsMetrics.UsersActiveDayCount.Value
|
||||||
),
|
),
|
||||||
new Limits(
|
new LimitsResponse(
|
||||||
MembersController.MaxMemberCount,
|
MembersController.MaxMemberCount,
|
||||||
ValidationUtils.MaxBioLength,
|
ValidationUtils.MaxBioLength,
|
||||||
ValidationUtils.MaxCustomPreferences,
|
ValidationUtils.MaxCustomPreferences,
|
||||||
AuthUtils.MaxAuthMethodsPerType
|
AuthUtils.MaxAuthMethodsPerType,
|
||||||
|
FlagsController.MaxFlagCount
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -35,23 +37,4 @@ public class MetaController : ApiControllerBase
|
||||||
[HttpGet("/api/v2/coffee")]
|
[HttpGet("/api/v2/coffee")]
|
||||||
public IActionResult BrewCoffee() =>
|
public IActionResult BrewCoffee() =>
|
||||||
Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
|
Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
|
||||||
|
|
||||||
private record MetaResponse(
|
|
||||||
string Repository,
|
|
||||||
string Version,
|
|
||||||
string Hash,
|
|
||||||
int Members,
|
|
||||||
UserInfo Users,
|
|
||||||
Limits Limits
|
|
||||||
);
|
|
||||||
|
|
||||||
private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay);
|
|
||||||
|
|
||||||
// All limits that the frontend should know about (for UI purposes)
|
|
||||||
private record Limits(
|
|
||||||
int MemberCount,
|
|
||||||
int BioLength,
|
|
||||||
int CustomPreferences,
|
|
||||||
int MaxAuthMethods
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
using Coravel.Queuing.Interfaces;
|
using Coravel.Queuing.Interfaces;
|
||||||
using EntityFramework.Exceptions.Common;
|
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.Dto;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
@ -27,7 +27,7 @@ public class UsersController(
|
||||||
private readonly ILogger _logger = logger.ForContext<UsersController>();
|
private readonly ILogger _logger = logger.ForContext<UsersController>();
|
||||||
|
|
||||||
[HttpGet("{userRef}")]
|
[HttpGet("{userRef}")]
|
||||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||||
|
@ -38,7 +38,7 @@ public class UsersController(
|
||||||
|
|
||||||
[HttpPatch("@me")]
|
[HttpPatch("@me")]
|
||||||
[Authorize("user.update")]
|
[Authorize("user.update")]
|
||||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> UpdateUserAsync(
|
public async Task<IActionResult> UpdateUserAsync(
|
||||||
[FromBody] UpdateUserRequest req,
|
[FromBody] UpdateUserRequest req,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
|
@ -196,7 +196,7 @@ public class UsersController(
|
||||||
[Authorize("user.update")]
|
[Authorize("user.update")]
|
||||||
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> UpdateCustomPreferencesAsync(
|
public async Task<IActionResult> UpdateCustomPreferencesAsync(
|
||||||
[FromBody] List<CustomPreferenceUpdate> req,
|
[FromBody] List<CustomPreferenceUpdateRequest> req,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
@ -207,7 +207,7 @@ public class UsersController(
|
||||||
.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
|
.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
|
||||||
.ToDictionary();
|
.ToDictionary();
|
||||||
|
|
||||||
foreach (CustomPreferenceUpdate? r in req)
|
foreach (CustomPreferenceUpdateRequest? r in req)
|
||||||
{
|
{
|
||||||
if (r.Id != null && preferences.ContainsKey(r.Id.Value))
|
if (r.Id != null && preferences.ContainsKey(r.Id.Value))
|
||||||
{
|
{
|
||||||
|
@ -239,33 +239,6 @@ public class UsersController(
|
||||||
return Ok(user.CustomPreferences);
|
return Ok(user.CustomPreferences);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
|
|
||||||
public class CustomPreferenceUpdate
|
|
||||||
{
|
|
||||||
public Snowflake? Id { get; init; }
|
|
||||||
public required string Icon { get; set; }
|
|
||||||
public required string Tooltip { get; set; }
|
|
||||||
public PreferenceSize Size { get; set; }
|
|
||||||
public bool Muted { get; set; }
|
|
||||||
public bool Favourite { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class UpdateUserRequest : PatchRequest
|
|
||||||
{
|
|
||||||
public string? Username { get; init; }
|
|
||||||
public string? DisplayName { get; init; }
|
|
||||||
public string? Bio { get; init; }
|
|
||||||
public string? Avatar { get; init; }
|
|
||||||
public string[]? Links { get; init; }
|
|
||||||
public FieldEntry[]? Names { get; init; }
|
|
||||||
public Pronoun[]? Pronouns { get; init; }
|
|
||||||
public Field[]? Fields { get; init; }
|
|
||||||
public Snowflake[]? Flags { get; init; }
|
|
||||||
public string? MemberTitle { get; init; }
|
|
||||||
public bool? MemberListHidden { get; init; }
|
|
||||||
public string? Timezone { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("@me/settings")]
|
[HttpGet("@me/settings")]
|
||||||
[Authorize("user.read_hidden")]
|
[Authorize("user.read_hidden")]
|
||||||
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
|
||||||
|
@ -294,14 +267,9 @@ public class UsersController(
|
||||||
return Ok(user.Settings);
|
return Ok(user.Settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateUserSettingsRequest : PatchRequest
|
|
||||||
{
|
|
||||||
public bool? DarkMode { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("@me/reroll-sid")]
|
[HttpPost("@me/reroll-sid")]
|
||||||
[Authorize("user.update")]
|
[Authorize("user.update")]
|
||||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> RerollSidAsync()
|
public async Task<IActionResult> RerollSidAsync()
|
||||||
{
|
{
|
||||||
Instant minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1);
|
Instant minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1);
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(DatabaseContext))]
|
||||||
|
[Migration("20241209134148_NullableFlagHash")]
|
||||||
|
public partial class NullableFlagHash : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "hash",
|
||||||
|
table: "pride_flags",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "hash",
|
||||||
|
table: "pride_flags",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -282,7 +282,6 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasColumnName("description");
|
.HasColumnName("description");
|
||||||
|
|
||||||
b.Property<string>("Hash")
|
b.Property<string>("Hash")
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("hash");
|
.HasColumnName("hash");
|
||||||
|
|
||||||
|
@ -546,7 +545,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
|
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
|
||||||
.WithMany()
|
.WithMany("DataExports")
|
||||||
.HasForeignKey("UserId")
|
.HasForeignKey("UserId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
|
@ -645,6 +644,8 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
{
|
{
|
||||||
b.Navigation("AuthMethods");
|
b.Navigation("AuthMethods");
|
||||||
|
|
||||||
|
b.Navigation("DataExports");
|
||||||
|
|
||||||
b.Navigation("Flags");
|
b.Navigation("Flags");
|
||||||
|
|
||||||
b.Navigation("Members");
|
b.Navigation("Members");
|
||||||
|
|
|
@ -3,7 +3,9 @@ namespace Foxnouns.Backend.Database.Models;
|
||||||
public class PrideFlag : BaseModel
|
public class PrideFlag : BaseModel
|
||||||
{
|
{
|
||||||
public required Snowflake UserId { get; init; }
|
public required Snowflake UserId { get; init; }
|
||||||
public required string Hash { get; init; }
|
|
||||||
|
// A null hash means the flag hasn't been processed yet.
|
||||||
|
public string? Hash { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
}
|
}
|
||||||
|
|
47
Foxnouns.Backend/Dto/Auth.cs
Normal file
47
Foxnouns.Backend/Dto/Auth.cs
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
// ReSharper disable ClassNeverInstantiated.Global
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record CallbackResponse(
|
||||||
|
bool HasAccount,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] UserResponse? User,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public record UrlsResponse(bool EmailEnabled, string? Discord, string? Google, string? Tumblr);
|
||||||
|
|
||||||
|
public record AuthResponse(UserResponse User, string Token, Instant ExpiresAt);
|
||||||
|
|
||||||
|
public record SingleUrlResponse(string Url);
|
||||||
|
|
||||||
|
public record AddOauthAccountResponse(
|
||||||
|
Snowflake Id,
|
||||||
|
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
|
||||||
|
string RemoteId,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername
|
||||||
|
);
|
||||||
|
|
||||||
|
public record OauthRegisterRequest(string Ticket, string Username);
|
||||||
|
|
||||||
|
public record CallbackRequest(string Code, string State);
|
||||||
|
|
||||||
|
public record EmailLoginRequest(string Email, string Password);
|
||||||
|
|
||||||
|
public record EmailRegisterRequest(string Email);
|
||||||
|
|
||||||
|
public record EmailCompleteRegistrationRequest(string Ticket, string Username, string Password);
|
||||||
|
|
||||||
|
public record EmailCallbackRequest(string State);
|
||||||
|
|
||||||
|
public record EmailChangePasswordRequest(string Current, string New);
|
||||||
|
|
||||||
|
public record FediverseCallbackRequest(string Instance, string Code, string State);
|
6
Foxnouns.Backend/Dto/DataExport.cs
Normal file
6
Foxnouns.Backend/Dto/DataExport.cs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record DataExportResponse(string? Url, Instant? ExpiresAt);
|
17
Foxnouns.Backend/Dto/Flag.cs
Normal file
17
Foxnouns.Backend/Dto/Flag.cs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record PrideFlagResponse(Snowflake Id, string? ImageUrl, string Name, string? Description);
|
||||||
|
|
||||||
|
public record CreateFlagRequest(string Name, string Image, string? Description);
|
||||||
|
|
||||||
|
public record CreateFlagResponse(Snowflake Id, string Name, string? Description);
|
||||||
|
|
||||||
|
public class UpdateFlagRequest : PatchRequest
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public string? Description { get; init; }
|
||||||
|
}
|
8
Foxnouns.Backend/Dto/Internal.cs
Normal file
8
Foxnouns.Backend/Dto/Internal.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record RequestDataRequest(string? Token, string Method, string Path);
|
||||||
|
|
||||||
|
public record RequestDataResponse(Snowflake? UserId, string Template);
|
61
Foxnouns.Backend/Dto/Member.cs
Normal file
61
Foxnouns.Backend/Dto/Member.cs
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record CreateMemberRequest(
|
||||||
|
string Name,
|
||||||
|
string? DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
string? Avatar,
|
||||||
|
bool? Unlisted,
|
||||||
|
string[]? Links,
|
||||||
|
List<FieldEntry>? Names,
|
||||||
|
List<Pronoun>? Pronouns,
|
||||||
|
List<Field>? Fields
|
||||||
|
);
|
||||||
|
|
||||||
|
public class UpdateMemberRequest : PatchRequest
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
public string? Bio { get; init; }
|
||||||
|
public string? Avatar { get; init; }
|
||||||
|
public string[]? Links { get; init; }
|
||||||
|
public FieldEntry[]? Names { get; init; }
|
||||||
|
public Pronoun[]? Pronouns { get; init; }
|
||||||
|
public Field[]? Fields { get; init; }
|
||||||
|
public Snowflake[]? Flags { get; init; }
|
||||||
|
public bool? Unlisted { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PartialMember(
|
||||||
|
Snowflake Id,
|
||||||
|
string Sid,
|
||||||
|
string Name,
|
||||||
|
string DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
string? AvatarUrl,
|
||||||
|
IEnumerable<FieldEntry> Names,
|
||||||
|
IEnumerable<Pronoun> Pronouns,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted
|
||||||
|
);
|
||||||
|
|
||||||
|
public record MemberResponse(
|
||||||
|
Snowflake Id,
|
||||||
|
string Sid,
|
||||||
|
string Name,
|
||||||
|
string DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
string? AvatarUrl,
|
||||||
|
string[] Links,
|
||||||
|
IEnumerable<FieldEntry> Names,
|
||||||
|
IEnumerable<Pronoun> Pronouns,
|
||||||
|
IEnumerable<Field> Fields,
|
||||||
|
IEnumerable<PrideFlagResponse> Flags,
|
||||||
|
PartialUser User,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted
|
||||||
|
);
|
21
Foxnouns.Backend/Dto/Meta.cs
Normal file
21
Foxnouns.Backend/Dto/Meta.cs
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record MetaResponse(
|
||||||
|
string Repository,
|
||||||
|
string Version,
|
||||||
|
string Hash,
|
||||||
|
int Members,
|
||||||
|
UserInfoResponse Users,
|
||||||
|
LimitsResponse Limits
|
||||||
|
);
|
||||||
|
|
||||||
|
public record UserInfoResponse(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay);
|
||||||
|
|
||||||
|
public record LimitsResponse(
|
||||||
|
int MemberCount,
|
||||||
|
int BioLength,
|
||||||
|
int CustomPreferences,
|
||||||
|
int MaxAuthMethods,
|
||||||
|
int MaxFlags
|
||||||
|
);
|
82
Foxnouns.Backend/Dto/User.cs
Normal file
82
Foxnouns.Backend/Dto/User.cs
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
// ReSharper disable ClassNeverInstantiated.Global
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record UserResponse(
|
||||||
|
Snowflake Id,
|
||||||
|
string Sid,
|
||||||
|
string Username,
|
||||||
|
string? DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
string? MemberTitle,
|
||||||
|
string? AvatarUrl,
|
||||||
|
string[] Links,
|
||||||
|
IEnumerable<FieldEntry> Names,
|
||||||
|
IEnumerable<Pronoun> Pronouns,
|
||||||
|
IEnumerable<Field> Fields,
|
||||||
|
Dictionary<Snowflake, User.CustomPreference> CustomPreferences,
|
||||||
|
IEnumerable<PrideFlagResponse> Flags,
|
||||||
|
int? UtcOffset,
|
||||||
|
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
IEnumerable<PartialMember>? Members,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
IEnumerable<AuthMethodResponse>? AuthMethods,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? MemberListHidden,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone
|
||||||
|
);
|
||||||
|
|
||||||
|
public record AuthMethodResponse(
|
||||||
|
Snowflake Id,
|
||||||
|
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
|
||||||
|
string RemoteId,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername
|
||||||
|
);
|
||||||
|
|
||||||
|
public record PartialUser(
|
||||||
|
Snowflake Id,
|
||||||
|
string Sid,
|
||||||
|
string Username,
|
||||||
|
string? DisplayName,
|
||||||
|
string? AvatarUrl,
|
||||||
|
Dictionary<Snowflake, User.CustomPreference> CustomPreferences
|
||||||
|
);
|
||||||
|
|
||||||
|
public class UpdateUserSettingsRequest : PatchRequest
|
||||||
|
{
|
||||||
|
public bool? DarkMode { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CustomPreferenceUpdateRequest
|
||||||
|
{
|
||||||
|
public Snowflake? Id { get; init; }
|
||||||
|
public required string Icon { get; set; }
|
||||||
|
public required string Tooltip { get; set; }
|
||||||
|
public PreferenceSize Size { get; set; }
|
||||||
|
public bool Muted { get; set; }
|
||||||
|
public bool Favourite { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateUserRequest : PatchRequest
|
||||||
|
{
|
||||||
|
public string? Username { get; init; }
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
public string? Bio { get; init; }
|
||||||
|
public string? Avatar { get; init; }
|
||||||
|
public string[]? Links { get; init; }
|
||||||
|
public FieldEntry[]? Names { get; init; }
|
||||||
|
public Pronoun[]? Pronouns { get; init; }
|
||||||
|
public Field[]? Fields { get; init; }
|
||||||
|
public Snowflake[]? Flags { get; init; }
|
||||||
|
public string? MemberTitle { get; init; }
|
||||||
|
public bool? MemberListHidden { get; init; }
|
||||||
|
public string? Timezone { get; init; }
|
||||||
|
}
|
|
@ -108,6 +108,12 @@ public class CreateDataExportInvocable(
|
||||||
|
|
||||||
private async Task WritePrideFlag(ZipArchive zip, PrideFlag flag)
|
private async Task WritePrideFlag(ZipArchive zip, PrideFlag flag)
|
||||||
{
|
{
|
||||||
|
if (flag.Hash == null)
|
||||||
|
{
|
||||||
|
_logger.Debug("Flag {FlagId} has a null hash, ignoring it", flag.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_logger.Debug("Writing flag {FlagId}", flag.Id);
|
_logger.Debug("Writing flag {FlagId}", flag.Id);
|
||||||
|
|
||||||
var flagData = $"""
|
var flagData = $"""
|
||||||
|
|
|
@ -3,6 +3,7 @@ using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Jobs;
|
namespace Foxnouns.Backend.Jobs;
|
||||||
|
|
||||||
|
@ -26,6 +27,18 @@ public class CreateFlagInvocable(
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
||||||
|
f.Id == Payload.Id && f.UserId == Payload.UserId
|
||||||
|
);
|
||||||
|
if (flag == null)
|
||||||
|
{
|
||||||
|
_logger.Warning(
|
||||||
|
"Got a flag create job for {FlagId} but it doesn't exist, aborting",
|
||||||
|
Payload.Id
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
(string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage(
|
(string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage(
|
||||||
Payload.ImageData,
|
Payload.ImageData,
|
||||||
256,
|
256,
|
||||||
|
@ -33,16 +46,8 @@ public class CreateFlagInvocable(
|
||||||
);
|
);
|
||||||
await objectStorageService.PutObjectAsync(Path(hash), image, "image/webp");
|
await objectStorageService.PutObjectAsync(Path(hash), image, "image/webp");
|
||||||
|
|
||||||
var flag = new PrideFlag
|
flag.Hash = hash;
|
||||||
{
|
db.Update(flag);
|
||||||
Id = Payload.Id,
|
|
||||||
UserId = Payload.UserId,
|
|
||||||
Hash = hash,
|
|
||||||
Name = Payload.Name,
|
|
||||||
Description = Payload.Description,
|
|
||||||
};
|
|
||||||
db.Add(flag);
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_logger.Information("Uploaded flag {FlagId} with hash {Hash}", flag.Id, flag.Hash);
|
_logger.Information("Uploaded flag {FlagId} with hash {Hash}", flag.Id, flag.Hash);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using Foxnouns.Backend.Controllers.Authentication;
|
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
@ -49,7 +50,7 @@ public class MemberRendererService(DatabaseContext db, Config config)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserRendererService.PartialUser RenderPartialUser(User user) =>
|
private PartialUser RenderPartialUser(User user) =>
|
||||||
new(
|
new(
|
||||||
user.Id,
|
user.Id,
|
||||||
user.Sid,
|
user.Sid,
|
||||||
|
@ -82,36 +83,9 @@ public class MemberRendererService(DatabaseContext db, Config config)
|
||||||
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp";
|
private string? ImageUrlFor(PrideFlag flag) =>
|
||||||
|
flag.Hash != null ? $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp" : null;
|
||||||
|
|
||||||
private UserRendererService.PrideFlagResponse RenderPrideFlag(PrideFlag flag) =>
|
private PrideFlagResponse RenderPrideFlag(PrideFlag flag) =>
|
||||||
new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description);
|
new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description);
|
||||||
|
|
||||||
public record PartialMember(
|
|
||||||
Snowflake Id,
|
|
||||||
string Sid,
|
|
||||||
string Name,
|
|
||||||
string DisplayName,
|
|
||||||
string? Bio,
|
|
||||||
string? AvatarUrl,
|
|
||||||
IEnumerable<FieldEntry> Names,
|
|
||||||
IEnumerable<Pronoun> Pronouns,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted
|
|
||||||
);
|
|
||||||
|
|
||||||
public record MemberResponse(
|
|
||||||
Snowflake Id,
|
|
||||||
string Sid,
|
|
||||||
string Name,
|
|
||||||
string DisplayName,
|
|
||||||
string? Bio,
|
|
||||||
string? AvatarUrl,
|
|
||||||
string[] Links,
|
|
||||||
IEnumerable<FieldEntry> Names,
|
|
||||||
IEnumerable<Pronoun> Pronouns,
|
|
||||||
IEnumerable<Field> Fields,
|
|
||||||
IEnumerable<UserRendererService.PrideFlagResponse> Flags,
|
|
||||||
UserRendererService.PartialUser User,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
@ -130,60 +131,9 @@ public class UserRendererService(
|
||||||
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
public string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp";
|
public string? ImageUrlFor(PrideFlag flag) =>
|
||||||
|
flag.Hash != null ? $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp" : null;
|
||||||
public record UserResponse(
|
|
||||||
Snowflake Id,
|
|
||||||
string Sid,
|
|
||||||
string Username,
|
|
||||||
string? DisplayName,
|
|
||||||
string? Bio,
|
|
||||||
string? MemberTitle,
|
|
||||||
string? AvatarUrl,
|
|
||||||
string[] Links,
|
|
||||||
IEnumerable<FieldEntry> Names,
|
|
||||||
IEnumerable<Pronoun> Pronouns,
|
|
||||||
IEnumerable<Field> Fields,
|
|
||||||
Dictionary<Snowflake, User.CustomPreference> CustomPreferences,
|
|
||||||
IEnumerable<PrideFlagResponse> Flags,
|
|
||||||
int? UtcOffset,
|
|
||||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
IEnumerable<MemberRendererService.PartialMember>? Members,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
IEnumerable<AuthMethodResponse>? AuthMethods,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
bool? MemberListHidden,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
Instant? LastSidReroll,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone
|
|
||||||
);
|
|
||||||
|
|
||||||
public record AuthMethodResponse(
|
|
||||||
Snowflake Id,
|
|
||||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
|
|
||||||
string RemoteId,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
string? RemoteUsername
|
|
||||||
);
|
|
||||||
|
|
||||||
public record PartialUser(
|
|
||||||
Snowflake Id,
|
|
||||||
string Sid,
|
|
||||||
string Username,
|
|
||||||
string? DisplayName,
|
|
||||||
string? AvatarUrl,
|
|
||||||
Dictionary<Snowflake, User.CustomPreference> CustomPreferences
|
|
||||||
);
|
|
||||||
|
|
||||||
public PrideFlagResponse RenderPrideFlag(PrideFlag flag) =>
|
public PrideFlagResponse RenderPrideFlag(PrideFlag flag) =>
|
||||||
new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description);
|
new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description);
|
||||||
|
|
||||||
public record PrideFlagResponse(
|
|
||||||
Snowflake Id,
|
|
||||||
string ImageUrl,
|
|
||||||
string Name,
|
|
||||||
string? Description
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
using Foxnouns.Backend.Controllers;
|
using Foxnouns.Backend.Controllers;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Utils;
|
namespace Foxnouns.Backend.Utils;
|
||||||
|
|
||||||
|
@ -23,7 +24,7 @@ public static partial class ValidationUtils
|
||||||
public const int MaxPreferenceTooltipLength = 128;
|
public const int MaxPreferenceTooltipLength = 128;
|
||||||
|
|
||||||
public static List<(string, ValidationError?)> ValidateCustomPreferences(
|
public static List<(string, ValidationError?)> ValidateCustomPreferences(
|
||||||
List<UsersController.CustomPreferenceUpdate> preferences
|
List<CustomPreferenceUpdateRequest> preferences
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var errors = new List<(string, ValidationError?)>();
|
var errors = new List<(string, ValidationError?)>();
|
||||||
|
@ -46,11 +47,7 @@ public static partial class ValidationUtils
|
||||||
if (preferences.Count > 50)
|
if (preferences.Count > 50)
|
||||||
return errors;
|
return errors;
|
||||||
|
|
||||||
foreach (
|
foreach ((CustomPreferenceUpdateRequest? p, int i) in preferences.Select((p, i) => (p, i)))
|
||||||
(UsersController.CustomPreferenceUpdate? p, int i) in preferences.Select(
|
|
||||||
(p, i) => (p, i)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
if (!BootstrapIcons.IsValid(p.Icon))
|
if (!BootstrapIcons.IsValid(p.Icon))
|
||||||
{
|
{
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
"prettier-plugin-svelte": "^3.2.6",
|
"prettier-plugin-svelte": "^3.2.6",
|
||||||
"sass": "^1.81.0",
|
"sass": "^1.81.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-bootstrap-icons": "^3.1.1",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"sveltekit-i18n": "^2.4.2",
|
"sveltekit-i18n": "^2.4.2",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
|
|
8
Foxnouns.Frontend/pnpm-lock.yaml
generated
8
Foxnouns.Frontend/pnpm-lock.yaml
generated
|
@ -93,6 +93,9 @@ importers:
|
||||||
svelte:
|
svelte:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.2.2
|
version: 5.2.2
|
||||||
|
svelte-bootstrap-icons:
|
||||||
|
specifier: ^3.1.1
|
||||||
|
version: 3.1.1
|
||||||
svelte-check:
|
svelte-check:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3)
|
version: 4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3)
|
||||||
|
@ -1321,6 +1324,9 @@ packages:
|
||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
svelte-bootstrap-icons@3.1.1:
|
||||||
|
resolution: {integrity: sha512-ghJlt6TX3IX35M7wSvGyrmVgXeT5GMRF+7+q6L4OUT2RJWF09mQIvZTZ04Ii3FBfg10KdzFdvVuoB8M0cVHfzw==}
|
||||||
|
|
||||||
svelte-check@4.0.9:
|
svelte-check@4.0.9:
|
||||||
resolution: {integrity: sha512-SVNCz2L+9ZELGli7G0n3B3QE5kdf0u27RtKr2ZivWQhcWIXatZxwM4VrQ6AiA2k9zKp2mk5AxkEhdjbpjv7rEw==}
|
resolution: {integrity: sha512-SVNCz2L+9ZELGli7G0n3B3QE5kdf0u27RtKr2ZivWQhcWIXatZxwM4VrQ6AiA2k9zKp2mk5AxkEhdjbpjv7rEw==}
|
||||||
engines: {node: '>= 18.0.0'}
|
engines: {node: '>= 18.0.0'}
|
||||||
|
@ -2564,6 +2570,8 @@ snapshots:
|
||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
svelte-bootstrap-icons@3.1.1: {}
|
||||||
|
|
||||||
svelte-check@4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3):
|
svelte-check@4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.25
|
'@jridgewell/trace-mapping': 0.3.25
|
||||||
|
|
|
@ -17,4 +17,5 @@ export type Limits = {
|
||||||
bio_length: number;
|
bio_length: number;
|
||||||
custom_preferences: number;
|
custom_preferences: number;
|
||||||
max_auth_methods: number;
|
max_auth_methods: number;
|
||||||
|
max_flags: number;
|
||||||
};
|
};
|
||||||
|
|
|
@ -66,7 +66,7 @@ export type Field = {
|
||||||
|
|
||||||
export type PrideFlag = {
|
export type PrideFlag = {
|
||||||
id: string;
|
id: string;
|
||||||
image_url: string;
|
image_url: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
};
|
};
|
||||||
|
|
37
Foxnouns.Frontend/src/lib/components/ClientPaginator.svelte
Normal file
37
Foxnouns.Frontend/src/lib/components/ClientPaginator.svelte
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Pagination, PaginationItem, PaginationLink } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
currentPage: number;
|
||||||
|
pageCount: number;
|
||||||
|
center?: boolean;
|
||||||
|
};
|
||||||
|
let { currentPage = $bindable(), pageCount, center }: Props = $props();
|
||||||
|
|
||||||
|
let prevPage = $derived(currentPage > 0 ? currentPage - 1 : 0);
|
||||||
|
let nextPage = $derived(currentPage < pageCount - 1 ? currentPage + 1 : pageCount - 1);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pageCount > 1}
|
||||||
|
<div>
|
||||||
|
<Pagination listClassName={center ? "justify-content-center" : undefined}>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink first onclick={() => (currentPage = 0)} />
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink previous onclick={() => (currentPage = prevPage)} />
|
||||||
|
</PaginationItem>
|
||||||
|
{#each new Array(pageCount) as _, page}
|
||||||
|
<PaginationItem active={page === currentPage}>
|
||||||
|
<PaginationLink onclick={() => (currentPage = page)}>{page + 1}</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
{/each}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink next onclick={() => (currentPage = nextPage)} />
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink last onclick={() => (currentPage = pageCount - 1)} />
|
||||||
|
</PaginationItem>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
{/if}
|
|
@ -10,11 +10,18 @@
|
||||||
type?: "submit" | "reset" | "button";
|
type?: "submit" | "reset" | "button";
|
||||||
id?: string;
|
id?: string;
|
||||||
onclick?: MouseEventHandler<HTMLButtonElement>;
|
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
outline?: boolean;
|
||||||
};
|
};
|
||||||
let { icon, tooltip, color = "primary", type, id, onclick }: Props = $props();
|
let { icon, tooltip, color = "primary", type, id, onclick, outline }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button {id} {type} use:tippy={{ content: tooltip }} class="btn btn-{color}" {onclick}>
|
<button
|
||||||
|
{id}
|
||||||
|
{type}
|
||||||
|
use:tippy={{ content: tooltip }}
|
||||||
|
class="btn {outline ? `btn-outline-${color}` : `btn-${color}`}"
|
||||||
|
{onclick}
|
||||||
|
>
|
||||||
<Icon name={icon} />
|
<Icon name={icon} />
|
||||||
<span class="visually-hidden">{tooltip}</span>
|
<span class="visually-hidden">{tooltip}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PrideFlag } from "$api/models/user";
|
||||||
|
import { DEFAULT_FLAG } from "$lib";
|
||||||
|
|
||||||
|
type Props = { flag: PrideFlag };
|
||||||
|
let { flag }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<img class="flag" src={flag.image_url ?? DEFAULT_FLAG} alt={flag.description ?? flag.name} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flag {
|
||||||
|
height: 1.5rem;
|
||||||
|
max-width: 200px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,35 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PrideFlag } from "$api/models";
|
||||||
|
import type { MouseEventHandler } from "svelte/elements";
|
||||||
|
import EditorFlagImage from "./EditorFlagImage.svelte";
|
||||||
|
import tippy from "$lib/tippy";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
flag: PrideFlag;
|
||||||
|
tooltip?: string;
|
||||||
|
class?: string;
|
||||||
|
onclick: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
padding?: boolean;
|
||||||
|
};
|
||||||
|
let { flag, tooltip, class: className, onclick, padding }: Props = $props();
|
||||||
|
|
||||||
|
let tip = $derived(tooltip ? tippy : () => {});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary {className || ''}"
|
||||||
|
class:padding
|
||||||
|
{onclick}
|
||||||
|
use:tip={{ content: tooltip }}
|
||||||
|
>
|
||||||
|
<EditorFlagImage {flag} />
|
||||||
|
{flag.name}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.padding {
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PrideFlag } from "$api/models";
|
||||||
|
import { DEFAULT_FLAG } from "$lib";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
|
||||||
|
type UpdateParams = { name: string; description: string | null };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
flag: PrideFlag;
|
||||||
|
update(id: string, params: UpdateParams): Promise<void>;
|
||||||
|
deleteFlag(id: string): Promise<void>;
|
||||||
|
};
|
||||||
|
let { flag, update, deleteFlag }: Props = $props();
|
||||||
|
|
||||||
|
let name = $state(flag.name);
|
||||||
|
let description = $state(flag.description);
|
||||||
|
|
||||||
|
const saveChanges = () =>
|
||||||
|
update(flag.id, { name, description: description ? description : null });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="d-flex">
|
||||||
|
<span class="me-3">
|
||||||
|
<img class="flag" src={flag.image_url ?? DEFAULT_FLAG} alt={flag.description ?? flag.name} />
|
||||||
|
</span>
|
||||||
|
<div class="w-lg-50">
|
||||||
|
<input
|
||||||
|
class="mb-2 form-control"
|
||||||
|
placeholder={$t("settings.flag-name-placeholder")}
|
||||||
|
bind:value={name}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
class="mb-2 form-control"
|
||||||
|
style="height: 5rem;"
|
||||||
|
placeholder={$t("settings.flag-description-placeholder")}
|
||||||
|
bind:value={description}
|
||||||
|
autocomplete="off"
|
||||||
|
></textarea>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-primary" onclick={saveChanges}>
|
||||||
|
{$t("save-changes")}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger" onclick={() => deleteFlag(flag.id)}>
|
||||||
|
{$t("settings.flag-delete-button")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flag {
|
||||||
|
max-height: 6rem;
|
||||||
|
max-width: 256px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,48 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PrideFlag } from "$api/models";
|
||||||
|
import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte";
|
||||||
|
import Search from "svelte-bootstrap-icons/lib/Search.svelte";
|
||||||
|
import FlagButton from "./FlagButton.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
|
||||||
|
type Props = { flags: PrideFlag[]; select(flag: PrideFlag): void };
|
||||||
|
let { flags, select }: Props = $props();
|
||||||
|
|
||||||
|
let query = $state("");
|
||||||
|
let filteredFlags = $derived(search(query));
|
||||||
|
|
||||||
|
function search(q: string) {
|
||||||
|
if (!q) return flags.slice(0, 20);
|
||||||
|
return flags.filter((f) => f.name.toLowerCase().indexOf(q.toLowerCase()) !== -1).slice(0, 20);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input class="form-control" placeholder={$t("editor.flag-search-placeholder")} bind:value={query} />
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
{#each filteredFlags as flag (flag.id)}
|
||||||
|
<FlagButton {flag} onclick={() => select(flag)} padding />
|
||||||
|
{:else}
|
||||||
|
<div class="text-secondary text-center">
|
||||||
|
<p>
|
||||||
|
<Search class="no-flags-icon" height={64} width={64} aria-hidden />
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{#if query}
|
||||||
|
{$t("editor.flag-search-no-flags")}
|
||||||
|
{:else}
|
||||||
|
{$t("editor.flag-search-no-account-flags")}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if flags.length > 0}
|
||||||
|
<p class="text-secondary mt-2">
|
||||||
|
<InfoCircleFill aria-hidden />
|
||||||
|
{$t("editor.flag-search-hint")}
|
||||||
|
<a href="/settings/flags">{$t("editor.flag-manage-your-flags")}</a>
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p><a href="/settings/flags">{$t("editor.flag-manage-your-flags")}</a></p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
|
@ -0,0 +1,95 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { RawApiError } from "$api/error";
|
||||||
|
import type { PrideFlag } from "$api/models";
|
||||||
|
import FlagSearch from "$components/editor/FlagSearch.svelte";
|
||||||
|
import IconButton from "$components/IconButton.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import FlagButton from "./FlagButton.svelte";
|
||||||
|
import FormStatusMarker from "./FormStatusMarker.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
profileFlags: PrideFlag[];
|
||||||
|
allFlags: PrideFlag[];
|
||||||
|
save(flags: string[]): Promise<void>;
|
||||||
|
form: { ok: boolean; error: RawApiError | null } | null;
|
||||||
|
};
|
||||||
|
let { profileFlags, allFlags, save, form }: Props = $props();
|
||||||
|
|
||||||
|
let flags = $state(profileFlags);
|
||||||
|
|
||||||
|
const select = (flag: PrideFlag) => {
|
||||||
|
flags = [...flags, flag];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFlag = (flag: PrideFlag) => {
|
||||||
|
const idx = flags.indexOf(flag);
|
||||||
|
if (idx === -1) return;
|
||||||
|
flags.splice(idx, 1);
|
||||||
|
flags = [...flags];
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveFlag = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == flags.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
const temp = flags[index];
|
||||||
|
flags[index] = flags[newIndex];
|
||||||
|
flags[newIndex] = temp;
|
||||||
|
flags = [...flags];
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveChanges = () => save(flags.map((f) => f.id));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h4>
|
||||||
|
{$t("settings.flag-title")}
|
||||||
|
<button type="button" class="btn btn-primary" onclick={() => saveChanges()}>
|
||||||
|
{$t("save-changes")}
|
||||||
|
</button>
|
||||||
|
</h4>
|
||||||
|
<FormStatusMarker {form} />
|
||||||
|
{#each flags as flag, i}
|
||||||
|
<div class="d-block">
|
||||||
|
<div class="btn-group flag-group">
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-up"
|
||||||
|
tooltip={$t("editor.move-flag-up")}
|
||||||
|
color="secondary"
|
||||||
|
outline
|
||||||
|
onclick={() => moveFlag(i, true)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-down"
|
||||||
|
tooltip={$t("editor.move-flag-down")}
|
||||||
|
color="secondary"
|
||||||
|
outline
|
||||||
|
onclick={() => moveFlag(i, false)}
|
||||||
|
/>
|
||||||
|
<FlagButton
|
||||||
|
{flag}
|
||||||
|
onclick={() => removeFlag(flag)}
|
||||||
|
tooltip={$t("editor.remove-this-flag")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-secondary">
|
||||||
|
{$t("editor.no-flags-hint")}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<h4>{$t("editor.add-flags-header")}</h4>
|
||||||
|
<FlagSearch flags={allFlags} {select} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flag-group {
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrideFlag } from "$api/models/user";
|
import type { PrideFlag } from "$api/models/user";
|
||||||
|
import { DEFAULT_FLAG } from "$lib";
|
||||||
import tippy from "$lib/tippy";
|
import tippy from "$lib/tippy";
|
||||||
|
|
||||||
type Props = { flag: PrideFlag };
|
type Props = { flag: PrideFlag };
|
||||||
|
@ -10,7 +11,7 @@
|
||||||
<img
|
<img
|
||||||
use:tippy={{ content: flag.description ?? flag.name }}
|
use:tippy={{ content: flag.description ?? flag.name }}
|
||||||
class="flag"
|
class="flag"
|
||||||
src={flag.image_url}
|
src={flag.image_url ?? DEFAULT_FLAG}
|
||||||
alt={flag.description ?? flag.name}
|
alt={flag.description ?? flag.name}
|
||||||
/>
|
/>
|
||||||
{flag.name}
|
{flag.name}
|
||||||
|
|
|
@ -123,7 +123,15 @@
|
||||||
"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-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-expires-at": "(expires {{expiresAt}})",
|
||||||
"export-download": "Download export",
|
"export-download": "Download export",
|
||||||
"export-request-button": "Request a new export"
|
"export-request-button": "Request a new export",
|
||||||
|
"flag-delete-button": "Delete flag",
|
||||||
|
"flag-current-flags-title": "Current flags ({{count}}/{{max}})",
|
||||||
|
"flag-title": "Flags",
|
||||||
|
"flag-upload-title": "Upload a new flag",
|
||||||
|
"flag-upload-button": "Upload",
|
||||||
|
"flag-description-placeholder": "Description",
|
||||||
|
"flag-name-placeholder": "Name",
|
||||||
|
"flag-upload-success": "Successfully uploaded your flag! It may take a few seconds before it's saved."
|
||||||
},
|
},
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
|
@ -183,6 +191,18 @@
|
||||||
"remove-field": "Remove field",
|
"remove-field": "Remove field",
|
||||||
"field-name": "Field name",
|
"field-name": "Field name",
|
||||||
"add-field": "Add field",
|
"add-field": "Add field",
|
||||||
"new-entry": "New entry"
|
"new-entry": "New entry",
|
||||||
}
|
"add-this-flag": "Add this flag",
|
||||||
|
"add-flags-header": "Add flags",
|
||||||
|
"move-flag-up": "Move flag up",
|
||||||
|
"move-flag-down": "Move flag down",
|
||||||
|
"remove-this-flag": "Remove this flag",
|
||||||
|
"no-flags-hint": "This profile doesn't have any flags yet! Add some with the search box.",
|
||||||
|
"flag-search-placeholder": "Type to start searching",
|
||||||
|
"flag-search-no-flags": "No flags matched your search query.",
|
||||||
|
"flag-search-no-account-flags": "You haven't uploaded any flags yet.",
|
||||||
|
"flag-search-hint": "Can't find the flag you're looking for? Try using the search bar above.",
|
||||||
|
"flag-manage-your-flags": "Manage your flags"
|
||||||
|
},
|
||||||
|
"cancel": "Cancel"
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ export const clearToken = (cookies: Cookies) => cookies.delete(TOKEN_COOKIE_NAME
|
||||||
|
|
||||||
// TODO: change this to something we actually clearly have the rights to use
|
// TODO: change this to something we actually clearly have the rights to use
|
||||||
export const DEFAULT_AVATAR = "https://pronouns.cc/default/512.webp";
|
export const DEFAULT_AVATAR = "https://pronouns.cc/default/512.webp";
|
||||||
|
export const DEFAULT_FLAG = "/unknown_flag.svg";
|
||||||
|
|
||||||
export const idTimestamp = (id: string) =>
|
export const idTimestamp = (id: string) =>
|
||||||
DateTime.fromMillis(parseInt(id, 10) / (1 << 22) + 1_640_995_200_000);
|
DateTime.fromMillis(parseInt(id, 10) / (1 << 22) + 1_640_995_200_000);
|
||||||
|
|
35
Foxnouns.Frontend/src/lib/paginate.ts
Normal file
35
Foxnouns.Frontend/src/lib/paginate.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
export type PaginatedArray<T> = {
|
||||||
|
data: T[];
|
||||||
|
currentPage: number;
|
||||||
|
pageCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginates an array.
|
||||||
|
* @param arr The array to paginate.
|
||||||
|
* @param page The zero-indexed page number.
|
||||||
|
* @param perPage How many items to display per page.
|
||||||
|
* @returns An object containing a slice of the array, the current page number, and the page count.
|
||||||
|
*/
|
||||||
|
export default function paginate<T>(
|
||||||
|
arr: T[] | null,
|
||||||
|
page: string | number | null,
|
||||||
|
perPage: number,
|
||||||
|
): PaginatedArray<T> {
|
||||||
|
if (arr && arr.length > 0) {
|
||||||
|
let currentPage = 0;
|
||||||
|
if (page && typeof page === "string") currentPage = parseInt(page);
|
||||||
|
if (page && typeof page === "number") currentPage = page;
|
||||||
|
|
||||||
|
const pageCount = Math.ceil(arr.length / perPage);
|
||||||
|
let data = arr.slice(currentPage * perPage, (currentPage + 1) * perPage);
|
||||||
|
if (data.length === 0) {
|
||||||
|
data = arr.slice(0, perPage);
|
||||||
|
currentPage = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, currentPage, pageCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: [], currentPage: 0, pageCount: 1 };
|
||||||
|
}
|
|
@ -1,7 +1,8 @@
|
||||||
import { apiRequest } from "$api";
|
import { apiRequest } from "$api";
|
||||||
import ApiError, { ErrorCode } from "$api/error.js";
|
import ApiError, { ErrorCode } from "$api/error.js";
|
||||||
import type { PartialMember, User, UserWithMembers } from "$api/models";
|
import type { UserWithMembers } from "$api/models";
|
||||||
import log from "$lib/log.js";
|
import log from "$lib/log.js";
|
||||||
|
import paginate from "$lib/paginate";
|
||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
|
|
||||||
const MEMBERS_PER_PAGE = 20;
|
const MEMBERS_PER_PAGE = 20;
|
||||||
|
@ -20,22 +21,11 @@ export const load = async ({ params, fetch, cookies, url }) => {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paginate members on the server side
|
const { data, currentPage, pageCount } = paginate(
|
||||||
let currentPage = 0;
|
user.members,
|
||||||
let pageCount = 0;
|
url.searchParams.get("page"),
|
||||||
let members: PartialMember[] = [];
|
MEMBERS_PER_PAGE,
|
||||||
if (user.members) {
|
|
||||||
currentPage = Number(url.searchParams.get("page") || "0");
|
|
||||||
pageCount = Math.ceil(user.members.length / MEMBERS_PER_PAGE);
|
|
||||||
members = user.members.slice(
|
|
||||||
currentPage * MEMBERS_PER_PAGE,
|
|
||||||
(currentPage + 1) * MEMBERS_PER_PAGE,
|
|
||||||
);
|
);
|
||||||
if (members.length === 0) {
|
|
||||||
members = user.members.slice(0, MEMBERS_PER_PAGE);
|
|
||||||
currentPage = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { user, members, currentPage, pageCount };
|
return { user, members: data, currentPage, pageCount };
|
||||||
};
|
};
|
||||||
|
|
42
Foxnouns.Frontend/src/routes/settings/flags/+page.server.ts
Normal file
42
Foxnouns.Frontend/src/routes/settings/flags/+page.server.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { apiRequest, fastRequest } from "$api";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import type { PrideFlag } from "$api/models/user";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import { encode } from "base64-arraybuffer";
|
||||||
|
|
||||||
|
export const load = async ({ url, fetch, cookies }) => {
|
||||||
|
const resp = await apiRequest<PrideFlag[]>("GET", "/users/@me/flags", { fetch, cookies });
|
||||||
|
|
||||||
|
return {
|
||||||
|
flags: resp,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
upload: async ({ request, fetch, cookies }) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
const name = body.get("name") as string;
|
||||||
|
const description = body.get("desc") as string;
|
||||||
|
const image = body.get("image") as File;
|
||||||
|
|
||||||
|
const buffer = await image.arrayBuffer();
|
||||||
|
const base64 = encode(buffer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fastRequest("POST", "/users/@me/flags", {
|
||||||
|
body: {
|
||||||
|
name,
|
||||||
|
description: description ? description : null,
|
||||||
|
image: `data:${image.type};base64,${base64}`,
|
||||||
|
},
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
return { ok: true, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { ok: false, error: e.obj };
|
||||||
|
log.error("error uploading flag:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
129
Foxnouns.Frontend/src/routes/settings/flags/+page.svelte
Normal file
129
Foxnouns.Frontend/src/routes/settings/flags/+page.svelte
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Accordion, AccordionItem } from "@sveltestrap/sveltestrap";
|
||||||
|
import type { ActionData, PageData } from "./$types";
|
||||||
|
import EditorFlagImage from "$components/editor/EditorFlagImage.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import type { PrideFlag } from "$api/models";
|
||||||
|
import paginate from "$lib/paginate";
|
||||||
|
import ClientPaginator from "$components/ClientPaginator.svelte";
|
||||||
|
import FlagEditor from "$components/editor/FlagEditor.svelte";
|
||||||
|
import NoscriptWarning from "$components/editor/NoscriptWarning.svelte";
|
||||||
|
import { fastRequest } from "$api";
|
||||||
|
import type { RawApiError } from "$api/error";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||||
|
|
||||||
|
type Props = { data: PageData; form: ActionData };
|
||||||
|
let { data, form }: Props = $props();
|
||||||
|
|
||||||
|
let flags = $state(data.flags);
|
||||||
|
|
||||||
|
let arr: PrideFlag[] = $state([]);
|
||||||
|
let currentPage = $state(0);
|
||||||
|
let pageCount = $state(0);
|
||||||
|
|
||||||
|
const FLAGS_PER_PAGE = 50;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const pages = paginate(flags, currentPage, FLAGS_PER_PAGE);
|
||||||
|
arr = pages.data;
|
||||||
|
pageCount = pages.pageCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastEditedFlag: string | null = $state(null);
|
||||||
|
let ok: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
||||||
|
|
||||||
|
const update = async (
|
||||||
|
id: string,
|
||||||
|
{ name, description }: { name: string; description: string | null },
|
||||||
|
) => {
|
||||||
|
lastEditedFlag = id;
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", `/users/@me/flags/${id}`, {
|
||||||
|
body: { name, description },
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
ok = { ok: true, error: null };
|
||||||
|
|
||||||
|
const idx = flags.findIndex((f) => f.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
console.log("yippee");
|
||||||
|
flags[idx] = { ...flags[idx], name, description };
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Could not update flag %s:", id, e);
|
||||||
|
if (e instanceof ApiError) ok = { ok: false, error: e.obj };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFlag = async (id: string) => {
|
||||||
|
lastEditedFlag = id;
|
||||||
|
try {
|
||||||
|
await fastRequest("DELETE", `/users/@me/flags/${id}`, { token: data.token });
|
||||||
|
ok = { ok: true, error: null };
|
||||||
|
|
||||||
|
const idx = flags.findIndex((f) => f.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
flags.splice(idx, 1);
|
||||||
|
flags = [...flags];
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Could not remove flag %s:", id, e);
|
||||||
|
if (e instanceof ApiError) ok = { ok: false, error: e.obj };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h3>{$t("settings.flag-title")}</h3>
|
||||||
|
|
||||||
|
<NoscriptWarning />
|
||||||
|
|
||||||
|
<form method="POST" action="?/upload" enctype="multipart/form-data">
|
||||||
|
<FormStatusMarker {form} successMessage={$t("settings.flag-upload-success")} />
|
||||||
|
<h4>{$t("settings.flag-upload-title")}</h4>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="image"
|
||||||
|
accept="image/png, image/jpeg, image/gif, image/webp"
|
||||||
|
class="mb-2 form-control"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="mb-2 form-control"
|
||||||
|
name="name"
|
||||||
|
placeholder={$t("settings.flag-name-placeholder")}
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="mb-2 form-control"
|
||||||
|
name="desc"
|
||||||
|
placeholder={$t("settings.flag-description-placeholder")}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary">{$t("settings.flag-upload-button")}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h4 class="mt-3">
|
||||||
|
{$t("settings.flag-current-flags-title", {
|
||||||
|
count: data.flags.length,
|
||||||
|
max: data.meta.limits.max_flags,
|
||||||
|
})}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<ClientPaginator center bind:currentPage {pageCount} />
|
||||||
|
|
||||||
|
<Accordion>
|
||||||
|
{#each arr as flag (flag.id)}
|
||||||
|
<AccordionItem>
|
||||||
|
<span slot="header">
|
||||||
|
<EditorFlagImage {flag} />
|
||||||
|
{flag.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if lastEditedFlag === flag.id}<FormStatusMarker form={ok} />{/if}
|
||||||
|
<FlagEditor {flag} {update} {deleteFlag} />
|
||||||
|
</AccordionItem>
|
||||||
|
{/each}
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<ClientPaginator center bind:currentPage {pageCount} />
|
|
@ -1,18 +1,15 @@
|
||||||
|
import paginate from "$lib/paginate";
|
||||||
|
|
||||||
const MEMBERS_PER_PAGE = 15;
|
const MEMBERS_PER_PAGE = 15;
|
||||||
|
|
||||||
export const load = async ({ url, parent }) => {
|
export const load = async ({ url, parent }) => {
|
||||||
const { user } = await parent();
|
const { user } = await parent();
|
||||||
|
|
||||||
let currentPage = Number(url.searchParams.get("page") || "0");
|
const { data, currentPage, pageCount } = paginate(
|
||||||
let pageCount = Math.ceil(user.members.length / MEMBERS_PER_PAGE);
|
user.members,
|
||||||
let members = user.members.slice(
|
url.searchParams.get("page"),
|
||||||
currentPage * MEMBERS_PER_PAGE,
|
MEMBERS_PER_PAGE,
|
||||||
(currentPage + 1) * MEMBERS_PER_PAGE,
|
|
||||||
);
|
);
|
||||||
if (members.length === 0) {
|
|
||||||
members = user.members.slice(0, MEMBERS_PER_PAGE);
|
|
||||||
currentPage = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { members, currentPage, pageCount };
|
return { members: data, currentPage, pageCount };
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
import ApiError from "$api/error";
|
import ApiError from "$api/error";
|
||||||
import log from "$lib/log";
|
import log from "$lib/log";
|
||||||
import { Icon, InputGroup } from "@sveltestrap/sveltestrap";
|
import { Icon, InputGroup } from "@sveltestrap/sveltestrap";
|
||||||
|
import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import AvatarEditor from "$components/editor/AvatarEditor.svelte";
|
import AvatarEditor from "$components/editor/AvatarEditor.svelte";
|
||||||
import ErrorAlert from "$components/ErrorAlert.svelte";
|
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||||
|
@ -133,7 +134,7 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted mt-1">
|
<p class="text-muted mt-1">
|
||||||
<Icon name="info-circle-fill" aria-hidden />
|
<InfoCircleFill aria-hidden />
|
||||||
{$t("edit-profile.unlisted-note")}
|
{$t("edit-profile.unlisted-note")}
|
||||||
<code>
|
<code>
|
||||||
{PUBLIC_BASE_URL.substring("https://".length)}/@{data.member.user.username}/{data.member
|
{PUBLIC_BASE_URL.substring("https://".length)}/@{data.member.user.username}/{data.member
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import ApiError, { type RawApiError } from "$api/error";
|
||||||
|
import { mergePreferences, type User } from "$api/models/user";
|
||||||
|
import FieldsEditor from "$components/editor/FieldsEditor.svelte";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let fields = $state(data.member.fields);
|
||||||
|
let ok: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
||||||
|
let allPreferences = $derived(mergePreferences(data.user.custom_preferences));
|
||||||
|
|
||||||
|
const update = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiRequest<User>("PATCH", `/users/@me/members/${data.member.id}`, {
|
||||||
|
body: { fields },
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
fields = resp.fields;
|
||||||
|
ok = { ok: true, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
ok = { ok: false, error: null };
|
||||||
|
log.error("Could not update fields:", e);
|
||||||
|
if (e instanceof ApiError) ok.error = e.obj;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FieldsEditor bind:fields {ok} {allPreferences} {update} />
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import type { PrideFlag } from "$api/models/user";
|
||||||
|
|
||||||
|
export const load = async ({ fetch, cookies }) => {
|
||||||
|
const flags = await apiRequest<PrideFlag[]>("GET", "/users/@me/flags", { fetch, cookies });
|
||||||
|
return { flags };
|
||||||
|
};
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { fastRequest } from "$api";
|
||||||
|
import type { RawApiError } from "$api/error";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import ProfileFlagsEditor from "$components/editor/ProfileFlagsEditor.svelte";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let form: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
||||||
|
|
||||||
|
const save = async (flags: string[]) => {
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", `/users/@me/members/${data.member.id}`, {
|
||||||
|
body: { flags },
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
form = { ok: true, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Could not update profile flags for member %s:", data.member.id, e);
|
||||||
|
if (e instanceof ApiError) form = { ok: false, error: e.obj };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProfileFlagsEditor profileFlags={data.member.flags} allFlags={data.flags} {save} {form} />
|
|
@ -29,4 +29,4 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FieldsEditor {fields} {ok} {allPreferences} {update} />
|
<FieldsEditor bind:fields {ok} {allPreferences} {update} />
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import type { PrideFlag } from "$api/models/user";
|
||||||
|
|
||||||
|
export const load = async ({ fetch, cookies }) => {
|
||||||
|
const flags = await apiRequest<PrideFlag[]>("GET", "/users/@me/flags", { fetch, cookies });
|
||||||
|
return { flags };
|
||||||
|
};
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { fastRequest } from "$api";
|
||||||
|
import type { RawApiError } from "$api/error";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import ProfileFlagsEditor from "$components/editor/ProfileFlagsEditor.svelte";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let form: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
||||||
|
|
||||||
|
const save = async (flags: string[]) => {
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", "/users/@me", {
|
||||||
|
body: { flags },
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
form = { ok: true, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Could not update profile flags:", e);
|
||||||
|
if (e instanceof ApiError) form = { ok: false, error: e.obj };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProfileFlagsEditor profileFlags={data.user.flags} allFlags={data.flags} {save} {form} />
|
9
Foxnouns.Frontend/static/unknown_flag.svg
Normal file
9
Foxnouns.Frontend/static/unknown_flag.svg
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" version="1.0" width="225" height="150" id="svg2">
|
||||||
|
<defs id="defs5"/>
|
||||||
|
<path d="M 0,0 L 0,50 L 225,50 L 225,0 L 0,0 z " style="fill:#bbb" id="rect1308"/>
|
||||||
|
<path d="M 0,50 L 0,100 L 225,100 L 225,50 L 0,50 z " style="fill:#d4d4d4" id="rect1310"/>
|
||||||
|
<path d="M 0,100 L 0,150 L 225,150 L 225,100 L 0,100 z " style="fill:#e3e3e3" id="rect1313"/>
|
||||||
|
<path d="M 64.255859,62.075068 L 63.136719,62.075068 C 63.128904,62.047727 63.092772,61.948118 63.02832,61.77624 C 62.963866,61.604368 62.931639,61.453977 62.931641,61.325068 C 62.931639,61.078978 62.965819,60.84265 63.03418,60.616084 C 63.102537,60.389525 63.203123,60.177611 63.335938,59.980341 C 63.468748,59.783081 63.731443,59.467651 64.124023,59.034052 C 64.516598,58.600465 64.712887,58.243043 64.712891,57.961787 C 64.712887,57.414919 64.355466,57.141482 63.640625,57.141474 C 63.292967,57.141482 62.929686,57.31531 62.550781,57.662959 L 61.947266,56.532099 C 62.451171,56.137576 63.113279,55.940311 63.933594,55.940302 C 64.566403,55.940311 65.094723,56.116092 65.518555,56.467646 C 65.942378,56.819216 66.154292,57.286013 66.154297,57.868037 C 66.154292,58.266481 66.077144,58.603394 65.922852,58.878779 C 65.76855,59.154175 65.497066,59.477417 65.108398,59.848505 C 64.719723,60.219604 64.466794,60.528197 64.349609,60.774287 C 64.232419,61.020384 64.173825,61.289915 64.173828,61.58288 C 64.173825,61.645383 64.201169,61.809446 64.255859,62.075068 L 64.255859,62.075068 z M 63.757813,62.871943 C 64.023435,62.871945 64.249021,62.965695 64.43457,63.153193 C 64.620114,63.340694 64.712887,63.567257 64.712891,63.83288 C 64.712887,64.098506 64.620114,64.325068 64.43457,64.512568 C 64.249021,64.700068 64.023435,64.793818 63.757813,64.793818 C 63.492185,64.793818 63.265623,64.700068 63.078125,64.512568 C 62.890623,64.325068 62.796874,64.098506 62.796875,63.83288 C 62.796874,63.567257 62.890623,63.340694 63.078125,63.153193 C 63.265623,62.965695 63.492185,62.871945 63.757813,62.871943 L 63.757813,62.871943 z " transform="matrix(10.52848,0,0,10.52848,-561.8574,-560.5734)" style="font-size:12px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;fill:white;font-family:Trebuchet MS" id="flowRoot1875"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.4 KiB |
|
@ -33,3 +33,8 @@ Code taken entirely or almost entirely from external sources:
|
||||||
taken from [PluralKit](https://github.com/PluralKit/PluralKit/blob/32a6e97342acc3b35e6f9e7b4dd169e21d888770/PluralKit.Core/Database/Functions/functions.sql)
|
taken from [PluralKit](https://github.com/PluralKit/PluralKit/blob/32a6e97342acc3b35e6f9e7b4dd169e21d888770/PluralKit.Core/Database/Functions/functions.sql)
|
||||||
- `Foxnouns.Backend/Database/prune-designer-cs-files.sh`,
|
- `Foxnouns.Backend/Database/prune-designer-cs-files.sh`,
|
||||||
taken from [Iceshrimp.NET](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/commit/7c93dcf79dda54fc1a4ea9772e3f80874e6bcefb/Iceshrimp.Backend/Core/Database/prune-designer-cs-files.sh)
|
taken from [Iceshrimp.NET](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/commit/7c93dcf79dda54fc1a4ea9772e3f80874e6bcefb/Iceshrimp.Backend/Core/Database/prune-designer-cs-files.sh)
|
||||||
|
|
||||||
|
Files under a different license:
|
||||||
|
|
||||||
|
- `Foxnouns.Frontend/static/unknown_flag.svg` is https://commons.wikimedia.org/wiki/File:Unknown_flag.svg,
|
||||||
|
by 8938e on Wikimedia Commons, licensed as CC BY-SA 4.0.
|
Loading…
Add table
Reference in a new issue