Compare commits

...

2 commits

28 changed files with 382 additions and 194 deletions

View file

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Serilog.Events; using Serilog.Events;
namespace Foxnouns.Backend; namespace Foxnouns.Backend;
@ -15,6 +16,7 @@ public class Config
public LoggingConfig Logging { get; init; } = new(); public LoggingConfig Logging { get; init; } = new();
public DatabaseConfig Database { get; init; } = new(); public DatabaseConfig Database { get; init; } = new();
public StorageConfig Storage { get; init; } = new(); public StorageConfig Storage { get; init; } = new();
public EmailAuthConfig EmailAuth { get; init; } = new();
public DiscordAuthConfig DiscordAuth { get; init; } = new(); public DiscordAuthConfig DiscordAuth { get; init; } = new();
public GoogleAuthConfig GoogleAuth { get; init; } = new(); public GoogleAuthConfig GoogleAuth { get; init; } = new();
public TumblrAuthConfig TumblrAuth { get; init; } = new(); public TumblrAuthConfig TumblrAuth { get; init; } = new();
@ -46,6 +48,12 @@ public class Config
public string Bucket { get; init; } = string.Empty; public string Bucket { get; init; } = string.Empty;
} }
public class EmailAuthConfig
{
public bool Enabled => From != null;
public string? From { get; init; }
}
public class DiscordAuthConfig public class DiscordAuthConfig
{ {
public bool Enabled => ClientId != null && ClientSecret != null; public bool Enabled => ClientId != null && ClientSecret != null;

View file

@ -7,19 +7,19 @@ using NodaTime;
namespace Foxnouns.Backend.Controllers.Authentication; namespace Foxnouns.Backend.Controllers.Authentication;
[Route("/api/v2/auth")] [Route("/api/v2/auth")]
public class AuthController(Config config, KeyCacheService keyCacheSvc, ILogger logger) : ApiControllerBase public class AuthController(Config config, KeyCacheService keyCache, ILogger logger) : ApiControllerBase
{ {
private readonly ILogger _logger = logger.ForContext<AuthController>(); private readonly ILogger _logger = logger.ForContext<AuthController>();
[HttpPost("urls")] [HttpPost("urls")]
[ProducesResponseType<UrlsResponse>(StatusCodes.Status200OK)] [ProducesResponseType<UrlsResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> UrlsAsync() public async Task<IActionResult> UrlsAsync(CancellationToken ct = default)
{ {
_logger.Debug("Generating auth URLs for Discord: {Discord}, Google: {Google}, Tumblr: {Tumblr}", _logger.Debug("Generating auth URLs for Discord: {Discord}, Google: {Google}, Tumblr: {Tumblr}",
config.DiscordAuth.Enabled, config.DiscordAuth.Enabled,
config.GoogleAuth.Enabled, config.GoogleAuth.Enabled,
config.TumblrAuth.Enabled); config.TumblrAuth.Enabled);
var state = HttpUtility.UrlEncode(await keyCacheSvc.GenerateAuthStateAsync()); var state = HttpUtility.UrlEncode(await keyCache.GenerateAuthStateAsync(ct));
string? discord = null; string? discord = null;
if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null })
discord = discord =

View file

@ -15,10 +15,10 @@ public class DiscordAuthController(
ILogger logger, ILogger logger,
IClock clock, IClock clock,
DatabaseContext db, DatabaseContext db,
KeyCacheService keyCacheSvc, KeyCacheService keyCacheService,
AuthService authSvc, AuthService authService,
RemoteAuthService remoteAuthSvc, RemoteAuthService remoteAuthService,
UserRendererService userRendererSvc) : ApiControllerBase UserRendererService userRenderer) : ApiControllerBase
{ {
private readonly ILogger _logger = logger.ForContext<DiscordAuthController>(); private readonly ILogger _logger = logger.ForContext<DiscordAuthController>();
@ -27,58 +27,58 @@ public class DiscordAuthController(
// leaving it here for documentation purposes // leaving it here for documentation purposes
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)] [ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<AuthController.CallbackResponse>(StatusCodes.Status200OK)] [ProducesResponseType<AuthController.CallbackResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> CallbackAsync([FromBody] AuthController.CallbackRequest req) public async Task<IActionResult> CallbackAsync([FromBody] AuthController.CallbackRequest req, CancellationToken ct = default)
{ {
CheckRequirements(); CheckRequirements();
await keyCacheSvc.ValidateAuthStateAsync(req.State); await keyCacheService.ValidateAuthStateAsync(req.State, ct);
var remoteUser = await remoteAuthSvc.RequestDiscordTokenAsync(req.Code, req.State); var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code, req.State, ct);
var user = await authSvc.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id); var user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id, ct: ct);
if (user != null) return Ok(await GenerateUserTokenAsync(user)); if (user != null) return Ok(await GenerateUserTokenAsync(user,ct));
_logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username, _logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username,
remoteUser.Id); remoteUser.Id);
var ticket = AuthUtils.RandomToken(); var ticket = AuthUtils.RandomToken();
await keyCacheSvc.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20)); await keyCacheService.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20), ct);
return Ok(new AuthController.CallbackResponse(false, ticket, remoteUser.Username)); return Ok(new AuthController.CallbackResponse(false, ticket, remoteUser.Username));
} }
[HttpPost("register")] [HttpPost("register")]
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)] [ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> RegisterAsync([FromBody] AuthController.OauthRegisterRequest req) public async Task<IActionResult> RegisterAsync([FromBody] AuthController.OauthRegisterRequest req, CancellationToken ct = default)
{ {
var remoteUser = await keyCacheSvc.GetKeyAsync<RemoteAuthService.RemoteUser>($"discord:{req.Ticket}"); var remoteUser = await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>($"discord:{req.Ticket}",ct:ct);
if (remoteUser == null) throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); if (remoteUser == null) throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id)) if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id, ct))
{ {
_logger.Error("Discord user {Id} has valid ticket but is already linked to an existing account", _logger.Error("Discord user {Id} has valid ticket but is already linked to an existing account",
remoteUser.Id); remoteUser.Id);
throw new FoxnounsError("Discord ticket was issued for user with existing link"); throw new FoxnounsError("Discord ticket was issued for user with existing link");
} }
var user = await authSvc.CreateUserWithRemoteAuthAsync(req.Username, AuthType.Discord, remoteUser.Id, var user = await authService.CreateUserWithRemoteAuthAsync(req.Username, AuthType.Discord, remoteUser.Id,
remoteUser.Username); remoteUser.Username, ct: ct);
return Ok(await GenerateUserTokenAsync(user)); return Ok(await GenerateUserTokenAsync(user, ct));
} }
private async Task<AuthController.AuthResponse> GenerateUserTokenAsync(User user) private async Task<AuthController.AuthResponse> GenerateUserTokenAsync(User user, CancellationToken ct = default)
{ {
var frontendApp = await db.GetFrontendApplicationAsync(); var frontendApp = await db.GetFrontendApplicationAsync(ct);
_logger.Debug("Logging user {Id} in with Discord", user.Id); _logger.Debug("Logging user {Id} in with Discord", user.Id);
var (tokenStr, token) = var (tokenStr, token) =
authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365));
db.Add(token); db.Add(token);
_logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id); _logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id);
await db.SaveChangesAsync(); await db.SaveChangesAsync(ct);
return new AuthController.AuthResponse( return new AuthController.AuthResponse(
await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false), await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct),
tokenStr, tokenStr,
token.ExpiresAt token.ExpiresAt
); );

View file

@ -1,6 +1,10 @@
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace Foxnouns.Backend.Controllers.Authentication; namespace Foxnouns.Backend.Controllers.Authentication;
@ -8,39 +12,78 @@ namespace Foxnouns.Backend.Controllers.Authentication;
[Route("/api/v2/auth/email")] [Route("/api/v2/auth/email")]
public class EmailAuthController( public class EmailAuthController(
DatabaseContext db, DatabaseContext db,
AuthService authSvc, AuthService authService,
UserRendererService userRendererSvc, MailService mailService,
KeyCacheService keyCacheService,
UserRendererService userRenderer,
IClock clock, IClock clock,
ILogger logger) : ApiControllerBase ILogger logger) : ApiControllerBase
{ {
private readonly ILogger _logger = logger.ForContext<EmailAuthController>(); private readonly ILogger _logger = logger.ForContext<EmailAuthController>();
[HttpPost("register")]
public async Task<IActionResult> RegisterAsync([FromBody] RegisterRequest req, CancellationToken ct = default)
{
if (!req.Email.Contains('@')) throw new ApiError.BadRequest("Email is invalid", "email", req.Email);
var state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, userId: null, ct);
if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Email && a.RemoteId == req.Email, ct))
return NoContent();
mailService.QueueAccountCreationEmail(req.Email, state);
return NoContent();
}
[HttpPost("callback")]
public async Task<IActionResult> CallbackAsync([FromBody] CallbackRequest req, CancellationToken ct = default)
{
var state = await keyCacheService.GetRegisterEmailStateAsync(req.State, ct);
if (state == null) throw new ApiError.BadRequest("Invalid state", "state", req.State);
if (state.ExistingUserId != null)
{
var authMethod =
await authService.AddAuthMethodAsync(state.ExistingUserId.Value, AuthType.Email, state.Email, ct: ct);
_logger.Debug("Added email auth {AuthId} for user {UserId}", authMethod.Id, state.ExistingUserId);
return NoContent();
}
var ticket = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20));
return Ok(new AuthController.CallbackResponse(false, ticket, state.Email));
}
[HttpPost("login")] [HttpPost("login")]
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)] [ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> LoginAsync([FromBody] LoginRequest req) public async Task<IActionResult> LoginAsync([FromBody] LoginRequest req, CancellationToken ct = default)
{ {
var (user, authenticationResult) = await authSvc.AuthenticateUserAsync(req.Email, req.Password); var (user, authenticationResult) = await authService.AuthenticateUserAsync(req.Email, req.Password, ct);
if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired) if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired)
throw new NotImplementedException("MFA is not implemented yet"); throw new NotImplementedException("MFA is not implemented yet");
var frontendApp = await db.GetFrontendApplicationAsync(); var frontendApp = await db.GetFrontendApplicationAsync(ct);
_logger.Debug("Logging user {Id} in with email and password", user.Id); _logger.Debug("Logging user {Id} in with email and password", user.Id);
var (tokenStr, token) = var (tokenStr, token) =
authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365));
db.Add(token); db.Add(token);
_logger.Debug("Generated token {TokenId} for {UserId}", token.Id, user.Id); _logger.Debug("Generated token {TokenId} for {UserId}", token.Id, user.Id);
await db.SaveChangesAsync(); await db.SaveChangesAsync(ct);
return Ok(new AuthController.AuthResponse( return Ok(new AuthController.AuthResponse(
await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false), await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct),
tokenStr, tokenStr,
token.ExpiresAt token.ExpiresAt
)); ));
} }
public record LoginRequest(string Email, string Password); public record LoginRequest(string Email, string Password);
public record RegisterRequest(string Email);
public record CallbackRequest(string State);
} }

View file

@ -9,8 +9,8 @@ namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/debug")] [Route("/api/v2/debug")]
public class DebugController( public class DebugController(
DatabaseContext db, DatabaseContext db,
AuthService authSvc, AuthService authService,
UserRendererService userRendererSvc, UserRendererService userRenderer,
IClock clock, IClock clock,
ILogger logger) : ApiControllerBase ILogger logger) : ApiControllerBase
{ {
@ -22,17 +22,17 @@ public class DebugController(
{ {
_logger.Debug("Creating user with username {Username} and email {Email}", req.Username, req.Email); _logger.Debug("Creating user with username {Username} and email {Email}", req.Username, req.Email);
var user = await authSvc.CreateUserWithPasswordAsync(req.Username, req.Email, req.Password); var user = await authService.CreateUserWithPasswordAsync(req.Username, req.Email, req.Password);
var frontendApp = await db.GetFrontendApplicationAsync(); var frontendApp = await db.GetFrontendApplicationAsync();
var (tokenStr, token) = var (tokenStr, token) =
authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365));
db.Add(token); db.Add(token);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new AuthController.AuthResponse( return Ok(new AuthController.AuthResponse(
await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false), await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false),
tokenStr, tokenStr,
token.ExpiresAt token.ExpiresAt
)); ));

View file

@ -16,33 +16,33 @@ namespace Foxnouns.Backend.Controllers;
public class MembersController( public class MembersController(
ILogger logger, ILogger logger,
DatabaseContext db, DatabaseContext db,
MemberRendererService memberRendererService, MemberRendererService memberRenderer,
ISnowflakeGenerator snowflakeGenerator, ISnowflakeGenerator snowflakeGenerator,
ObjectStorageService objectStorage, ObjectStorageService objectStorageService,
IQueue queue) : ApiControllerBase IQueue queue) : ApiControllerBase
{ {
private readonly ILogger _logger = logger.ForContext<MembersController>(); private readonly ILogger _logger = logger.ForContext<MembersController>();
[HttpGet] [HttpGet]
[ProducesResponseType<IEnumerable<MemberRendererService.PartialMember>>(StatusCodes.Status200OK)] [ProducesResponseType<IEnumerable<MemberRendererService.PartialMember>>(StatusCodes.Status200OK)]
public async Task<IActionResult> GetMembersAsync(string userRef) public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
{ {
var user = await db.ResolveUserAsync(userRef, CurrentToken); var user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
return Ok(await memberRendererService.RenderUserMembersAsync(user, CurrentToken)); return Ok(await memberRenderer.RenderUserMembersAsync(user, CurrentToken));
} }
[HttpGet("{memberRef}")] [HttpGet("{memberRef}")]
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)] [ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> GetMemberAsync(string userRef, string memberRef) public async Task<IActionResult> GetMemberAsync(string userRef, string memberRef, CancellationToken ct = default)
{ {
var member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken); var member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken, ct);
return Ok(memberRendererService.RenderMember(member, CurrentToken)); return Ok(memberRenderer.RenderMember(member, CurrentToken));
} }
[HttpPost("/api/v2/users/@me/members")] [HttpPost("/api/v2/users/@me/members")]
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)] [ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
[Authorize("member.create")] [Authorize("member.create")]
public async Task<IActionResult> CreateMemberAsync([FromBody] CreateMemberRequest req) public async Task<IActionResult> CreateMemberAsync([FromBody] CreateMemberRequest req, CancellationToken ct = default)
{ {
ValidationUtils.Validate([ ValidationUtils.Validate([
("name", ValidationUtils.ValidateMemberName(req.Name)), ("name", ValidationUtils.ValidateMemberName(req.Name)),
@ -71,7 +71,7 @@ public class MembersController(
try try
{ {
await db.SaveChangesAsync(); await db.SaveChangesAsync(ct);
} }
catch (UniqueConstraintException) catch (UniqueConstraintException)
{ {
@ -83,25 +83,25 @@ public class MembersController(
queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>( queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
new AvatarUpdatePayload(member.Id, req.Avatar)); new AvatarUpdatePayload(member.Id, req.Avatar));
return Ok(memberRendererService.RenderMember(member, CurrentToken)); return Ok(memberRenderer.RenderMember(member, CurrentToken));
} }
[HttpDelete("/api/v2/users/@me/members/{memberRef}")] [HttpDelete("/api/v2/users/@me/members/{memberRef}")]
[Authorize("member.update")] [Authorize("member.update")]
public async Task<IActionResult> DeleteMemberAsync(string memberRef) public async Task<IActionResult> DeleteMemberAsync(string memberRef, CancellationToken ct = default)
{ {
var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef); var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef, ct);
var deleteCount = await db.Members.Where(m => m.UserId == CurrentUser!.Id && m.Id == member.Id) var deleteCount = await db.Members.Where(m => m.UserId == CurrentUser!.Id && m.Id == member.Id)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync(ct);
if (deleteCount == 0) if (deleteCount == 0)
{ {
_logger.Warning("Successfully resolved member {Id} but could not delete them", member.Id); _logger.Warning("Successfully resolved member {Id} but could not delete them", member.Id);
return NoContent(); return NoContent();
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync(ct);
if (member.Avatar != null) await objectStorage.DeleteMemberAvatarAsync(member.Id, member.Avatar); if (member.Avatar != null) await objectStorageService.DeleteMemberAvatarAsync(member.Id, member.Avatar);
return NoContent(); return NoContent();
} }

View file

@ -14,7 +14,7 @@ namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/users")] [Route("/api/v2/users")]
public class UsersController( public class UsersController(
DatabaseContext db, DatabaseContext db,
UserRendererService userRendererService, UserRendererService userRenderer,
ISnowflakeGenerator snowflakeGenerator, ISnowflakeGenerator snowflakeGenerator,
IQueue queue) : ApiControllerBase IQueue queue) : ApiControllerBase
{ {
@ -23,7 +23,7 @@ public class UsersController(
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default) public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
{ {
var user = await db.ResolveUserAsync(userRef, CurrentToken, ct); var user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
return Ok(await userRendererService.RenderUserAsync( return Ok(await userRenderer.RenderUserAsync(
user, user,
selfUser: CurrentUser, selfUser: CurrentUser,
token: CurrentToken, token: CurrentToken,
@ -36,10 +36,10 @@ public class UsersController(
[HttpPatch("@me")] [HttpPatch("@me")]
[Authorize("user.update")] [Authorize("user.update")]
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateUserAsync([FromBody] UpdateUserRequest req) public async Task<IActionResult> UpdateUserAsync([FromBody] UpdateUserRequest req, CancellationToken ct = default)
{ {
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync(ct);
var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id); var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
var errors = new List<(string, ValidationError?)>(); var errors = new List<(string, ValidationError?)>();
if (req.Username != null && req.Username != user.Username) if (req.Username != null && req.Username != user.Username)
@ -76,20 +76,20 @@ public class UsersController(
queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>( queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)); new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar));
await db.SaveChangesAsync(); await db.SaveChangesAsync(ct);
await tx.CommitAsync(); await tx.CommitAsync(ct);
return Ok(await userRendererService.RenderUserAsync(user, CurrentUser, renderMembers: false, return Ok(await userRenderer.RenderUserAsync(user, CurrentUser, renderMembers: false,
renderAuthMethods: false)); renderAuthMethods: false, ct: ct));
} }
[HttpPatch("@me/custom-preferences")] [HttpPatch("@me/custom-preferences")]
[Authorize("user.update")] [Authorize("user.update")]
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)] [ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateCustomPreferencesAsync([FromBody] List<CustomPreferencesUpdateRequest> req) public async Task<IActionResult> UpdateCustomPreferencesAsync([FromBody] List<CustomPreferencesUpdateRequest> req, CancellationToken ct = default)
{ {
ValidationUtils.Validate(ValidateCustomPreferences(req)); ValidationUtils.Validate(ValidateCustomPreferences(req));
var user = await db.ResolveUserAsync(CurrentUser!.Id); var user = await db.ResolveUserAsync(CurrentUser!.Id, ct);
var preferences = user.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key)).ToDictionary(); var preferences = user.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key)).ToDictionary();
foreach (var r in req) foreach (var r in req)
@ -119,7 +119,7 @@ public class UsersController(
} }
user.CustomPreferences = preferences; user.CustomPreferences = preferences;
await db.SaveChangesAsync(); await db.SaveChangesAsync(ct);
return Ok(user.CustomPreferences); return Ok(user.CustomPreferences);
} }

View file

@ -36,34 +36,36 @@ public static class DatabaseQueryExtensions
throw new ApiError.NotFound("No user with that ID or username found.", code: ErrorCode.UserNotFound); throw new ApiError.NotFound("No user with that ID or username found.", code: ErrorCode.UserNotFound);
} }
public static async Task<User> ResolveUserAsync(this DatabaseContext context, Snowflake id) public static async Task<User> ResolveUserAsync(this DatabaseContext context, Snowflake id,
CancellationToken ct = default)
{ {
var user = await context.Users var user = await context.Users
.Where(u => !u.Deleted) .Where(u => !u.Deleted)
.FirstOrDefaultAsync(u => u.Id == id); .FirstOrDefaultAsync(u => u.Id == id, ct);
if (user != null) return user; 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.", code: ErrorCode.UserNotFound);
} }
public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, Snowflake id) public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, Snowflake id,
CancellationToken ct = default)
{ {
var member = await context.Members var member = await context.Members
.Include(m => m.User) .Include(m => m.User)
.Where(m => !m.User.Deleted) .Where(m => !m.User.Deleted)
.FirstOrDefaultAsync(m => m.Id == id); .FirstOrDefaultAsync(m => m.Id == id, ct);
if (member != null) return member; 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.", code: ErrorCode.MemberNotFound);
} }
public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, string userRef, string memberRef, public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, string userRef, string memberRef,
Token? token) Token? token, CancellationToken ct = default)
{ {
var user = await context.ResolveUserAsync(userRef, token); var user = await context.ResolveUserAsync(userRef, token, ct);
return await context.ResolveMemberAsync(user.Id, memberRef); return await context.ResolveMemberAsync(user.Id, memberRef, ct);
} }
public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, Snowflake userId, public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, Snowflake userId,
string memberRef) string memberRef, CancellationToken ct = default)
{ {
Member? member; Member? member;
if (Snowflake.TryParse(memberRef, out var snowflake)) if (Snowflake.TryParse(memberRef, out var snowflake))
@ -71,21 +73,22 @@ public static class DatabaseQueryExtensions
member = await context.Members member = await context.Members
.Include(m => m.User) .Include(m => m.User)
.Where(m => !m.User.Deleted) .Where(m => !m.User.Deleted)
.FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId); .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct);
if (member != null) return member; if (member != null) return member;
} }
member = await context.Members member = await context.Members
.Include(m => m.User) .Include(m => m.User)
.Where(m => !m.User.Deleted) .Where(m => !m.User.Deleted)
.FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId); .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct);
if (member != null) return member; if (member != null) return member;
throw new ApiError.NotFound("No member with that ID or name found.", code: ErrorCode.MemberNotFound); throw new ApiError.NotFound("No member with that ID or name found.", code: ErrorCode.MemberNotFound);
} }
public static async Task<Application> GetFrontendApplicationAsync(this DatabaseContext context) public static async Task<Application> GetFrontendApplicationAsync(this DatabaseContext context,
CancellationToken ct = default)
{ {
var app = await context.Applications.FirstOrDefaultAsync(a => a.Id == new Snowflake(0)); var app = await context.Applications.FirstOrDefaultAsync(a => a.Id == new Snowflake(0), ct);
if (app != null) return app; if (app != null) return app;
app = new Application app = new Application
@ -99,7 +102,7 @@ public static class DatabaseQueryExtensions
}; };
context.Add(app); context.Add(app);
await context.SaveChangesAsync(); await context.SaveChangesAsync(ct);
return app; return app;
} }
} }

View file

@ -14,12 +14,12 @@ public static class AvatarObjectExtensions
private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"]; private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"];
public static async Task public static async Task
DeleteMemberAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash) => DeleteMemberAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash, CancellationToken ct = default) =>
await objectStorage.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash)); await objectStorageService.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash), ct);
public static async Task public static async Task
DeleteUserAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash) => DeleteUserAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash, CancellationToken ct = default) =>
await objectStorage.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash)); await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct);
public static async Task<Stream> ConvertBase64UriToAvatar(this string uri) public static async Task<Stream> ConvertBase64UriToAvatar(this string uri)
{ {

View file

@ -1,3 +1,4 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using NodaTime; using NodaTime;
@ -6,16 +7,33 @@ namespace Foxnouns.Backend.Extensions;
public static class KeyCacheExtensions public static class KeyCacheExtensions
{ {
public static async Task<string> GenerateAuthStateAsync(this KeyCacheService keyCacheSvc) public static async Task<string> GenerateAuthStateAsync(this KeyCacheService keyCacheService,
CancellationToken ct = default)
{ {
var state = AuthUtils.RandomToken(); var state = AuthUtils.RandomToken();
await keyCacheSvc.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10)); await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct);
return state; return state;
} }
public static async Task ValidateAuthStateAsync(this KeyCacheService keyCacheSvc, string state) public static async Task ValidateAuthStateAsync(this KeyCacheService keyCacheService, string state,
CancellationToken ct = default)
{ {
var val = await keyCacheSvc.GetKeyAsync($"oauth_state:{state}", delete: true); var val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", delete: true, ct);
if (val == null) throw new ApiError.BadRequest("Invalid OAuth state"); if (val == null) throw new ApiError.BadRequest("Invalid OAuth state");
} }
}
public static async Task<string> GenerateRegisterEmailStateAsync(this KeyCacheService keyCacheService, string email,
Snowflake? userId = null, CancellationToken ct = default)
{
var state = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync($"email_state:{state}", new RegisterEmailState(email, userId),
Duration.FromDays(1), ct);
return state;
}
public static async Task<RegisterEmailState?> GetRegisterEmailStateAsync(this KeyCacheService keyCacheService,
string state, CancellationToken ct = default) =>
await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}", delete: true, ct);
}
public record RegisterEmailState(string Email, Snowflake? ExistingUserId);

