Compare commits

...

4 commits

54 changed files with 1252 additions and 554 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1,6 @@
// ReSharper disable NotAccessedPositionalProperty.Global
using NodaTime;
namespace Foxnouns.Backend.Dto;
public record DataExportResponse(string? Url, Instant? ExpiresAt);

View 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; }
}

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

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

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

View 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; }
}

View file

@ -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 = $"""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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 };
}

View file

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

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

View 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} />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,4 +29,4 @@
}; };
</script> </script>
<FieldsEditor {fields} {ok} {allPreferences} {update} /> <FieldsEditor bind:fields {ok} {allPreferences} {update} />

View file

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

View file

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

View 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

View file

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