chore: add csharpier to husky, format backend with csharpier

This commit is contained in:
sam 2024-10-02 00:28:07 +02:00
parent 5fab66444f
commit 7f971e8549
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
73 changed files with 2098 additions and 1048 deletions

View file

@ -10,4 +10,4 @@ public class ApiControllerBase : ControllerBase
{
internal Token? CurrentToken => HttpContext.GetToken();
internal User? CurrentUser => HttpContext.GetUser();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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