View file

@ -70,38 +70,43 @@ public static class WebApplicationExtensions
} }
/// <summary> /// <summary>
/// Adds required services to the IServiceCollection. /// Adds required services to the WebApplicationBuilder.
/// This should only add services that are not ASP.NET-related (i.e. no middleware). /// This should only add services that are not ASP.NET-related (i.e. no middleware).
/// </summary> /// </summary>
public static IServiceCollection AddServices(this IServiceCollection services, Config config) public static IServiceCollection AddServices(this WebApplicationBuilder builder, Config config)
{ {
services builder.Host.ConfigureServices((ctx, services) =>
.AddQueue() {
.AddDbContext<DatabaseContext>() services
.AddMetricServer(o => o.Port = config.Logging.MetricsPort) .AddQueue()
.AddMinio(c => .AddMailer(ctx.Configuration)
c.WithEndpoint(config.Storage.Endpoint) .AddDbContext<DatabaseContext>()
.WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey) .AddMetricServer(o => o.Port = config.Logging.MetricsPort)
.Build()) .AddMinio(c =>
.AddSingleton<MetricsCollectionService>() c.WithEndpoint(config.Storage.Endpoint)
.AddSingleton<IClock>(SystemClock.Instance) .WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey)
.AddSnowflakeGenerator() .Build())
.AddScoped<UserRendererService>() .AddSingleton<MetricsCollectionService>()
.AddScoped<MemberRendererService>() .AddSingleton<IClock>(SystemClock.Instance)
.AddScoped<AuthService>() .AddSnowflakeGenerator()
.AddScoped<KeyCacheService>() .AddSingleton<MailService>()
.AddScoped<RemoteAuthService>() .AddScoped<UserRendererService>()
.AddScoped<ObjectStorageService>() .AddScoped<MemberRendererService>()
// Background services .AddScoped<AuthService>()
.AddHostedService<PeriodicTasksService>() .AddScoped<KeyCacheService>()
// Transient jobs .AddScoped<RemoteAuthService>()
.AddTransient<MemberAvatarUpdateInvocable>() .AddScoped<ObjectStorageService>()
.AddTransient<UserAvatarUpdateInvocable>(); // Background services
.AddHostedService<PeriodicTasksService>()
if (!config.Logging.EnableMetrics) // Transient jobs
services.AddHostedService<BackgroundMetricsCollectionService>(); .AddTransient<MemberAvatarUpdateInvocable>()
.AddTransient<UserAvatarUpdateInvocable>();
return services;
if (!config.Logging.EnableMetrics)
services.AddHostedService<BackgroundMetricsCollectionService>();
});
return builder.Services;
} }
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services

View file

@ -6,31 +6,32 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Coravel" Version="5.0.4"/> <PackageReference Include="Coravel" Version="5.0.4" />
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/> <PackageReference Include="Coravel.Mailer" Version="5.0.1" />
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2"/> <PackageReference Include="EFCore.NamingConventions" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7"/> <PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Minio" Version="6.0.3"/> <PackageReference Include="Minio" Version="6.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NodaTime" Version="3.1.11"/> <PackageReference Include="NodaTime" Version="3.1.11" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4" />
<PackageReference Include="Npgsql.Json.NET" Version="8.0.3"/> <PackageReference Include="Npgsql.Json.NET" Version="8.0.3" />
<PackageReference Include="prometheus-net" Version="8.2.1"/> <PackageReference Include="prometheus-net" Version="8.2.1" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/> <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="Sentry.AspNetCore" Version="4.9.0"/> <PackageReference Include="Sentry.AspNetCore" Version="4.9.0" />
<PackageReference Include="Serilog" Version="4.0.1"/> <PackageReference Include="Serilog" Version="4.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/> <PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0"/> <PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5"/> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup> </ItemGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation"> <Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">
@ -39,6 +40,6 @@
</Target> </Target>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="..\.version" LogicalName="version"/> <EmbeddedResource Include="..\.version" LogicalName="version" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -6,7 +6,7 @@ using Foxnouns.Backend.Services;
namespace Foxnouns.Backend.Jobs; namespace Foxnouns.Backend.Jobs;
public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorage, ILogger logger) public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger)
: IInvocable, IInvocableWithPayload<AvatarUpdatePayload> : IInvocable, IInvocableWithPayload<AvatarUpdatePayload>
{ {
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>(); private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>();
@ -36,13 +36,13 @@ public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageServic
image.Seek(0, SeekOrigin.Begin); image.Seek(0, SeekOrigin.Begin);
var prevHash = member.Avatar; var prevHash = member.Avatar;
await objectStorage.PutObjectAsync(Path(id, hash), image, "image/webp"); await objectStorageService.PutObjectAsync(Path(id, hash), image, "image/webp");
member.Avatar = hash; member.Avatar = hash;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
if (prevHash != null && prevHash != hash) if (prevHash != null && prevHash != hash)
await objectStorage.RemoveObjectAsync(Path(id, prevHash)); await objectStorageService.RemoveObjectAsync(Path(id, prevHash));
_logger.Information("Updated avatar for member {MemberId}", id); _logger.Information("Updated avatar for member {MemberId}", id);
} }
@ -69,7 +69,7 @@ public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageServic
return; return;
} }
await objectStorage.RemoveObjectAsync(Path(member.Id, member.Avatar)); await objectStorageService.RemoveObjectAsync(Path(member.Id, member.Avatar));
member.Avatar = null; member.Avatar = null;
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View file

