chore: add csharpier to husky, format backend with csharpier
This commit is contained in:
parent
5fab66444f
commit
7f971e8549
73 changed files with 2098 additions and 1048 deletions
|
@ -10,4 +10,4 @@ public class ApiControllerBase : ControllerBase
|
|||
{
|
||||
internal Token? CurrentToken => HttpContext.GetToken();
|
||||
internal User? CurrentUser => HttpContext.GetUser();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,8 +11,12 @@ using NodaTime;
|
|||
namespace Foxnouns.Backend.Controllers.Authentication;
|
||||
|
||||
[Route("/api/internal/auth")]
|
||||
public class AuthController(Config config, DatabaseContext db, KeyCacheService keyCache, ILogger logger)
|
||||
: ApiControllerBase
|
||||
public class AuthController(
|
||||
Config config,
|
||||
DatabaseContext db,
|
||||
KeyCacheService keyCache,
|
||||
ILogger logger
|
||||
) : ApiControllerBase
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<AuthController>();
|
||||
|
||||
|
@ -20,27 +24,25 @@ public class AuthController(Config config, DatabaseContext db, KeyCacheService k
|
|||
[ProducesResponseType<UrlsResponse>(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> UrlsAsync(CancellationToken ct = default)
|
||||
{
|
||||
_logger.Debug("Generating auth URLs for Discord: {Discord}, Google: {Google}, Tumblr: {Tumblr}",
|
||||
_logger.Debug(
|
||||
"Generating auth URLs for Discord: {Discord}, Google: {Google}, Tumblr: {Tumblr}",
|
||||
config.DiscordAuth.Enabled,
|
||||
config.GoogleAuth.Enabled,
|
||||
config.TumblrAuth.Enabled);
|
||||
config.TumblrAuth.Enabled
|
||||
);
|
||||
var state = HttpUtility.UrlEncode(await keyCache.GenerateAuthStateAsync(ct));
|
||||
string? discord = null;
|
||||
if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null })
|
||||
discord =
|
||||
$"https://discord.com/oauth2/authorize?response_type=code" +
|
||||
$"&client_id={config.DiscordAuth.ClientId}&scope=identify" +
|
||||
$"&prompt=none&state={state}" +
|
||||
$"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
|
||||
$"https://discord.com/oauth2/authorize?response_type=code"
|
||||
+ $"&client_id={config.DiscordAuth.ClientId}&scope=identify"
|
||||
+ $"&prompt=none&state={state}"
|
||||
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
|
||||
|
||||
return Ok(new UrlsResponse(discord, null, null));
|
||||
}
|
||||
|
||||
private record UrlsResponse(
|
||||
string? Discord,
|
||||
string? Google,
|
||||
string? Tumblr
|
||||
);
|
||||
private record UrlsResponse(string? Discord, string? Google, string? Tumblr);
|
||||
|
||||
public record AuthResponse(
|
||||
UserRendererService.UserResponse User,
|
||||
|
@ -50,16 +52,13 @@ public class AuthController(Config config, DatabaseContext db, KeyCacheService k
|
|||
|
||||
public record CallbackResponse(
|
||||
bool HasAccount,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
string? Ticket,
|
||||
string? RemoteUsername,
|
||||
[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
|
||||
UserRendererService.UserResponse? User,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt
|
||||
);
|
||||
|
||||
public record OauthRegisterRequest(string Ticket, string Username);
|
||||
|
@ -71,9 +70,10 @@ public class AuthController(Config config, DatabaseContext db, KeyCacheService k
|
|||
public async Task<IActionResult> ForceLogoutAsync()
|
||||
{
|
||||
_logger.Information("Invalidating all tokens for user {UserId}", CurrentUser!.Id);
|
||||
await db.Tokens.Where(t => t.UserId == CurrentUser.Id)
|
||||
await db
|
||||
.Tokens.Where(t => t.UserId == CurrentUser.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ManuallyExpired, true));
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,8 @@ public class DiscordAuthController(
|
|||
KeyCacheService keyCacheService,
|
||||
AuthService authService,
|
||||
RemoteAuthService remoteAuthService,
|
||||
UserRendererService userRenderer) : ApiControllerBase
|
||||
UserRendererService userRenderer
|
||||
) : ApiControllerBase
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<DiscordAuthController>();
|
||||
|
||||
|
@ -27,59 +28,93 @@ public class DiscordAuthController(
|
|||
// TODO: duplicating attribute doesn't work, find another way to mark both as possible response
|
||||
// leaving it here for documentation purposes
|
||||
[ProducesResponseType<AuthController.CallbackResponse>(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> CallbackAsync([FromBody] AuthController.CallbackRequest req,
|
||||
CancellationToken ct = default)
|
||||
public async Task<IActionResult> CallbackAsync(
|
||||
[FromBody] AuthController.CallbackRequest req,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
CheckRequirements();
|
||||
await keyCacheService.ValidateAuthStateAsync(req.State, ct);
|
||||
|
||||
var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code, req.State, ct);
|
||||
var user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id, ct: ct);
|
||||
if (user != null) return Ok(await GenerateUserTokenAsync(user, ct));
|
||||
if (user != null)
|
||||
return Ok(await GenerateUserTokenAsync(user, ct));
|
||||
|
||||
_logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username,
|
||||
remoteUser.Id);
|
||||
_logger.Debug(
|
||||
"Discord user {Username} ({Id}) authenticated with no local account",
|
||||
remoteUser.Username,
|
||||
remoteUser.Id
|
||||
);
|
||||
|
||||
var ticket = AuthUtils.RandomToken();
|
||||
await keyCacheService.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20), ct);
|
||||
await keyCacheService.SetKeyAsync(
|
||||
$"discord:{ticket}",
|
||||
remoteUser,
|
||||
Duration.FromMinutes(20),
|
||||
ct
|
||||
);
|
||||
|
||||
return Ok(new AuthController.CallbackResponse(
|
||||
HasAccount: false,
|
||||
Ticket: ticket,
|
||||
RemoteUsername: remoteUser.Username,
|
||||
User: null,
|
||||
Token: null,
|
||||
ExpiresAt: null
|
||||
));
|
||||
return Ok(
|
||||
new AuthController.CallbackResponse(
|
||||
HasAccount: false,
|
||||
Ticket: ticket,
|
||||
RemoteUsername: remoteUser.Username,
|
||||
User: null,
|
||||
Token: null,
|
||||
ExpiresAt: null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> RegisterAsync([FromBody] AuthController.OauthRegisterRequest req)
|
||||
public async Task<IActionResult> RegisterAsync(
|
||||
[FromBody] AuthController.OauthRegisterRequest req
|
||||
)
|
||||
{
|
||||
var remoteUser = await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>($"discord:{req.Ticket}");
|
||||
if (remoteUser == null) throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
|
||||
if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id))
|
||||
var remoteUser = await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>(
|
||||
$"discord:{req.Ticket}"
|
||||
);
|
||||
if (remoteUser == null)
|
||||
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
|
||||
if (
|
||||
await db.AuthMethods.AnyAsync(a =>
|
||||
a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id
|
||||
)
|
||||
)
|
||||
{
|
||||
_logger.Error("Discord user {Id} has valid ticket but is already linked to an existing account",
|
||||
remoteUser.Id);
|
||||
_logger.Error(
|
||||
"Discord user {Id} has valid ticket but is already linked to an existing account",
|
||||
remoteUser.Id
|
||||
);
|
||||
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
|
||||
}
|
||||
|
||||
var user = await authService.CreateUserWithRemoteAuthAsync(req.Username, AuthType.Discord, remoteUser.Id,
|
||||
remoteUser.Username);
|
||||
var user = await authService.CreateUserWithRemoteAuthAsync(
|
||||
req.Username,
|
||||
AuthType.Discord,
|
||||
remoteUser.Id,
|
||||
remoteUser.Username
|
||||
);
|
||||
|
||||
return Ok(await GenerateUserTokenAsync(user));
|
||||
}
|
||||
|
||||
private async Task<AuthController.CallbackResponse> GenerateUserTokenAsync(User user,
|
||||
CancellationToken ct = default)
|
||||
private async Task<AuthController.CallbackResponse> GenerateUserTokenAsync(
|
||||
User user,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var frontendApp = await db.GetFrontendApplicationAsync(ct);
|
||||
_logger.Debug("Logging user {Id} in with Discord", user.Id);
|
||||
|
||||
var (tokenStr, token) =
|
||||
authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365));
|
||||
var (tokenStr, token) = authService.GenerateToken(
|
||||
user,
|
||||
frontendApp,
|
||||
["*"],
|
||||
clock.GetCurrentInstant() + Duration.FromDays(365)
|
||||
);
|
||||
db.Add(token);
|
||||
|
||||
_logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id);
|
||||
|
@ -90,7 +125,12 @@ public class DiscordAuthController(
|
|||
HasAccount: true,
|
||||
Ticket: null,
|
||||
RemoteUsername: null,
|
||||
User: await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct),
|
||||
User: await userRenderer.RenderUserAsync(
|
||||
user,
|
||||
selfUser: user,
|
||||
renderMembers: false,
|
||||
ct: ct
|
||||
),
|
||||
Token: tokenStr,
|
||||
ExpiresAt: token.ExpiresAt
|
||||
);
|
||||
|
@ -99,6 +139,8 @@ public class DiscordAuthController(
|
|||
private void CheckRequirements()
|
||||
{
|
||||
if (!config.DiscordAuth.Enabled)
|
||||
throw new ApiError.BadRequest("Discord authentication is not enabled on this instance.");
|
||||
throw new ApiError.BadRequest(
|
||||
"Discord authentication is not enabled on this instance."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,21 +20,35 @@ public class EmailAuthController(
|
|||
KeyCacheService keyCacheService,
|
||||
UserRendererService userRenderer,
|
||||
IClock clock,
|
||||
ILogger logger) : ApiControllerBase
|
||||
ILogger logger
|
||||
) : ApiControllerBase
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<EmailAuthController>();
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> RegisterAsync([FromBody] RegisterRequest req, CancellationToken ct = default)
|
||||
public async Task<IActionResult> RegisterAsync(
|
||||
[FromBody] RegisterRequest req,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
CheckRequirements();
|
||||
|
||||
if (!req.Email.Contains('@')) throw new ApiError.BadRequest("Email is invalid", "email", req.Email);
|
||||
if (!req.Email.Contains('@'))
|
||||
throw new ApiError.BadRequest("Email is invalid", "email", req.Email);
|
||||
|
||||
var state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, userId: null, ct);
|
||||
var state = await keyCacheService.GenerateRegisterEmailStateAsync(
|
||||
req.Email,
|
||||
userId: null,
|
||||
ct
|
||||
);
|
||||
|
||||
// If there's already a user with that email address, pretend we sent an email but actually ignore it
|
||||
if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Email && a.RemoteId == req.Email, ct))
|
||||
if (
|
||||
await db.AuthMethods.AnyAsync(
|
||||
a => a.AuthType == AuthType.Email && a.RemoteId == req.Email,
|
||||
ct
|
||||
)
|
||||
)
|
||||
return NoContent();
|
||||
|
||||
mailService.QueueAccountCreationEmail(req.Email, state);
|
||||
|
@ -47,29 +61,48 @@ public class EmailAuthController(
|
|||
CheckRequirements();
|
||||
|
||||
var state = await keyCacheService.GetRegisterEmailStateAsync(req.State);
|
||||
if (state == null) throw new ApiError.BadRequest("Invalid state", "state", req.State);
|
||||
if (state == null)
|
||||
throw new ApiError.BadRequest("Invalid state", "state", req.State);
|
||||
|
||||
// If this callback is for an existing user, add the email address to their auth methods
|
||||
if (state.ExistingUserId != null)
|
||||
{
|
||||
var authMethod =
|
||||
await authService.AddAuthMethodAsync(state.ExistingUserId.Value, AuthType.Email, state.Email);
|
||||
_logger.Debug("Added email auth {AuthId} for user {UserId}", authMethod.Id, state.ExistingUserId);
|
||||
var authMethod = await authService.AddAuthMethodAsync(
|
||||
state.ExistingUserId.Value,
|
||||
AuthType.Email,
|
||||
state.Email
|
||||
);
|
||||
_logger.Debug(
|
||||
"Added email auth {AuthId} for user {UserId}",
|
||||
authMethod.Id,
|
||||
state.ExistingUserId
|
||||
);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
var ticket = AuthUtils.RandomToken();
|
||||
await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20));
|
||||
|
||||
return Ok(new AuthController.CallbackResponse(HasAccount: false, Ticket: ticket, RemoteUsername: state.Email,
|
||||
User: null, Token: null, ExpiresAt: null));
|
||||
return Ok(
|
||||
new AuthController.CallbackResponse(
|
||||
HasAccount: false,
|
||||
Ticket: ticket,
|
||||
RemoteUsername: state.Email,
|
||||
User: null,
|
||||
Token: null,
|
||||
ExpiresAt: null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("complete-registration")]
|
||||
public async Task<IActionResult> CompleteRegistrationAsync([FromBody] CompleteRegistrationRequest req)
|
||||
public async Task<IActionResult> CompleteRegistrationAsync(
|
||||
[FromBody] CompleteRegistrationRequest req
|
||||
)
|
||||
{
|
||||
var email = await keyCacheService.GetKeyAsync($"email:{req.Ticket}");
|
||||
if (email == null) throw new ApiError.BadRequest("Unknown ticket", "ticket", req.Ticket);
|
||||
if (email == null)
|
||||
throw new ApiError.BadRequest("Unknown ticket", "ticket", req.Ticket);
|
||||
|
||||
// Check if username is valid at all
|
||||
ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(req.Username))]);
|
||||
|
@ -80,28 +113,41 @@ public class EmailAuthController(
|
|||
var user = await authService.CreateUserWithPasswordAsync(req.Username, email, req.Password);
|
||||
var frontendApp = await db.GetFrontendApplicationAsync();
|
||||
|
||||
var (tokenStr, token) =
|
||||
authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365));
|
||||
var (tokenStr, token) = authService.GenerateToken(
|
||||
user,
|
||||
frontendApp,
|
||||
["*"],
|
||||
clock.GetCurrentInstant() + Duration.FromDays(365)
|
||||
);
|
||||
db.Add(token);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}");
|
||||
|
||||
return Ok(new AuthController.AuthResponse(
|
||||
await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false),
|
||||
tokenStr,
|
||||
token.ExpiresAt
|
||||
));
|
||||
return Ok(
|
||||
new AuthController.AuthResponse(
|
||||
await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false),
|
||||
tokenStr,
|
||||
token.ExpiresAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> LoginAsync([FromBody] LoginRequest req, CancellationToken ct = default)
|
||||
public async Task<IActionResult> LoginAsync(
|
||||
[FromBody] LoginRequest req,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
CheckRequirements();
|
||||
|
||||
var (user, authenticationResult) = await authService.AuthenticateUserAsync(req.Email, req.Password, ct);
|
||||
var (user, authenticationResult) = await authService.AuthenticateUserAsync(
|
||||
req.Email,
|
||||
req.Password,
|
||||
ct
|
||||
);
|
||||
if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired)
|
||||
throw new NotImplementedException("MFA is not implemented yet");
|
||||
|
||||
|
@ -109,19 +155,30 @@ public class EmailAuthController(
|
|||
|
||||
_logger.Debug("Logging user {Id} in with email and password", user.Id);
|
||||
|
||||
var (tokenStr, token) =
|
||||
authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365));
|
||||
var (tokenStr, token) = authService.GenerateToken(
|
||||
user,
|
||||
frontendApp,
|
||||
["*"],
|
||||
clock.GetCurrentInstant() + Duration.FromDays(365)
|
||||
);
|
||||
db.Add(token);
|
||||
|
||||
_logger.Debug("Generated token {TokenId} for {UserId}", token.Id, user.Id);
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new AuthController.AuthResponse(
|
||||
await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct),
|
||||
tokenStr,
|
||||
token.ExpiresAt
|
||||
));
|
||||
return Ok(
|
||||
new AuthController.AuthResponse(
|
||||
await userRenderer.RenderUserAsync(
|
||||
user,
|
||||
selfUser: user,
|
||||
renderMembers: false,
|
||||
ct: ct
|
||||
),
|
||||
tokenStr,
|
||||
token.ExpiresAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("add")]
|
||||
|
@ -148,4 +205,4 @@ public class EmailAuthController(
|
|||
public record CompleteRegistrationRequest(string Ticket, string Username, string Password);
|
||||
|
||||
public record CallbackRequest(string State);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,13 +18,16 @@ public class FlagsController(
|
|||
UserRendererService userRenderer,
|
||||
ObjectStorageService objectStorageService,
|
||||
ISnowflakeGenerator snowflakeGenerator,
|
||||
IQueue queue) : ApiControllerBase
|
||||
IQueue queue
|
||||
) : ApiControllerBase
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<FlagsController>();
|
||||
|
||||
[HttpGet]
|
||||
[Authorize("identify")]
|
||||
[ProducesResponseType<IEnumerable<UserRendererService.PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<IEnumerable<UserRendererService.PrideFlagResponse>>(
|
||||
statusCode: StatusCodes.Status200OK
|
||||
)]
|
||||
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var flags = await db.PrideFlags.Where(f => f.UserId == CurrentUser!.Id).ToListAsync(ct);
|
||||
|
@ -34,7 +37,9 @@ public class FlagsController(
|
|||
|
||||
[HttpPost]
|
||||
[Authorize("user.update")]
|
||||
[ProducesResponseType<UserRendererService.PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType<UserRendererService.PrideFlagResponse>(
|
||||
statusCode: StatusCodes.Status202Accepted
|
||||
)]
|
||||
public IActionResult CreateFlag([FromBody] CreateFlagRequest req)
|
||||
{
|
||||
ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image));
|
||||
|
@ -42,7 +47,8 @@ public class FlagsController(
|
|||
var id = snowflakeGenerator.GenerateSnowflake();
|
||||
|
||||
queue.QueueInvocableWithPayload<CreateFlagInvocable, CreateFlagPayload>(
|
||||
new CreateFlagPayload(id, CurrentUser!.Id, req.Name, req.Image, req.Description));
|
||||
new CreateFlagPayload(id, CurrentUser!.Id, req.Name, req.Image, req.Description)
|
||||
);
|
||||
|
||||
return Accepted(new CreateFlagResponse(id, req.Name, req.Description));
|
||||
}
|
||||
|
@ -57,10 +63,14 @@ public class FlagsController(
|
|||
{
|
||||
ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, null));
|
||||
|
||||
var flag = await db.PrideFlags.FirstOrDefaultAsync(f => f.Id == id && f.UserId == CurrentUser!.Id);
|
||||
if (flag == null) throw new ApiError.NotFound("Unknown flag ID, or it's not your flag.");
|
||||
var flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
||||
f.Id == id && f.UserId == CurrentUser!.Id
|
||||
);
|
||||
if (flag == null)
|
||||
throw new ApiError.NotFound("Unknown flag ID, or it's not your flag.");
|
||||
|
||||
if (req.Name != null) flag.Name = req.Name;
|
||||
if (req.Name != null)
|
||||
flag.Name = req.Name;
|
||||
|
||||
if (req.HasProperty(nameof(req.Description)))
|
||||
flag.Description = req.Description;
|
||||
|
@ -83,8 +93,11 @@ public class FlagsController(
|
|||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
var flag = await db.PrideFlags.FirstOrDefaultAsync(f => f.Id == id && f.UserId == CurrentUser!.Id);
|
||||
if (flag == null) throw new ApiError.NotFound("Unknown flag ID, or it's not your flag.");
|
||||
var flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
||||
f.Id == id && f.UserId == CurrentUser!.Id
|
||||
);
|
||||
if (flag == null)
|
||||
throw new ApiError.NotFound("Unknown flag ID, or it's not your flag.");
|
||||
|
||||
var hash = flag.Hash;
|
||||
|
||||
|
@ -96,7 +109,10 @@ public class FlagsController(
|
|||
{
|
||||
try
|
||||
{
|
||||
_logger.Information("Deleting flag file {Hash} as it is no longer used by any flags", hash);
|
||||
_logger.Information(
|
||||
"Deleting flag file {Hash} as it is no longer used by any flags",
|
||||
hash
|
||||
);
|
||||
await objectStorageService.DeleteFlagAsync(hash);
|
||||
}
|
||||
catch (Exception e)
|
||||
|
@ -104,14 +120,19 @@ public class FlagsController(
|
|||
_logger.Error(e, "Error deleting flag file {Hash}", hash);
|
||||
}
|
||||
}
|
||||
else _logger.Debug("Flag file {Hash} is used by other flags, not deleting", hash);
|
||||
else
|
||||
_logger.Debug("Flag file {Hash} is used by other flags, not deleting", hash);
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static List<(string, ValidationError?)> ValidateFlag(string? name, string? description, string? imageData)
|
||||
private static List<(string, ValidationError?)> ValidateFlag(
|
||||
string? name,
|
||||
string? description,
|
||||
string? imageData
|
||||
)
|
||||
{
|
||||
var errors = new List<(string, ValidationError?)>();
|
||||
|
||||
|
@ -120,10 +141,20 @@ public class FlagsController(
|
|||
switch (name.Length)
|
||||
{
|
||||
case < 1:
|
||||
errors.Add(("name", ValidationError.LengthError("Name is too short", 1, 100, name.Length)));
|
||||
errors.Add(
|
||||
(
|
||||
"name",
|
||||
ValidationError.LengthError("Name is too short", 1, 100, name.Length)
|
||||
)
|
||||
);
|
||||
break;
|
||||
case > 100:
|
||||
errors.Add(("name", ValidationError.LengthError("Name is too long", 1, 100, name.Length)));
|
||||
errors.Add(
|
||||
(
|
||||
"name",
|
||||
ValidationError.LengthError("Name is too long", 1, 100, name.Length)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -133,12 +164,30 @@ public class FlagsController(
|
|||
switch (description.Length)
|
||||
{
|
||||
case < 1:
|
||||
errors.Add(("description",
|
||||
ValidationError.LengthError("Description is too short", 1, 100, description.Length)));
|
||||
errors.Add(
|
||||
(
|
||||
"description",
|
||||
ValidationError.LengthError(
|
||||
"Description is too short",
|
||||
1,
|
||||
100,
|
||||
description.Length
|
||||
)
|
||||
)
|
||||
);
|
||||
break;
|
||||
case > 500:
|
||||
errors.Add(("description",
|
||||
ValidationError.LengthError("Description is too long", 1, 100, description.Length)));
|
||||
errors.Add(
|
||||
(
|
||||
"description",
|
||||
ValidationError.LengthError(
|
||||
"Description is too long",
|
||||
1,
|
||||
100,
|
||||
description.Length
|
||||
)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -148,14 +197,24 @@ public class FlagsController(
|
|||
switch (imageData.Length)
|
||||
{
|
||||
case 0:
|
||||
errors.Add(("image", ValidationError.GenericValidationError("Image cannot be empty", null)));
|
||||
errors.Add(
|
||||
(
|
||||
"image",
|
||||
ValidationError.GenericValidationError("Image cannot be empty", null)
|
||||
)
|
||||
);
|
||||
break;
|
||||
case > 1_500_000:
|
||||
errors.Add(("image", ValidationError.GenericValidationError("Image is too large", null)));
|
||||
errors.Add(
|
||||
(
|
||||
"image",
|
||||
ValidationError.GenericValidationError("Image is too large", null)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,11 +17,13 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
|
|||
|
||||
private static string GetCleanedTemplate(string template)
|
||||
{
|
||||
if (template.StartsWith("api/v2")) template = template["api/v2".Length..];
|
||||
if (template.StartsWith("api/v2"))
|
||||
template = template["api/v2".Length..];
|
||||
template = PathVarRegex()
|
||||
.Replace(template, "{id}") // Replace all path variables (almost always IDs) with `{id}`
|
||||
.Replace("@me", "{id}"); // Also replace hardcoded `@me` with `{id}`
|
||||
if (template.Contains("{id}")) return template.Split("{id}")[0] + "{id}";
|
||||
if (template.Contains("{id}"))
|
||||
return template.Split("{id}")[0] + "{id}";
|
||||
return template;
|
||||
}
|
||||
|
||||
|
@ -29,11 +31,13 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
|
|||
public async Task<IActionResult> GetRequestDataAsync([FromBody] RequestDataRequest req)
|
||||
{
|
||||
var endpoint = GetEndpoint(HttpContext, req.Path, req.Method);
|
||||
if (endpoint == null) throw new ApiError.BadRequest("Path/method combination is invalid");
|
||||
if (endpoint == null)
|
||||
throw new ApiError.BadRequest("Path/method combination is invalid");
|
||||
|
||||
var actionDescriptor = endpoint.Metadata.GetMetadata<ControllerActionDescriptor>();
|
||||
var template = actionDescriptor?.AttributeRouteInfo?.Template;
|
||||
if (template == null) throw new FoxnounsError("Template value was null on valid endpoint");
|
||||
if (template == null)
|
||||
throw new FoxnounsError("Template value was null on valid endpoint");
|
||||
template = GetCleanedTemplate(template);
|
||||
|
||||
// If no token was supplied, or it isn't valid base 64, return a null user ID (limiting by IP)
|
||||
|
@ -46,30 +50,41 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
|
|||
|
||||
public record RequestDataRequest(string? Token, string Method, string Path);
|
||||
|
||||
public record RequestDataResponse(
|
||||
Snowflake? UserId,
|
||||
string Template);
|
||||
public record RequestDataResponse(Snowflake? UserId, string Template);
|
||||
|
||||
private static RouteEndpoint? GetEndpoint(HttpContext httpContext, string url, string requestMethod)
|
||||
private static RouteEndpoint? GetEndpoint(
|
||||
HttpContext httpContext,
|
||||
string url,
|
||||
string requestMethod
|
||||
)
|
||||
{
|
||||
var endpointDataSource = httpContext.RequestServices.GetService<EndpointDataSource>();
|
||||
if (endpointDataSource == null) return null;
|
||||
if (endpointDataSource == null)
|
||||
return null;
|
||||
var endpoints = endpointDataSource.Endpoints.OfType<RouteEndpoint>();
|
||||
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
if (endpoint.RoutePattern.RawText == null) continue;
|
||||
if (endpoint.RoutePattern.RawText == null)
|
||||
continue;
|
||||
|
||||
var templateMatcher = new TemplateMatcher(TemplateParser.Parse(endpoint.RoutePattern.RawText),
|
||||
new RouteValueDictionary());
|
||||
if (!templateMatcher.TryMatch(url, new())) continue;
|
||||
var templateMatcher = new TemplateMatcher(
|
||||
TemplateParser.Parse(endpoint.RoutePattern.RawText),
|
||||
new RouteValueDictionary()
|
||||
);
|
||||
if (!templateMatcher.TryMatch(url, new()))
|
||||
continue;
|
||||
var httpMethodAttribute = endpoint.Metadata.GetMetadata<HttpMethodAttribute>();
|
||||
if (httpMethodAttribute != null &&
|
||||
!httpMethodAttribute.HttpMethods.Any(x => x.Equals(requestMethod, StringComparison.OrdinalIgnoreCase)))
|
||||
if (
|
||||
httpMethodAttribute != null
|
||||
&& !httpMethodAttribute.HttpMethods.Any(x =>
|
||||
x.Equals(requestMethod, StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
)
|
||||
continue;
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,12 +21,15 @@ public class MembersController(
|
|||
ISnowflakeGenerator snowflakeGenerator,
|
||||
ObjectStorageService objectStorageService,
|
||||
IQueue queue,
|
||||
IClock clock) : ApiControllerBase
|
||||
IClock clock
|
||||
) : ApiControllerBase
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<MembersController>();
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType<IEnumerable<MemberRendererService.PartialMember>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<IEnumerable<MemberRendererService.PartialMember>>(
|
||||
StatusCodes.Status200OK
|
||||
)]
|
||||
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
||||
{
|
||||
var user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||
|
@ -35,7 +38,11 @@ public class MembersController(
|
|||
|
||||
[HttpGet("{memberRef}")]
|
||||
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetMemberAsync(string userRef, string memberRef, CancellationToken ct = default)
|
||||
public async Task<IActionResult> GetMemberAsync(
|
||||
string userRef,
|
||||
string memberRef,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken, ct);
|
||||
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
||||
|
@ -46,19 +53,30 @@ public class MembersController(
|
|||
[HttpPost("/api/v2/users/@me/members")]
|
||||
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
|
||||
[Authorize("member.create")]
|
||||
public async Task<IActionResult> CreateMemberAsync([FromBody] CreateMemberRequest req,
|
||||
CancellationToken ct = default)
|
||||
public async Task<IActionResult> CreateMemberAsync(
|
||||
[FromBody] CreateMemberRequest req,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
ValidationUtils.Validate([
|
||||
("name", ValidationUtils.ValidateMemberName(req.Name)),
|
||||
("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)),
|
||||
("bio", ValidationUtils.ValidateBio(req.Bio)),
|
||||
("avatar", ValidationUtils.ValidateAvatar(req.Avatar)),
|
||||
.. ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences),
|
||||
.. ValidationUtils.ValidateFieldEntries(req.Names?.ToArray(), CurrentUser!.CustomPreferences, "names"),
|
||||
.. ValidationUtils.ValidatePronouns(req.Pronouns?.ToArray(), CurrentUser!.CustomPreferences),
|
||||
.. ValidationUtils.ValidateLinks(req.Links)
|
||||
]);
|
||||
ValidationUtils.Validate(
|
||||
[
|
||||
("name", ValidationUtils.ValidateMemberName(req.Name)),
|
||||
("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)),
|
||||
("bio", ValidationUtils.ValidateBio(req.Bio)),
|
||||
("avatar", ValidationUtils.ValidateAvatar(req.Avatar)),
|
||||
.. ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences),
|
||||
.. ValidationUtils.ValidateFieldEntries(
|
||||
req.Names?.ToArray(),
|
||||
CurrentUser!.CustomPreferences,
|
||||
"names"
|
||||
),
|
||||
.. ValidationUtils.ValidatePronouns(
|
||||
req.Pronouns?.ToArray(),
|
||||
CurrentUser!.CustomPreferences
|
||||
),
|
||||
.. ValidationUtils.ValidateLinks(req.Links),
|
||||
]
|
||||
);
|
||||
|
||||
var memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct);
|
||||
if (memberCount >= MaxMemberCount)
|
||||
|
@ -75,11 +93,16 @@ public class MembersController(
|
|||
Fields = req.Fields ?? [],
|
||||
Names = req.Names ?? [],
|
||||
Pronouns = req.Pronouns ?? [],
|
||||
Unlisted = req.Unlisted ?? false
|
||||
Unlisted = req.Unlisted ?? false,
|
||||
};
|
||||
db.Add(member);
|
||||
|
||||
_logger.Debug("Creating member {MemberName} ({Id}) for {UserId}", member.Name, member.Id, CurrentUser!.Id);
|
||||
_logger.Debug(
|
||||
"Creating member {MemberName} ({Id}) for {UserId}",
|
||||
member.Name,
|
||||
member.Id,
|
||||
CurrentUser!.Id
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
|
@ -88,19 +111,27 @@ public class MembersController(
|
|||
catch (UniqueConstraintException)
|
||||
{
|
||||
_logger.Debug("Could not create member {Id} due to name conflict", member.Id);
|
||||
throw new ApiError.BadRequest("A member with that name already exists", "name", req.Name);
|
||||
throw new ApiError.BadRequest(
|
||||
"A member with that name already exists",
|
||||
"name",
|
||||
req.Name
|
||||
);
|
||||
}
|
||||
|
||||
if (req.Avatar != null)
|
||||
queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
|
||||
new AvatarUpdatePayload(member.Id, req.Avatar));
|
||||
new AvatarUpdatePayload(member.Id, req.Avatar)
|
||||
);
|
||||
|
||||
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
||||
}
|
||||
|
||||
[HttpPatch("/api/v2/users/@me/members/{memberRef}")]
|
||||
[Authorize("member.update")]
|
||||
public async Task<IActionResult> UpdateMemberAsync(string memberRef, [FromBody] UpdateMemberRequest req)
|
||||
public async Task<IActionResult> UpdateMemberAsync(
|
||||
string memberRef,
|
||||
[FromBody] UpdateMemberRequest req
|
||||
)
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
||||
|
@ -134,26 +165,37 @@ public class MembersController(
|
|||
|
||||
if (req.Names != null)
|
||||
{
|
||||
errors.AddRange(ValidationUtils.ValidateFieldEntries(req.Names, CurrentUser!.CustomPreferences, "names"));
|
||||
errors.AddRange(
|
||||
ValidationUtils.ValidateFieldEntries(
|
||||
req.Names,
|
||||
CurrentUser!.CustomPreferences,
|
||||
"names"
|
||||
)
|
||||
);
|
||||
member.Names = req.Names.ToList();
|
||||
}
|
||||
|
||||
if (req.Pronouns != null)
|
||||
{
|
||||
errors.AddRange(ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences));
|
||||
errors.AddRange(
|
||||
ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)
|
||||
);
|
||||
member.Pronouns = req.Pronouns.ToList();
|
||||
}
|
||||
|
||||
if (req.Fields != null)
|
||||
{
|
||||
errors.AddRange(ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences));
|
||||
errors.AddRange(
|
||||
ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences)
|
||||
);
|
||||
member.Fields = req.Fields.ToList();
|
||||
}
|
||||
|
||||
if (req.Flags != null)
|
||||
{
|
||||
var flagError = await db.SetMemberFlagsAsync(CurrentUser!.Id, member.Id, req.Flags);
|
||||
if (flagError != null) errors.Add(("flags", flagError));
|
||||
if (flagError != null)
|
||||
errors.Add(("flags", flagError));
|
||||
}
|
||||
|
||||
if (req.HasProperty(nameof(req.Avatar)))
|
||||
|
@ -165,16 +207,25 @@ public class MembersController(
|
|||
// so it's in a separate block to the validation above.
|
||||
if (req.HasProperty(nameof(req.Avatar)))
|
||||
queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
|
||||
new AvatarUpdatePayload(member.Id, req.Avatar));
|
||||
new AvatarUpdatePayload(member.Id, req.Avatar)
|
||||
);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (UniqueConstraintException)
|
||||
{
|
||||
_logger.Debug("Could not update member {Id} due to name conflict ({CurrentName} / {NewName})", member.Id,
|
||||
member.Name, req.Name);
|
||||
throw new ApiError.BadRequest("A member with that name already exists", "name", req.Name!);
|
||||
_logger.Debug(
|
||||
"Could not update member {Id} due to name conflict ({CurrentName} / {NewName})",
|
||||
member.Id,
|
||||
member.Name,
|
||||
req.Name
|
||||
);
|
||||
throw new ApiError.BadRequest(
|
||||
"A member with that name already exists",
|
||||
"name",
|
||||
req.Name!
|
||||
);
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
@ -199,15 +250,20 @@ public class MembersController(
|
|||
public async Task<IActionResult> DeleteMemberAsync(string memberRef)
|
||||
{
|
||||
var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
||||
var deleteCount = await db.Members.Where(m => m.UserId == CurrentUser!.Id && m.Id == member.Id)
|
||||
var deleteCount = await db
|
||||
.Members.Where(m => m.UserId == CurrentUser!.Id && m.Id == member.Id)
|
||||
.ExecuteDeleteAsync();
|
||||
if (deleteCount == 0)
|
||||
{
|
||||
_logger.Warning("Successfully resolved member {Id} but could not delete them", member.Id);
|
||||
_logger.Warning(
|
||||
"Successfully resolved member {Id} but could not delete them",
|
||||
member.Id
|
||||
);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
if (member.Avatar != null) await objectStorageService.DeleteMemberAvatarAsync(member.Id, member.Avatar);
|
||||
if (member.Avatar != null)
|
||||
await objectStorageService.DeleteMemberAvatarAsync(member.Id, member.Avatar);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
@ -220,7 +276,8 @@ public class MembersController(
|
|||
string[]? Links,
|
||||
List<FieldEntry>? Names,
|
||||
List<Pronoun>? Pronouns,
|
||||
List<Field>? Fields);
|
||||
List<Field>? Fields
|
||||
);
|
||||
|
||||
[HttpPost("/api/v2/users/@me/members/{memberRef}/reroll-sid")]
|
||||
[Authorize("member.update")]
|
||||
|
@ -234,17 +291,19 @@ public class MembersController(
|
|||
throw new ApiError.BadRequest("Cannot reroll short ID yet");
|
||||
|
||||
// Using ExecuteUpdateAsync here as the new short ID is generated by the database
|
||||
await db.Members.Where(m => m.Id == member.Id)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(m => m.Sid, _ => db.FindFreeMemberSid()));
|
||||
await db
|
||||
.Members.Where(m => m.Id == member.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(m => m.Sid, _ => db.FindFreeMemberSid()));
|
||||
|
||||
await db.Users.Where(u => u.Id == CurrentUser.Id)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant())
|
||||
.SetProperty(u => u.LastActive, clock.GetCurrentInstant()));
|
||||
await db
|
||||
.Users.Where(u => u.Id == CurrentUser.Id)
|
||||
.ExecuteUpdateAsync(s =>
|
||||
s.SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant())
|
||||
.SetProperty(u => u.LastActive, clock.GetCurrentInstant())
|
||||
);
|
||||
|
||||
// Re-fetch member to fetch the new sid
|
||||
var updatedMember = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
||||
return Ok(memberRenderer.RenderMember(updatedMember, CurrentToken));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,23 +12,30 @@ public class MetaController : ApiControllerBase
|
|||
[ProducesResponseType<MetaResponse>(StatusCodes.Status200OK)]
|
||||
public IActionResult GetMeta()
|
||||
{
|
||||
return Ok(new MetaResponse(
|
||||
Repository, BuildInfo.Version, BuildInfo.Hash, (int)FoxnounsMetrics.MemberCount.Value,
|
||||
new UserInfo(
|
||||
(int)FoxnounsMetrics.UsersCount.Value,
|
||||
(int)FoxnounsMetrics.UsersActiveMonthCount.Value,
|
||||
(int)FoxnounsMetrics.UsersActiveWeekCount.Value,
|
||||
(int)FoxnounsMetrics.UsersActiveDayCount.Value
|
||||
),
|
||||
new Limits(
|
||||
MemberCount: MembersController.MaxMemberCount,
|
||||
BioLength: ValidationUtils.MaxBioLength,
|
||||
CustomPreferences: UsersController.MaxCustomPreferences))
|
||||
return Ok(
|
||||
new MetaResponse(
|
||||
Repository,
|
||||
BuildInfo.Version,
|
||||
BuildInfo.Hash,
|
||||
(int)FoxnounsMetrics.MemberCount.Value,
|
||||
new UserInfo(
|
||||
(int)FoxnounsMetrics.UsersCount.Value,
|
||||
(int)FoxnounsMetrics.UsersActiveMonthCount.Value,
|
||||
(int)FoxnounsMetrics.UsersActiveWeekCount.Value,
|
||||
(int)FoxnounsMetrics.UsersActiveDayCount.Value
|
||||
),
|
||||
new Limits(
|
||||
MemberCount: MembersController.MaxMemberCount,
|
||||
BioLength: ValidationUtils.MaxBioLength,
|
||||
CustomPreferences: UsersController.MaxCustomPreferences
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/coffee")]
|
||||
public IActionResult BrewCoffee() => Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
|
||||
public IActionResult BrewCoffee() =>
|
||||
Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
|
||||
|
||||
private record MetaResponse(
|
||||
string Repository,
|
||||
|
@ -36,13 +43,11 @@ public class MetaController : ApiControllerBase
|
|||
string Hash,
|
||||
int Members,
|
||||
UserInfo Users,
|
||||
Limits Limits);
|
||||
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);
|
||||
}
|
||||
private record Limits(int MemberCount, int BioLength, int CustomPreferences);
|
||||
}
|
||||
|
|
|
@ -20,7 +20,8 @@ public class UsersController(
|
|||
UserRendererService userRenderer,
|
||||
ISnowflakeGenerator snowflakeGenerator,
|
||||
IQueue queue,
|
||||
IClock clock) : ApiControllerBase
|
||||
IClock clock
|
||||
) : ApiControllerBase
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<UsersController>();
|
||||
|
||||
|
@ -29,20 +30,25 @@ public class UsersController(
|
|||
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
||||
{
|
||||
var user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||
return Ok(await userRenderer.RenderUserAsync(
|
||||
user,
|
||||
selfUser: CurrentUser,
|
||||
token: CurrentToken,
|
||||
renderMembers: true,
|
||||
renderAuthMethods: true,
|
||||
ct: ct
|
||||
));
|
||||
return Ok(
|
||||
await userRenderer.RenderUserAsync(
|
||||
user,
|
||||
selfUser: CurrentUser,
|
||||
token: CurrentToken,
|
||||
renderMembers: true,
|
||||
renderAuthMethods: true,
|
||||
ct: ct
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPatch("@me")]
|
||||
[Authorize("user.update")]
|
||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> UpdateUserAsync([FromBody] UpdateUserRequest req, CancellationToken ct = default)
|
||||
public async Task<IActionResult> UpdateUserAsync(
|
||||
[FromBody] UpdateUserRequest req,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync(ct);
|
||||
var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
|
||||
|
@ -74,26 +80,37 @@ public class UsersController(
|
|||
|
||||
if (req.Names != null)
|
||||
{
|
||||
errors.AddRange(ValidationUtils.ValidateFieldEntries(req.Names, CurrentUser!.CustomPreferences, "names"));
|
||||
errors.AddRange(
|
||||
ValidationUtils.ValidateFieldEntries(
|
||||
req.Names,
|
||||
CurrentUser!.CustomPreferences,
|
||||
"names"
|
||||
)
|
||||
);
|
||||
user.Names = req.Names.ToList();
|
||||
}
|
||||
|
||||
if (req.Pronouns != null)
|
||||
{
|
||||
errors.AddRange(ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences));
|
||||
errors.AddRange(
|
||||
ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)
|
||||
);
|
||||
user.Pronouns = req.Pronouns.ToList();
|
||||
}
|
||||
|
||||
if (req.Fields != null)
|
||||
{
|
||||
errors.AddRange(ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences));
|
||||
errors.AddRange(
|
||||
ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences)
|
||||
);
|
||||
user.Fields = req.Fields.ToList();
|
||||
}
|
||||
|
||||
if (req.Flags != null)
|
||||
{
|
||||
var flagError = await db.SetUserFlagsAsync(CurrentUser!.Id, req.Flags);
|
||||
if (flagError != null) errors.Add(("flags", flagError));
|
||||
if (flagError != null)
|
||||
errors.Add(("flags", flagError));
|
||||
}
|
||||
|
||||
if (req.HasProperty(nameof(req.Avatar)))
|
||||
|
@ -105,7 +122,8 @@ public class UsersController(
|
|||
// so it's in a separate block to the validation above.
|
||||
if (req.HasProperty(nameof(req.Avatar)))
|
||||
queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
|
||||
new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar));
|
||||
new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
|
@ -113,26 +131,45 @@ public class UsersController(
|
|||
}
|
||||
catch (UniqueConstraintException)
|
||||
{
|
||||
_logger.Debug("Could not update user {Id} due to name conflict ({CurrentName} / {NewName})", user.Id,
|
||||
user.Username, req.Username);
|
||||
throw new ApiError.BadRequest("That username is already taken.", "username", req.Username!);
|
||||
_logger.Debug(
|
||||
"Could not update user {Id} due to name conflict ({CurrentName} / {NewName})",
|
||||
user.Id,
|
||||
user.Username,
|
||||
req.Username
|
||||
);
|
||||
throw new ApiError.BadRequest(
|
||||
"That username is already taken.",
|
||||
"username",
|
||||
req.Username!
|
||||
);
|
||||
}
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
return Ok(await userRenderer.RenderUserAsync(user, CurrentUser, renderMembers: false,
|
||||
renderAuthMethods: false, ct: ct));
|
||||
return Ok(
|
||||
await userRenderer.RenderUserAsync(
|
||||
user,
|
||||
CurrentUser,
|
||||
renderMembers: false,
|
||||
renderAuthMethods: false,
|
||||
ct: ct
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPatch("@me/custom-preferences")]
|
||||
[Authorize("user.update")]
|
||||
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> UpdateCustomPreferencesAsync([FromBody] List<CustomPreferencesUpdateRequest> req,
|
||||
CancellationToken ct = default)
|
||||
public async Task<IActionResult> UpdateCustomPreferencesAsync(
|
||||
[FromBody] List<CustomPreferencesUpdateRequest> req,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
ValidationUtils.Validate(ValidateCustomPreferences(req));
|
||||
|
||||
var user = await db.ResolveUserAsync(CurrentUser!.Id, ct);
|
||||
var preferences = user.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key)).ToDictionary();
|
||||
var preferences = user
|
||||
.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
|
||||
.ToDictionary();
|
||||
|
||||
foreach (var r in req)
|
||||
{
|
||||
|
@ -144,7 +181,7 @@ public class UsersController(
|
|||
Icon = r.Icon,
|
||||
Muted = r.Muted,
|
||||
Size = r.Size,
|
||||
Tooltip = r.Tooltip
|
||||
Tooltip = r.Tooltip,
|
||||
};
|
||||
}
|
||||
else
|
||||
|
@ -155,7 +192,7 @@ public class UsersController(
|
|||
Icon = r.Icon,
|
||||
Muted = r.Muted,
|
||||
Size = r.Size,
|
||||
Tooltip = r.Tooltip
|
||||
Tooltip = r.Tooltip,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -180,15 +217,25 @@ public class UsersController(
|
|||
public const int MaxCustomPreferences = 25;
|
||||
|
||||
private static List<(string, ValidationError?)> ValidateCustomPreferences(
|
||||
List<CustomPreferencesUpdateRequest> preferences)
|
||||
List<CustomPreferencesUpdateRequest> preferences
|
||||
)
|
||||
{
|
||||
var errors = new List<(string, ValidationError?)>();
|
||||
|
||||
if (preferences.Count > MaxCustomPreferences)
|
||||
errors.Add(("custom_preferences",
|
||||
ValidationError.LengthError("Too many custom preferences", 0, MaxCustomPreferences,
|
||||
preferences.Count)));
|
||||
if (preferences.Count > 50) return errors;
|
||||
errors.Add(
|
||||
(
|
||||
"custom_preferences",
|
||||
ValidationError.LengthError(
|
||||
"Too many custom preferences",
|
||||
0,
|
||||
MaxCustomPreferences,
|
||||
preferences.Count
|
||||
)
|
||||
)
|
||||
);
|
||||
if (preferences.Count > 50)
|
||||
return errors;
|
||||
|
||||
// TODO: validate individual preferences
|
||||
|
||||
|
@ -208,7 +255,6 @@ public class UsersController(
|
|||
public Snowflake[]? Flags { get; init; }
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("@me/settings")]
|
||||
[Authorize("user.read_hidden")]
|
||||
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
|
||||
|
@ -221,8 +267,10 @@ public class UsersController(
|
|||
[HttpPatch("@me/settings")]
|
||||
[Authorize("user.read_hidden", "user.update")]
|
||||
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> UpdateUserSettingsAsync([FromBody] UpdateUserSettingsRequest req,
|
||||
CancellationToken ct = default)
|
||||
public async Task<IActionResult> UpdateUserSettingsAsync(
|
||||
[FromBody] UpdateUserSettingsRequest req,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
|
||||
|
||||
|
@ -250,13 +298,22 @@ public class UsersController(
|
|||
throw new ApiError.BadRequest("Cannot reroll short ID yet");
|
||||
|
||||
// Using ExecuteUpdateAsync here as the new short ID is generated by the database
|
||||
await db.Users.Where(u => u.Id == CurrentUser.Id)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(u => u.Sid, _ => db.FindFreeUserSid())
|
||||
.SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant())
|
||||
.SetProperty(u => u.LastActive, clock.GetCurrentInstant()));
|
||||
await db
|
||||
.Users.Where(u => u.Id == CurrentUser.Id)
|
||||
.ExecuteUpdateAsync(s =>
|
||||
s.SetProperty(u => u.Sid, _ => db.FindFreeUserSid())
|
||||
.SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant())
|
||||
.SetProperty(u => u.LastActive, clock.GetCurrentInstant())
|
||||
);
|
||||
|
||||
var user = await db.ResolveUserAsync(CurrentUser.Id);
|
||||
return Ok(await userRenderer.RenderUserAsync(user, CurrentUser, CurrentToken, renderMembers: false));
|
||||
return Ok(
|
||||
await userRenderer.RenderUserAsync(
|
||||
user,
|
||||
CurrentUser,
|
||||
CurrentToken,
|
||||
renderMembers: false
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue