Compare commits
3 commits
57e1ec09c0
...
f8e6032449
Author | SHA1 | Date | |
---|---|---|---|
f8e6032449 | |||
649988db25 | |||
bc7fd6d804 |
71 changed files with 1158 additions and 832 deletions
|
@ -1,8 +1,52 @@
|
|||
[*.cs]
|
||||
[*]
|
||||
# We use PostgresSQL which doesn't recommend more specific string types
|
||||
resharper_entity_framework_model_validation_unlimited_string_length_highlighting = none
|
||||
# This is raised for every single property of records returned by endpoints
|
||||
resharper_not_accessed_positional_property_local_highlighting = none
|
||||
|
||||
|
||||
# Microsoft .NET properties
|
||||
csharp_new_line_before_members_in_object_initializers = false
|
||||
csharp_preferred_modifier_order = public, internal, protected, private, file, new, required, abstract, virtual, sealed, static, override, extern, unsafe, volatile, async, readonly:suggestion
|
||||
|
||||
# ReSharper properties
|
||||
resharper_align_multiline_binary_expressions_chain = false
|
||||
resharper_arguments_skip_single = true
|
||||
resharper_blank_lines_after_start_comment = 0
|
||||
resharper_blank_lines_around_single_line_invocable = 0
|
||||
resharper_blank_lines_before_block_statements = 0
|
||||
resharper_braces_for_foreach = required_for_multiline
|
||||
resharper_braces_for_ifelse = required_for_multiline
|
||||
resharper_braces_redundant = false
|
||||
resharper_csharp_blank_lines_around_field = 0
|
||||
resharper_csharp_empty_block_style = together_same_line
|
||||
resharper_csharp_max_line_length = 166
|
||||
resharper_csharp_wrap_after_declaration_lpar = true
|
||||
resharper_csharp_wrap_before_binary_opsign = true
|
||||
resharper_csharp_wrap_before_declaration_rpar = true
|
||||
resharper_csharp_wrap_parameters_style = chop_if_long
|
||||
resharper_indent_preprocessor_other = do_not_change
|
||||
resharper_instance_members_qualify_declared_in =
|
||||
resharper_keep_existing_attribute_arrangement = true
|
||||
resharper_max_attribute_length_for_same_line = 70
|
||||
resharper_place_accessorholder_attribute_on_same_line = false
|
||||
resharper_place_expr_method_on_single_line = if_owner_is_single_line
|
||||
resharper_place_method_attribute_on_same_line = if_owner_is_single_line
|
||||
resharper_place_record_field_attribute_on_same_line = true
|
||||
resharper_place_simple_embedded_statement_on_same_line = false
|
||||
resharper_place_simple_initializer_on_single_line = false
|
||||
resharper_place_simple_list_pattern_on_single_line = false
|
||||
resharper_space_within_empty_braces = false
|
||||
resharper_trailing_comma_in_multiline_lists = true
|
||||
resharper_wrap_after_invocation_lpar = false
|
||||
resharper_wrap_before_invocation_rpar = false
|
||||
resharper_wrap_before_primary_constructor_declaration_rpar = true
|
||||
resharper_wrap_chained_binary_patterns = chop_if_long
|
||||
resharper_wrap_list_pattern = chop_always
|
||||
resharper_wrap_object_and_collection_initializer_style = chop_always
|
||||
|
||||
# Roslynator properties
|
||||
dotnet_diagnostic.RCS1194.severity = none
|
||||
|
||||
[*generated.cs]
|
||||
generated_code = true
|
|
@ -7,19 +7,21 @@ public static class BuildInfo
|
|||
|
||||
public static async Task ReadBuildInfo()
|
||||
{
|
||||
await using var stream = typeof(BuildInfo).Assembly.GetManifestResourceStream("version");
|
||||
await using Stream? stream = typeof(BuildInfo).Assembly.GetManifestResourceStream(
|
||||
"version"
|
||||
);
|
||||
if (stream == null)
|
||||
return;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var data = (await reader.ReadToEndAsync()).Trim().Split("\n");
|
||||
string[] data = (await reader.ReadToEndAsync()).Trim().Split("\n");
|
||||
if (data.Length < 3)
|
||||
return;
|
||||
|
||||
Hash = data[0];
|
||||
var dirty = data[2] == "dirty";
|
||||
bool dirty = data[2] == "dirty";
|
||||
|
||||
var versionData = data[1].Split("-");
|
||||
string[] versionData = data[1].Split("-");
|
||||
if (versionData.Length < 3)
|
||||
return;
|
||||
Version = versionData[0];
|
||||
|
|
|
@ -33,14 +33,16 @@ public class AuthController(
|
|||
config.GoogleAuth.Enabled,
|
||||
config.TumblrAuth.Enabled
|
||||
);
|
||||
var state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct));
|
||||
string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct));
|
||||
string? discord = null;
|
||||
if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null })
|
||||
{
|
||||
discord =
|
||||
$"https://discord.com/oauth2/authorize?response_type=code"
|
||||
"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(config.EmailAuth.Enabled, discord, null, null));
|
||||
}
|
||||
|
@ -86,7 +88,7 @@ public class AuthController(
|
|||
)]
|
||||
public async Task<IActionResult> GetAuthMethodAsync(Snowflake id)
|
||||
{
|
||||
var authMethod = await db
|
||||
AuthMethod? authMethod = await db
|
||||
.AuthMethods.Include(a => a.FediverseApplication)
|
||||
.FirstOrDefaultAsync(a => a.UserId == CurrentUser!.Id && a.Id == id);
|
||||
if (authMethod == null)
|
||||
|
@ -99,17 +101,19 @@ public class AuthController(
|
|||
[Authorize("*")]
|
||||
public async Task<IActionResult> DeleteAuthMethodAsync(Snowflake id)
|
||||
{
|
||||
var authMethods = await db
|
||||
List<AuthMethod> authMethods = await db
|
||||
.AuthMethods.Where(a => a.UserId == CurrentUser!.Id)
|
||||
.ToListAsync();
|
||||
if (authMethods.Count < 2)
|
||||
{
|
||||
throw new ApiError(
|
||||
"You cannot remove your last authentication method.",
|
||||
HttpStatusCode.BadRequest,
|
||||
ErrorCode.LastAuthMethod
|
||||
);
|
||||
}
|
||||
|
||||
var authMethod = authMethods.FirstOrDefault(a => a.Id == id);
|
||||
AuthMethod? authMethod = authMethods.FirstOrDefault(a => a.Id == id);
|
||||
if (authMethod == null)
|
||||
throw new ApiError.NotFound("No authentication method with that ID found.");
|
||||
|
||||
|
@ -119,6 +123,20 @@ public class AuthController(
|
|||
CurrentUser!.Id
|
||||
);
|
||||
|
||||
// If this is the user's last email, we should also clear the user's password.
|
||||
if (
|
||||
authMethod.AuthType == AuthType.Email
|
||||
&& authMethods.Count(a => a.AuthType == AuthType.Email) == 1
|
||||
)
|
||||
{
|
||||
_logger.Debug(
|
||||
"Deleted last email address for user {UserId}, resetting their password",
|
||||
CurrentUser.Id
|
||||
);
|
||||
CurrentUser.Password = null;
|
||||
db.Update(CurrentUser);
|
||||
}
|
||||
|
||||
db.Remove(authMethod);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
|
|
@ -34,8 +34,10 @@ public class DiscordAuthController(
|
|||
CheckRequirements();
|
||||
await keyCacheService.ValidateAuthStateAsync(req.State);
|
||||
|
||||
var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code);
|
||||
var user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id);
|
||||
RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestDiscordTokenAsync(
|
||||
req.Code
|
||||
);
|
||||
User? user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id);
|
||||
if (user != null)
|
||||
return Ok(await authService.GenerateUserTokenAsync(user));
|
||||
|
||||
|
@ -45,23 +47,14 @@ public class DiscordAuthController(
|
|||
remoteUser.Id
|
||||
);
|
||||
|
||||
var ticket = AuthUtils.RandomToken();
|
||||
string ticket = AuthUtils.RandomToken();
|
||||
await keyCacheService.SetKeyAsync(
|
||||
$"discord:{ticket}",
|
||||
remoteUser,
|
||||
Duration.FromMinutes(20)
|
||||
);
|
||||
|
||||
return Ok(
|
||||
new CallbackResponse(
|
||||
HasAccount: false,
|
||||
Ticket: ticket,
|
||||
RemoteUsername: remoteUser.Username,
|
||||
User: null,
|
||||
Token: null,
|
||||
ExpiresAt: null
|
||||
)
|
||||
);
|
||||
return Ok(new CallbackResponse(false, ticket, remoteUser.Username, null, null, null));
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
|
@ -70,9 +63,10 @@ public class DiscordAuthController(
|
|||
[FromBody] AuthController.OauthRegisterRequest req
|
||||
)
|
||||
{
|
||||
var remoteUser = await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>(
|
||||
$"discord:{req.Ticket}"
|
||||
);
|
||||
RemoteAuthService.RemoteUser? remoteUser =
|
||||
await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>(
|
||||
$"discord:{req.Ticket}"
|
||||
);
|
||||
if (remoteUser == null)
|
||||
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
|
||||
if (
|
||||
|
@ -88,7 +82,7 @@ public class DiscordAuthController(
|
|||
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
|
||||
}
|
||||
|
||||
var user = await authService.CreateUserWithRemoteAuthAsync(
|
||||
User user = await authService.CreateUserWithRemoteAuthAsync(
|
||||
req.Username,
|
||||
AuthType.Discord,
|
||||
remoteUser.Id,
|
||||
|
@ -104,13 +98,13 @@ public class DiscordAuthController(
|
|||
{
|
||||
CheckRequirements();
|
||||
|
||||
var state = await remoteAuthService.ValidateAddAccountRequestAsync(
|
||||
string state = await remoteAuthService.ValidateAddAccountRequestAsync(
|
||||
CurrentUser!.Id,
|
||||
AuthType.Discord
|
||||
);
|
||||
|
||||
var url =
|
||||
$"https://discord.com/oauth2/authorize?response_type=code"
|
||||
string url =
|
||||
"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")}";
|
||||
|
@ -132,10 +126,12 @@ public class DiscordAuthController(
|
|||
AuthType.Discord
|
||||
);
|
||||
|
||||
var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code);
|
||||
RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestDiscordTokenAsync(
|
||||
req.Code
|
||||
);
|
||||
try
|
||||
{
|
||||
var authMethod = await authService.AddAuthMethodAsync(
|
||||
AuthMethod authMethod = await authService.AddAuthMethodAsync(
|
||||
CurrentUser.Id,
|
||||
AuthType.Discord,
|
||||
remoteUser.Id,
|
||||
|
@ -169,8 +165,10 @@ public class DiscordAuthController(
|
|||
private void CheckRequirements()
|
||||
{
|
||||
if (!config.DiscordAuth.Enabled)
|
||||
{
|
||||
throw new ApiError.BadRequest(
|
||||
"Discord authentication is not enabled on this instance."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
using System.Net;
|
||||
using EntityFramework.Exceptions.Common;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Extensions;
|
||||
|
@ -26,8 +28,8 @@ public class EmailAuthController(
|
|||
{
|
||||
private readonly ILogger _logger = logger.ForContext<EmailAuthController>();
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> RegisterAsync(
|
||||
[HttpPost("register/init")]
|
||||
public async Task<IActionResult> RegisterInitAsync(
|
||||
[FromBody] RegisterRequest req,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
|
@ -37,11 +39,7 @@ public class EmailAuthController(
|
|||
if (!req.Email.Contains('@'))
|
||||
throw new ApiError.BadRequest("Email is invalid", "email", req.Email);
|
||||
|
||||
var state = await keyCacheService.GenerateRegisterEmailStateAsync(
|
||||
req.Email,
|
||||
userId: null,
|
||||
ct
|
||||
);
|
||||
string state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, null, ct);
|
||||
|
||||
// If there's already a user with that email address, pretend we sent an email but actually ignore it
|
||||
if (
|
||||
|
@ -50,7 +48,9 @@ public class EmailAuthController(
|
|||
ct
|
||||
)
|
||||
)
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
mailService.QueueAccountCreationEmail(req.Email, state);
|
||||
return NoContent();
|
||||
|
@ -61,62 +61,35 @@ public class EmailAuthController(
|
|||
{
|
||||
CheckRequirements();
|
||||
|
||||
var state = await keyCacheService.GetRegisterEmailStateAsync(req.State);
|
||||
if (state == null)
|
||||
RegisterEmailState? state = await keyCacheService.GetRegisterEmailStateAsync(req.State);
|
||||
if (state is not { ExistingUserId: 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
|
||||
);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
var ticket = AuthUtils.RandomToken();
|
||||
string ticket = AuthUtils.RandomToken();
|
||||
await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20));
|
||||
|
||||
return Ok(
|
||||
new CallbackResponse(
|
||||
HasAccount: false,
|
||||
Ticket: ticket,
|
||||
RemoteUsername: state.Email,
|
||||
User: null,
|
||||
Token: null,
|
||||
ExpiresAt: null
|
||||
)
|
||||
);
|
||||
return Ok(new CallbackResponse(false, ticket, state.Email, null, null, null));
|
||||
}
|
||||
|
||||
[HttpPost("complete-registration")]
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> CompleteRegistrationAsync(
|
||||
[FromBody] CompleteRegistrationRequest req
|
||||
)
|
||||
{
|
||||
CheckRequirements();
|
||||
|
||||
var email = await keyCacheService.GetKeyAsync($"email:{req.Ticket}");
|
||||
string? email = await keyCacheService.GetKeyAsync($"email:{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))]);
|
||||
// Check if username is already taken
|
||||
if (await db.Users.AnyAsync(u => u.Username == req.Username))
|
||||
throw new ApiError.BadRequest("Username is already taken", "username", req.Username);
|
||||
User user = await authService.CreateUserWithPasswordAsync(
|
||||
req.Username,
|
||||
email,
|
||||
req.Password
|
||||
);
|
||||
Application frontendApp = await db.GetFrontendApplicationAsync();
|
||||
|
||||
var user = await authService.CreateUserWithPasswordAsync(req.Username, email, req.Password);
|
||||
var frontendApp = await db.GetFrontendApplicationAsync();
|
||||
|
||||
var (tokenStr, token) = authService.GenerateToken(
|
||||
(string? tokenStr, Token? token) = authService.GenerateToken(
|
||||
user,
|
||||
frontendApp,
|
||||
["*"],
|
||||
|
@ -130,7 +103,7 @@ public class EmailAuthController(
|
|||
|
||||
return Ok(
|
||||
new AuthController.AuthResponse(
|
||||
await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false),
|
||||
await userRenderer.RenderUserAsync(user, user, renderMembers: false),
|
||||
tokenStr,
|
||||
token.ExpiresAt
|
||||
)
|
||||
|
@ -146,19 +119,16 @@ public class EmailAuthController(
|
|||
{
|
||||
CheckRequirements();
|
||||
|
||||
var (user, authenticationResult) = await authService.AuthenticateUserAsync(
|
||||
req.Email,
|
||||
req.Password,
|
||||
ct
|
||||
);
|
||||
(User? user, AuthService.EmailAuthenticationResult authenticationResult) =
|
||||
await authService.AuthenticateUserAsync(req.Email, req.Password, ct);
|
||||
if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired)
|
||||
throw new NotImplementedException("MFA is not implemented yet");
|
||||
|
||||
var frontendApp = await db.GetFrontendApplicationAsync(ct);
|
||||
Application frontendApp = await db.GetFrontendApplicationAsync(ct);
|
||||
|
||||
_logger.Debug("Logging user {Id} in with email and password", user.Id);
|
||||
|
||||
var (tokenStr, token) = authService.GenerateToken(
|
||||
(string? tokenStr, Token? token) = authService.GenerateToken(
|
||||
user,
|
||||
frontendApp,
|
||||
["*"],
|
||||
|
@ -172,25 +142,34 @@ public class EmailAuthController(
|
|||
|
||||
return Ok(
|
||||
new AuthController.AuthResponse(
|
||||
await userRenderer.RenderUserAsync(
|
||||
user,
|
||||
selfUser: user,
|
||||
renderMembers: false,
|
||||
ct: ct
|
||||
),
|
||||
await userRenderer.RenderUserAsync(user, user, renderMembers: false, ct: ct),
|
||||
tokenStr,
|
||||
token.ExpiresAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("add")]
|
||||
[HttpPost("change-password")]
|
||||
[Authorize("*")]
|
||||
public async Task<IActionResult> UpdatePasswordAsync([FromBody] ChangePasswordRequest req)
|
||||
{
|
||||
if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Current))
|
||||
throw new ApiError.Forbidden("Invalid password");
|
||||
|
||||
ValidationUtils.Validate([("new", ValidationUtils.ValidatePassword(req.New))]);
|
||||
|
||||
await authService.SetUserPasswordAsync(CurrentUser!, req.New);
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("add-email")]
|
||||
[Authorize("*")]
|
||||
public async Task<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req)
|
||||
{
|
||||
CheckRequirements();
|
||||
|
||||
var emails = await db
|
||||
List<AuthMethod> emails = await db
|
||||
.AuthMethods.Where(m => m.UserId == CurrentUser!.Id && m.AuthType == AuthType.Email)
|
||||
.ToListAsync();
|
||||
if (emails.Count > AuthUtils.MaxAuthMethodsPerType)
|
||||
|
@ -204,11 +183,8 @@ public class EmailAuthController(
|
|||
|
||||
if (emails.Count != 0)
|
||||
{
|
||||
var validPassword = await authService.ValidatePasswordAsync(CurrentUser!, req.Password);
|
||||
if (!validPassword)
|
||||
{
|
||||
if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Password))
|
||||
throw new ApiError.Forbidden("Invalid password");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -216,12 +192,12 @@ public class EmailAuthController(
|
|||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var state = await keyCacheService.GenerateRegisterEmailStateAsync(
|
||||
string state = await keyCacheService.GenerateRegisterEmailStateAsync(
|
||||
req.Email,
|
||||
userId: CurrentUser!.Id
|
||||
CurrentUser!.Id
|
||||
);
|
||||
|
||||
var emailExists = await db
|
||||
bool emailExists = await db
|
||||
.AuthMethods.Where(m => m.AuthType == AuthType.Email && m.RemoteId == req.Email)
|
||||
.AnyAsync();
|
||||
if (emailExists)
|
||||
|
@ -233,6 +209,48 @@ public class EmailAuthController(
|
|||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("add-email/callback")]
|
||||
[Authorize("*")]
|
||||
public async Task<IActionResult> AddEmailCallbackAsync([FromBody] CallbackRequest req)
|
||||
{
|
||||
CheckRequirements();
|
||||
|
||||
RegisterEmailState? state = await keyCacheService.GetRegisterEmailStateAsync(req.State);
|
||||
if (state?.ExistingUserId != CurrentUser!.Id)
|
||||
throw new ApiError.BadRequest("Invalid state", "state", req.State);
|
||||
|
||||
try
|
||||
{
|
||||
AuthMethod authMethod = await authService.AddAuthMethodAsync(
|
||||
CurrentUser.Id,
|
||||
AuthType.Email,
|
||||
state.Email
|
||||
);
|
||||
_logger.Debug(
|
||||
"Added email auth {AuthId} for user {UserId}",
|
||||
authMethod.Id,
|
||||
CurrentUser.Id
|
||||
);
|
||||
|
||||
return Ok(
|
||||
new AuthController.AddOauthAccountResponse(
|
||||
authMethod.Id,
|
||||
AuthType.Email,
|
||||
authMethod.RemoteId,
|
||||
null
|
||||
)
|
||||
);
|
||||
}
|
||||
catch (UniqueConstraintException)
|
||||
{
|
||||
throw new ApiError(
|
||||
"That email address is already linked.",
|
||||
HttpStatusCode.BadRequest,
|
||||
ErrorCode.AccountAlreadyLinked
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public record AddEmailAddressRequest(string Email, string Password);
|
||||
|
||||
private void CheckRequirements()
|
||||
|
@ -248,4 +266,6 @@ public class EmailAuthController(
|
|||
public record CompleteRegistrationRequest(string Ticket, string Username, string Password);
|
||||
|
||||
public record CallbackRequest(string State);
|
||||
|
||||
public record ChangePasswordRequest(string Current, string New);
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ public class FediverseAuthController(
|
|||
if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.'))
|
||||
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
|
||||
|
||||
var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh);
|
||||
string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh);
|
||||
return Ok(new AuthController.SingleUrlResponse(url));
|
||||
}
|
||||
|
||||
|
@ -42,22 +42,19 @@ public class FediverseAuthController(
|
|||
[ProducesResponseType<CallbackResponse>(statusCode: StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> FediverseCallbackAsync([FromBody] CallbackRequest req)
|
||||
{
|
||||
var app = await fediverseAuthService.GetApplicationAsync(req.Instance);
|
||||
var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(
|
||||
app,
|
||||
req.Code,
|
||||
req.State
|
||||
);
|
||||
FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
|
||||
FediverseAuthService.FediverseUser remoteUser =
|
||||
await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code, req.State);
|
||||
|
||||
var user = await authService.AuthenticateUserAsync(
|
||||
User? user = await authService.AuthenticateUserAsync(
|
||||
AuthType.Fediverse,
|
||||
remoteUser.Id,
|
||||
instance: app
|
||||
app
|
||||
);
|
||||
if (user != null)
|
||||
return Ok(await authService.GenerateUserTokenAsync(user));
|
||||
|
||||
var ticket = AuthUtils.RandomToken();
|
||||
string ticket = AuthUtils.RandomToken();
|
||||
await keyCacheService.SetKeyAsync(
|
||||
$"fediverse:{ticket}",
|
||||
new FediverseTicketData(app.Id, remoteUser),
|
||||
|
@ -66,12 +63,12 @@ public class FediverseAuthController(
|
|||
|
||||
return Ok(
|
||||
new CallbackResponse(
|
||||
HasAccount: false,
|
||||
Ticket: ticket,
|
||||
RemoteUsername: $"@{remoteUser.Username}@{app.Domain}",
|
||||
User: null,
|
||||
Token: null,
|
||||
ExpiresAt: null
|
||||
false,
|
||||
ticket,
|
||||
$"@{remoteUser.Username}@{app.Domain}",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -82,14 +79,16 @@ public class FediverseAuthController(
|
|||
[FromBody] AuthController.OauthRegisterRequest req
|
||||
)
|
||||
{
|
||||
var ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
|
||||
FediverseTicketData? ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
|
||||
$"fediverse:{req.Ticket}",
|
||||
delete: true
|
||||
true
|
||||
);
|
||||
if (ticketData == null)
|
||||
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
|
||||
|
||||
var app = await db.FediverseApplications.FindAsync(ticketData.ApplicationId);
|
||||
FediverseApplication? app = await db.FediverseApplications.FindAsync(
|
||||
ticketData.ApplicationId
|
||||
);
|
||||
if (app == null)
|
||||
throw new FoxnounsError("Null application found for ticket");
|
||||
|
||||
|
@ -111,12 +110,12 @@ public class FediverseAuthController(
|
|||
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
|
||||
}
|
||||
|
||||
var user = await authService.CreateUserWithRemoteAuthAsync(
|
||||
User user = await authService.CreateUserWithRemoteAuthAsync(
|
||||
req.Username,
|
||||
AuthType.Fediverse,
|
||||
ticketData.User.Id,
|
||||
ticketData.User.Username,
|
||||
instance: app
|
||||
app
|
||||
);
|
||||
|
||||
return Ok(await authService.GenerateUserTokenAsync(user));
|
||||
|
@ -132,13 +131,13 @@ public class FediverseAuthController(
|
|||
if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.'))
|
||||
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
|
||||
|
||||
var state = await remoteAuthService.ValidateAddAccountRequestAsync(
|
||||
string state = await remoteAuthService.ValidateAddAccountRequestAsync(
|
||||
CurrentUser!.Id,
|
||||
AuthType.Fediverse,
|
||||
instance
|
||||
);
|
||||
|
||||
var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state);
|
||||
string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state);
|
||||
return Ok(new AuthController.SingleUrlResponse(url));
|
||||
}
|
||||
|
||||
|
@ -153,11 +152,12 @@ public class FediverseAuthController(
|
|||
req.Instance
|
||||
);
|
||||
|
||||
var app = await fediverseAuthService.GetApplicationAsync(req.Instance);
|
||||
var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code);
|
||||
FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
|
||||
FediverseAuthService.FediverseUser remoteUser =
|
||||
await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code);
|
||||
try
|
||||
{
|
||||
var authMethod = await authService.AddAuthMethodAsync(
|
||||
AuthMethod authMethod = await authService.AddAuthMethodAsync(
|
||||
CurrentUser.Id,
|
||||
AuthType.Fediverse,
|
||||
remoteUser.Id,
|
||||
|
|
|
@ -25,7 +25,7 @@ public class ExportsController(
|
|||
[HttpGet]
|
||||
public async Task<IActionResult> GetDataExportsAsync()
|
||||
{
|
||||
var export = await db
|
||||
DataExport? export = await db
|
||||
.DataExports.Where(d => d.UserId == CurrentUser!.Id)
|
||||
.OrderByDescending(d => d.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using Coravel.Queuing.Interfaces;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Extensions;
|
||||
using Foxnouns.Backend.Jobs;
|
||||
using Foxnouns.Backend.Middleware;
|
||||
|
@ -7,6 +8,7 @@ using Foxnouns.Backend.Services;
|
|||
using Foxnouns.Backend.Utils;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Foxnouns.Backend.Controllers;
|
||||
|
||||
|
@ -29,7 +31,9 @@ public class FlagsController(
|
|||
)]
|
||||
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var flags = await db.PrideFlags.Where(f => f.UserId == CurrentUser!.Id).ToListAsync(ct);
|
||||
List<PrideFlag> flags = await db
|
||||
.PrideFlags.Where(f => f.UserId == CurrentUser!.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Ok(flags.Select(userRenderer.RenderPrideFlag));
|
||||
}
|
||||
|
@ -43,7 +47,7 @@ public class FlagsController(
|
|||
{
|
||||
ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image));
|
||||
|
||||
var id = snowflakeGenerator.GenerateSnowflake();
|
||||
Snowflake id = snowflakeGenerator.GenerateSnowflake();
|
||||
|
||||
queue.QueueInvocableWithPayload<CreateFlagInvocable, CreateFlagPayload>(
|
||||
new CreateFlagPayload(id, CurrentUser!.Id, req.Name, req.Image, req.Description)
|
||||
|
@ -62,7 +66,7 @@ public class FlagsController(
|
|||
{
|
||||
ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, null));
|
||||
|
||||
var flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
||||
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
||||
f.Id == id && f.UserId == CurrentUser!.Id
|
||||
);
|
||||
if (flag == null)
|
||||
|
@ -90,20 +94,20 @@ public class FlagsController(
|
|||
[Authorize("user.update")]
|
||||
public async Task<IActionResult> DeleteFlagAsync(Snowflake id)
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
var flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
||||
PrideFlag? 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;
|
||||
string hash = flag.Hash;
|
||||
|
||||
db.PrideFlags.Remove(flag);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var flagCount = await db.PrideFlags.CountAsync(f => f.Hash == flag.Hash);
|
||||
int flagCount = await db.PrideFlags.CountAsync(f => f.Hash == flag.Hash);
|
||||
if (flagCount == 0)
|
||||
{
|
||||
try
|
||||
|
@ -120,7 +124,9 @@ public class FlagsController(
|
|||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("Flag file {Hash} is used by other flags, not deleting", hash);
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
|
|
|
@ -44,21 +44,22 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
|
|||
[HttpPost("request-data")]
|
||||
public async Task<IActionResult> GetRequestDataAsync([FromBody] RequestDataRequest req)
|
||||
{
|
||||
var endpoint = GetEndpoint(HttpContext, req.Path, req.Method);
|
||||
RouteEndpoint? endpoint = GetEndpoint(HttpContext, req.Path, req.Method);
|
||||
if (endpoint == null)
|
||||
throw new ApiError.BadRequest("Path/method combination is invalid");
|
||||
|
||||
var actionDescriptor = endpoint.Metadata.GetMetadata<ControllerActionDescriptor>();
|
||||
var template = actionDescriptor?.AttributeRouteInfo?.Template;
|
||||
ControllerActionDescriptor? actionDescriptor =
|
||||
endpoint.Metadata.GetMetadata<ControllerActionDescriptor>();
|
||||
string? template = actionDescriptor?.AttributeRouteInfo?.Template;
|
||||
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)
|
||||
if (!AuthUtils.TryParseToken(req.Token, out var rawToken))
|
||||
if (!AuthUtils.TryParseToken(req.Token, out byte[]? rawToken))
|
||||
return Ok(new RequestDataResponse(null, template));
|
||||
|
||||
var userId = await db.GetTokenUserId(rawToken);
|
||||
Snowflake? userId = await db.GetTokenUserId(rawToken);
|
||||
return Ok(new RequestDataResponse(userId, template));
|
||||
}
|
||||
|
||||
|
@ -72,12 +73,13 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
|
|||
string requestMethod
|
||||
)
|
||||
{
|
||||
var endpointDataSource = httpContext.RequestServices.GetService<EndpointDataSource>();
|
||||
EndpointDataSource? endpointDataSource =
|
||||
httpContext.RequestServices.GetService<EndpointDataSource>();
|
||||
if (endpointDataSource == null)
|
||||
return null;
|
||||
var endpoints = endpointDataSource.Endpoints.OfType<RouteEndpoint>();
|
||||
IEnumerable<RouteEndpoint> endpoints = endpointDataSource.Endpoints.OfType<RouteEndpoint>();
|
||||
|
||||
foreach (var endpoint in endpoints)
|
||||
foreach (RouteEndpoint? endpoint in endpoints)
|
||||
{
|
||||
if (endpoint.RoutePattern.RawText == null)
|
||||
continue;
|
||||
|
@ -86,16 +88,19 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
|
|||
TemplateParser.Parse(endpoint.RoutePattern.RawText),
|
||||
new RouteValueDictionary()
|
||||
);
|
||||
if (!templateMatcher.TryMatch(url, new()))
|
||||
if (!templateMatcher.TryMatch(url, new RouteValueDictionary()))
|
||||
continue;
|
||||
var httpMethodAttribute = endpoint.Metadata.GetMetadata<HttpMethodAttribute>();
|
||||
HttpMethodAttribute? httpMethodAttribute =
|
||||
endpoint.Metadata.GetMetadata<HttpMethodAttribute>();
|
||||
if (
|
||||
httpMethodAttribute != null
|
||||
&& !httpMethodAttribute.HttpMethods.Any(x =>
|
||||
httpMethodAttribute?.HttpMethods.Any(x =>
|
||||
x.Equals(requestMethod, StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
) == false
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ using Foxnouns.Backend.Services;
|
|||
using Foxnouns.Backend.Utils;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using NodaTime;
|
||||
|
||||
namespace Foxnouns.Backend.Controllers;
|
||||
|
@ -32,7 +33,7 @@ public class MembersController(
|
|||
)]
|
||||
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
||||
{
|
||||
var user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||
return Ok(await memberRenderer.RenderUserMembersAsync(user, CurrentToken));
|
||||
}
|
||||
|
||||
|
@ -44,7 +45,7 @@ public class MembersController(
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken, ct);
|
||||
Member member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken, ct);
|
||||
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
||||
}
|
||||
|
||||
|
@ -78,7 +79,7 @@ public class MembersController(
|
|||
]
|
||||
);
|
||||
|
||||
var memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct);
|
||||
int memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct);
|
||||
if (memberCount >= MaxMemberCount)
|
||||
throw new ApiError.BadRequest("Maximum number of members reached");
|
||||
|
||||
|
@ -120,9 +121,11 @@ public class MembersController(
|
|||
}
|
||||
|
||||
if (req.Avatar != null)
|
||||
{
|
||||
queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
|
||||
new AvatarUpdatePayload(member.Id, req.Avatar)
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
||||
}
|
||||
|
@ -134,8 +137,8 @@ public class MembersController(
|
|||
[FromBody] UpdateMemberRequest req
|
||||
)
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
||||
await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync();
|
||||
Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
||||
var errors = new List<(string, ValidationError?)>();
|
||||
|
||||
// We might add extra validations for names later down the line.
|
||||
|
@ -197,7 +200,11 @@ public class MembersController(
|
|||
|
||||
if (req.Flags != null)
|
||||
{
|
||||
var flagError = await db.SetMemberFlagsAsync(CurrentUser!.Id, member.Id, req.Flags);
|
||||
ValidationError? flagError = await db.SetMemberFlagsAsync(
|
||||
CurrentUser!.Id,
|
||||
member.Id,
|
||||
req.Flags
|
||||
);
|
||||
if (flagError != null)
|
||||
errors.Add(("flags", flagError));
|
||||
}
|
||||
|
@ -210,9 +217,12 @@ public class MembersController(
|
|||
// (atomic operations are hard when combined with background jobs)
|
||||
// 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)
|
||||
);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
|
@ -228,7 +238,7 @@ public class MembersController(
|
|||
throw new ApiError.BadRequest(
|
||||
"A member with that name already exists",
|
||||
"name",
|
||||
req.Name!
|
||||
req.Name
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -254,8 +264,8 @@ public class MembersController(
|
|||
[Authorize("member.update")]
|
||||
public async Task<IActionResult> DeleteMemberAsync(string memberRef)
|
||||
{
|
||||
var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
||||
var deleteCount = await db
|
||||
Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
||||
int deleteCount = await db
|
||||
.Members.Where(m => m.UserId == CurrentUser!.Id && m.Id == member.Id)
|
||||
.ExecuteDeleteAsync();
|
||||
if (deleteCount == 0)
|
||||
|
@ -289,9 +299,9 @@ public class MembersController(
|
|||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> RerollSidAsync(string memberRef)
|
||||
{
|
||||
var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
||||
Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
||||
|
||||
var minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1);
|
||||
Instant minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1);
|
||||
if (CurrentUser!.LastSidReroll > minTimeAgo)
|
||||
throw new ApiError.BadRequest("Cannot reroll short ID yet");
|
||||
|
||||
|
@ -308,7 +318,10 @@ public class MembersController(
|
|||
);
|
||||
|
||||
// Fetch the new sid then pass that to RenderMember
|
||||
var newSid = await db.Members.Where(m => m.Id == member.Id).Select(m => m.Sid).FirstAsync();
|
||||
string newSid = await db
|
||||
.Members.Where(m => m.Id == member.Id)
|
||||
.Select(m => m.Sid)
|
||||
.FirstAsync();
|
||||
return Ok(memberRenderer.RenderMember(member, CurrentToken, newSid));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,9 +10,8 @@ public class MetaController : ApiControllerBase
|
|||
|
||||
[HttpGet]
|
||||
[ProducesResponseType<MetaResponse>(StatusCodes.Status200OK)]
|
||||
public IActionResult GetMeta()
|
||||
{
|
||||
return Ok(
|
||||
public IActionResult GetMeta() =>
|
||||
Ok(
|
||||
new MetaResponse(
|
||||
Repository,
|
||||
BuildInfo.Version,
|
||||
|
@ -25,14 +24,13 @@ public class MetaController : ApiControllerBase
|
|||
(int)FoxnounsMetrics.UsersActiveDayCount.Value
|
||||
),
|
||||
new Limits(
|
||||
MemberCount: MembersController.MaxMemberCount,
|
||||
BioLength: ValidationUtils.MaxBioLength,
|
||||
CustomPreferences: ValidationUtils.MaxCustomPreferences,
|
||||
MaxAuthMethods: AuthUtils.MaxAuthMethodsPerType
|
||||
MembersController.MaxMemberCount,
|
||||
ValidationUtils.MaxBioLength,
|
||||
ValidationUtils.MaxCustomPreferences,
|
||||
AuthUtils.MaxAuthMethodsPerType
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/coffee")]
|
||||
public IActionResult BrewCoffee() =>
|
||||
|
|
|
@ -24,7 +24,7 @@ public class SidController(Config config, DatabaseContext db) : ApiControllerBas
|
|||
|
||||
private async Task<IActionResult> ResolveUserSidAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
var username = await db
|
||||
string? username = await db
|
||||
.Users.Where(u => u.Sid == id.ToLowerInvariant() && !u.Deleted)
|
||||
.Select(u => u.Username)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
|
|
@ -9,6 +9,7 @@ using Foxnouns.Backend.Services;
|
|||
using Foxnouns.Backend.Utils;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using NodaTime;
|
||||
|
||||
namespace Foxnouns.Backend.Controllers;
|
||||
|
@ -29,16 +30,9 @@ public class UsersController(
|
|||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
||||
{
|
||||
var user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||
return Ok(
|
||||
await userRenderer.RenderUserAsync(
|
||||
user,
|
||||
selfUser: CurrentUser,
|
||||
token: CurrentToken,
|
||||
renderMembers: true,
|
||||
renderAuthMethods: true,
|
||||
ct: ct
|
||||
)
|
||||
await userRenderer.RenderUserAsync(user, CurrentUser, CurrentToken, true, true, ct: ct)
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -50,8 +44,8 @@ public class UsersController(
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync(ct);
|
||||
var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
|
||||
await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync(ct);
|
||||
User user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
|
||||
var errors = new List<(string, ValidationError?)>();
|
||||
|
||||
if (req.Username != null && req.Username != user.Username)
|
||||
|
@ -108,7 +102,7 @@ public class UsersController(
|
|||
|
||||
if (req.Flags != null)
|
||||
{
|
||||
var flagError = await db.SetUserFlagsAsync(CurrentUser!.Id, req.Flags);
|
||||
ValidationError? flagError = await db.SetUserFlagsAsync(CurrentUser!.Id, req.Flags);
|
||||
if (flagError != null)
|
||||
errors.Add(("flags", flagError));
|
||||
}
|
||||
|
@ -141,14 +135,18 @@ public class UsersController(
|
|||
else
|
||||
{
|
||||
if (TimeZoneInfo.TryFindSystemTimeZoneById(req.Timezone, out _))
|
||||
{
|
||||
user.Timezone = req.Timezone;
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add(
|
||||
(
|
||||
"timezone",
|
||||
ValidationError.GenericValidationError("Invalid timezone", req.Timezone)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -157,9 +155,11 @@ public class UsersController(
|
|||
// (atomic operations are hard when combined with background jobs)
|
||||
// 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)
|
||||
);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
@ -176,7 +176,7 @@ public class UsersController(
|
|||
throw new ApiError.BadRequest(
|
||||
"That username is already taken.",
|
||||
"username",
|
||||
req.Username!
|
||||
req.Username
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -202,12 +202,12 @@ public class UsersController(
|
|||
{
|
||||
ValidationUtils.Validate(ValidationUtils.ValidateCustomPreferences(req));
|
||||
|
||||
var user = await db.ResolveUserAsync(CurrentUser!.Id, ct);
|
||||
User user = await db.ResolveUserAsync(CurrentUser!.Id, ct);
|
||||
var preferences = user
|
||||
.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
|
||||
.ToDictionary();
|
||||
|
||||
foreach (var r in req)
|
||||
foreach (CustomPreferenceUpdate? r in req)
|
||||
{
|
||||
if (r.Id != null && preferences.ContainsKey(r.Id.Value))
|
||||
{
|
||||
|
@ -271,7 +271,7 @@ public class UsersController(
|
|||
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetUserSettingsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
|
||||
User user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
|
||||
return Ok(user.Settings);
|
||||
}
|
||||
|
||||
|
@ -283,7 +283,7 @@ public class UsersController(
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
|
||||
User user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
|
||||
|
||||
if (req.HasProperty(nameof(req.DarkMode)))
|
||||
user.Settings.DarkMode = req.DarkMode;
|
||||
|
@ -304,7 +304,7 @@ public class UsersController(
|
|||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> RerollSidAsync()
|
||||
{
|
||||
var minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1);
|
||||
Instant minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1);
|
||||
if (CurrentUser!.LastSidReroll > minTimeAgo)
|
||||
throw new ApiError.BadRequest("Cannot reroll short ID yet");
|
||||
|
||||
|
@ -318,18 +318,18 @@ public class UsersController(
|
|||
);
|
||||
|
||||
// Get the user's new sid
|
||||
var newSid = await db
|
||||
string newSid = await db
|
||||
.Users.Where(u => u.Id == CurrentUser.Id)
|
||||
.Select(u => u.Sid)
|
||||
.FirstAsync();
|
||||
|
||||
var user = await db.ResolveUserAsync(CurrentUser.Id);
|
||||
User user = await db.ResolveUserAsync(CurrentUser.Id);
|
||||
return Ok(
|
||||
await userRenderer.RenderUserAsync(
|
||||
CurrentUser,
|
||||
user,
|
||||
CurrentUser,
|
||||
CurrentToken,
|
||||
renderMembers: false,
|
||||
false,
|
||||
overrideSid: newSid
|
||||
)
|
||||
);
|
||||
|
|
|
@ -11,9 +11,8 @@ namespace Foxnouns.Backend.Database;
|
|||
|
||||
public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
||||
{
|
||||
private static string GenerateConnectionString(Config.DatabaseConfig config)
|
||||
{
|
||||
return new NpgsqlConnectionStringBuilder(config.Url)
|
||||
private static string GenerateConnectionString(Config.DatabaseConfig config) =>
|
||||
new NpgsqlConnectionStringBuilder(config.Url)
|
||||
{
|
||||
Pooling = config.EnablePooling ?? true,
|
||||
Timeout = config.Timeout ?? 5,
|
||||
|
@ -22,7 +21,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
|||
ConnectionPruningInterval = 10,
|
||||
ConnectionIdleLifetime = 10,
|
||||
}.ConnectionString;
|
||||
}
|
||||
|
||||
public static NpgsqlDataSource BuildDataSource(Config config)
|
||||
{
|
||||
|
@ -46,18 +44,18 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
|||
.UseSnakeCaseNamingConvention()
|
||||
.UseExceptionProcessor();
|
||||
|
||||
public DbSet<User> Users { get; init; }
|
||||
public DbSet<Member> Members { get; init; }
|
||||
public DbSet<AuthMethod> AuthMethods { get; init; }
|
||||
public DbSet<FediverseApplication> FediverseApplications { get; init; }
|
||||
public DbSet<Token> Tokens { get; init; }
|
||||
public DbSet<Application> Applications { get; init; }
|
||||
public DbSet<TemporaryKey> TemporaryKeys { get; init; }
|
||||
public DbSet<DataExport> DataExports { get; init; }
|
||||
public DbSet<User> Users { get; init; } = null!;
|
||||
public DbSet<Member> Members { get; init; } = null!;
|
||||
public DbSet<AuthMethod> AuthMethods { get; init; } = null!;
|
||||
public DbSet<FediverseApplication> FediverseApplications { get; init; } = null!;
|
||||
public DbSet<Token> Tokens { get; init; } = null!;
|
||||
public DbSet<Application> Applications { get; init; } = null!;
|
||||
public DbSet<TemporaryKey> TemporaryKeys { get; init; } = null!;
|
||||
public DbSet<DataExport> DataExports { get; init; } = null!;
|
||||
|
||||
public DbSet<PrideFlag> PrideFlags { get; init; }
|
||||
public DbSet<UserFlag> UserFlags { get; init; }
|
||||
public DbSet<MemberFlag> MemberFlags { get; init; }
|
||||
public DbSet<PrideFlag> PrideFlags { get; init; } = null!;
|
||||
public DbSet<UserFlag> UserFlags { get; init; } = null!;
|
||||
public DbSet<MemberFlag> MemberFlags { get; init; } = null!;
|
||||
|
||||
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
||||
{
|
||||
|
@ -138,16 +136,16 @@ public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory<Data
|
|||
public DatabaseContext CreateDbContext(string[] args)
|
||||
{
|
||||
// Read the configuration file
|
||||
var config =
|
||||
Config config =
|
||||
new ConfigurationBuilder()
|
||||
.AddConfiguration()
|
||||
.Build()
|
||||
// Get the configuration as our config class
|
||||
.Get<Config>() ?? new();
|
||||
.Get<Config>() ?? new Config();
|
||||
|
||||
var dataSource = DatabaseContext.BuildDataSource(config);
|
||||
NpgsqlDataSource dataSource = DatabaseContext.BuildDataSource(config);
|
||||
|
||||
var options = DatabaseContext
|
||||
DbContextOptions options = DatabaseContext
|
||||
.BuildOptions(new DbContextOptionsBuilder(), dataSource, null)
|
||||
.Options;
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@ public static class DatabaseQueryExtensions
|
|||
}
|
||||
|
||||
User? user;
|
||||
if (Snowflake.TryParse(userRef, out var snowflake))
|
||||
if (Snowflake.TryParse(userRef, out Snowflake? snowflake))
|
||||
{
|
||||
user = await context
|
||||
.Users.Where(u => !u.Deleted)
|
||||
|
@ -42,7 +42,7 @@ public static class DatabaseQueryExtensions
|
|||
return user;
|
||||
throw new ApiError.NotFound(
|
||||
"No user with that ID or username found.",
|
||||
code: ErrorCode.UserNotFound
|
||||
ErrorCode.UserNotFound
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -52,12 +52,12 @@ public static class DatabaseQueryExtensions
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var user = await context
|
||||
User? user = await context
|
||||
.Users.Where(u => !u.Deleted)
|
||||
.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||
if (user != null)
|
||||
return user;
|
||||
throw new ApiError.NotFound("No user with that ID found.", code: ErrorCode.UserNotFound);
|
||||
throw new ApiError.NotFound("No user with that ID found.", ErrorCode.UserNotFound);
|
||||
}
|
||||
|
||||
public static async Task<Member> ResolveMemberAsync(
|
||||
|
@ -66,16 +66,13 @@ public static class DatabaseQueryExtensions
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var member = await context
|
||||
Member? member = await context
|
||||
.Members.Include(m => m.User)
|
||||
.Where(m => !m.User.Deleted)
|
||||
.FirstOrDefaultAsync(m => m.Id == id, ct);
|
||||
if (member != null)
|
||||
return member;
|
||||
throw new ApiError.NotFound(
|
||||
"No member with that ID found.",
|
||||
code: ErrorCode.MemberNotFound
|
||||
);
|
||||
throw new ApiError.NotFound("No member with that ID found.", ErrorCode.MemberNotFound);
|
||||
}
|
||||
|
||||
public static async Task<Member> ResolveMemberAsync(
|
||||
|
@ -86,7 +83,7 @@ public static class DatabaseQueryExtensions
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var user = await context.ResolveUserAsync(userRef, token, ct);
|
||||
User user = await context.ResolveUserAsync(userRef, token, ct);
|
||||
return await context.ResolveMemberAsync(user.Id, memberRef, ct);
|
||||
}
|
||||
|
||||
|
@ -98,7 +95,7 @@ public static class DatabaseQueryExtensions
|
|||
)
|
||||
{
|
||||
Member? member;
|
||||
if (Snowflake.TryParse(memberRef, out var snowflake))
|
||||
if (Snowflake.TryParse(memberRef, out Snowflake? snowflake))
|
||||
{
|
||||
member = await context
|
||||
.Members.Include(m => m.User)
|
||||
|
@ -118,7 +115,7 @@ public static class DatabaseQueryExtensions
|
|||
return member;
|
||||
throw new ApiError.NotFound(
|
||||
"No member with that ID or name found.",
|
||||
code: ErrorCode.MemberNotFound
|
||||
ErrorCode.MemberNotFound
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -127,7 +124,10 @@ public static class DatabaseQueryExtensions
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var app = await context.Applications.FirstOrDefaultAsync(a => a.Id == new Snowflake(0), ct);
|
||||
Application? app = await context.Applications.FirstOrDefaultAsync(
|
||||
a => a.Id == new Snowflake(0),
|
||||
ct
|
||||
);
|
||||
if (app != null)
|
||||
return app;
|
||||
|
||||
|
@ -152,9 +152,9 @@ public static class DatabaseQueryExtensions
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var hash = SHA512.HashData(rawToken);
|
||||
byte[] hash = SHA512.HashData(rawToken);
|
||||
|
||||
var oauthToken = await context
|
||||
Token? oauthToken = await context
|
||||
.Tokens.Include(t => t.Application)
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(
|
||||
|
@ -174,7 +174,7 @@ public static class DatabaseQueryExtensions
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var hash = SHA512.HashData(rawToken);
|
||||
byte[] hash = SHA512.HashData(rawToken);
|
||||
return await context
|
||||
.Tokens.Where(t =>
|
||||
t.Hash == hash
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
using Npgsql;
|
||||
using Serilog;
|
||||
|
||||
namespace Foxnouns.Backend.Database;
|
||||
|
@ -9,8 +10,8 @@ public static class DatabaseServiceExtensions
|
|||
Config config
|
||||
)
|
||||
{
|
||||
var dataSource = DatabaseContext.BuildDataSource(config);
|
||||
var loggerFactory = new LoggerFactory().AddSerilog(dispose: false);
|
||||
NpgsqlDataSource dataSource = DatabaseContext.BuildDataSource(config);
|
||||
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(dispose: false);
|
||||
|
||||
serviceCollection.AddDbContext<DatabaseContext>(options =>
|
||||
DatabaseContext.BuildOptions(options, dataSource, loggerFactory)
|
||||
|
|
|
@ -20,8 +20,10 @@ public static class FlagQueryExtensions
|
|||
Snowflake[] flagIds
|
||||
)
|
||||
{
|
||||
var currentFlags = await db.UserFlags.Where(f => f.UserId == userId).ToListAsync();
|
||||
foreach (var flag in currentFlags)
|
||||
List<UserFlag> currentFlags = await db
|
||||
.UserFlags.Where(f => f.UserId == userId)
|
||||
.ToListAsync();
|
||||
foreach (UserFlag flag in currentFlags)
|
||||
db.UserFlags.Remove(flag);
|
||||
|
||||
// If there's no new flags to set, we're done
|
||||
|
@ -30,12 +32,16 @@ public static class FlagQueryExtensions
|
|||
if (flagIds.Length > 100)
|
||||
return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length);
|
||||
|
||||
var flags = await db.GetFlagsAsync(userId);
|
||||
var unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray();
|
||||
List<PrideFlag> flags = await db.GetFlagsAsync(userId);
|
||||
Snowflake[] unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray();
|
||||
if (unknownFlagIds.Length != 0)
|
||||
return ValidationError.GenericValidationError("Unknown flag IDs", unknownFlagIds);
|
||||
|
||||
var userFlags = flagIds.Select(id => new UserFlag { PrideFlagId = id, UserId = userId });
|
||||
IEnumerable<UserFlag> userFlags = flagIds.Select(id => new UserFlag
|
||||
{
|
||||
PrideFlagId = id,
|
||||
UserId = userId,
|
||||
});
|
||||
db.UserFlags.AddRange(userFlags);
|
||||
|
||||
return null;
|
||||
|
@ -48,8 +54,10 @@ public static class FlagQueryExtensions
|
|||
Snowflake[] flagIds
|
||||
)
|
||||
{
|
||||
var currentFlags = await db.MemberFlags.Where(f => f.MemberId == memberId).ToListAsync();
|
||||
foreach (var flag in currentFlags)
|
||||
List<MemberFlag> currentFlags = await db
|
||||
.MemberFlags.Where(f => f.MemberId == memberId)
|
||||
.ToListAsync();
|
||||
foreach (MemberFlag flag in currentFlags)
|
||||
db.MemberFlags.Remove(flag);
|
||||
|
||||
if (flagIds.Length == 0)
|
||||
|
@ -57,12 +65,12 @@ public static class FlagQueryExtensions
|
|||
if (flagIds.Length > 100)
|
||||
return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length);
|
||||
|
||||
var flags = await db.GetFlagsAsync(userId);
|
||||
var unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray();
|
||||
List<PrideFlag> flags = await db.GetFlagsAsync(userId);
|
||||
Snowflake[] unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray();
|
||||
if (unknownFlagIds.Length != 0)
|
||||
return ValidationError.GenericValidationError("Unknown flag IDs", unknownFlagIds);
|
||||
|
||||
var memberFlags = flagIds.Select(id => new MemberFlag
|
||||
IEnumerable<MemberFlag> memberFlags = flagIds.Select(id => new MemberFlag
|
||||
{
|
||||
PrideFlagId = id,
|
||||
MemberId = memberId,
|
||||
|
|
|
@ -24,10 +24,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
client_secret = table.Column<string>(type: "text", nullable: false),
|
||||
instance_type = table.Column<int>(type: "integer", nullable: false),
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_fediverse_applications", x => x.id);
|
||||
}
|
||||
constraints: table => table.PrimaryKey("pk_fediverse_applications", x => x.id)
|
||||
);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
|
@ -46,10 +43,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
names = table.Column<string>(type: "jsonb", nullable: false),
|
||||
pronouns = table.Column<string>(type: "jsonb", nullable: false),
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_users", x => x.id);
|
||||
}
|
||||
constraints: table => table.PrimaryKey("pk_users", x => x.id)
|
||||
);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
|
|
|
@ -26,7 +26,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
table: "tokens",
|
||||
type: "bytea",
|
||||
nullable: false,
|
||||
defaultValue: new byte[0]
|
||||
defaultValue: Array.Empty<byte>()
|
||||
);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
|
@ -40,10 +40,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
scopes = table.Column<string[]>(type: "text[]", nullable: false),
|
||||
redirect_uris = table.Column<string[]>(type: "text[]", nullable: false),
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_applications", x => x.id);
|
||||
}
|
||||
constraints: table => table.PrimaryKey("pk_applications", x => x.id)
|
||||
);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
|
|
|
@ -32,10 +32,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
nullable: false
|
||||
),
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_temporary_keys", x => x.id);
|
||||
}
|
||||
constraints: table => table.PrimaryKey("pk_temporary_keys", x => x.id)
|
||||
);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
|
|
|
@ -18,8 +18,8 @@ public class Application : BaseModel
|
|||
string[] redirectUrls
|
||||
)
|
||||
{
|
||||
var clientId = RandomNumberGenerator.GetHexString(32, true);
|
||||
var clientSecret = AuthUtils.RandomToken();
|
||||
string clientId = RandomNumberGenerator.GetHexString(32, true);
|
||||
string clientSecret = AuthUtils.RandomToken();
|
||||
|
||||
if (scopes.Except(AuthUtils.ApplicationScopes).Any())
|
||||
{
|
||||
|
|
|
@ -59,7 +59,7 @@ public readonly struct Snowflake(ulong value) : IEquatable<Snowflake>
|
|||
public static bool TryParse(string input, [NotNullWhen(true)] out Snowflake? snowflake)
|
||||
{
|
||||
snowflake = null;
|
||||
if (!ulong.TryParse(input, out var res))
|
||||
if (!ulong.TryParse(input, out ulong res))
|
||||
return false;
|
||||
snowflake = new Snowflake(res);
|
||||
return true;
|
||||
|
@ -70,10 +70,7 @@ public readonly struct Snowflake(ulong value) : IEquatable<Snowflake>
|
|||
|
||||
public override bool Equals(object? obj) => obj is Snowflake other && Value == other.Value;
|
||||
|
||||
public bool Equals(Snowflake other)
|
||||
{
|
||||
return Value == other.Value;
|
||||
}
|
||||
public bool Equals(Snowflake other) => Value == other.Value;
|
||||
|
||||
public override int GetHashCode() => Value.GetHashCode();
|
||||
|
||||
|
@ -83,11 +80,7 @@ public readonly struct Snowflake(ulong value) : IEquatable<Snowflake>
|
|||
/// An Entity Framework ValueConverter for Snowflakes to longs.
|
||||
/// </summary>
|
||||
// ReSharper disable once ClassNeverInstantiated.Global
|
||||
public class ValueConverter()
|
||||
: ValueConverter<Snowflake, long>(
|
||||
convertToProviderExpression: x => x,
|
||||
convertFromProviderExpression: x => x
|
||||
);
|
||||
public class ValueConverter() : ValueConverter<Snowflake, long>(x => x, x => x);
|
||||
|
||||
private class JsonConverter : JsonConverter<Snowflake>
|
||||
{
|
||||
|
@ -106,10 +99,7 @@ public readonly struct Snowflake(ulong value) : IEquatable<Snowflake>
|
|||
Snowflake existingValue,
|
||||
bool hasExistingValue,
|
||||
JsonSerializer serializer
|
||||
)
|
||||
{
|
||||
return ulong.Parse((string)reader.Value!);
|
||||
}
|
||||
) => ulong.Parse((string)reader.Value!);
|
||||
}
|
||||
|
||||
private class TypeConverter : System.ComponentModel.TypeConverter
|
||||
|
@ -126,9 +116,6 @@ public readonly struct Snowflake(ulong value) : IEquatable<Snowflake>
|
|||
ITypeDescriptorContext? context,
|
||||
CultureInfo? culture,
|
||||
object value
|
||||
)
|
||||
{
|
||||
return TryParse((string)value, out var snowflake) ? snowflake : null;
|
||||
}
|
||||
) => TryParse((string)value, out Snowflake? snowflake) ? snowflake : null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,9 +28,9 @@ public class SnowflakeGenerator : ISnowflakeGenerator
|
|||
public Snowflake GenerateSnowflake(Instant? time = null)
|
||||
{
|
||||
time ??= SystemClock.Instance.GetCurrentInstant();
|
||||
var increment = Interlocked.Increment(ref _increment);
|
||||
var threadId = Environment.CurrentManagedThreadId % 32;
|
||||
var timestamp = time.Value.ToUnixTimeMilliseconds() - Snowflake.Epoch;
|
||||
long increment = Interlocked.Increment(ref _increment);
|
||||
int threadId = Environment.CurrentManagedThreadId % 32;
|
||||
long timestamp = time.Value.ToUnixTimeMilliseconds() - Snowflake.Epoch;
|
||||
|
||||
return (timestamp << 22)
|
||||
| (uint)(_processId << 17)
|
||||
|
@ -44,8 +44,5 @@ public static class SnowflakeGeneratorServiceExtensions
|
|||
public static IServiceCollection AddSnowflakeGenerator(
|
||||
this IServiceCollection services,
|
||||
int? processId = null
|
||||
)
|
||||
{
|
||||
return services.AddSingleton<ISnowflakeGenerator>(new SnowflakeGenerator(processId));
|
||||
}
|
||||
) => services.AddSingleton<ISnowflakeGenerator>(new SnowflakeGenerator(processId));
|
||||
}
|
||||
|
|
|
@ -35,13 +35,13 @@ public class ApiError(
|
|||
public readonly ErrorCode ErrorCode = errorCode ?? ErrorCode.InternalServerError;
|
||||
|
||||
public class Unauthorized(string message, ErrorCode errorCode = ErrorCode.AuthenticationError)
|
||||
: ApiError(message, statusCode: HttpStatusCode.Unauthorized, errorCode: errorCode);
|
||||
: ApiError(message, HttpStatusCode.Unauthorized, errorCode);
|
||||
|
||||
public class Forbidden(
|
||||
string message,
|
||||
IEnumerable<string>? scopes = null,
|
||||
ErrorCode errorCode = ErrorCode.Forbidden
|
||||
) : ApiError(message, statusCode: HttpStatusCode.Forbidden, errorCode: errorCode)
|
||||
) : ApiError(message, HttpStatusCode.Forbidden, errorCode)
|
||||
{
|
||||
public readonly string[] Scopes = scopes?.ToArray() ?? [];
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ public class ApiError(
|
|||
public class BadRequest(
|
||||
string message,
|
||||
IReadOnlyDictionary<string, IEnumerable<ValidationError>>? errors = null
|
||||
) : ApiError(message, statusCode: HttpStatusCode.BadRequest)
|
||||
) : ApiError(message, HttpStatusCode.BadRequest)
|
||||
{
|
||||
public BadRequest(string message, string field, object? actualValue)
|
||||
: this(
|
||||
|
@ -72,7 +72,7 @@ public class ApiError(
|
|||
return o;
|
||||
|
||||
var a = new JArray();
|
||||
foreach (var error in errors)
|
||||
foreach (KeyValuePair<string, IEnumerable<ValidationError>> error in errors)
|
||||
{
|
||||
var errorObj = new JObject
|
||||
{
|
||||
|
@ -92,7 +92,7 @@ public class ApiError(
|
|||
/// Any other methods should use <see cref="ApiError.BadRequest" /> instead.
|
||||
/// </summary>
|
||||
public class AspBadRequest(string message, ModelStateDictionary? modelState = null)
|
||||
: ApiError(message, statusCode: HttpStatusCode.BadRequest)
|
||||
: ApiError(message, HttpStatusCode.BadRequest)
|
||||
{
|
||||
public JObject ToJson()
|
||||
{
|
||||
|
@ -106,7 +106,11 @@ public class ApiError(
|
|||
return o;
|
||||
|
||||
var a = new JArray();
|
||||
foreach (var error in modelState.Where(e => e.Value is { Errors.Count: > 0 }))
|
||||
foreach (
|
||||
KeyValuePair<string, ModelStateEntry?> error in modelState.Where(e =>
|
||||
e.Value is { Errors.Count: > 0 }
|
||||
)
|
||||
)
|
||||
{
|
||||
var errorObj = new JObject
|
||||
{
|
||||
|
@ -130,10 +134,9 @@ public class ApiError(
|
|||
}
|
||||
|
||||
public class NotFound(string message, ErrorCode? code = null)
|
||||
: ApiError(message, statusCode: HttpStatusCode.NotFound, errorCode: code);
|
||||
: ApiError(message, HttpStatusCode.NotFound, code);
|
||||
|
||||
public class AuthenticationError(string message)
|
||||
: ApiError(message, statusCode: HttpStatusCode.BadRequest);
|
||||
public class AuthenticationError(string message) : ApiError(message, HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
public enum ErrorCode
|
||||
|
@ -175,33 +178,27 @@ public class ValidationError
|
|||
int minLength,
|
||||
int maxLength,
|
||||
int actualLength
|
||||
)
|
||||
{
|
||||
return new ValidationError
|
||||
) =>
|
||||
new()
|
||||
{
|
||||
Message = message,
|
||||
MinLength = minLength,
|
||||
MaxLength = maxLength,
|
||||
ActualLength = actualLength,
|
||||
};
|
||||
}
|
||||
|
||||
public static ValidationError DisallowedValueError(
|
||||
string message,
|
||||
IEnumerable<object> allowedValues,
|
||||
object actualValue
|
||||
)
|
||||
{
|
||||
return new ValidationError
|
||||
) =>
|
||||
new()
|
||||
{
|
||||
Message = message,
|
||||
AllowedValues = allowedValues,
|
||||
ActualValue = actualValue,
|
||||
};
|
||||
}
|
||||
|
||||
public static ValidationError GenericValidationError(string message, object? actualValue)
|
||||
{
|
||||
return new ValidationError { Message = message, ActualValue = actualValue };
|
||||
}
|
||||
public static ValidationError GenericValidationError(string message, object? actualValue) =>
|
||||
new() { Message = message, ActualValue = actualValue };
|
||||
}
|
||||
|
|
|
@ -47,13 +47,13 @@ public static class ImageObjectExtensions
|
|||
if (!uri.StartsWith("data:image/"))
|
||||
throw new ArgumentException("Not a data URI", nameof(uri));
|
||||
|
||||
var split = uri.Remove(0, "data:".Length).Split(";base64,");
|
||||
var contentType = split[0];
|
||||
var encoded = split[1];
|
||||
string[] split = uri.Remove(0, "data:".Length).Split(";base64,");
|
||||
string contentType = split[0];
|
||||
string encoded = split[1];
|
||||
if (!ValidContentTypes.Contains(contentType))
|
||||
throw new ArgumentException("Invalid content type for image", nameof(uri));
|
||||
|
||||
if (!AuthUtils.TryFromBase64String(encoded, out var rawImage))
|
||||
if (!AuthUtils.TryFromBase64String(encoded, out byte[]? rawImage))
|
||||
throw new ArgumentException("Invalid base64 string", nameof(uri));
|
||||
|
||||
var image = Image.Load(rawImage);
|
||||
|
@ -74,7 +74,7 @@ public static class ImageObjectExtensions
|
|||
await image.SaveAsync(stream, new WebpEncoder { Quality = 95, NearLossless = false });
|
||||
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream)).ToLower();
|
||||
string hash = Convert.ToHexString(await SHA256.HashDataAsync(stream)).ToLower();
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
return (hash, stream);
|
||||
|
|
|
@ -14,7 +14,7 @@ public static class KeyCacheExtensions
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_');
|
||||
string state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_');
|
||||
await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct);
|
||||
return state;
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ public static class KeyCacheExtensions
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", delete: true, ct);
|
||||
string? val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", ct: ct);
|
||||
if (val == null)
|
||||
throw new ApiError.BadRequest("Invalid OAuth state");
|
||||
}
|
||||
|
@ -38,7 +38,7 @@ public static class KeyCacheExtensions
|
|||
)
|
||||
{
|
||||
// This state is used in links, not just as JSON values, so make it URL-safe
|
||||
var state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_');
|
||||
string state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_');
|
||||
await keyCacheService.SetKeyAsync(
|
||||
$"email_state:{state}",
|
||||
new RegisterEmailState(email, userId),
|
||||
|
@ -52,12 +52,7 @@ public static class KeyCacheExtensions
|
|||
this KeyCacheService keyCacheService,
|
||||
string state,
|
||||
CancellationToken ct = default
|
||||
) =>
|
||||
await keyCacheService.GetKeyAsync<RegisterEmailState>(
|
||||
$"email_state:{state}",
|
||||
delete: true,
|
||||
ct
|
||||
);
|
||||
) => await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}", ct: ct);
|
||||
|
||||
public static async Task<string> GenerateAddExtraAccountStateAsync(
|
||||
this KeyCacheService keyCacheService,
|
||||
|
@ -67,7 +62,7 @@ public static class KeyCacheExtensions
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var state = AuthUtils.RandomToken();
|
||||
string state = AuthUtils.RandomToken();
|
||||
await keyCacheService.SetKeyAsync(
|
||||
$"add_account:{state}",
|
||||
new AddExtraAccountState(authType, userId, instance),
|
||||
|
@ -81,12 +76,7 @@ public static class KeyCacheExtensions
|
|||
this KeyCacheService keyCacheService,
|
||||
string state,
|
||||
CancellationToken ct = default
|
||||
) =>
|
||||
await keyCacheService.GetKeyAsync<AddExtraAccountState>(
|
||||
$"add_account:{state}",
|
||||
delete: true,
|
||||
ct
|
||||
);
|
||||
) => await keyCacheService.GetKeyAsync<AddExtraAccountState>($"add_account:{state}", true, ct);
|
||||
}
|
||||
|
||||
public record RegisterEmailState(
|
||||
|
|
|
@ -24,9 +24,9 @@ public static class WebApplicationExtensions
|
|||
/// </summary>
|
||||
public static WebApplicationBuilder AddSerilog(this WebApplicationBuilder builder)
|
||||
{
|
||||
var config = builder.Configuration.Get<Config>() ?? new();
|
||||
Config config = builder.Configuration.Get<Config>() ?? new Config();
|
||||
|
||||
var logCfg = new LoggerConfiguration()
|
||||
LoggerConfiguration logCfg = new LoggerConfiguration()
|
||||
.Enrich.FromLogContext()
|
||||
.MinimumLevel.Is(config.Logging.LogEventLevel)
|
||||
// ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead.
|
||||
|
@ -43,10 +43,7 @@ public static class WebApplicationExtensions
|
|||
|
||||
if (config.Logging.SeqLogUrl != null)
|
||||
{
|
||||
logCfg.WriteTo.Seq(
|
||||
config.Logging.SeqLogUrl,
|
||||
restrictedToMinimumLevel: LogEventLevel.Verbose
|
||||
);
|
||||
logCfg.WriteTo.Seq(config.Logging.SeqLogUrl, LogEventLevel.Verbose);
|
||||
}
|
||||
|
||||
// AddSerilog doesn't seem to add an ILogger to the service collection, so add that manually.
|
||||
|
@ -60,19 +57,19 @@ public static class WebApplicationExtensions
|
|||
builder.Configuration.Sources.Clear();
|
||||
builder.Configuration.AddConfiguration();
|
||||
|
||||
var config = builder.Configuration.Get<Config>() ?? new();
|
||||
Config config = builder.Configuration.Get<Config>() ?? new Config();
|
||||
builder.Services.AddSingleton(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
public static IConfigurationBuilder AddConfiguration(this IConfigurationBuilder builder)
|
||||
{
|
||||
var file = Environment.GetEnvironmentVariable("FOXNOUNS_CONFIG_FILE") ?? "config.ini";
|
||||
string file = Environment.GetEnvironmentVariable("FOXNOUNS_CONFIG_FILE") ?? "config.ini";
|
||||
|
||||
return builder
|
||||
.SetBasePath(Directory.GetCurrentDirectory())
|
||||
.AddJsonFile("appSettings.json", true)
|
||||
.AddIniFile(file, optional: false, reloadOnChange: true)
|
||||
.AddIniFile(file, false, true)
|
||||
.AddEnvironmentVariables();
|
||||
}
|
||||
|
||||
|
@ -142,11 +139,15 @@ public static class WebApplicationExtensions
|
|||
app.Services.ConfigureQueue()
|
||||
.LogQueuedTaskProgress(app.Services.GetRequiredService<ILogger<IQueue>>());
|
||||
|
||||
await using var scope = app.Services.CreateAsyncScope();
|
||||
await using AsyncServiceScope scope = app.Services.CreateAsyncScope();
|
||||
|
||||
// The types of these variables are obvious from the methods being called to create them
|
||||
// ReSharper disable SuggestVarOrType_SimpleTypes
|
||||
var logger = scope
|
||||
.ServiceProvider.GetRequiredService<ILogger>()
|
||||
.ForContext<WebApplication>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||
// ReSharper restore SuggestVarOrType_SimpleTypes
|
||||
|
||||
logger.Information(
|
||||
"Starting Foxnouns.NET {Version} ({Hash})",
|
||||
|
|
|
@ -30,6 +30,10 @@
|
|||
<PackageReference Include="Npgsql.Json.NET" Version="8.0.3"/>
|
||||
<PackageReference Include="prometheus-net" Version="8.2.1"/>
|
||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
|
||||
<PackageReference Include="Roslynator.Analyzers" Version="4.12.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Sentry.AspNetCore" Version="4.9.0"/>
|
||||
<PackageReference Include="Serilog" Version="4.0.1"/>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/>
|
||||
|
|
|
@ -40,7 +40,7 @@ public class CreateDataExportInvocable(
|
|||
|
||||
private async Task InvokeAsync()
|
||||
{
|
||||
var user = await db
|
||||
User? user = await db
|
||||
.Users.Include(u => u.AuthMethods)
|
||||
.Include(u => u.Flags)
|
||||
.Include(u => u.ProfileFlags)
|
||||
|
@ -57,7 +57,7 @@ public class CreateDataExportInvocable(
|
|||
|
||||
_logger.Information("Generating data export for user {UserId}", user.Id);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
await using var stream = new MemoryStream();
|
||||
using var zip = new ZipArchive(stream, ZipArchiveMode.Create, true);
|
||||
zip.Comment =
|
||||
$"This archive for {user.Username} ({user.Id}) was generated at {InstantPattern.General.Format(clock.GetCurrentInstant())}";
|
||||
|
@ -66,25 +66,19 @@ public class CreateDataExportInvocable(
|
|||
WriteJson(
|
||||
zip,
|
||||
"user.json",
|
||||
await userRenderer.RenderUserInnerAsync(
|
||||
user,
|
||||
true,
|
||||
["*"],
|
||||
renderMembers: false,
|
||||
renderAuthMethods: true
|
||||
)
|
||||
await userRenderer.RenderUserInnerAsync(user, true, ["*"], false, true)
|
||||
);
|
||||
await WriteS3Object(zip, "user-avatar.webp", userRenderer.AvatarUrlFor(user));
|
||||
|
||||
foreach (var flag in user.Flags)
|
||||
foreach (PrideFlag? flag in user.Flags)
|
||||
await WritePrideFlag(zip, flag);
|
||||
|
||||
var members = await db
|
||||
List<Member> members = await db
|
||||
.Members.Include(m => m.User)
|
||||
.Include(m => m.ProfileFlags)
|
||||
.Where(m => m.UserId == user.Id)
|
||||
.ToListAsync();
|
||||
foreach (var member in members)
|
||||
foreach (Member? member in members)
|
||||
await WriteMember(zip, member);
|
||||
|
||||
// We want to dispose the ZipArchive on an error, but we need to dispose it manually to upload to object storage.
|
||||
|
@ -94,7 +88,7 @@ public class CreateDataExportInvocable(
|
|||
stream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
// Upload the file!
|
||||
var filename = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_');
|
||||
string filename = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_');
|
||||
await objectStorageService.PutObjectAsync(
|
||||
ExportPath(user.Id, filename),
|
||||
stream,
|
||||
|
@ -132,8 +126,8 @@ public class CreateDataExportInvocable(
|
|||
return;
|
||||
}
|
||||
|
||||
var entry = zip.CreateEntry($"flag-{flag.Id}/flag.txt");
|
||||
await using var stream = entry.Open();
|
||||
ZipArchiveEntry entry = zip.CreateEntry($"flag-{flag.Id}/flag.txt");
|
||||
await using Stream stream = entry.Open();
|
||||
await using var writer = new StreamWriter(stream);
|
||||
await writer.WriteAsync(flagData);
|
||||
}
|
||||
|
@ -164,7 +158,7 @@ public class CreateDataExportInvocable(
|
|||
|
||||
private void WriteJson(ZipArchive zip, string filename, object data)
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(data, Formatting.Indented);
|
||||
string json = JsonConvert.SerializeObject(data, Formatting.Indented);
|
||||
|
||||
_logger.Debug(
|
||||
"Writing file {Filename} to archive with size {Length}",
|
||||
|
@ -172,8 +166,8 @@ public class CreateDataExportInvocable(
|
|||
json.Length
|
||||
);
|
||||
|
||||
var entry = zip.CreateEntry(filename);
|
||||
using var stream = entry.Open();
|
||||
ZipArchiveEntry entry = zip.CreateEntry(filename);
|
||||
using Stream stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.Write(json);
|
||||
}
|
||||
|
@ -183,14 +177,14 @@ public class CreateDataExportInvocable(
|
|||
if (s3Path == null)
|
||||
return;
|
||||
|
||||
var resp = await Client.GetAsync(s3Path);
|
||||
HttpResponseMessage resp = await Client.GetAsync(s3Path);
|
||||
if (resp.StatusCode != HttpStatusCode.OK)
|
||||
{
|
||||
_logger.Warning("S3 path {S3Path} returned a non-200 status, not saving file", s3Path);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var respStream = await resp.Content.ReadAsStreamAsync();
|
||||
await using Stream respStream = await resp.Content.ReadAsStreamAsync();
|
||||
|
||||
_logger.Debug(
|
||||
"Writing file {Filename} to archive with size {Length}",
|
||||
|
@ -198,8 +192,8 @@ public class CreateDataExportInvocable(
|
|||
respStream.Length
|
||||
);
|
||||
|
||||
var entry = zip.CreateEntry(filename);
|
||||
await using var entryStream = entry.Open();
|
||||
ZipArchiveEntry entry = zip.CreateEntry(filename);
|
||||
await using Stream entryStream = entry.Open();
|
||||
|
||||
respStream.Seek(0, SeekOrigin.Begin);
|
||||
await respStream.CopyToAsync(entryStream);
|
||||
|
|
|
@ -26,10 +26,10 @@ public class CreateFlagInvocable(
|
|||
|
||||
try
|
||||
{
|
||||
var (hash, image) = await ImageObjectExtensions.ConvertBase64UriToImage(
|
||||
(string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage(
|
||||
Payload.ImageData,
|
||||
size: 256,
|
||||
crop: false
|
||||
256,
|
||||
false
|
||||
);
|
||||
await objectStorageService.PutObjectAsync(Path(hash), image, "image/webp");
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using Coravel.Invocable;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Extensions;
|
||||
using Foxnouns.Backend.Services;
|
||||
|
||||
|
@ -26,7 +27,7 @@ public class MemberAvatarUpdateInvocable(
|
|||
{
|
||||
_logger.Debug("Updating avatar for member {MemberId}", id);
|
||||
|
||||
var member = await db.Members.FindAsync(id);
|
||||
Member? member = await db.Members.FindAsync(id);
|
||||
if (member == null)
|
||||
{
|
||||
_logger.Warning(
|
||||
|
@ -38,12 +39,12 @@ public class MemberAvatarUpdateInvocable(
|
|||
|
||||
try
|
||||
{
|
||||
var (hash, image) = await ImageObjectExtensions.ConvertBase64UriToImage(
|
||||
(string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage(
|
||||
newAvatar,
|
||||
size: 512,
|
||||
crop: true
|
||||
512,
|
||||
true
|
||||
);
|
||||
var prevHash = member.Avatar;
|
||||
string? prevHash = member.Avatar;
|
||||
|
||||
await objectStorageService.PutObjectAsync(Path(id, hash), image, "image/webp");
|
||||
|
||||
|
@ -69,7 +70,7 @@ public class MemberAvatarUpdateInvocable(
|
|||
{
|
||||
_logger.Debug("Clearing avatar for member {MemberId}", id);
|
||||
|
||||
var member = await db.Members.FindAsync(id);
|
||||
Member? member = await db.Members.FindAsync(id);
|
||||
if (member == null)
|
||||
{
|
||||
_logger.Warning(
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using Coravel.Invocable;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Extensions;
|
||||
using Foxnouns.Backend.Services;
|
||||
|
||||
|
@ -26,7 +27,7 @@ public class UserAvatarUpdateInvocable(
|
|||
{
|
||||
_logger.Debug("Updating avatar for user {MemberId}", id);
|
||||
|
||||
var user = await db.Users.FindAsync(id);
|
||||
User? user = await db.Users.FindAsync(id);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.Warning(
|
||||
|
@ -38,13 +39,13 @@ public class UserAvatarUpdateInvocable(
|
|||
|
||||
try
|
||||
{
|
||||
var (hash, image) = await ImageObjectExtensions.ConvertBase64UriToImage(
|
||||
(string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage(
|
||||
newAvatar,
|
||||
size: 512,
|
||||
crop: true
|
||||
512,
|
||||
true
|
||||
);
|
||||
image.Seek(0, SeekOrigin.Begin);
|
||||
var prevHash = user.Avatar;
|
||||
string? prevHash = user.Avatar;
|
||||
|
||||
await objectStorageService.PutObjectAsync(Path(id, hash), image, "image/webp");
|
||||
|
||||
|
@ -70,7 +71,7 @@ public class UserAvatarUpdateInvocable(
|
|||
{
|
||||
_logger.Debug("Clearing avatar for user {MemberId}", id);
|
||||
|
||||
var user = await db.Users.FindAsync(id);
|
||||
User? user = await db.Users.FindAsync(id);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.Warning(
|
||||
|
|
|
@ -8,8 +8,8 @@ public class AuthenticationMiddleware(DatabaseContext db) : IMiddleware
|
|||
{
|
||||
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||
{
|
||||
var endpoint = ctx.GetEndpoint();
|
||||
var metadata = endpoint?.Metadata.GetMetadata<AuthenticateAttribute>();
|
||||
Endpoint? endpoint = ctx.GetEndpoint();
|
||||
AuthenticateAttribute? metadata = endpoint?.Metadata.GetMetadata<AuthenticateAttribute>();
|
||||
|
||||
if (metadata == null)
|
||||
{
|
||||
|
@ -18,14 +18,17 @@ public class AuthenticationMiddleware(DatabaseContext db) : IMiddleware
|
|||
}
|
||||
|
||||
if (
|
||||
!AuthUtils.TryParseToken(ctx.Request.Headers.Authorization.ToString(), out var rawToken)
|
||||
!AuthUtils.TryParseToken(
|
||||
ctx.Request.Headers.Authorization.ToString(),
|
||||
out byte[]? rawToken
|
||||
)
|
||||
)
|
||||
{
|
||||
await next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
var oauthToken = await db.GetToken(rawToken);
|
||||
Token? oauthToken = await db.GetToken(rawToken);
|
||||
if (oauthToken == null)
|
||||
{
|
||||
await next(ctx);
|
||||
|
@ -50,7 +53,7 @@ public static class HttpContextExtensions
|
|||
|
||||
public static Token? GetToken(this HttpContext ctx)
|
||||
{
|
||||
if (ctx.Items.TryGetValue(Key, out var token))
|
||||
if (ctx.Items.TryGetValue(Key, out object? token))
|
||||
return token as Token;
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -7,8 +7,8 @@ public class AuthorizationMiddleware : IMiddleware
|
|||
{
|
||||
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||
{
|
||||
var endpoint = ctx.GetEndpoint();
|
||||
var attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
|
||||
Endpoint? endpoint = ctx.GetEndpoint();
|
||||
AuthorizeAttribute? attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
|
||||
|
||||
if (attribute == null)
|
||||
{
|
||||
|
@ -16,21 +16,27 @@ public class AuthorizationMiddleware : IMiddleware
|
|||
return;
|
||||
}
|
||||
|
||||
var token = ctx.GetToken();
|
||||
Token? token = ctx.GetToken();
|
||||
if (token == null)
|
||||
{
|
||||
throw new ApiError.Unauthorized(
|
||||
"This endpoint requires an authenticated user.",
|
||||
ErrorCode.AuthenticationRequired
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
attribute.Scopes.Length > 0
|
||||
&& attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()
|
||||
)
|
||||
{
|
||||
throw new ApiError.Forbidden(
|
||||
"This endpoint requires ungranted scopes.",
|
||||
attribute.Scopes.Except(token.Scopes.ExpandScopes()),
|
||||
ErrorCode.MissingScopes
|
||||
);
|
||||
}
|
||||
|
||||
if (attribute.RequireAdmin && token.User.Role != UserRole.Admin)
|
||||
throw new ApiError.Forbidden("This endpoint can only be used by admins.");
|
||||
if (
|
||||
|
@ -38,7 +44,9 @@ public class AuthorizationMiddleware : IMiddleware
|
|||
&& token.User.Role != UserRole.Admin
|
||||
&& token.User.Role != UserRole.Moderator
|
||||
)
|
||||
{
|
||||
throw new ApiError.Forbidden("This endpoint can only be used by moderators.");
|
||||
}
|
||||
|
||||
await next(ctx);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System.Net;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Utils;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
@ -14,9 +15,9 @@ public class ErrorHandlerMiddleware(ILogger baseLogger, IHub sentry) : IMiddlewa
|
|||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var type = e.TargetSite?.DeclaringType ?? typeof(ErrorHandlerMiddleware);
|
||||
var typeName = e.TargetSite?.DeclaringType?.FullName ?? "<unknown>";
|
||||
var logger = baseLogger.ForContext(type);
|
||||
Type type = e.TargetSite?.DeclaringType ?? typeof(ErrorHandlerMiddleware);
|
||||
string typeName = e.TargetSite?.DeclaringType?.FullName ?? "<unknown>";
|
||||
ILogger logger = baseLogger.ForContext(type);
|
||||
|
||||
if (ctx.Response.HasStarted)
|
||||
{
|
||||
|
@ -31,13 +32,15 @@ public class ErrorHandlerMiddleware(ILogger baseLogger, IHub sentry) : IMiddlewa
|
|||
e,
|
||||
scope =>
|
||||
{
|
||||
var user = ctx.GetUser();
|
||||
User? user = ctx.GetUser();
|
||||
if (user != null)
|
||||
{
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = user.Id.ToString(),
|
||||
Username = user.Username,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -98,17 +101,19 @@ public class ErrorHandlerMiddleware(ILogger baseLogger, IHub sentry) : IMiddlewa
|
|||
logger.Error(e, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path);
|
||||
}
|
||||
|
||||
var errorId = sentry.CaptureException(
|
||||
SentryId errorId = sentry.CaptureException(
|
||||
e,
|
||||
scope =>
|
||||
{
|
||||
var user = ctx.GetUser();
|
||||
User? user = ctx.GetUser();
|
||||
if (user != null)
|
||||
{
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = user.Id.ToString(),
|
||||
Username = user.Username,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -9,9 +9,9 @@ using Prometheus;
|
|||
using Sentry.Extensibility;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var config = builder.AddConfiguration();
|
||||
Config config = builder.AddConfiguration();
|
||||
|
||||
builder.AddSerilog();
|
||||
|
||||
|
@ -58,7 +58,7 @@ JsonConvert.DefaultSettings = () =>
|
|||
|
||||
builder.AddServices(config).AddCustomMiddleware().AddEndpointsApiExplorer().AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
await app.Initialize(args);
|
||||
|
||||
|
|
|
@ -31,6 +31,16 @@ public class AuthService(
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
// Validate username and whether it's not taken
|
||||
ValidationUtils.Validate(
|
||||
[
|
||||
("username", ValidationUtils.ValidateUsername(username)),
|
||||
("password", ValidationUtils.ValidatePassword(password)),
|
||||
]
|
||||
);
|
||||
if (await db.Users.AnyAsync(u => u.Username == username, ct))
|
||||
throw new ApiError.BadRequest("Username is already taken", "username", username);
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||
|
@ -49,7 +59,7 @@ public class AuthService(
|
|||
};
|
||||
|
||||
db.Add(user);
|
||||
user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct);
|
||||
user.Password = await HashPasswordAsync(user, password, ct);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
@ -70,6 +80,8 @@ public class AuthService(
|
|||
{
|
||||
AssertValidAuthType(authType, instance);
|
||||
|
||||
// Validate username and whether it's not taken
|
||||
ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(username))]);
|
||||
if (await db.Users.AnyAsync(u => u.Username == username, ct))
|
||||
throw new ApiError.BadRequest("Username is already taken", "username", username);
|
||||
|
||||
|
@ -111,28 +123,30 @@ public class AuthService(
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var user = await db.Users.FirstOrDefaultAsync(
|
||||
User? user = await db.Users.FirstOrDefaultAsync(
|
||||
u => u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email),
|
||||
ct
|
||||
);
|
||||
if (user == null)
|
||||
{
|
||||
throw new ApiError.NotFound(
|
||||
"No user with that email address found, or password is incorrect",
|
||||
ErrorCode.UserNotFound
|
||||
);
|
||||
}
|
||||
|
||||
var pwResult = await Task.Run(
|
||||
() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password),
|
||||
ct
|
||||
);
|
||||
PasswordVerificationResult pwResult = await VerifyHashedPasswordAsync(user, password, ct);
|
||||
if (pwResult == PasswordVerificationResult.Failed) // TODO: this seems to fail on some valid passwords?
|
||||
{
|
||||
throw new ApiError.NotFound(
|
||||
"No user with that email address found, or password is incorrect",
|
||||
ErrorCode.UserNotFound
|
||||
);
|
||||
}
|
||||
|
||||
if (pwResult == PasswordVerificationResult.SuccessRehashNeeded)
|
||||
{
|
||||
user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct);
|
||||
user.Password = await HashPasswordAsync(user, password, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
|
@ -160,10 +174,7 @@ public class AuthService(
|
|||
throw new FoxnounsError("Password for user supplied to ValidatePasswordAsync was null");
|
||||
}
|
||||
|
||||
var pwResult = await Task.Run(
|
||||
() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password),
|
||||
ct
|
||||
);
|
||||
PasswordVerificationResult pwResult = await VerifyHashedPasswordAsync(user, password, ct);
|
||||
return pwResult
|
||||
is PasswordVerificationResult.SuccessRehashNeeded
|
||||
or PasswordVerificationResult.Success;
|
||||
|
@ -178,7 +189,7 @@ public class AuthService(
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct);
|
||||
user.Password = await HashPasswordAsync(user, password, ct);
|
||||
db.Update(user);
|
||||
}
|
||||
|
||||
|
@ -225,13 +236,15 @@ public class AuthService(
|
|||
AssertValidAuthType(authType, app);
|
||||
|
||||
// This is already checked when
|
||||
var currentCount = await db
|
||||
int currentCount = await db
|
||||
.AuthMethods.Where(m => m.UserId == userId && m.AuthType == authType)
|
||||
.CountAsync(ct);
|
||||
if (currentCount >= AuthUtils.MaxAuthMethodsPerType)
|
||||
{
|
||||
throw new ApiError.BadRequest(
|
||||
"Too many linked accounts of this type, maximum of 3 per account."
|
||||
);
|
||||
}
|
||||
|
||||
var authMethod = new AuthMethod
|
||||
{
|
||||
|
@ -256,13 +269,15 @@ public class AuthService(
|
|||
)
|
||||
{
|
||||
if (!AuthUtils.ValidateScopes(application, scopes))
|
||||
{
|
||||
throw new ApiError.BadRequest(
|
||||
"Invalid scopes requested for this token",
|
||||
"scopes",
|
||||
scopes
|
||||
);
|
||||
}
|
||||
|
||||
var (token, hash) = GenerateToken();
|
||||
(string? token, byte[]? hash) = GenerateToken();
|
||||
return (
|
||||
token,
|
||||
new Token
|
||||
|
@ -287,9 +302,9 @@ public class AuthService(
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var frontendApp = await db.GetFrontendApplicationAsync(ct);
|
||||
Application frontendApp = await db.GetFrontendApplicationAsync(ct);
|
||||
|
||||
var (tokenStr, token) = GenerateToken(
|
||||
(string? tokenStr, Token? token) = GenerateToken(
|
||||
user,
|
||||
frontendApp,
|
||||
["*"],
|
||||
|
@ -302,24 +317,35 @@ public class AuthService(
|
|||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return new CallbackResponse(
|
||||
HasAccount: true,
|
||||
Ticket: null,
|
||||
RemoteUsername: null,
|
||||
User: await userRenderer.RenderUserAsync(
|
||||
user,
|
||||
selfUser: user,
|
||||
renderMembers: false,
|
||||
ct: ct
|
||||
),
|
||||
Token: tokenStr,
|
||||
ExpiresAt: token.ExpiresAt
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
await userRenderer.RenderUserAsync(user, user, renderMembers: false, ct: ct),
|
||||
tokenStr,
|
||||
token.ExpiresAt
|
||||
);
|
||||
}
|
||||
|
||||
private Task<string> HashPasswordAsync(
|
||||
User user,
|
||||
string password,
|
||||
CancellationToken ct = default
|
||||
) => Task.Run(() => _passwordHasher.HashPassword(user, password), ct);
|
||||
|
||||
private Task<PasswordVerificationResult> VerifyHashedPasswordAsync(
|
||||
User user,
|
||||
string providedPassword,
|
||||
CancellationToken ct = default
|
||||
) =>
|
||||
Task.Run(
|
||||
() => _passwordHasher.VerifyHashedPassword(user, user.Password!, providedPassword),
|
||||
ct
|
||||
);
|
||||
|
||||
private static (string, byte[]) GenerateToken()
|
||||
{
|
||||
var token = AuthUtils.RandomToken();
|
||||
var hash = SHA512.HashData(Convert.FromBase64String(token));
|
||||
string token = AuthUtils.RandomToken();
|
||||
byte[] hash = SHA512.HashData(Convert.FromBase64String(token));
|
||||
|
||||
return (token, hash);
|
||||
}
|
||||
|
|
|
@ -18,22 +18,25 @@ public partial class FediverseAuthService
|
|||
Snowflake? existingAppId = null
|
||||
)
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
HttpResponseMessage resp = await _client.PostAsJsonAsync(
|
||||
$"https://{instance}/api/v1/apps",
|
||||
new CreateMastodonApplicationRequest(
|
||||
ClientName: $"pronouns.cc (+{_config.BaseUrl})",
|
||||
RedirectUris: MastodonRedirectUri(instance),
|
||||
Scopes: "read read:accounts",
|
||||
Website: _config.BaseUrl
|
||||
$"pronouns.cc (+{_config.BaseUrl})",
|
||||
MastodonRedirectUri(instance),
|
||||
"read read:accounts",
|
||||
_config.BaseUrl
|
||||
)
|
||||
);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
var mastodonApp = await resp.Content.ReadFromJsonAsync<PartialMastodonApplication>();
|
||||
PartialMastodonApplication? mastodonApp =
|
||||
await resp.Content.ReadFromJsonAsync<PartialMastodonApplication>();
|
||||
if (mastodonApp == null)
|
||||
{
|
||||
throw new FoxnounsError(
|
||||
$"Application created on Mastodon-compatible instance {instance} was null"
|
||||
);
|
||||
}
|
||||
|
||||
FediverseApplication app;
|
||||
|
||||
|
@ -75,7 +78,7 @@ public partial class FediverseAuthService
|
|||
if (state != null)
|
||||
await _keyCacheService.ValidateAuthStateAsync(state);
|
||||
|
||||
var tokenResp = await _client.PostAsync(
|
||||
HttpResponseMessage tokenResp = await _client.PostAsync(
|
||||
MastodonTokenUri(app.Domain),
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
|
@ -95,7 +98,7 @@ public partial class FediverseAuthService
|
|||
}
|
||||
|
||||
tokenResp.EnsureSuccessStatusCode();
|
||||
var token = (
|
||||
string? token = (
|
||||
await tokenResp.Content.ReadFromJsonAsync<MastodonTokenResponse>()
|
||||
)?.AccessToken;
|
||||
if (token == null)
|
||||
|
@ -106,9 +109,9 @@ public partial class FediverseAuthService
|
|||
var req = new HttpRequestMessage(HttpMethod.Get, MastodonCurrentUserUri(app.Domain));
|
||||
req.Headers.Add("Authorization", $"Bearer {token}");
|
||||
|
||||
var currentUserResp = await _client.SendAsync(req);
|
||||
HttpResponseMessage currentUserResp = await _client.SendAsync(req);
|
||||
currentUserResp.EnsureSuccessStatusCode();
|
||||
var user = await currentUserResp.Content.ReadFromJsonAsync<FediverseUser>();
|
||||
FediverseUser? user = await currentUserResp.Content.ReadFromJsonAsync<FediverseUser>();
|
||||
if (user == null)
|
||||
{
|
||||
throw new FoxnounsError($"User response from instance {app.Domain} was invalid");
|
||||
|
@ -131,7 +134,7 @@ public partial class FediverseAuthService
|
|||
"An app credentials refresh was requested for {ApplicationId}, creating a new application",
|
||||
app.Id
|
||||
);
|
||||
app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id);
|
||||
app = await CreateMastodonApplicationAsync(app.Domain, app.Id);
|
||||
}
|
||||
|
||||
state ??= HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync());
|
||||
|
|
|
@ -43,7 +43,7 @@ public partial class FediverseAuthService
|
|||
string? state = null
|
||||
)
|
||||
{
|
||||
var app = await GetApplicationAsync(instance);
|
||||
FediverseApplication app = await GetApplicationAsync(instance);
|
||||
return await GenerateAuthUrlAsync(app, forceRefresh, state);
|
||||
}
|
||||
|
||||
|
@ -56,13 +56,15 @@ public partial class FediverseAuthService
|
|||
|
||||
public async Task<FediverseApplication> GetApplicationAsync(string instance)
|
||||
{
|
||||
var app = await _db.FediverseApplications.FirstOrDefaultAsync(a => a.Domain == instance);
|
||||
FediverseApplication? app = await _db.FediverseApplications.FirstOrDefaultAsync(a =>
|
||||
a.Domain == instance
|
||||
);
|
||||
if (app != null)
|
||||
return app;
|
||||
|
||||
_logger.Debug("No application for fediverse instance {Instance}, creating it", instance);
|
||||
|
||||
var softwareName = await GetSoftwareNameAsync(instance);
|
||||
string softwareName = await GetSoftwareNameAsync(instance);
|
||||
|
||||
if (IsMastodonCompatible(softwareName))
|
||||
{
|
||||
|
@ -76,13 +78,14 @@ public partial class FediverseAuthService
|
|||
{
|
||||
_logger.Debug("Requesting software name for fediverse instance {Instance}", instance);
|
||||
|
||||
var wellKnownResp = await _client.GetAsync(
|
||||
HttpResponseMessage wellKnownResp = await _client.GetAsync(
|
||||
new Uri($"https://{instance}/.well-known/nodeinfo")
|
||||
);
|
||||
wellKnownResp.EnsureSuccessStatusCode();
|
||||
|
||||
var wellKnown = await wellKnownResp.Content.ReadFromJsonAsync<WellKnownResponse>();
|
||||
var nodeInfoUrl = wellKnown?.Links.FirstOrDefault(l => l.Rel == NodeInfoRel)?.Href;
|
||||
WellKnownResponse? wellKnown =
|
||||
await wellKnownResp.Content.ReadFromJsonAsync<WellKnownResponse>();
|
||||
string? nodeInfoUrl = wellKnown?.Links.FirstOrDefault(l => l.Rel == NodeInfoRel)?.Href;
|
||||
if (nodeInfoUrl == null)
|
||||
{
|
||||
throw new FoxnounsError(
|
||||
|
@ -90,10 +93,10 @@ public partial class FediverseAuthService
|
|||
);
|
||||
}
|
||||
|
||||
var nodeInfoResp = await _client.GetAsync(nodeInfoUrl);
|
||||
HttpResponseMessage nodeInfoResp = await _client.GetAsync(nodeInfoUrl);
|
||||
nodeInfoResp.EnsureSuccessStatusCode();
|
||||
|
||||
var nodeInfo = await nodeInfoResp.Content.ReadFromJsonAsync<PartialNodeInfo>();
|
||||
PartialNodeInfo? nodeInfo = await nodeInfoResp.Content.ReadFromJsonAsync<PartialNodeInfo>();
|
||||
return nodeInfo?.Software.Name
|
||||
?? throw new FoxnounsError(
|
||||
$"Nodeinfo response for instance {instance} was invalid, no software name"
|
||||
|
|
|
@ -29,7 +29,7 @@ public class RemoteAuthService(
|
|||
)
|
||||
{
|
||||
var redirectUri = $"{config.BaseUrl}/auth/callback/discord";
|
||||
var resp = await _httpClient.PostAsync(
|
||||
HttpResponseMessage resp = await _httpClient.PostAsync(
|
||||
_discordTokenUri,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
|
@ -45,7 +45,7 @@ public class RemoteAuthService(
|
|||
);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
string respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
_logger.Error(
|
||||
"Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}",
|
||||
(int)resp.StatusCode,
|
||||
|
@ -55,16 +55,18 @@ public class RemoteAuthService(
|
|||
}
|
||||
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(ct);
|
||||
DiscordTokenResponse? token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(
|
||||
ct
|
||||
);
|
||||
if (token == null)
|
||||
throw new FoxnounsError("Discord token response was null");
|
||||
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri);
|
||||
req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}");
|
||||
|
||||
var resp2 = await _httpClient.SendAsync(req, ct);
|
||||
HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct);
|
||||
resp2.EnsureSuccessStatusCode();
|
||||
var user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(ct);
|
||||
DiscordUserResponse? user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(ct);
|
||||
if (user == null)
|
||||
throw new FoxnounsError("Discord user response was null");
|
||||
|
||||
|
@ -104,7 +106,7 @@ public class RemoteAuthService(
|
|||
string? instance = null
|
||||
)
|
||||
{
|
||||
var existingAccounts = await db
|
||||
int existingAccounts = await db
|
||||
.AuthMethods.Where(m => m.UserId == userId && m.AuthType == authType)
|
||||
.CountAsync();
|
||||
if (existingAccounts > AuthUtils.MaxAuthMethodsPerType)
|
||||
|
@ -131,13 +133,17 @@ public class RemoteAuthService(
|
|||
string? instance = null
|
||||
)
|
||||
{
|
||||
var accountState = await keyCacheService.GetAddExtraAccountStateAsync(state);
|
||||
AddExtraAccountState? accountState = await keyCacheService.GetAddExtraAccountStateAsync(
|
||||
state
|
||||
);
|
||||
if (
|
||||
accountState == null
|
||||
|| accountState.AuthType != authType
|
||||
|| accountState.UserId != userId
|
||||
|| (instance != null && accountState.Instance != instance)
|
||||
)
|
||||
{
|
||||
throw new ApiError.BadRequest("Invalid state", "state", state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,9 +28,9 @@ public class DataCleanupService(
|
|||
|
||||
private async Task CleanUsersAsync(CancellationToken ct = default)
|
||||
{
|
||||
var selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter;
|
||||
var suspendExpires = clock.GetCurrentInstant() - User.DeleteSuspendedAfter;
|
||||
var users = await db
|
||||
Instant selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter;
|
||||
Instant suspendExpires = clock.GetCurrentInstant() - User.DeleteSuspendedAfter;
|
||||
List<User> users = await db
|
||||
.Users.Include(u => u.Members)
|
||||
.Include(u => u.DataExports)
|
||||
.Where(u =>
|
||||
|
@ -92,13 +92,15 @@ public class DataCleanupService(
|
|||
private async Task CleanExportsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var minExpiredId = Snowflake.FromInstant(clock.GetCurrentInstant() - DataExport.Expiration);
|
||||
var exports = await db.DataExports.Where(d => d.Id < minExpiredId).ToListAsync(ct);
|
||||
List<DataExport> exports = await db
|
||||
.DataExports.Where(d => d.Id < minExpiredId)
|
||||
.ToListAsync(ct);
|
||||
if (exports.Count == 0)
|
||||
return;
|
||||
|
||||
_logger.Debug("Deleting {Count} expired exports", exports.Count);
|
||||
|
||||
foreach (var export in exports)
|
||||
foreach (DataExport? export in exports)
|
||||
{
|
||||
_logger.Debug("Deleting export {ExportId}", export.Id);
|
||||
await objectStorageService.RemoveObjectAsync(
|
||||
|
|
|
@ -41,7 +41,7 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key, ct);
|
||||
TemporaryKey? value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key, ct);
|
||||
if (value == null)
|
||||
return null;
|
||||
|
||||
|
@ -56,7 +56,7 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
|
|||
|
||||
public async Task DeleteExpiredKeysAsync(CancellationToken ct)
|
||||
{
|
||||
var count = await db
|
||||
int count = await db
|
||||
.TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant())
|
||||
.ExecuteDeleteAsync(ct);
|
||||
if (count != 0)
|
||||
|
@ -79,7 +79,7 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
|
|||
)
|
||||
where T : class
|
||||
{
|
||||
var value = JsonConvert.SerializeObject(obj);
|
||||
string value = JsonConvert.SerializeObject(obj);
|
||||
await SetKeyAsync(key, value, expires, ct);
|
||||
}
|
||||
|
||||
|
@ -90,7 +90,7 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
|
|||
)
|
||||
where T : class
|
||||
{
|
||||
var value = await GetKeyAsync(key, delete, ct);
|
||||
string? value = await GetKeyAsync(key, delete, ct);
|
||||
return value == null ? default : JsonConvert.DeserializeObject<T>(value);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,11 +10,11 @@ public class MemberRendererService(DatabaseContext db, Config config)
|
|||
{
|
||||
public async Task<IEnumerable<PartialMember>> RenderUserMembersAsync(User user, Token? token)
|
||||
{
|
||||
var canReadHiddenMembers =
|
||||
bool canReadHiddenMembers =
|
||||
token != null && token.UserId == user.Id && token.HasScope("member.read");
|
||||
var renderUnlisted =
|
||||
bool renderUnlisted =
|
||||
token != null && token.UserId == user.Id && token.HasScope("user.read_hidden");
|
||||
var canReadMemberList = !user.ListHidden || canReadHiddenMembers;
|
||||
bool canReadMemberList = !user.ListHidden || canReadHiddenMembers;
|
||||
|
||||
IEnumerable<Member> members = canReadMemberList
|
||||
? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync()
|
||||
|
@ -30,7 +30,7 @@ public class MemberRendererService(DatabaseContext db, Config config)
|
|||
string? overrideSid = null
|
||||
)
|
||||
{
|
||||
var renderUnlisted = token?.UserId == member.UserId && token.HasScope("user.read_hidden");
|
||||
bool renderUnlisted = token?.UserId == member.UserId && token.HasScope("user.read_hidden");
|
||||
|
||||
return new MemberResponse(
|
||||
member.Id,
|
||||
|
|
|
@ -3,6 +3,7 @@ using Foxnouns.Backend.Database;
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Prometheus;
|
||||
using ITimer = Prometheus.ITimer;
|
||||
|
||||
namespace Foxnouns.Backend.Services;
|
||||
|
||||
|
@ -16,19 +17,23 @@ public class MetricsCollectionService(ILogger logger, IServiceProvider services,
|
|||
|
||||
public async Task CollectMetricsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var timer = FoxnounsMetrics.MetricsCollectionTime.NewTimer();
|
||||
var now = clock.GetCurrentInstant();
|
||||
ITimer timer = FoxnounsMetrics.MetricsCollectionTime.NewTimer();
|
||||
Instant now = clock.GetCurrentInstant();
|
||||
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
await using AsyncServiceScope scope = services.CreateAsyncScope();
|
||||
// ReSharper disable once SuggestVarOrType_SimpleTypes
|
||||
await using var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||
|
||||
var users = await db.Users.Where(u => !u.Deleted).Select(u => u.LastActive).ToListAsync(ct);
|
||||
List<Instant>? users = await db
|
||||
.Users.Where(u => !u.Deleted)
|
||||
.Select(u => u.LastActive)
|
||||
.ToListAsync(ct);
|
||||
FoxnounsMetrics.UsersCount.Set(users.Count);
|
||||
FoxnounsMetrics.UsersActiveMonthCount.Set(users.Count(i => i > now - Month));
|
||||
FoxnounsMetrics.UsersActiveWeekCount.Set(users.Count(i => i > now - Week));
|
||||
FoxnounsMetrics.UsersActiveDayCount.Set(users.Count(i => i > now - Day));
|
||||
|
||||
var memberCount = await db
|
||||
int memberCount = await db
|
||||
.Members.Include(m => m.User)
|
||||
.Where(m => !m.Unlisted && !m.User.ListHidden && !m.User.Deleted)
|
||||
.CountAsync(ct);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using Minio;
|
||||
using Minio.DataModel;
|
||||
using Minio.DataModel.Args;
|
||||
using Minio.Exceptions;
|
||||
|
||||
|
@ -48,13 +49,4 @@ public class ObjectStorageService(ILogger logger, Config config, IMinioClient mi
|
|||
ct
|
||||
);
|
||||
}
|
||||
|
||||
public async Task GetObjectAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
var resp = await minioClient.GetObjectAsync(
|
||||
new GetObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path),
|
||||
ct
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,10 +15,13 @@ public class PeriodicTasksService(ILogger logger, IServiceProvider services) : B
|
|||
{
|
||||
_logger.Debug("Running periodic tasks");
|
||||
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
await using AsyncServiceScope scope = services.CreateAsyncScope();
|
||||
|
||||
// The type is literally written on the same line, we can just use `var`
|
||||
// ReSharper disable SuggestVarOrType_SimpleTypes
|
||||
var keyCacheService = scope.ServiceProvider.GetRequiredService<KeyCacheService>();
|
||||
var dataCleanupService = scope.ServiceProvider.GetRequiredService<DataCleanupService>();
|
||||
// ReSharper restore SuggestVarOrType_SimpleTypes
|
||||
|
||||
await keyCacheService.DeleteExpiredKeysAsync(ct);
|
||||
await dataCleanupService.InvokeAsync(ct);
|
||||
|
|
|
@ -43,9 +43,9 @@ public class UserRendererService(
|
|||
)
|
||||
{
|
||||
scopes = scopes.ExpandScopes();
|
||||
var tokenCanReadHiddenMembers = scopes.Contains("member.read") && isSelfUser;
|
||||
var tokenHidden = scopes.Contains("user.read_hidden") && isSelfUser;
|
||||
var tokenPrivileged = scopes.Contains("user.read_privileged") && isSelfUser;
|
||||
bool tokenCanReadHiddenMembers = scopes.Contains("member.read") && isSelfUser;
|
||||
bool tokenHidden = scopes.Contains("user.read_hidden") && isSelfUser;
|
||||
bool tokenPrivileged = scopes.Contains("user.read_privileged") && isSelfUser;
|
||||
|
||||
renderMembers = renderMembers && (!user.ListHidden || tokenCanReadHiddenMembers);
|
||||
renderAuthMethods = renderAuthMethods && tokenPrivileged;
|
||||
|
@ -57,12 +57,12 @@ public class UserRendererService(
|
|||
if (!(isSelfUser && tokenCanReadHiddenMembers))
|
||||
members = members.Where(m => !m.Unlisted);
|
||||
|
||||
var flags = await db
|
||||
List<UserFlag> flags = await db
|
||||
.UserFlags.Where(f => f.UserId == user.Id)
|
||||
.OrderBy(f => f.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var authMethods = renderAuthMethods
|
||||
List<AuthMethod> authMethods = renderAuthMethods
|
||||
? await db
|
||||
.AuthMethods.Where(a => a.UserId == user.Id)
|
||||
.Include(a => a.FediverseApplication)
|
||||
|
@ -72,9 +72,11 @@ public class UserRendererService(
|
|||
int? utcOffset = null;
|
||||
if (
|
||||
user.Timezone != null
|
||||
&& TimeZoneInfo.TryFindSystemTimeZoneById(user.Timezone, out var tz)
|
||||
&& TimeZoneInfo.TryFindSystemTimeZoneById(user.Timezone, out TimeZoneInfo? tz)
|
||||
)
|
||||
{
|
||||
utcOffset = (int)tz.GetUtcOffset(DateTimeOffset.UtcNow).TotalSeconds;
|
||||
}
|
||||
|
||||
return new UserResponse(
|
||||
user.Id,
|
||||
|
|
|
@ -69,8 +69,8 @@ public static class AuthUtils
|
|||
|
||||
public static bool ValidateScopes(Application application, string[] scopes)
|
||||
{
|
||||
var expandedScopes = scopes.ExpandScopes();
|
||||
var appScopes = application.Scopes.ExpandAppScopes();
|
||||
string[] expandedScopes = scopes.ExpandScopes();
|
||||
string[] appScopes = application.Scopes.ExpandAppScopes();
|
||||
return !expandedScopes.Except(appScopes).Any();
|
||||
}
|
||||
|
||||
|
@ -78,7 +78,7 @@ public static class AuthUtils
|
|||
{
|
||||
try
|
||||
{
|
||||
var scheme = new Uri(uri).Scheme;
|
||||
string scheme = new Uri(uri).Scheme;
|
||||
return !ForbiddenSchemes.Contains(scheme);
|
||||
}
|
||||
catch
|
||||
|
|
|
@ -5,10 +5,11 @@ using Newtonsoft.Json.Serialization;
|
|||
namespace Foxnouns.Backend.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// A base class used for PATCH requests which stores information on whether a key is explicitly set to null or not passed at all.
|
||||
///
|
||||
/// <para>A base class used for PATCH requests which stores information on whether a key is explicitly set to null or not passed at all.</para>
|
||||
/// <para>
|
||||
/// HasProperty() should not be used for properties that cannot be set to null--a null value should be treated
|
||||
/// as an unset value in those cases.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public abstract class PatchRequest
|
||||
{
|
||||
|
@ -30,7 +31,7 @@ public class PatchRequestContractResolver : DefaultContractResolver
|
|||
MemberSerialization memberSerialization
|
||||
)
|
||||
{
|
||||
var prop = base.CreateProperty(member, memberSerialization);
|
||||
JsonProperty prop = base.CreateProperty(member, memberSerialization);
|
||||
|
||||
prop.SetIsSpecified += (o, _) =>
|
||||
{
|
||||
|
|
|
@ -39,6 +39,7 @@ public static partial class ValidationUtils
|
|||
|
||||
var errors = new List<(string, ValidationError?)>();
|
||||
if (fields.Count > 25)
|
||||
{
|
||||
errors.Add(
|
||||
(
|
||||
"fields",
|
||||
|
@ -50,11 +51,13 @@ public static partial class ValidationUtils
|
|||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// No overwhelming this function, thank you
|
||||
if (fields.Count > 100)
|
||||
return errors;
|
||||
|
||||
foreach (var (field, index) in fields.Select((field, index) => (field, index)))
|
||||
foreach ((Field? field, int index) in fields.Select((field, index) => (field, index)))
|
||||
{
|
||||
switch (field.Name.Length)
|
||||
{
|
||||
|
@ -111,6 +114,7 @@ public static partial class ValidationUtils
|
|||
var errors = new List<(string, ValidationError?)>();
|
||||
|
||||
if (entries.Length > Limits.FieldEntriesLimit)
|
||||
{
|
||||
errors.Add(
|
||||
(
|
||||
errorPrefix,
|
||||
|
@ -122,15 +126,19 @@ public static partial class ValidationUtils
|
|||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Same as above, no overwhelming this function with a ridiculous amount of entries
|
||||
if (entries.Length > Limits.FieldEntriesLimit + 50)
|
||||
return errors;
|
||||
|
||||
var customPreferenceIds =
|
||||
customPreferences?.Keys.Select(id => id.ToString()).ToArray() ?? [];
|
||||
string[] customPreferenceIds = customPreferences.Keys.Select(id => id.ToString()).ToArray();
|
||||
|
||||
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
|
||||
foreach (
|
||||
(FieldEntry? entry, int entryIdx) in entries.Select(
|
||||
(entry, entryIdx) => (entry, entryIdx)
|
||||
)
|
||||
)
|
||||
{
|
||||
switch (entry.Value.Length)
|
||||
{
|
||||
|
@ -166,12 +174,14 @@ public static partial class ValidationUtils
|
|||
!DefaultStatusOptions.Contains(entry.Status)
|
||||
&& !customPreferenceIds.Contains(entry.Status)
|
||||
)
|
||||
{
|
||||
errors.Add(
|
||||
(
|
||||
$"{errorPrefix}.{entryIdx}.status",
|
||||
ValidationError.GenericValidationError("Invalid status", entry.Status)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
|
@ -188,6 +198,7 @@ public static partial class ValidationUtils
|
|||
var errors = new List<(string, ValidationError?)>();
|
||||
|
||||
if (entries.Length > Limits.FieldEntriesLimit)
|
||||
{
|
||||
errors.Add(
|
||||
(
|
||||
errorPrefix,
|
||||
|
@ -199,15 +210,17 @@ public static partial class ValidationUtils
|
|||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Same as above, no overwhelming this function with a ridiculous amount of entries
|
||||
if (entries.Length > Limits.FieldEntriesLimit + 50)
|
||||
return errors;
|
||||
|
||||
var customPreferenceIds =
|
||||
customPreferences?.Keys.Select(id => id.ToString()).ToList() ?? [];
|
||||
string[] customPreferenceIds = customPreferences.Keys.Select(id => id.ToString()).ToArray();
|
||||
|
||||
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
|
||||
foreach (
|
||||
(Pronoun? entry, int entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))
|
||||
)
|
||||
{
|
||||
switch (entry.Value.Length)
|
||||
{
|
||||
|
@ -276,12 +289,14 @@ public static partial class ValidationUtils
|
|||
!DefaultStatusOptions.Contains(entry.Status)
|
||||
&& !customPreferenceIds.Contains(entry.Status)
|
||||
)
|
||||
{
|
||||
errors.Add(
|
||||
(
|
||||
$"{errorPrefix}.{entryIdx}.status",
|
||||
ValidationError.GenericValidationError("Invalid status", entry.Status)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
|
|
|
@ -29,6 +29,7 @@ public static partial class ValidationUtils
|
|||
var errors = new List<(string, ValidationError?)>();
|
||||
|
||||
if (preferences.Count > MaxCustomPreferences)
|
||||
{
|
||||
errors.Add(
|
||||
(
|
||||
"custom_preferences",
|
||||
|
@ -40,20 +41,29 @@ public static partial class ValidationUtils
|
|||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (preferences.Count > 50)
|
||||
return errors;
|
||||
|
||||
foreach (var (p, i) in preferences.Select((p, i) => (p, i)))
|
||||
foreach (
|
||||
(UsersController.CustomPreferenceUpdate? p, int i) in preferences.Select(
|
||||
(p, i) => (p, i)
|
||||
)
|
||||
)
|
||||
{
|
||||
if (!BootstrapIcons.IsValid(p.Icon))
|
||||
{
|
||||
errors.Add(
|
||||
(
|
||||
$"custom_preferences.{i}.icon",
|
||||
ValidationError.DisallowedValueError("Invalid icon name", [], p.Icon)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (p.Tooltip.Length is 1 or > MaxPreferenceTooltipLength)
|
||||
{
|
||||
errors.Add(
|
||||
(
|
||||
$"custom_preferences.{i}.tooltip",
|
||||
|
@ -65,6 +75,7 @@ public static partial class ValidationUtils
|
|||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
|
|
|
@ -46,6 +46,7 @@ public static partial class ValidationUtils
|
|||
public static ValidationError? ValidateUsername(string username)
|
||||
{
|
||||
if (!UsernameRegex().IsMatch(username))
|
||||
{
|
||||
return username.Length switch
|
||||
{
|
||||
< 2 => ValidationError.LengthError("Username is too short", 2, 40, username.Length),
|
||||
|
@ -55,19 +56,24 @@ public static partial class ValidationUtils
|
|||
username
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
InvalidUsernames.Any(u =>
|
||||
string.Equals(u, username, StringComparison.InvariantCultureIgnoreCase)
|
||||
)
|
||||
)
|
||||
{
|
||||
return ValidationError.GenericValidationError("Username is not allowed", username);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static ValidationError? ValidateMemberName(string memberName)
|
||||
{
|
||||
if (!MemberRegex().IsMatch(memberName))
|
||||
{
|
||||
return memberName.Length switch
|
||||
{
|
||||
< 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length),
|
||||
|
@ -79,13 +85,17 @@ public static partial class ValidationUtils
|
|||
memberName
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
InvalidMemberNames.Any(u =>
|
||||
string.Equals(u, memberName, StringComparison.InvariantCultureIgnoreCase)
|
||||
)
|
||||
)
|
||||
{
|
||||
return ValidationError.GenericValidationError("Name is not allowed", memberName);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -117,13 +127,15 @@ public static partial class ValidationUtils
|
|||
if (links == null)
|
||||
return [];
|
||||
if (links.Length > MaxLinks)
|
||||
{
|
||||
return
|
||||
[
|
||||
("links", ValidationError.LengthError("Too many links", 0, MaxLinks, links.Length)),
|
||||
];
|
||||
}
|
||||
|
||||
var errors = new List<(string, ValidationError?)>();
|
||||
foreach (var (link, idx) in links.Select((l, i) => (l, i)))
|
||||
foreach ((string link, int idx) in links.Select((l, i) => (l, i)))
|
||||
{
|
||||
switch (link.Length)
|
||||
{
|
||||
|
@ -185,6 +197,27 @@ public static partial class ValidationUtils
|
|||
};
|
||||
}
|
||||
|
||||
public const int MinimumPasswordLength = 12;
|
||||
public const int MaximumPasswordLength = 1024;
|
||||
|
||||
public static ValidationError? ValidatePassword(string password) =>
|
||||
password.Length switch
|
||||
{
|
||||
< MinimumPasswordLength => ValidationError.LengthError(
|
||||
"Password is too short",
|
||||
MinimumPasswordLength,
|
||||
MaximumPasswordLength,
|
||||
password.Length
|
||||
),
|
||||
> MaximumPasswordLength => ValidationError.LengthError(
|
||||
"Password is too long",
|
||||
MinimumPasswordLength,
|
||||
MaximumPasswordLength,
|
||||
password.Length
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
[GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")]
|
||||
private static partial Regex UsernameRegex();
|
||||
|
||||
|
|
|
@ -12,9 +12,9 @@ public static partial class ValidationUtils
|
|||
return;
|
||||
|
||||
var errorDict = new Dictionary<string, IEnumerable<ValidationError>>();
|
||||
foreach (var error in errors)
|
||||
foreach ((string, ValidationError?) error in errors)
|
||||
{
|
||||
if (errorDict.TryGetValue(error.Item1, out var value))
|
||||
if (errorDict.TryGetValue(error.Item1, out IEnumerable<ValidationError>? value))
|
||||
errorDict[error.Item1] = value.Append(error.Item2!);
|
||||
errorDict.Add(error.Item1, [error.Item2!]);
|
||||
}
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
<p>
|
||||
Please continue creating a new pronouns.cc account by using the following link:
|
||||
<br/>
|
||||
<a href="@Model.BaseUrl/auth/signup/confirm/@Model.Code">Confirm your email address</a>
|
||||
<br/>
|
||||
<br />
|
||||
<a href="@Model.BaseUrl/auth/callback/email/@Model.Code">Confirm your email address</a>
|
||||
<br />
|
||||
Note that this link will expire in one hour.
|
||||
</p>
|
||||
<p>
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
<p>
|
||||
Hello @@@Model.Username, please confirm adding this email address to your account by using the following link:
|
||||
<br/>
|
||||
<a href="@Model.BaseUrl/settings/auth/confirm-email/@Model.Code">Confirm your email address</a>
|
||||
<br/>
|
||||
<br />
|
||||
<a href="@Model.BaseUrl/auth/callback/email/@Model.Code">Confirm your email address</a>
|
||||
<br />
|
||||
Note that this link will expire in one hour.
|
||||
</p>
|
||||
<p>
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<title></title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
|
|
|
@ -194,6 +194,12 @@
|
|||
"prometheus-net": "8.2.1"
|
||||
}
|
||||
},
|
||||
"Roslynator.Analyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.12.9, )",
|
||||
"resolved": "4.12.9",
|
||||
"contentHash": "X6lDpN/D5wuinq37KIx+l3GSUe9No+8bCjGBTI5sEEtxapLztkHg6gzNVhMXpXw8P+/5gFYxTXJ5Pf8O4iNz/w=="
|
||||
},
|
||||
"Sentry.AspNetCore": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.9.0, )",
|
||||
|
|
69
Foxnouns.Frontend/src/lib/actions/callback.ts
Normal file
69
Foxnouns.Frontend/src/lib/actions/callback.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { apiRequest } from "$api";
|
||||
import ApiError, { ErrorCode } from "$api/error";
|
||||
import type { AddAccountResponse, CallbackResponse } from "$api/models";
|
||||
import { setToken } from "$lib";
|
||||
import log from "$lib/log";
|
||||
import { isRedirect, redirect, type ServerLoadEvent } from "@sveltejs/kit";
|
||||
|
||||
export default function createCallbackLoader(
|
||||
callbackType: string,
|
||||
bodyFn?: (event: ServerLoadEvent) => Promise<unknown>,
|
||||
) {
|
||||
return async (event: ServerLoadEvent) => {
|
||||
const { url, parent, fetch, cookies } = event;
|
||||
|
||||
bodyFn ??= async ({ url }) => {
|
||||
const code = url.searchParams.get("code") as string | null;
|
||||
const state = url.searchParams.get("state") as string | null;
|
||||
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
|
||||
return { code, state };
|
||||
};
|
||||
|
||||
const { meUser } = await parent();
|
||||
if (meUser) {
|
||||
try {
|
||||
const resp = await apiRequest<AddAccountResponse>(
|
||||
"POST",
|
||||
`/auth/${callbackType}/add-account/callback`,
|
||||
{
|
||||
isInternal: true,
|
||||
body: await bodyFn(event),
|
||||
fetch,
|
||||
cookies,
|
||||
},
|
||||
);
|
||||
|
||||
return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj };
|
||||
log.error("error linking new %s account to user %s:", callbackType, meUser.id, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await apiRequest<CallbackResponse>("POST", `/auth/${callbackType}/callback`, {
|
||||
body: await bodyFn(event),
|
||||
isInternal: true,
|
||||
fetch,
|
||||
});
|
||||
|
||||
if (resp.has_account) {
|
||||
setToken(cookies, resp.token!);
|
||||
redirect(303, `/@${resp.user!.username}`);
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccount: false,
|
||||
isLinkRequest: false,
|
||||
ticket: resp.ticket!,
|
||||
remoteUser: resp.remote_username!,
|
||||
};
|
||||
} catch (e) {
|
||||
if (isRedirect(e)) throw e;
|
||||
if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj };
|
||||
log.error("error while requesting %s callback:", callbackType, e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
|
@ -4,8 +4,8 @@
|
|||
import type { RawApiError } from "$api/error";
|
||||
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||
|
||||
type Props = { form: { error: RawApiError | null; ok: boolean } | null };
|
||||
let { form }: Props = $props();
|
||||
type Props = { form: { error: RawApiError | null; ok: boolean } | null; successMessage?: string };
|
||||
let { form, successMessage }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if form?.error}
|
||||
|
@ -13,6 +13,6 @@
|
|||
{:else if form?.ok}
|
||||
<p class="text-success-emphasis">
|
||||
<Icon name="check-circle-fill" />
|
||||
{$t("edit-profile.saved-changes")}
|
||||
{successMessage ?? $t("edit-profile.saved-changes")}
|
||||
</p>
|
||||
{/if}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import type { RawApiError } from "$api/error";
|
||||
import { enhance } from "$app/forms";
|
||||
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import { Button, Input, Label } from "@sveltestrap/sveltestrap";
|
||||
|
@ -21,7 +20,7 @@
|
|||
<ErrorAlert {error} />
|
||||
{/if}
|
||||
|
||||
<form method="POST" use:enhance>
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<Label>{remoteLabel}</Label>
|
||||
<Input type="text" readonly value={remoteUser} />
|
||||
|
|
|
@ -1,184 +1,188 @@
|
|||
{
|
||||
"hello": "Hello, {{name}}!",
|
||||
"nav": {
|
||||
"log-in": "Log in or sign up",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"avatar-tooltip": "Avatar for {{name}}",
|
||||
"profile": {
|
||||
"edit-member-profile-notice": "You are currently viewing the public profile of {{memberName}}.",
|
||||
"edit-user-profile-notice": "You are currently viewing your public profile.",
|
||||
"edit-profile-link": "Edit profile",
|
||||
"names-header": "Names",
|
||||
"pronouns-header": "Pronouns",
|
||||
"default-members-header": "Members",
|
||||
"create-member-button": "Create member",
|
||||
"back-to-user": "Back to {{name}}"
|
||||
},
|
||||
"title": {
|
||||
"log-in": "Log in",
|
||||
"welcome": "Welcome",
|
||||
"settings": "Settings",
|
||||
"an-error-occurred": "An error occurred"
|
||||
},
|
||||
"auth": {
|
||||
"log-in-form-title": "Log in with email",
|
||||
"log-in-form-email-label": "Email address",
|
||||
"log-in-form-password-label": "Password",
|
||||
"register-with-email-button": "Register with email",
|
||||
"log-in-button": "Log in",
|
||||
"log-in-3rd-party-header": "Log in with another service",
|
||||
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
|
||||
"log-in-with-discord": "Log in with Discord",
|
||||
"log-in-with-google": "Log in with Google",
|
||||
"log-in-with-tumblr": "Log in with Tumblr",
|
||||
"log-in-with-the-fediverse": "Log in with the Fediverse",
|
||||
"remote-fediverse-account-label": "Your Fediverse account",
|
||||
"register-username-label": "Username",
|
||||
"register-button": "Register account",
|
||||
"register-with-mastodon": "Register with a Fediverse account",
|
||||
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?",
|
||||
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end",
|
||||
"register-with-discord": "Register with a Discord account",
|
||||
"new-auth-method-added": "Successfully added authentication method!",
|
||||
"successful-link-discord": "Your account has successfully been linked to the following Discord account:",
|
||||
"successful-link-google": "Your account has successfully been linked to the following Google account:",
|
||||
"successful-link-tumblr": "Your account has successfully been linked to the following Tumblr account:",
|
||||
"successful-link-fedi": "Your account has successfully been linked to the following fediverse account:",
|
||||
"successful-link-profile-hint": "You now can close this page, or go back to your profile:",
|
||||
"successful-link-profile-link": "Go to your profile",
|
||||
"remote-discord-account-label": "Your Discord account",
|
||||
"log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)"
|
||||
},
|
||||
"error": {
|
||||
"bad-request-header": "Something was wrong with your input",
|
||||
"generic-header": "Something went wrong",
|
||||
"raw-header": "Raw error",
|
||||
"authentication-error": "Something went wrong when logging you in.",
|
||||
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
|
||||
"forbidden": "You are not allowed to perform that action.",
|
||||
"internal-server-error": "Server experienced an internal error, please try again later.",
|
||||
"authentication-required": "You need to log in first.",
|
||||
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
|
||||
"generic-error": "An unknown error occurred.",
|
||||
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
|
||||
"member-not-found": "Member not found, please check your spelling and try again.",
|
||||
"account-already-linked": "This account is already linked with a pronouns.cc account.",
|
||||
"last-auth-method": "You cannot remove your last authentication method.",
|
||||
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.",
|
||||
"validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.",
|
||||
"validation-disallowed-value-1": "The following value is not allowed here",
|
||||
"validation-disallowed-value-2": "Allowed values are",
|
||||
"validation-reason": "Reason",
|
||||
"validation-generic": "The value you entered is not allowed here. Reason",
|
||||
"extra-info-header": "Extra error information",
|
||||
"noscript-title": "This page requires JavaScript",
|
||||
"noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.",
|
||||
"noscript-short": "Requires JavaScript",
|
||||
"404-description": "The page you were trying to visit was not found. If you're sure the page should exist, check your address bar for any typos.",
|
||||
"back-to-profile-button": "Go back to your profile",
|
||||
"back-to-main-page-button": "Go back to the main page",
|
||||
"back-to-prev-page-button": "Go back to the previous page",
|
||||
"400-description": "Something went wrong with your request. This error should never land you on this page, so it's probably a bug.",
|
||||
"500-description": "Something went wrong on the server. Please try again later.",
|
||||
"unknown-status-description": "Something went wrong, but we're not sure what. Please try again."
|
||||
},
|
||||
"settings": {
|
||||
"general-information-tab": "General information",
|
||||
"your-profile-tab": "Your profile",
|
||||
"members-tab": "Members",
|
||||
"authentication-tab": "Authentication",
|
||||
"export-tab": "Export your data",
|
||||
"change-username-button": "Change username",
|
||||
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
|
||||
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
|
||||
"change-avatar-link": "Change your avatar here",
|
||||
"new-username": "New username",
|
||||
"table-role": "Role",
|
||||
"table-custom-preferences": "Custom preferences",
|
||||
"table-member-list-hidden": "Member list hidden?",
|
||||
"table-member-count": "Member count",
|
||||
"table-created-at": "Account created at",
|
||||
"table-id": "Your ID",
|
||||
"table-title": "Account information",
|
||||
"force-log-out-title": "Log out everywhere",
|
||||
"force-log-out-button": "Force log out",
|
||||
"force-log-out-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.",
|
||||
"log-out-title": "Log out",
|
||||
"log-out-hint": "Use this button to log out on this device only.",
|
||||
"log-out-button": "Log out",
|
||||
"avatar": "Avatar",
|
||||
"username-update-success": "Successfully changed your username!",
|
||||
"create-member-title": "Create a new member",
|
||||
"create-member-name-label": "Member name",
|
||||
"auth-remove-method": "Remove",
|
||||
"force-log-out-warning": "Make sure you're still able to log in before using this!",
|
||||
"force-log-out-confirmation": "Are you sure you want to log out from all devices? If you just want to log out from this device, click the \"Log out\" button on your settings page.",
|
||||
"export-request-success": "Successfully requested a new export! Please note that it may take a few minutes to complete, especially if you have a lot of members.",
|
||||
"export-title": "Request a copy of your data",
|
||||
"export-info": "You can request a copy of your data once every 24 hours. Exports are stored for 15 days (a little over two weeks) and then deleted.",
|
||||
"export-expires-at": "(expires {{expiresAt}})",
|
||||
"export-download": "Download export",
|
||||
"export-request-button": "Request a new export"
|
||||
},
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"edit-profile": {
|
||||
"user-header": "Editing your profile",
|
||||
"general-tab": "General",
|
||||
"names-pronouns-tab": "Names & pronouns",
|
||||
"file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})",
|
||||
"sid-current": "Current short ID:",
|
||||
"sid": "Short ID",
|
||||
"sid-reroll": "Reroll short ID",
|
||||
"sid-hint": "This ID is used in prns.cc links. You can reroll one short ID every hour (shared between your main profile and all members) by pressing the button above.",
|
||||
"sid-copy": "Copy short link",
|
||||
"update-avatar": "Update avatar",
|
||||
"avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.",
|
||||
"member-header-label": "\"Members\" header text",
|
||||
"member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.",
|
||||
"hide-member-list-label": "Hide member list",
|
||||
"timezone-label": "Timezone",
|
||||
"timezone-preview": "This will show up on your profile like this:",
|
||||
"timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.",
|
||||
"hide-member-list-info": "This only hides your member list. Individual members will still be visible to anyone with a direct link to their pages.",
|
||||
"profile-options-header": "Profile options",
|
||||
"bio-tab": "Bio",
|
||||
"saved-changes": "Successfully saved changes!",
|
||||
"bio-length-hint": "Using {{length}}/{{maxLength}} characters",
|
||||
"preview": "Preview",
|
||||
"fields-tab": "Fields",
|
||||
"flags-links-tab": "Flags & links",
|
||||
"back-to-settings-tab": "Back to settings",
|
||||
"member-header": "Editing profile of {{name}}",
|
||||
"username": "Username",
|
||||
"change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.",
|
||||
"change-username-link": "Go to settings",
|
||||
"member-name": "Name",
|
||||
"change-member-name": "Change name",
|
||||
"display-name": "Display name",
|
||||
"unlisted-label": "Hide from member list",
|
||||
"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:",
|
||||
"edit-names-pronouns-header": "Edit names and pronouns",
|
||||
"back-to-profile-tab": "Back to profile",
|
||||
"editing-fields-header": "Editing fields"
|
||||
},
|
||||
"save-changes": "Save changes",
|
||||
"change": "Change",
|
||||
"editor": {
|
||||
"remove-entry": "Remove entry",
|
||||
"move-entry-down": "Move entry down",
|
||||
"move-entry-up": "Move entry up",
|
||||
"add-entry": "Add entry",
|
||||
"change-display-text": "Change display text",
|
||||
"display-text-example": "Optional display text (e.g. it/its)",
|
||||
"display-text-label": "Display text",
|
||||
"display-text-info": "This is the short text shown on your profile page. If you leave it empty, it will default to the first two forms of the full set.",
|
||||
"move-field-up": "Move field up",
|
||||
"move-field-down": "Move field down",
|
||||
"remove-field": "Remove field",
|
||||
"field-name": "Field name",
|
||||
"add-field": "Add field",
|
||||
"new-entry": "New entry"
|
||||
}
|
||||
"hello": "Hello, {{name}}!",
|
||||
"nav": {
|
||||
"log-in": "Log in or sign up",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"avatar-tooltip": "Avatar for {{name}}",
|
||||
"profile": {
|
||||
"edit-member-profile-notice": "You are currently viewing the public profile of {{memberName}}.",
|
||||
"edit-user-profile-notice": "You are currently viewing your public profile.",
|
||||
"edit-profile-link": "Edit profile",
|
||||
"names-header": "Names",
|
||||
"pronouns-header": "Pronouns",
|
||||
"default-members-header": "Members",
|
||||
"create-member-button": "Create member",
|
||||
"back-to-user": "Back to {{name}}"
|
||||
},
|
||||
"title": {
|
||||
"log-in": "Log in",
|
||||
"welcome": "Welcome",
|
||||
"settings": "Settings",
|
||||
"an-error-occurred": "An error occurred"
|
||||
},
|
||||
"auth": {
|
||||
"log-in-form-title": "Log in with email",
|
||||
"log-in-form-email-label": "Email address",
|
||||
"log-in-form-password-label": "Password",
|
||||
"register-with-email-button": "Register with email",
|
||||
"log-in-button": "Log in",
|
||||
"log-in-3rd-party-header": "Log in with another service",
|
||||
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
|
||||
"log-in-with-discord": "Log in with Discord",
|
||||
"log-in-with-google": "Log in with Google",
|
||||
"log-in-with-tumblr": "Log in with Tumblr",
|
||||
"log-in-with-the-fediverse": "Log in with the Fediverse",
|
||||
"remote-fediverse-account-label": "Your Fediverse account",
|
||||
"register-username-label": "Username",
|
||||
"register-button": "Register account",
|
||||
"register-with-mastodon": "Register with a Fediverse account",
|
||||
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?",
|
||||
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end",
|
||||
"register-with-discord": "Register with a Discord account",
|
||||
"new-auth-method-added": "Successfully added authentication method!",
|
||||
"successful-link-discord": "Your account has successfully been linked to the following Discord account:",
|
||||
"successful-link-google": "Your account has successfully been linked to the following Google account:",
|
||||
"successful-link-tumblr": "Your account has successfully been linked to the following Tumblr account:",
|
||||
"successful-link-fedi": "Your account has successfully been linked to the following fediverse account:",
|
||||
"successful-link-profile-hint": "You now can close this page, or go back to your profile:",
|
||||
"successful-link-profile-link": "Go to your profile",
|
||||
"remote-discord-account-label": "Your Discord account",
|
||||
"log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)",
|
||||
"register-with-email": "Register with an email address",
|
||||
"email-label": "Your email address",
|
||||
"confirm-password-label": "Confirm password",
|
||||
"register-with-email-init-success": "Success! An email has been sent to your inbox, please press the link there to continue."
|
||||
},
|
||||
"error": {
|
||||
"bad-request-header": "Something was wrong with your input",
|
||||
"generic-header": "Something went wrong",
|
||||
"raw-header": "Raw error",
|
||||
"authentication-error": "Something went wrong when logging you in.",
|
||||
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
|
||||
"forbidden": "You are not allowed to perform that action.",
|
||||
"internal-server-error": "Server experienced an internal error, please try again later.",
|
||||
"authentication-required": "You need to log in first.",
|
||||
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
|
||||
"generic-error": "An unknown error occurred.",
|
||||
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
|
||||
"member-not-found": "Member not found, please check your spelling and try again.",
|
||||
"account-already-linked": "This account is already linked with a pronouns.cc account.",
|
||||
"last-auth-method": "You cannot remove your last authentication method.",
|
||||
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.",
|
||||
"validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.",
|
||||
"validation-disallowed-value-1": "The following value is not allowed here",
|
||||
"validation-disallowed-value-2": "Allowed values are",
|
||||
"validation-reason": "Reason",
|
||||
"validation-generic": "The value you entered is not allowed here. Reason",
|
||||
"extra-info-header": "Extra error information",
|
||||
"noscript-title": "This page requires JavaScript",
|
||||
"noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.",
|
||||
"noscript-short": "Requires JavaScript",
|
||||
"404-description": "The page you were trying to visit was not found. If you're sure the page should exist, check your address bar for any typos.",
|
||||
"back-to-profile-button": "Go back to your profile",
|
||||
"back-to-main-page-button": "Go back to the main page",
|
||||
"back-to-prev-page-button": "Go back to the previous page",
|
||||
"400-description": "Something went wrong with your request. This error should never land you on this page, so it's probably a bug.",
|
||||
"500-description": "Something went wrong on the server. Please try again later.",
|
||||
"unknown-status-description": "Something went wrong, but we're not sure what. Please try again."
|
||||
},
|
||||
"settings": {
|
||||
"general-information-tab": "General information",
|
||||
"your-profile-tab": "Your profile",
|
||||
"members-tab": "Members",
|
||||
"authentication-tab": "Authentication",
|
||||
"export-tab": "Export your data",
|
||||
"change-username-button": "Change username",
|
||||
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
|
||||
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
|
||||
"change-avatar-link": "Change your avatar here",
|
||||
"new-username": "New username",
|
||||
"table-role": "Role",
|
||||
"table-custom-preferences": "Custom preferences",
|
||||
"table-member-list-hidden": "Member list hidden?",
|
||||
"table-member-count": "Member count",
|
||||
"table-created-at": "Account created at",
|
||||
"table-id": "Your ID",
|
||||
"table-title": "Account information",
|
||||
"force-log-out-title": "Log out everywhere",
|
||||
"force-log-out-button": "Force log out",
|
||||
"force-log-out-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.",
|
||||
"log-out-title": "Log out",
|
||||
"log-out-hint": "Use this button to log out on this device only.",
|
||||
"log-out-button": "Log out",
|
||||
"avatar": "Avatar",
|
||||
"username-update-success": "Successfully changed your username!",
|
||||
"create-member-title": "Create a new member",
|
||||
"create-member-name-label": "Member name",
|
||||
"auth-remove-method": "Remove",
|
||||
"force-log-out-warning": "Make sure you're still able to log in before using this!",
|
||||
"force-log-out-confirmation": "Are you sure you want to log out from all devices? If you just want to log out from this device, click the \"Log out\" button on your settings page.",
|
||||
"export-request-success": "Successfully requested a new export! Please note that it may take a few minutes to complete, especially if you have a lot of members.",
|
||||
"export-title": "Request a copy of your data",
|
||||
"export-info": "You can request a copy of your data once every 24 hours. Exports are stored for 15 days (a little over two weeks) and then deleted.",
|
||||
"export-expires-at": "(expires {{expiresAt}})",
|
||||
"export-download": "Download export",
|
||||
"export-request-button": "Request a new export"
|
||||
},
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"edit-profile": {
|
||||
"user-header": "Editing your profile",
|
||||
"general-tab": "General",
|
||||
"names-pronouns-tab": "Names & pronouns",
|
||||
"file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})",
|
||||
"sid-current": "Current short ID:",
|
||||
"sid": "Short ID",
|
||||
"sid-reroll": "Reroll short ID",
|
||||
"sid-hint": "This ID is used in prns.cc links. You can reroll one short ID every hour (shared between your main profile and all members) by pressing the button above.",
|
||||
"sid-copy": "Copy short link",
|
||||
"update-avatar": "Update avatar",
|
||||
"avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.",
|
||||
"member-header-label": "\"Members\" header text",
|
||||
"member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.",
|
||||
"hide-member-list-label": "Hide member list",
|
||||
"timezone-label": "Timezone",
|
||||
"timezone-preview": "This will show up on your profile like this:",
|
||||
"timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.",
|
||||
"hide-member-list-info": "This only hides your member list. Individual members will still be visible to anyone with a direct link to their pages.",
|
||||
"profile-options-header": "Profile options",
|
||||
"bio-tab": "Bio",
|
||||
"saved-changes": "Successfully saved changes!",
|
||||
"bio-length-hint": "Using {{length}}/{{maxLength}} characters",
|
||||
"preview": "Preview",
|
||||
"fields-tab": "Fields",
|
||||
"flags-links-tab": "Flags & links",
|
||||
"back-to-settings-tab": "Back to settings",
|
||||
"member-header": "Editing profile of {{name}}",
|
||||
"username": "Username",
|
||||
"change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.",
|
||||
"change-username-link": "Go to settings",
|
||||
"member-name": "Name",
|
||||
"change-member-name": "Change name",
|
||||
"display-name": "Display name",
|
||||
"unlisted-label": "Hide from member list",
|
||||
"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:",
|
||||
"edit-names-pronouns-header": "Edit names and pronouns",
|
||||
"back-to-profile-tab": "Back to profile",
|
||||
"editing-fields-header": "Editing fields"
|
||||
},
|
||||
"save-changes": "Save changes",
|
||||
"change": "Change",
|
||||
"editor": {
|
||||
"remove-entry": "Remove entry",
|
||||
"move-entry-down": "Move entry down",
|
||||
"move-entry-up": "Move entry up",
|
||||
"add-entry": "Add entry",
|
||||
"change-display-text": "Change display text",
|
||||
"display-text-example": "Optional display text (e.g. it/its)",
|
||||
"display-text-label": "Display text",
|
||||
"display-text-info": "This is the short text shown on your profile page. If you leave it empty, it will default to the first two forms of the full set.",
|
||||
"move-field-up": "Move field up",
|
||||
"move-field-down": "Move field down",
|
||||
"remove-field": "Remove field",
|
||||
"field-name": "Field name",
|
||||
"add-field": "Add field",
|
||||
"new-entry": "New entry"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,63 +1,7 @@
|
|||
import { apiRequest } from "$api";
|
||||
import ApiError, { ErrorCode } from "$api/error";
|
||||
import type { AddAccountResponse, CallbackResponse } from "$api/models/auth";
|
||||
import { setToken } from "$lib";
|
||||
import createRegisterAction from "$lib/actions/register.js";
|
||||
import log from "$lib/log.js";
|
||||
import { isRedirect, redirect } from "@sveltejs/kit";
|
||||
import createCallbackLoader from "$lib/actions/callback";
|
||||
import createRegisterAction from "$lib/actions/register";
|
||||
|
||||
export const load = async ({ url, parent, fetch, cookies }) => {
|
||||
const code = url.searchParams.get("code") as string | null;
|
||||
const state = url.searchParams.get("state") as string | null;
|
||||
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
|
||||
|
||||
const { meUser } = await parent();
|
||||
if (meUser) {
|
||||
try {
|
||||
const resp = await apiRequest<AddAccountResponse>(
|
||||
"POST",
|
||||
"/auth/discord/add-account/callback",
|
||||
{
|
||||
isInternal: true,
|
||||
body: { code, state },
|
||||
fetch,
|
||||
cookies,
|
||||
},
|
||||
);
|
||||
|
||||
return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj };
|
||||
log.error("error linking new discord account to user %s:", meUser.id, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await apiRequest<CallbackResponse>("POST", "/auth/discord/callback", {
|
||||
body: { code, state },
|
||||
isInternal: true,
|
||||
fetch,
|
||||
});
|
||||
|
||||
if (resp.has_account) {
|
||||
setToken(cookies, resp.token!);
|
||||
redirect(303, `/@${resp.user!.username}`);
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccount: false,
|
||||
isLinkRequest: false,
|
||||
ticket: resp.ticket!,
|
||||
remoteUser: resp.remote_username!,
|
||||
};
|
||||
} catch (e) {
|
||||
if (isRedirect(e)) throw e;
|
||||
if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj };
|
||||
log.error("error while requesting discord callback:", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
export const load = createCallbackLoader("discord");
|
||||
|
||||
export const actions = {
|
||||
default: createRegisterAction("/auth/discord/register"),
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
import { apiRequest } from "$api";
|
||||
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
|
||||
import type { AuthResponse } from "$api/models/auth";
|
||||
import { setToken } from "$lib";
|
||||
import createCallbackLoader from "$lib/actions/callback";
|
||||
import log from "$lib/log";
|
||||
import { redirect, isRedirect } from "@sveltejs/kit";
|
||||
|
||||
export const load = createCallbackLoader("email", async ({ params }) => {
|
||||
log.info("params:", params, "code:", params.code);
|
||||
|
||||
return { state: params.code! };
|
||||
});
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, fetch, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const username = data.get("username") as string | null;
|
||||
const ticket = data.get("ticket") as string | null;
|
||||
const password = data.get("password") as string | null;
|
||||
const password2 = data.get("confirm-password") as string | null;
|
||||
|
||||
if (!username || !ticket || !password || !password2)
|
||||
return {
|
||||
error: { message: "Bad request", code: ErrorCode.BadRequest, status: 400 } as RawApiError,
|
||||
};
|
||||
|
||||
if (password !== password2)
|
||||
return {
|
||||
error: {
|
||||
message: "Passwords do not match",
|
||||
code: ErrorCode.BadRequest,
|
||||
status: 400,
|
||||
} as RawApiError,
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await apiRequest<AuthResponse>("POST", "/auth/email/register", {
|
||||
body: { username, ticket, password },
|
||||
isInternal: true,
|
||||
fetch,
|
||||
});
|
||||
|
||||
setToken(cookies, resp.token);
|
||||
redirect(303, "/auth/welcome");
|
||||
} catch (e) {
|
||||
if (isRedirect(e)) throw e;
|
||||
log.error("Could not sign up user with username %s:", username, e);
|
||||
if (e instanceof ApiError) return { error: e.obj };
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
<script lang="ts">
|
||||
import Error from "$components/Error.svelte";
|
||||
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||
import NewAuthMethod from "$components/settings/NewAuthMethod.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import { Label, Input, Button } from "@sveltestrap/sveltestrap";
|
||||
import type { ActionData, PageData } from "./$types";
|
||||
|
||||
type Props = { data: PageData; form: ActionData };
|
||||
let { data, form }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t("auth.register-with-email")} • pronouns.cc</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
{#if data.error}
|
||||
<h1>{$t("auth.register-with-email")}</h1>
|
||||
<Error error={data.error} />
|
||||
{:else if data.isLinkRequest}
|
||||
<NewAuthMethod method={data.newAuthMethod!} user={data.meUser!} />
|
||||
{:else}
|
||||
<h1>{$t("auth.register-with-email")}</h1>
|
||||
|
||||
{#if form?.error}
|
||||
<ErrorAlert error={form.error} />
|
||||
{/if}
|
||||
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<Label>{$t("auth.email-label")}</Label>
|
||||
<Input type="text" readonly value={data.remoteUser} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<Label>{$t("auth.register-username-label")}</Label>
|
||||
<Input type="text" name="username" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<Label>{$t("auth.log-in-form-password-label")}</Label>
|
||||
<Input type="password" name="password" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<Label>{$t("auth.confirm-password-label")}</Label>
|
||||
<Input type="password" name="confirm-password" required />
|
||||
</div>
|
||||
<input type="hidden" name="ticket" value={data.ticket!} />
|
||||
<Button color="primary" type="submit">{$t("auth.register-button")}</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
|
@ -1,63 +1,14 @@
|
|||
import { apiRequest } from "$api";
|
||||
import ApiError, { ErrorCode } from "$api/error";
|
||||
import type { AddAccountResponse, CallbackResponse } from "$api/models/auth.js";
|
||||
import { setToken } from "$lib";
|
||||
import createRegisterAction from "$lib/actions/register.js";
|
||||
import log from "$lib/log";
|
||||
import { isRedirect, redirect } from "@sveltejs/kit";
|
||||
import createCallbackLoader from "$lib/actions/callback";
|
||||
import createRegisterAction from "$lib/actions/register";
|
||||
|
||||
export const load = async ({ parent, params, url, fetch, cookies }) => {
|
||||
export const load = createCallbackLoader("fediverse", async ({ params, url }) => {
|
||||
const code = url.searchParams.get("code") as string | null;
|
||||
const state = url.searchParams.get("state") as string | null;
|
||||
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
|
||||
|
||||
const { meUser } = await parent();
|
||||
if (meUser) {
|
||||
try {
|
||||
const resp = await apiRequest<AddAccountResponse>(
|
||||
"POST",
|
||||
"/auth/fediverse/add-account/callback",
|
||||
{
|
||||
isInternal: true,
|
||||
body: { code, state, instance: params.instance },
|
||||
fetch,
|
||||
cookies,
|
||||
},
|
||||
);
|
||||
|
||||
return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj };
|
||||
log.error("error linking new fediverse account to user %s:", meUser.id, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await apiRequest<CallbackResponse>("POST", "/auth/fediverse/callback", {
|
||||
body: { code, state, instance: params.instance },
|
||||
isInternal: true,
|
||||
fetch,
|
||||
});
|
||||
|
||||
if (resp.has_account) {
|
||||
setToken(cookies, resp.token!);
|
||||
redirect(303, `/@${resp.user!.username}`);
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccount: false,
|
||||
isLinkRequest: false,
|
||||
ticket: resp.ticket!,
|
||||
remoteUser: resp.remote_username!,
|
||||
};
|
||||
} catch (e) {
|
||||
if (isRedirect(e)) throw e;
|
||||
if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj };
|
||||
log.error("error while requesting fediverse callback:", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
return { code, state, instance: params.instance! };
|
||||
});
|
||||
|
||||
export const actions = {
|
||||
default: createRegisterAction("/auth/fediverse/register"),
|
||||
|
|
35
Foxnouns.Frontend/src/routes/auth/register/+page.server.ts
Normal file
35
Foxnouns.Frontend/src/routes/auth/register/+page.server.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { apiRequest, fastRequest } from "$api";
|
||||
import ApiError from "$api/error.js";
|
||||
import type { AuthUrls } from "$api/models/auth";
|
||||
import log from "$lib/log.js";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export const load = async ({ fetch, parent }) => {
|
||||
const parentData = await parent();
|
||||
if (parentData.meUser) redirect(303, `/@${parentData.meUser.username}`);
|
||||
|
||||
const urls = await apiRequest<AuthUrls>("POST", "/auth/urls", { fetch, isInternal: true });
|
||||
if (!urls.email_enabled) redirect(303, "/");
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, fetch, cookies }) => {
|
||||
const body = await request.formData();
|
||||
const email = body.get("email") as string;
|
||||
|
||||
try {
|
||||
await fastRequest("POST", `/auth/email/register/init`, {
|
||||
body: { email },
|
||||
isInternal: true,
|
||||
fetch,
|
||||
cookies,
|
||||
});
|
||||
|
||||
return { ok: true, error: null };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return { ok: false, error: e.obj };
|
||||
log.error("error initiating registration for email %s:", email, e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
};
|
29
Foxnouns.Frontend/src/routes/auth/register/+page.svelte
Normal file
29
Foxnouns.Frontend/src/routes/auth/register/+page.svelte
Normal file
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import type { ActionData, PageData } from "./$types";
|
||||
import { t } from "$lib/i18n";
|
||||
import { enhance } from "$app/forms";
|
||||
import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap";
|
||||
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||
|
||||
type Props = { data: PageData; form: ActionData };
|
||||
let { data, form }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t("auth.register-with-email")} • pronouns.cc</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<div class="mx-auto w-lg-75">
|
||||
<h2>{$t("auth.register-with-email")}</h2>
|
||||
|
||||
<FormStatusMarker {form} successMessage={$t("auth.register-with-email-init-success")} />
|
||||
|
||||
<form method="POST" use:enhance>
|
||||
<InputGroup>
|
||||
<Input name="email" type="email" placeholder={$t("auth.email-label")} />
|
||||
<Button type="submit" color="secondary">{$t("auth.register-with-email-button")}</Button>
|
||||
</InputGroup>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
|
@ -3,21 +3,23 @@
|
|||
import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap";
|
||||
</script>
|
||||
|
||||
<h3>Link a new Fediverse account</h3>
|
||||
<div class="mx-auto w-lg-75">
|
||||
<h3>Link a new Fediverse account</h3>
|
||||
|
||||
<form method="POST" action="?/add">
|
||||
<InputGroup>
|
||||
<Input
|
||||
name="instance"
|
||||
type="text"
|
||||
placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")}
|
||||
/>
|
||||
<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button>
|
||||
</InputGroup>
|
||||
<p>
|
||||
{$t("auth.log-in-with-fediverse-error-blurb")}
|
||||
<Button formaction="?/forceRefresh" type="submit" color="link">
|
||||
{$t("auth.log-in-with-fediverse-force-refresh-button")}
|
||||
</Button>
|
||||
</p>
|
||||
</form>
|
||||
<form method="POST" action="?/add">
|
||||
<InputGroup>
|
||||
<Input
|
||||
name="instance"
|
||||
type="text"
|
||||
placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")}
|
||||
/>
|
||||
<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button>
|
||||
</InputGroup>
|
||||
<p>
|
||||
{$t("auth.log-in-with-fediverse-error-blurb")}
|
||||
<Button formaction="?/forceRefresh" type="submit" color="link">
|
||||
{$t("auth.log-in-with-fediverse-force-refresh-button")}
|
||||
</Button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
import { Icon } from "@sveltestrap/sveltestrap";
|
||||
import { t } from "$lib/i18n";
|
||||
import { enhance } from "$app/forms";
|
||||
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||
|
||||
type Props = { data: PageData; form: ActionData };
|
||||
let { data, form }: Props = $props();
|
||||
|
@ -18,14 +19,7 @@
|
|||
<div class="mx-auto w-lg-75">
|
||||
<h3>{$t("settings.export-title")}</h3>
|
||||
|
||||
{#if form?.ok}
|
||||
<p class="text-success-emphasis">
|
||||
<Icon name="check-circle-fill" />
|
||||
{$t("settings.export-request-success")}
|
||||
</p>
|
||||
{:else if form?.error}
|
||||
<ErrorAlert error={form.error} />
|
||||
{/if}
|
||||
<FormStatusMarker {form} successMessage={$t("settings.export-request-success")} />
|
||||
|
||||
<p>
|
||||
{$t("settings.export-info")}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:String x:Key="/Default/CodeStyle/CSharpVarKeywordUsage/ForBuiltInTypes/@EntryValue">UseVarWhenEvident</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/FileHeader/FileHeaderText/@EntryValue">Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
|
|
5
STYLE.md
5
STYLE.md
|
@ -3,9 +3,8 @@
|
|||
## C# code style
|
||||
|
||||
- Code should be formatted with `dotnet format` or Rider's built-in formatter.
|
||||
- Variables should *always* be declared using `var`,
|
||||
unless the correct type can't be inferred from the declaration (i.e. if the variable needs to be an `IEnumerable<T>`
|
||||
instead of a `List<T>`, or if a variable is initialized as `null`).
|
||||
- Variables should always be declared with their type name, unless the type is obvious from the declaration.
|
||||
(For example, `var stream = new Stream()` or `var db = services.GetRequiredService<DatabaseContext>()`)
|
||||
|
||||
### Naming
|
||||
|
||||
|
|
Loading…
Reference in a new issue