@ -6,7 +6,7 @@ using Foxnouns.Backend.Services;
namespace Foxnouns.Backend.Jobs; namespace Foxnouns.Backend.Jobs;
public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorage, ILogger logger) public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger)
: IInvocable, IInvocableWithPayload<AvatarUpdatePayload> : IInvocable, IInvocableWithPayload<AvatarUpdatePayload>
{ {
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>(); private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>();
@ -36,13 +36,13 @@ public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService
image.Seek(0, SeekOrigin.Begin); image.Seek(0, SeekOrigin.Begin);
var prevHash = user.Avatar; var prevHash = user.Avatar;
await objectStorage.PutObjectAsync(Path(id, hash), image, "image/webp"); await objectStorageService.PutObjectAsync(Path(id, hash), image, "image/webp");
user.Avatar = hash; user.Avatar = hash;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
if (prevHash != null && prevHash != hash) if (prevHash != null && prevHash != hash)
await objectStorage.RemoveObjectAsync(Path(id, prevHash)); await objectStorageService.RemoveObjectAsync(Path(id, prevHash));
_logger.Information("Updated avatar for user {UserId}", id); _logger.Information("Updated avatar for user {UserId}", id);
} }
@ -69,7 +69,7 @@ public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService
return; return;
} }
await objectStorage.RemoveObjectAsync(Path(user.Id, user.Avatar)); await objectStorageService.RemoveObjectAsync(Path(user.Id, user.Avatar));
user.Avatar = null; user.Avatar = null;
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View file

@ -0,0 +1,19 @@
using Coravel.Mailer.Mail;
namespace Foxnouns.Backend.Mailables;
public class AccountCreationMailable(Config config, AccountCreationMailableView view) : Mailable<AccountCreationMailableView>
{
public override void Build()
{
To(view.To)
.From(config.EmailAuth.From!)
.View("~/Views/Mail/AccountCreation.cshtml", view);
}
}
public class AccountCreationMailableView
{
public required string To { get; init; }
public required string Code { get; init; }
}

View file

@ -58,8 +58,7 @@ JsonConvert.DefaultSettings = () => new JsonSerializerSettings
} }
}; };
builder.Services builder.AddServices(config)
.AddServices(config)
.AddCustomMiddleware() .AddCustomMiddleware()
.AddEndpointsApiExplorer() .AddEndpointsApiExplorer()
.AddSwaggerGen(); .AddSwaggerGen();

View file

@ -42,11 +42,11 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
/// This method does <i>not</i> save the resulting user, the caller must still call <see cref="M:Microsoft.EntityFrameworkCore.DbContext.SaveChanges" />. /// This method does <i>not</i> save the resulting user, the caller must still call <see cref="M:Microsoft.EntityFrameworkCore.DbContext.SaveChanges" />.
/// </summary> /// </summary>
public async Task<User> CreateUserWithRemoteAuthAsync(string username, AuthType authType, string remoteId, public async Task<User> CreateUserWithRemoteAuthAsync(string username, AuthType authType, string remoteId,
string remoteUsername, FediverseApplication? instance = null) string remoteUsername, FediverseApplication? instance = null, CancellationToken ct = default)
{ {
AssertValidAuthType(authType, instance); AssertValidAuthType(authType, instance);
if (await db.Users.AnyAsync(u => u.Username == username)) if (await db.Users.AnyAsync(u => u.Username == username, ct))
throw new ApiError.BadRequest("Username is already taken", "username", username); throw new ApiError.BadRequest("Username is already taken", "username", username);
var user = new User var user = new User
@ -76,20 +76,20 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
/// <returns>A tuple of the authenticated user and whether multi-factor authentication is required</returns> /// <returns>A tuple of the authenticated user and whether multi-factor authentication is required</returns>
/// <exception cref="ApiError.NotFound">Thrown if the email address is not associated with any user /// <exception cref="ApiError.NotFound">Thrown if the email address is not associated with any user
/// or if the password is incorrect</exception> /// or if the password is incorrect</exception>
public async Task<(User, EmailAuthenticationResult)> AuthenticateUserAsync(string email, string password) public async Task<(User, EmailAuthenticationResult)> AuthenticateUserAsync(string email, string password, CancellationToken ct = default)
{ {
var user = await db.Users.FirstOrDefaultAsync(u => var user = await db.Users.FirstOrDefaultAsync(u =>
u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email)); u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email), ct);
if (user == null) if (user == null)
throw new ApiError.NotFound("No user with that email address found, or password is incorrect"); throw new ApiError.NotFound("No user with that email address found, or password is incorrect");
var pwResult = await Task.Run(() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password)); var pwResult = await Task.Run(() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password), ct);
if (pwResult == PasswordVerificationResult.Failed) if (pwResult == PasswordVerificationResult.Failed)
throw new ApiError.NotFound("No user with that email address found, or password is incorrect"); throw new ApiError.NotFound("No user with that email address found, or password is incorrect");
if (pwResult == PasswordVerificationResult.SuccessRehashNeeded) if (pwResult == PasswordVerificationResult.SuccessRehashNeeded)
{ {
user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password)); user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct);
await db.SaveChangesAsync(); await db.SaveChangesAsync(ct);
} }
return (user, EmailAuthenticationResult.AuthSuccessful); return (user, EmailAuthenticationResult.AuthSuccessful);
@ -108,17 +108,38 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
/// <param name="remoteId">The remote user ID</param> /// <param name="remoteId">The remote user ID</param>
/// <param name="instance">The Fediverse instance, if authType is Fediverse. /// <param name="instance">The Fediverse instance, if authType is Fediverse.
/// Will throw an exception if passed with another authType.</param> /// Will throw an exception if passed with another authType.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A user object, or null if the remote account isn't linked to any user.</returns> /// <returns>A user object, or null if the remote account isn't linked to any user.</returns>
/// <exception cref="FoxnounsError">Thrown if <c>instance</c> is passed when not required, /// <exception cref="FoxnounsError">Thrown if <c>instance</c> is passed when not required,
/// or not passed when required</exception> /// or not passed when required</exception>
public async Task<User?> AuthenticateUserAsync(AuthType authType, string remoteId, public async Task<User?> AuthenticateUserAsync(AuthType authType, string remoteId,
FediverseApplication? instance = null) FediverseApplication? instance = null, CancellationToken ct = default)
{ {
AssertValidAuthType(authType, instance); AssertValidAuthType(authType, instance);
return await db.Users.FirstOrDefaultAsync(u => return await db.Users.FirstOrDefaultAsync(u =>
u.AuthMethods.Any(a => u.AuthMethods.Any(a =>
a.AuthType == authType && a.RemoteId == remoteId && a.FediverseApplication == instance)); a.AuthType == authType && a.RemoteId == remoteId && a.FediverseApplication == instance), ct);
}
public async Task<AuthMethod> AddAuthMethodAsync(Snowflake userId, AuthType authType, string remoteId,
string? remoteUsername = null,
CancellationToken ct = default)
{
AssertValidAuthType(authType, null);
var authMethod = new AuthMethod
{
Id = snowflakeGenerator.GenerateSnowflake(),
AuthType = authType,
RemoteId = remoteId,
RemoteUsername = remoteUsername,
UserId = userId
};
db.Add(authMethod);
await db.SaveChangesAsync(ct);
return authMethod;
} }
public (string, Token) GenerateToken(User user, Application application, string[] scopes, Instant expires) public (string, Token) GenerateToken(User user, Application application, string[] scopes, Instant expires)

View file

@ -11,10 +11,10 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
{ {
private readonly ILogger _logger = logger.ForContext<KeyCacheService>(); private readonly ILogger _logger = logger.ForContext<KeyCacheService>();
public Task SetKeyAsync(string key, string value, Duration expireAfter) => public Task SetKeyAsync(string key, string value, Duration expireAfter, CancellationToken ct = default) =>
SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter); SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter, ct);
public async Task SetKeyAsync(string key, string value, Instant expires) public async Task SetKeyAsync(string key, string value, Instant expires, CancellationToken ct = default)
{ {
db.TemporaryKeys.Add(new TemporaryKey db.TemporaryKeys.Add(new TemporaryKey
{ {
@ -22,18 +22,18 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
Key = key, Key = key,
Value = value, Value = value,
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync(ct);
} }
public async Task<string?> GetKeyAsync(string key, bool delete = false) public async Task<string?> GetKeyAsync(string key, bool delete = false, CancellationToken ct = default)
{ {
var value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key); var value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key, ct);
if (value == null) return null; if (value == null) return null;
if (delete) if (delete)
{ {
await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(); await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct);
await db.SaveChangesAsync(); await db.SaveChangesAsync(ct);
} }
return value.Value; return value.Value;
@ -45,18 +45,18 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
if (count != 0) _logger.Information("Removed {Count} expired keys from the database", count); if (count != 0) _logger.Information("Removed {Count} expired keys from the database", count);
} }
public Task SetKeyAsync<T>(string key, T obj, Duration expiresAt) where T : class => public Task SetKeyAsync<T>(string key, T obj, Duration expiresAt, CancellationToken ct = default) where T : class =>
SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt); SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct);
public async Task SetKeyAsync<T>(string key, T obj, Instant expires) where T : class public async Task SetKeyAsync<T>(string key, T obj, Instant expires, CancellationToken ct = default) where T : class
{ {
var value = JsonConvert.SerializeObject(obj); var value = JsonConvert.SerializeObject(obj);
await SetKeyAsync(key, value, expires); await SetKeyAsync(key, value, expires, ct);
} }
public async Task<T?> GetKeyAsync<T>(string key, bool delete = false) where T : class public async Task<T?> GetKeyAsync<T>(string key, bool delete = false, CancellationToken ct = default) where T : class
{ {
var value = await GetKeyAsync(key, delete: false); var value = await GetKeyAsync(key, delete, ct);
return value == null ? default : JsonConvert.DeserializeObject<T>(value); return value == null ? default : JsonConvert.DeserializeObject<T>(value);
} }
} }

View file

@ -0,0 +1,24 @@
using Coravel.Mailer.Mail.Interfaces;
using Coravel.Queuing.Interfaces;
using Foxnouns.Backend.Mailables;
namespace Foxnouns.Backend.Services;
public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config config)
{
private readonly ILogger _logger = logger.ForContext<MailService>();
public void QueueAccountCreationEmail(string to, string code)
{
_logger.Debug("Sending account creation email to {ToEmail}", to);
queue.QueueAsyncTask(async () =>
{
await mailer.SendAsync(new AccountCreationMailable(config, new AccountCreationMailableView
{
To = to,
Code = code
}));
});
}
}

View file

@ -47,7 +47,7 @@ public class MetricsCollectionService(
} }
} }
public class BackgroundMetricsCollectionService(ILogger logger, MetricsCollectionService innerService) public class BackgroundMetricsCollectionService(ILogger logger, MetricsCollectionService metricsCollectionService)
: BackgroundService : BackgroundService
{ {
private readonly ILogger _logger = logger.ForContext<BackgroundMetricsCollectionService>(); private readonly ILogger _logger = logger.ForContext<BackgroundMetricsCollectionService>();
@ -60,7 +60,7 @@ public class BackgroundMetricsCollectionService(ILogger logger, MetricsCollectio
while (await timer.WaitForNextTickAsync(ct)) while (await timer.WaitForNextTickAsync(ct))
{ {
_logger.Debug("Collecting metrics"); _logger.Debug("Collecting metrics");
await innerService.CollectMetricsAsync(ct); await metricsCollectionService.CollectMetricsAsync(ct);
} }
} }
} }

View file

@ -4,16 +4,18 @@ using Minio.Exceptions;
namespace Foxnouns.Backend.Services; namespace Foxnouns.Backend.Services;
public class ObjectStorageService(ILogger logger, Config config, IMinioClient minio) public class ObjectStorageService(ILogger logger, Config config, IMinioClient minioClient)
{ {
private readonly ILogger _logger = logger.ForContext<ObjectStorageService>(); private readonly ILogger _logger = logger.ForContext<ObjectStorageService>();
public async Task RemoveObjectAsync(string path) public async Task RemoveObjectAsync(string path, CancellationToken ct = default)
{ {
_logger.Debug("Deleting object at path {Path}", path); _logger.Debug("Deleting object at path {Path}", path);
try try
{ {
await minio.RemoveObjectAsync(new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path)); await minioClient.RemoveObjectAsync(
new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path),
ct);
} }
catch (InvalidObjectNameException) catch (InvalidObjectNameException)
{ {
@ -21,17 +23,17 @@ public class ObjectStorageService(ILogger logger, Config config, IMinioClient mi
} }
} }
public async Task PutObjectAsync(string path, Stream data, string contentType) public async Task PutObjectAsync(string path, Stream data, string contentType, CancellationToken ct = default)
{ {
_logger.Debug("Putting object at path {Path} with length {Length} and content type {ContentType}", path, _logger.Debug("Putting object at path {Path} with length {Length} and content type {ContentType}", path,
data.Length, contentType); data.Length, contentType);
await minio.PutObjectAsync(new PutObjectArgs() await minioClient.PutObjectAsync(new PutObjectArgs()
.WithBucket(config.Storage.Bucket) .WithBucket(config.Storage.Bucket)
.WithObject(path) .WithObject(path)
.WithObjectSize(data.Length) .WithObjectSize(data.Length)
.WithStreamData(data) .WithStreamData(data)
.WithContentType(contentType) .WithContentType(contentType), ct
); );
} }
} }

View file

@ -10,7 +10,7 @@ public class RemoteAuthService(Config config)
private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token"); private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token");
private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me"); private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me");
public async Task<RemoteUser> RequestDiscordTokenAsync(string code, string state) public async Task<RemoteUser> RequestDiscordTokenAsync(string code, string state, CancellationToken ct = default)
{ {
var redirectUri = $"{config.BaseUrl}/auth/login/discord"; var redirectUri = $"{config.BaseUrl}/auth/login/discord";
var resp = await _httpClient.PostAsync(_discordTokenUri, new FormUrlEncodedContent( var resp = await _httpClient.PostAsync(_discordTokenUri, new FormUrlEncodedContent(
@ -22,17 +22,17 @@ public class RemoteAuthService(Config config)
{ "code", code }, { "code", code },
{ "redirect_uri", redirectUri } { "redirect_uri", redirectUri }
} }
)); ), ct);
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
var token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(); var token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(ct);
if (token == null) throw new FoxnounsError("Discord token response was null"); if (token == null) throw new FoxnounsError("Discord token response was null");
var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri); var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri);
req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}"); req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}");
var resp2 = await _httpClient.SendAsync(req); var resp2 = await _httpClient.SendAsync(req, ct);
resp2.EnsureSuccessStatusCode(); resp2.EnsureSuccessStatusCode();
var user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(); var user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(ct);
if (user == null) throw new FoxnounsError("Discord user response was null"); if (user == null) throw new FoxnounsError("Discord user response was null");
return new RemoteUser(user.id, user.username); return new RemoteUser(user.id, user.username);

View file

@ -7,7 +7,7 @@ using NodaTime;
namespace Foxnouns.Backend.Services; namespace Foxnouns.Backend.Services;
public class UserRendererService(DatabaseContext db, MemberRendererService memberRendererService, Config config) public class UserRendererService(DatabaseContext db, MemberRendererService memberRenderer, Config config)
{ {
public async Task<UserResponse> RenderUserAsync(User user, User? selfUser = null, public async Task<UserResponse> RenderUserAsync(User user, User? selfUser = null,
Token? token = null, Token? token = null,
@ -39,7 +39,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
return new UserResponse( return new UserResponse(
user.Id, user.Username, user.DisplayName, user.Bio, user.MemberTitle, AvatarUrlFor(user), user.Links, user.Id, user.Username, user.DisplayName, user.Bio, user.MemberTitle, AvatarUrlFor(user), user.Links,
user.Names, user.Pronouns, user.Fields, user.CustomPreferences, user.Names, user.Pronouns, user.Fields, user.CustomPreferences,
renderMembers ? members.Select(memberRendererService.RenderPartialMember) : null, renderMembers ? members.Select(memberRenderer.RenderPartialMember) : null,
renderAuthMethods renderAuthMethods
? authMethods.Select(a => new AuthenticationMethodResponse( ? authMethods.Select(a => new AuthenticationMethodResponse(
a.Id, a.AuthType, a.RemoteId, a.Id, a.AuthType, a.RemoteId,

View file

@ -0,0 +1,15 @@
@{
ViewBag.Heading = "Welcome!";
ViewBag.Preview = "Example Email";
}
<p>
Let's see what you can build!
To render a button inside your email, use the EmailLinkButton component:
@await Component.InvokeAsync("EmailLinkButton", new { text = "Click Me!", url = "https://www.google.com" })
</p>
@section links
{
<a href="https://github.com/jamesmh/coravel">Coravel</a>
}

View file

@ -0,0 +1,3 @@
@using Foxnouns.Backend
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Coravel.Mailer.ViewComponents

View file

@ -0,0 +1,3 @@
@{
Layout = "~/Areas/Coravel/Pages/Mail/Template.cshtml";
}

View file

@ -41,6 +41,18 @@ AccessKey = <s3AccessKey>
SecretKey = <s3SecretKey> SecretKey = <s3SecretKey>
Bucket = pronounscc Bucket = pronounscc
[EmailAuth]
; The address that emails will be sent from. If not set, email auth is disabled.
From = noreply@accounts.pronouns.cc
; The Coravel mail driver configuration. Keys should be self-explanatory.
[Coravel.Mail]
Driver = SMTP
Host = localhost
Port = 1025
Username = smtp-username
Password = smtp-password
[DiscordAuth] [DiscordAuth]
ClientId = <clientIdHere> ClientId = <clientIdHere>
ClientSecret = <clientSecretHere> ClientSecret = <clientSecretHere>

View file

@ -2,10 +2,22 @@
## C# code style ## C# code style
Code should be formatted with `dotnet format` or Rider's built-in formatter. - Code should be formatted with `dotnet format` or Rider's built-in formatter.
Variables should *always* be declared using `var`, unless the correct type - Variables should *always* be declared using `var`,
can't be inferred from the declaration (i.e. if the variable needs to be an unless the correct type can't be inferred from the declaration (i.e. if the variable needs to be an `IEnumerable<T>`
`IEnumerable<T>` instead of a `List<T>`, or if a variable is initialized as `null`). instead of a `List<T>`, or if a variable is initialized as `null`).
### Naming
- Service values should be named the same as the type, but camel case, if the name of the service does *not*
in a verb (i.e. a variable of type `KeyCacheService` should be named `keyCacheService`).
If the name of the service *does* end in a verb, the final "service" should be omitted
(i.e. a variable of type `UserRendererService` should be named `userRenderer`).
- Interface values should be named the same as the type, but camel case, without the leading `I`
(i.e. a variable of type `ISnowflakeGenerator` should be named `snowflakeGenerator`).
- Values of type `DatabaseContext` should always be named `db`.
There are some exceptions to this. For example Sentry's `IHub` should be named `sentry` as the name `hub` isn't clear.
## TypeScript code style ## TypeScript code style