From 344a0071e5492f236c386854b4feeff8dadd2128 Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 9 Sep 2024 14:37:59 +0200 Subject: [PATCH 1/2] start (actual) email auth, add CancellationToken to most async methods --- Foxnouns.Backend/Config.cs | 8 +++ .../Authentication/AuthController.cs | 4 +- .../Authentication/DiscordAuthController.cs | 30 ++++----- .../Authentication/EmailAuthController.cs | 53 ++++++++++++++-- .../Controllers/MembersController.cs | 20 +++--- .../Controllers/UsersController.cs | 18 +++--- .../Database/DatabaseQueryExtensions.cs | 29 +++++---- .../Extensions/AvatarObjectExtensions.cs | 8 +-- .../Extensions/KeyCacheExtensions.cs | 26 ++++++-- .../Extensions/WebApplicationExtensions.cs | 63 ++++++++++--------- Foxnouns.Backend/Foxnouns.Backend.csproj | 45 ++++++------- .../Mailables/AccountCreationMailable.cs | 19 ++++++ Foxnouns.Backend/Program.cs | 3 +- Foxnouns.Backend/Services/AuthService.cs | 39 +++++++++--- Foxnouns.Backend/Services/KeyCacheService.cs | 28 ++++----- Foxnouns.Backend/Services/MailService.cs | 24 +++++++ .../Services/ObjectStorageService.cs | 17 ++--- .../Services/RemoteAuthService.cs | 10 +-- Foxnouns.Backend/Views/Mail/Example.cshtml | 15 +++++ .../Views/Mail/_ViewImports.cshtml | 3 + Foxnouns.Backend/Views/Mail/_ViewStart.cshtml | 3 + Foxnouns.Backend/config.example.ini | 12 ++++ 22 files changed, 325 insertions(+), 152 deletions(-) create mode 100644 Foxnouns.Backend/Mailables/AccountCreationMailable.cs create mode 100644 Foxnouns.Backend/Services/MailService.cs create mode 100644 Foxnouns.Backend/Views/Mail/Example.cshtml create mode 100644 Foxnouns.Backend/Views/Mail/_ViewImports.cshtml create mode 100644 Foxnouns.Backend/Views/Mail/_ViewStart.cshtml diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index 132a28c..96d724b 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Serilog.Events; namespace Foxnouns.Backend; @@ -15,6 +16,7 @@ public class Config public LoggingConfig Logging { get; init; } = new(); public DatabaseConfig Database { get; init; } = new(); public StorageConfig Storage { get; init; } = new(); + public EmailAuthConfig EmailAuth { get; init; } = new(); public DiscordAuthConfig DiscordAuth { get; init; } = new(); public GoogleAuthConfig GoogleAuth { get; init; } = new(); public TumblrAuthConfig TumblrAuth { get; init; } = new(); @@ -46,6 +48,12 @@ public class Config public string Bucket { get; init; } = string.Empty; } + public class EmailAuthConfig + { + public bool Enabled => From != null; + public string? From { get; init; } + } + public class DiscordAuthConfig { public bool Enabled => ClientId != null && ClientSecret != null; diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index db2e21f..2a0f3d4 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -13,13 +13,13 @@ public class AuthController(Config config, KeyCacheService keyCacheSvc, ILogger [HttpPost("urls")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task UrlsAsync() + public async Task UrlsAsync(CancellationToken ct = default) { _logger.Debug("Generating auth URLs for Discord: {Discord}, Google: {Google}, Tumblr: {Tumblr}", config.DiscordAuth.Enabled, config.GoogleAuth.Enabled, config.TumblrAuth.Enabled); - var state = HttpUtility.UrlEncode(await keyCacheSvc.GenerateAuthStateAsync()); + var state = HttpUtility.UrlEncode(await keyCacheSvc.GenerateAuthStateAsync(ct)); string? discord = null; if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) discord = diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index d717aba..6729fc0 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -27,31 +27,31 @@ public class DiscordAuthController( // leaving it here for documentation purposes [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task CallbackAsync([FromBody] AuthController.CallbackRequest req) + public async Task CallbackAsync([FromBody] AuthController.CallbackRequest req, CancellationToken ct = default) { CheckRequirements(); - await keyCacheSvc.ValidateAuthStateAsync(req.State); + await keyCacheSvc.ValidateAuthStateAsync(req.State, ct); - var remoteUser = await remoteAuthSvc.RequestDiscordTokenAsync(req.Code, req.State); - var user = await authSvc.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id); - if (user != null) return Ok(await GenerateUserTokenAsync(user)); + var remoteUser = await remoteAuthSvc.RequestDiscordTokenAsync(req.Code, req.State, ct); + var user = await authSvc.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id, ct: ct); + if (user != null) return Ok(await GenerateUserTokenAsync(user,ct)); _logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username, remoteUser.Id); var ticket = AuthUtils.RandomToken(); - await keyCacheSvc.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20)); + await keyCacheSvc.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20), ct); return Ok(new AuthController.CallbackResponse(false, ticket, remoteUser.Username)); } [HttpPost("register")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task RegisterAsync([FromBody] AuthController.OauthRegisterRequest req) + public async Task RegisterAsync([FromBody] AuthController.OauthRegisterRequest req, CancellationToken ct = default) { - var remoteUser = await keyCacheSvc.GetKeyAsync($"discord:{req.Ticket}"); + var remoteUser = await keyCacheSvc.GetKeyAsync($"discord:{req.Ticket}",ct:ct); 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", remoteUser.Id); @@ -59,14 +59,14 @@ public class DiscordAuthController( } var user = await authSvc.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 GenerateUserTokenAsync(User user) + private async Task 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); var (tokenStr, token) = @@ -75,10 +75,10 @@ public class DiscordAuthController( _logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id); - await db.SaveChangesAsync(); + await db.SaveChangesAsync(ct); return new AuthController.AuthResponse( - await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false), + await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), tokenStr, token.ExpiresAt ); diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index 19dfc2f..317d62c 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -1,6 +1,10 @@ using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; +using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; @@ -9,21 +13,56 @@ namespace Foxnouns.Backend.Controllers.Authentication; public class EmailAuthController( DatabaseContext db, AuthService authSvc, + MailService mailSvc, + KeyCacheService keyCacheSvc, UserRendererService userRendererSvc, IClock clock, ILogger logger) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); + [HttpPost("register")] + public async Task RegisterAsync([FromBody] RegisterRequest req, CancellationToken ct = default) + { + if (!req.Email.Contains('@')) throw new ApiError.BadRequest("Email is invalid", "email", req.Email); + + var state = await keyCacheSvc.GenerateRegisterEmailStateAsync(req.Email, userId: null, ct); + if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Email && a.RemoteId == req.Email, ct)) + return NoContent(); + + mailSvc.QueueAccountCreationEmail(req.Email, state); + return NoContent(); + } + + [HttpPost("callback")] + public async Task CallbackAsync([FromBody] CallbackRequest req, CancellationToken ct = default) + { + var state = await keyCacheSvc.GetRegisterEmailStateAsync(req.State, ct); + if (state == null) throw new ApiError.BadRequest("Invalid state", "state", req.State); + + if (state.ExistingUserId != null) + { + var authMethod = + await authSvc.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 keyCacheSvc.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); + + return Ok(new AuthController.CallbackResponse(false, ticket, state.Email)); + } + [HttpPost("login")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task LoginAsync([FromBody] LoginRequest req) + public async Task LoginAsync([FromBody] LoginRequest req, CancellationToken ct = default) { - var (user, authenticationResult) = await authSvc.AuthenticateUserAsync(req.Email, req.Password); + var (user, authenticationResult) = await authSvc.AuthenticateUserAsync(req.Email, req.Password, ct); if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired) 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); @@ -33,14 +72,18 @@ public class EmailAuthController( _logger.Debug("Generated token {TokenId} for {UserId}", token.Id, user.Id); - await db.SaveChangesAsync(); + await db.SaveChangesAsync(ct); return Ok(new AuthController.AuthResponse( - await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false), + await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), tokenStr, token.ExpiresAt )); } public record LoginRequest(string Email, string Password); + + public record RegisterRequest(string Email); + + public record CallbackRequest(string State); } \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index 19a9569..5f09e35 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -25,24 +25,24 @@ public class MembersController( [HttpGet] [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task GetMembersAsync(string userRef) + public async Task 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)); } [HttpGet("{memberRef}")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task GetMemberAsync(string userRef, string memberRef) + public async Task 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)); } [HttpPost("/api/v2/users/@me/members")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize("member.create")] - public async Task CreateMemberAsync([FromBody] CreateMemberRequest req) + public async Task CreateMemberAsync([FromBody] CreateMemberRequest req, CancellationToken ct = default) { ValidationUtils.Validate([ ("name", ValidationUtils.ValidateMemberName(req.Name)), @@ -71,7 +71,7 @@ public class MembersController( try { - await db.SaveChangesAsync(); + await db.SaveChangesAsync(ct); } catch (UniqueConstraintException) { @@ -88,18 +88,18 @@ public class MembersController( [HttpDelete("/api/v2/users/@me/members/{memberRef}")] [Authorize("member.update")] - public async Task DeleteMemberAsync(string memberRef) + public async Task 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) - .ExecuteDeleteAsync(); + .ExecuteDeleteAsync(ct); if (deleteCount == 0) { _logger.Warning("Successfully resolved member {Id} but could not delete them", member.Id); return NoContent(); } - await db.SaveChangesAsync(); + await db.SaveChangesAsync(ct); if (member.Avatar != null) await objectStorage.DeleteMemberAvatarAsync(member.Id, member.Avatar); return NoContent(); diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index a2c6219..8b9299a 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -36,10 +36,10 @@ public class UsersController( [HttpPatch("@me")] [Authorize("user.update")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public async Task UpdateUserAsync([FromBody] UpdateUserRequest req) + public async Task UpdateUserAsync([FromBody] UpdateUserRequest req, CancellationToken ct = default) { - await using var tx = await db.Database.BeginTransactionAsync(); - var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id); + await using var tx = await db.Database.BeginTransactionAsync(ct); + var 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) @@ -76,20 +76,20 @@ public class UsersController( queue.QueueInvocableWithPayload( new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)); - await db.SaveChangesAsync(); - await tx.CommitAsync(); + await db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); return Ok(await userRendererService.RenderUserAsync(user, CurrentUser, renderMembers: false, - renderAuthMethods: false)); + renderAuthMethods: false, ct: ct)); } [HttpPatch("@me/custom-preferences")] [Authorize("user.update")] [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task UpdateCustomPreferencesAsync([FromBody] List req) + public async Task UpdateCustomPreferencesAsync([FromBody] List req, CancellationToken ct = default) { 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(); foreach (var r in req) @@ -119,7 +119,7 @@ public class UsersController( } user.CustomPreferences = preferences; - await db.SaveChangesAsync(); + await db.SaveChangesAsync(ct); return Ok(user.CustomPreferences); } diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs index b6ccaaa..76043d7 100644 --- a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs +++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs @@ -36,34 +36,36 @@ public static class DatabaseQueryExtensions throw new ApiError.NotFound("No user with that ID or username found.", code: ErrorCode.UserNotFound); } - public static async Task ResolveUserAsync(this DatabaseContext context, Snowflake id) + public static async Task ResolveUserAsync(this DatabaseContext context, Snowflake id, + CancellationToken ct = default) { var user = await context.Users .Where(u => !u.Deleted) - .FirstOrDefaultAsync(u => u.Id == id); + .FirstOrDefaultAsync(u => u.Id == id, ct); if (user != null) return user; throw new ApiError.NotFound("No user with that ID found.", code: ErrorCode.UserNotFound); } - public static async Task ResolveMemberAsync(this DatabaseContext context, Snowflake id) + public static async Task ResolveMemberAsync(this DatabaseContext context, Snowflake id, + CancellationToken ct = default) { var member = await context.Members .Include(m => m.User) .Where(m => !m.User.Deleted) - .FirstOrDefaultAsync(m => m.Id == id); + .FirstOrDefaultAsync(m => m.Id == id, ct); if (member != null) return member; throw new ApiError.NotFound("No member with that ID found.", code: ErrorCode.MemberNotFound); } public static async Task ResolveMemberAsync(this DatabaseContext context, string userRef, string memberRef, - Token? token) + Token? token, CancellationToken ct = default) { - var user = await context.ResolveUserAsync(userRef, token); - return await context.ResolveMemberAsync(user.Id, memberRef); + var user = await context.ResolveUserAsync(userRef, token, ct); + return await context.ResolveMemberAsync(user.Id, memberRef, ct); } public static async Task ResolveMemberAsync(this DatabaseContext context, Snowflake userId, - string memberRef) + string memberRef, CancellationToken ct = default) { Member? member; if (Snowflake.TryParse(memberRef, out var snowflake)) @@ -71,21 +73,22 @@ public static class DatabaseQueryExtensions member = await context.Members .Include(m => m.User) .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; } member = await context.Members .Include(m => m.User) .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; throw new ApiError.NotFound("No member with that ID or name found.", code: ErrorCode.MemberNotFound); } - public static async Task GetFrontendApplicationAsync(this DatabaseContext context) + public static async Task 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; app = new Application @@ -99,7 +102,7 @@ public static class DatabaseQueryExtensions }; context.Add(app); - await context.SaveChangesAsync(); + await context.SaveChangesAsync(ct); return app; } } \ No newline at end of file diff --git a/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs b/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs index f7c2b6f..2d0fd16 100644 --- a/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs +++ b/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs @@ -14,12 +14,12 @@ public static class AvatarObjectExtensions private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"]; public static async Task - DeleteMemberAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash) => - await objectStorage.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash)); + DeleteMemberAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash, CancellationToken ct = default) => + await objectStorage.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash), ct); public static async Task - DeleteUserAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash) => - await objectStorage.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash)); + DeleteUserAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash, CancellationToken ct = default) => + await objectStorage.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); public static async Task ConvertBase64UriToAvatar(this string uri) { diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index 3b8e35c..eb1cecc 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -1,3 +1,4 @@ +using Foxnouns.Backend.Database; using Foxnouns.Backend.Services; using Foxnouns.Backend.Utils; using NodaTime; @@ -6,16 +7,31 @@ namespace Foxnouns.Backend.Extensions; public static class KeyCacheExtensions { - public static async Task GenerateAuthStateAsync(this KeyCacheService keyCacheSvc) + public static async Task GenerateAuthStateAsync(this KeyCacheService keyCacheSvc, CancellationToken ct = default) { var state = AuthUtils.RandomToken(); - await keyCacheSvc.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10)); + await keyCacheSvc.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); return state; } - public static async Task ValidateAuthStateAsync(this KeyCacheService keyCacheSvc, string state) + public static async Task ValidateAuthStateAsync(this KeyCacheService keyCacheSvc, string state, CancellationToken ct = default) { - var val = await keyCacheSvc.GetKeyAsync($"oauth_state:{state}", delete: true); + var val = await keyCacheSvc.GetKeyAsync($"oauth_state:{state}", delete: true, ct); if (val == null) throw new ApiError.BadRequest("Invalid OAuth state"); } -} \ No newline at end of file + + public static async Task GenerateRegisterEmailStateAsync(this KeyCacheService keyCacheSvc, string email, + Snowflake? userId = null, CancellationToken ct = default) + { + var state = AuthUtils.RandomToken(); + await keyCacheSvc.SetKeyAsync($"email_state:{state}", new RegisterEmailState(email, userId), + Duration.FromDays(1), ct); + return state; + } + + public static async Task GetRegisterEmailStateAsync(this KeyCacheService keyCacheSvc, + string state, CancellationToken ct = default) => + await keyCacheSvc.GetKeyAsync($"email_state:{state}", delete: true, ct); +} + +public record RegisterEmailState(string Email, Snowflake? ExistingUserId); \ No newline at end of file diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 1f1ee31..014eeb1 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -70,38 +70,43 @@ public static class WebApplicationExtensions } /// - /// 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). /// - public static IServiceCollection AddServices(this IServiceCollection services, Config config) + public static IServiceCollection AddServices(this WebApplicationBuilder builder, Config config) { - services - .AddQueue() - .AddDbContext() - .AddMetricServer(o => o.Port = config.Logging.MetricsPort) - .AddMinio(c => - c.WithEndpoint(config.Storage.Endpoint) - .WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey) - .Build()) - .AddSingleton() - .AddSingleton(SystemClock.Instance) - .AddSnowflakeGenerator() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - // Background services - .AddHostedService() - // Transient jobs - .AddTransient() - .AddTransient(); - - if (!config.Logging.EnableMetrics) - services.AddHostedService(); - - return services; + builder.Host.ConfigureServices((ctx, services) => + { + services + .AddQueue() + .AddMailer(ctx.Configuration) + .AddDbContext() + .AddMetricServer(o => o.Port = config.Logging.MetricsPort) + .AddMinio(c => + c.WithEndpoint(config.Storage.Endpoint) + .WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey) + .Build()) + .AddSingleton() + .AddSingleton(SystemClock.Instance) + .AddSnowflakeGenerator() + .AddSingleton() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + // Background services + .AddHostedService() + // Transient jobs + .AddTransient() + .AddTransient(); + + if (!config.Logging.EnableMetrics) + services.AddHostedService(); + }); + + return builder.Services; } public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index c22fdf4..92abc6a 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -6,31 +6,32 @@ - - - - - - + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + @@ -39,6 +40,6 @@ - + diff --git a/Foxnouns.Backend/Mailables/AccountCreationMailable.cs b/Foxnouns.Backend/Mailables/AccountCreationMailable.cs new file mode 100644 index 0000000..b55c9e6 --- /dev/null +++ b/Foxnouns.Backend/Mailables/AccountCreationMailable.cs @@ -0,0 +1,19 @@ +using Coravel.Mailer.Mail; + +namespace Foxnouns.Backend.Mailables; + +public class AccountCreationMailable(Config config, AccountCreationMailableView view) : Mailable +{ + 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; } +} \ No newline at end of file diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index 911c895..65e508a 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -58,8 +58,7 @@ JsonConvert.DefaultSettings = () => new JsonSerializerSettings } }; -builder.Services - .AddServices(config) +builder.AddServices(config) .AddCustomMiddleware() .AddEndpointsApiExplorer() .AddSwaggerGen(); diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index c75f926..8d6052d 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -42,11 +42,11 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// This method does not save the resulting user, the caller must still call . /// public async Task CreateUserWithRemoteAuthAsync(string username, AuthType authType, string remoteId, - string remoteUsername, FediverseApplication? instance = null) + string remoteUsername, FediverseApplication? instance = null, CancellationToken ct = default) { 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); var user = new User @@ -76,20 +76,20 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// A tuple of the authenticated user and whether multi-factor authentication is required /// Thrown if the email address is not associated with any user /// or if the password is incorrect - 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 => - 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) 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) throw new ApiError.NotFound("No user with that email address found, or password is incorrect"); if (pwResult == PasswordVerificationResult.SuccessRehashNeeded) { - user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password)); - await db.SaveChangesAsync(); + user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); + await db.SaveChangesAsync(ct); } return (user, EmailAuthenticationResult.AuthSuccessful); @@ -108,17 +108,38 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// The remote user ID /// The Fediverse instance, if authType is Fediverse. /// Will throw an exception if passed with another authType. + /// Cancellation token. /// A user object, or null if the remote account isn't linked to any user. /// Thrown if instance is passed when not required, /// or not passed when required public async Task AuthenticateUserAsync(AuthType authType, string remoteId, - FediverseApplication? instance = null) + FediverseApplication? instance = null, CancellationToken ct = default) { AssertValidAuthType(authType, instance); return await db.Users.FirstOrDefaultAsync(u => 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 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) diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs index bd8a862..d8c5434 100644 --- a/Foxnouns.Backend/Services/KeyCacheService.cs +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -11,10 +11,10 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) { private readonly ILogger _logger = logger.ForContext(); - public Task SetKeyAsync(string key, string value, Duration expireAfter) => - SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter); + public Task SetKeyAsync(string key, string value, Duration expireAfter, CancellationToken ct = default) => + 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 { @@ -22,18 +22,18 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) Key = key, Value = value, }); - await db.SaveChangesAsync(); + await db.SaveChangesAsync(ct); } - public async Task GetKeyAsync(string key, bool delete = false) + public async Task 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 (delete) { - await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(); - await db.SaveChangesAsync(); + await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); + await db.SaveChangesAsync(ct); } 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); } - public Task SetKeyAsync(string key, T obj, Duration expiresAt) where T : class => - SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt); + public Task SetKeyAsync(string key, T obj, Duration expiresAt, CancellationToken ct = default) where T : class => + SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct); - public async Task SetKeyAsync(string key, T obj, Instant expires) where T : class + public async Task SetKeyAsync(string key, T obj, Instant expires, CancellationToken ct = default) where T : class { var value = JsonConvert.SerializeObject(obj); - await SetKeyAsync(key, value, expires); + await SetKeyAsync(key, value, expires, ct); } - public async Task GetKeyAsync(string key, bool delete = false) where T : class + public async Task GetKeyAsync(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(value); } } \ No newline at end of file diff --git a/Foxnouns.Backend/Services/MailService.cs b/Foxnouns.Backend/Services/MailService.cs new file mode 100644 index 0000000..271d41c --- /dev/null +++ b/Foxnouns.Backend/Services/MailService.cs @@ -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(); + + 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 + })); + }); + } +} \ No newline at end of file diff --git a/Foxnouns.Backend/Services/ObjectStorageService.cs b/Foxnouns.Backend/Services/ObjectStorageService.cs index de60074..45e9678 100644 --- a/Foxnouns.Backend/Services/ObjectStorageService.cs +++ b/Foxnouns.Backend/Services/ObjectStorageService.cs @@ -8,12 +8,13 @@ public class ObjectStorageService(ILogger logger, Config config, IMinioClient mi { private readonly ILogger _logger = logger.ForContext(); - public async Task RemoveObjectAsync(string path) + public async Task RemoveObjectAsync(string path, CancellationToken ct = default) { _logger.Debug("Deleting object at path {Path}", path); try { - await minio.RemoveObjectAsync(new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path)); + await minio.RemoveObjectAsync(new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path), + ct); } catch (InvalidObjectNameException) { @@ -21,17 +22,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, data.Length, contentType); await minio.PutObjectAsync(new PutObjectArgs() - .WithBucket(config.Storage.Bucket) - .WithObject(path) - .WithObjectSize(data.Length) - .WithStreamData(data) - .WithContentType(contentType) + .WithBucket(config.Storage.Bucket) + .WithObject(path) + .WithObjectSize(data.Length) + .WithStreamData(data) + .WithContentType(contentType), ct ); } } \ No newline at end of file diff --git a/Foxnouns.Backend/Services/RemoteAuthService.cs b/Foxnouns.Backend/Services/RemoteAuthService.cs index 48d6b84..389412f 100644 --- a/Foxnouns.Backend/Services/RemoteAuthService.cs +++ b/Foxnouns.Backend/Services/RemoteAuthService.cs @@ -10,7 +10,7 @@ public class RemoteAuthService(Config config) private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token"); private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me"); - public async Task RequestDiscordTokenAsync(string code, string state) + public async Task RequestDiscordTokenAsync(string code, string state, CancellationToken ct = default) { var redirectUri = $"{config.BaseUrl}/auth/login/discord"; var resp = await _httpClient.PostAsync(_discordTokenUri, new FormUrlEncodedContent( @@ -22,17 +22,17 @@ public class RemoteAuthService(Config config) { "code", code }, { "redirect_uri", redirectUri } } - )); + ), ct); resp.EnsureSuccessStatusCode(); - var token = await resp.Content.ReadFromJsonAsync(); + var token = await resp.Content.ReadFromJsonAsync(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); + var resp2 = await _httpClient.SendAsync(req, ct); resp2.EnsureSuccessStatusCode(); - var user = await resp2.Content.ReadFromJsonAsync(); + var user = await resp2.Content.ReadFromJsonAsync(ct); if (user == null) throw new FoxnounsError("Discord user response was null"); return new RemoteUser(user.id, user.username); diff --git a/Foxnouns.Backend/Views/Mail/Example.cshtml b/Foxnouns.Backend/Views/Mail/Example.cshtml new file mode 100644 index 0000000..e1acaaa --- /dev/null +++ b/Foxnouns.Backend/Views/Mail/Example.cshtml @@ -0,0 +1,15 @@ +@{ + ViewBag.Heading = "Welcome!"; + ViewBag.Preview = "Example Email"; +} + +

+ 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" }) +

+ +@section links +{ + Coravel +} diff --git a/Foxnouns.Backend/Views/Mail/_ViewImports.cshtml b/Foxnouns.Backend/Views/Mail/_ViewImports.cshtml new file mode 100644 index 0000000..8c050b2 --- /dev/null +++ b/Foxnouns.Backend/Views/Mail/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using Foxnouns.Backend +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Coravel.Mailer.ViewComponents diff --git a/Foxnouns.Backend/Views/Mail/_ViewStart.cshtml b/Foxnouns.Backend/Views/Mail/_ViewStart.cshtml new file mode 100644 index 0000000..1d54d45 --- /dev/null +++ b/Foxnouns.Backend/Views/Mail/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "~/Areas/Coravel/Pages/Mail/Template.cshtml"; +} diff --git a/Foxnouns.Backend/config.example.ini b/Foxnouns.Backend/config.example.ini index 27e5cbf..edc8a28 100644 --- a/Foxnouns.Backend/config.example.ini +++ b/Foxnouns.Backend/config.example.ini @@ -41,6 +41,18 @@ AccessKey = SecretKey = 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] ClientId = ClientSecret = From c77ee660ca7a3ce48ab49aad4df30cc0de317293 Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 9 Sep 2024 14:50:00 +0200 Subject: [PATCH 2/2] refactor: more consistent field names, also in STYLE.md --- .../Authentication/AuthController.cs | 4 ++-- .../Authentication/DiscordAuthController.cs | 24 +++++++++---------- .../Authentication/EmailAuthController.cs | 24 +++++++++---------- .../Controllers/DebugController.cs | 10 ++++---- .../Controllers/MembersController.cs | 12 +++++----- .../Controllers/UsersController.cs | 6 ++--- .../Extensions/AvatarObjectExtensions.cs | 8 +++---- .../Extensions/KeyCacheExtensions.cs | 18 +++++++------- .../Jobs/MemberAvatarUpdateInvocable.cs | 8 +++---- .../Jobs/UserAvatarUpdateInvocable.cs | 8 +++---- .../Services/MetricsCollectionService.cs | 4 ++-- .../Services/ObjectStorageService.cs | 7 +++--- .../Services/UserRendererService.cs | 4 ++-- STYLE.md | 20 ++++++++++++---- 14 files changed, 86 insertions(+), 71 deletions(-) diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index 2a0f3d4..92267e6 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -7,7 +7,7 @@ using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; [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(); @@ -19,7 +19,7 @@ public class AuthController(Config config, KeyCacheService keyCacheSvc, ILogger config.DiscordAuth.Enabled, config.GoogleAuth.Enabled, config.TumblrAuth.Enabled); - var state = HttpUtility.UrlEncode(await keyCacheSvc.GenerateAuthStateAsync(ct)); + var state = HttpUtility.UrlEncode(await keyCache.GenerateAuthStateAsync(ct)); string? discord = null; if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) discord = diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index 6729fc0..068c8e9 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -15,10 +15,10 @@ public class DiscordAuthController( ILogger logger, IClock clock, DatabaseContext db, - KeyCacheService keyCacheSvc, - AuthService authSvc, - RemoteAuthService remoteAuthSvc, - UserRendererService userRendererSvc) : ApiControllerBase + KeyCacheService keyCacheService, + AuthService authService, + RemoteAuthService remoteAuthService, + UserRendererService userRenderer) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); @@ -30,17 +30,17 @@ public class DiscordAuthController( public async Task CallbackAsync([FromBody] AuthController.CallbackRequest req, CancellationToken ct = default) { CheckRequirements(); - await keyCacheSvc.ValidateAuthStateAsync(req.State, ct); + await keyCacheService.ValidateAuthStateAsync(req.State, ct); - var remoteUser = await remoteAuthSvc.RequestDiscordTokenAsync(req.Code, req.State, ct); - var user = await authSvc.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id, ct: ct); + var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code, req.State, ct); + var user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id, ct: ct); if (user != null) return Ok(await GenerateUserTokenAsync(user,ct)); _logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username, remoteUser.Id); var ticket = AuthUtils.RandomToken(); - await keyCacheSvc.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20), ct); + await keyCacheService.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20), ct); return Ok(new AuthController.CallbackResponse(false, ticket, remoteUser.Username)); } @@ -49,7 +49,7 @@ public class DiscordAuthController( [ProducesResponseType(StatusCodes.Status200OK)] public async Task RegisterAsync([FromBody] AuthController.OauthRegisterRequest req, CancellationToken ct = default) { - var remoteUser = await keyCacheSvc.GetKeyAsync($"discord:{req.Ticket}",ct:ct); + var remoteUser = await keyCacheService.GetKeyAsync($"discord:{req.Ticket}",ct:ct); 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, ct)) { @@ -58,7 +58,7 @@ public class DiscordAuthController( 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, ct: ct); return Ok(await GenerateUserTokenAsync(user, ct)); @@ -70,7 +70,7 @@ public class DiscordAuthController( _logger.Debug("Logging user {Id} in with Discord", user.Id); var (tokenStr, token) = - authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); + authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); db.Add(token); _logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id); @@ -78,7 +78,7 @@ public class DiscordAuthController( await db.SaveChangesAsync(ct); return new AuthController.AuthResponse( - await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), + await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), tokenStr, token.ExpiresAt ); diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index 317d62c..4e006af 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -12,10 +12,10 @@ namespace Foxnouns.Backend.Controllers.Authentication; [Route("/api/v2/auth/email")] public class EmailAuthController( DatabaseContext db, - AuthService authSvc, - MailService mailSvc, - KeyCacheService keyCacheSvc, - UserRendererService userRendererSvc, + AuthService authService, + MailService mailService, + KeyCacheService keyCacheService, + UserRendererService userRenderer, IClock clock, ILogger logger) : ApiControllerBase { @@ -26,30 +26,30 @@ public class EmailAuthController( { if (!req.Email.Contains('@')) throw new ApiError.BadRequest("Email is invalid", "email", req.Email); - var state = await keyCacheSvc.GenerateRegisterEmailStateAsync(req.Email, userId: null, ct); + 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(); - mailSvc.QueueAccountCreationEmail(req.Email, state); + mailService.QueueAccountCreationEmail(req.Email, state); return NoContent(); } [HttpPost("callback")] public async Task CallbackAsync([FromBody] CallbackRequest req, CancellationToken ct = default) { - var state = await keyCacheSvc.GetRegisterEmailStateAsync(req.State, ct); + 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 authSvc.AddAuthMethodAsync(state.ExistingUserId.Value, AuthType.Email, state.Email, ct: ct); + 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 keyCacheSvc.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); + await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); return Ok(new AuthController.CallbackResponse(false, ticket, state.Email)); } @@ -58,7 +58,7 @@ public class EmailAuthController( [ProducesResponseType(StatusCodes.Status200OK)] public async Task LoginAsync([FromBody] LoginRequest req, CancellationToken ct = default) { - var (user, authenticationResult) = await authSvc.AuthenticateUserAsync(req.Email, req.Password, ct); + var (user, authenticationResult) = await authService.AuthenticateUserAsync(req.Email, req.Password, ct); if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired) throw new NotImplementedException("MFA is not implemented yet"); @@ -67,7 +67,7 @@ public class EmailAuthController( _logger.Debug("Logging user {Id} in with email and password", user.Id); var (tokenStr, token) = - authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); + authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); db.Add(token); _logger.Debug("Generated token {TokenId} for {UserId}", token.Id, user.Id); @@ -75,7 +75,7 @@ public class EmailAuthController( await db.SaveChangesAsync(ct); return Ok(new AuthController.AuthResponse( - await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), + await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), tokenStr, token.ExpiresAt )); diff --git a/Foxnouns.Backend/Controllers/DebugController.cs b/Foxnouns.Backend/Controllers/DebugController.cs index a8d3ab2..2d22c03 100644 --- a/Foxnouns.Backend/Controllers/DebugController.cs +++ b/Foxnouns.Backend/Controllers/DebugController.cs @@ -9,8 +9,8 @@ namespace Foxnouns.Backend.Controllers; [Route("/api/v2/debug")] public class DebugController( DatabaseContext db, - AuthService authSvc, - UserRendererService userRendererSvc, + AuthService authService, + UserRendererService userRenderer, IClock clock, ILogger logger) : ApiControllerBase { @@ -22,17 +22,17 @@ public class DebugController( { _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 (tokenStr, token) = - authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); + authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); db.Add(token); await db.SaveChangesAsync(); return Ok(new AuthController.AuthResponse( - await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false), + await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false), tokenStr, token.ExpiresAt )); diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index 5f09e35..f051ca1 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -16,9 +16,9 @@ namespace Foxnouns.Backend.Controllers; public class MembersController( ILogger logger, DatabaseContext db, - MemberRendererService memberRendererService, + MemberRendererService memberRenderer, ISnowflakeGenerator snowflakeGenerator, - ObjectStorageService objectStorage, + ObjectStorageService objectStorageService, IQueue queue) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); @@ -28,7 +28,7 @@ public class MembersController( public async Task GetMembersAsync(string userRef, CancellationToken ct = default) { var user = await db.ResolveUserAsync(userRef, CurrentToken, ct); - return Ok(await memberRendererService.RenderUserMembersAsync(user, CurrentToken)); + return Ok(await memberRenderer.RenderUserMembersAsync(user, CurrentToken)); } [HttpGet("{memberRef}")] @@ -36,7 +36,7 @@ public class MembersController( public async Task GetMemberAsync(string userRef, string memberRef, CancellationToken ct = default) { 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")] @@ -83,7 +83,7 @@ public class MembersController( queue.QueueInvocableWithPayload( 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}")] @@ -101,7 +101,7 @@ public class MembersController( 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(); } diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index 8b9299a..c7052ec 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -14,7 +14,7 @@ namespace Foxnouns.Backend.Controllers; [Route("/api/v2/users")] public class UsersController( DatabaseContext db, - UserRendererService userRendererService, + UserRendererService userRenderer, ISnowflakeGenerator snowflakeGenerator, IQueue queue) : ApiControllerBase { @@ -23,7 +23,7 @@ public class UsersController( public async Task GetUserAsync(string userRef, CancellationToken ct = default) { var user = await db.ResolveUserAsync(userRef, CurrentToken, ct); - return Ok(await userRendererService.RenderUserAsync( + return Ok(await userRenderer.RenderUserAsync( user, selfUser: CurrentUser, token: CurrentToken, @@ -78,7 +78,7 @@ public class UsersController( await db.SaveChangesAsync(ct); await tx.CommitAsync(ct); - return Ok(await userRendererService.RenderUserAsync(user, CurrentUser, renderMembers: false, + return Ok(await userRenderer.RenderUserAsync(user, CurrentUser, renderMembers: false, renderAuthMethods: false, ct: ct)); } diff --git a/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs b/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs index 2d0fd16..cb70adf 100644 --- a/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs +++ b/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs @@ -14,12 +14,12 @@ public static class AvatarObjectExtensions private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"]; public static async Task - DeleteMemberAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash, CancellationToken ct = default) => - await objectStorage.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash), ct); + DeleteMemberAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash, CancellationToken ct = default) => + await objectStorageService.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash), ct); public static async Task - DeleteUserAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash, CancellationToken ct = default) => - await objectStorage.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); + DeleteUserAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash, CancellationToken ct = default) => + await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); public static async Task ConvertBase64UriToAvatar(this string uri) { diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index eb1cecc..e67d72e 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -7,31 +7,33 @@ namespace Foxnouns.Backend.Extensions; public static class KeyCacheExtensions { - public static async Task GenerateAuthStateAsync(this KeyCacheService keyCacheSvc, CancellationToken ct = default) + public static async Task GenerateAuthStateAsync(this KeyCacheService keyCacheService, + CancellationToken ct = default) { var state = AuthUtils.RandomToken(); - await keyCacheSvc.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); + await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); return state; } - public static async Task ValidateAuthStateAsync(this KeyCacheService keyCacheSvc, string state, CancellationToken ct = default) + public static async Task ValidateAuthStateAsync(this KeyCacheService keyCacheService, string state, + CancellationToken ct = default) { - var val = await keyCacheSvc.GetKeyAsync($"oauth_state:{state}", delete: true, ct); + var val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", delete: true, ct); if (val == null) throw new ApiError.BadRequest("Invalid OAuth state"); } - public static async Task GenerateRegisterEmailStateAsync(this KeyCacheService keyCacheSvc, string email, + public static async Task GenerateRegisterEmailStateAsync(this KeyCacheService keyCacheService, string email, Snowflake? userId = null, CancellationToken ct = default) { var state = AuthUtils.RandomToken(); - await keyCacheSvc.SetKeyAsync($"email_state:{state}", new RegisterEmailState(email, userId), + await keyCacheService.SetKeyAsync($"email_state:{state}", new RegisterEmailState(email, userId), Duration.FromDays(1), ct); return state; } - public static async Task GetRegisterEmailStateAsync(this KeyCacheService keyCacheSvc, + public static async Task GetRegisterEmailStateAsync(this KeyCacheService keyCacheService, string state, CancellationToken ct = default) => - await keyCacheSvc.GetKeyAsync($"email_state:{state}", delete: true, ct); + await keyCacheService.GetKeyAsync($"email_state:{state}", delete: true, ct); } public record RegisterEmailState(string Email, Snowflake? ExistingUserId); \ No newline at end of file diff --git a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs index 56d5077..3beff48 100644 --- a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs @@ -6,7 +6,7 @@ using Foxnouns.Backend.Services; namespace Foxnouns.Backend.Jobs; -public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorage, ILogger logger) +public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger) : IInvocable, IInvocableWithPayload { private readonly ILogger _logger = logger.ForContext(); @@ -36,13 +36,13 @@ public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageServic image.Seek(0, SeekOrigin.Begin); 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; await db.SaveChangesAsync(); 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); } @@ -69,7 +69,7 @@ public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageServic return; } - await objectStorage.RemoveObjectAsync(Path(member.Id, member.Avatar)); + await objectStorageService.RemoveObjectAsync(Path(member.Id, member.Avatar)); member.Avatar = null; await db.SaveChangesAsync(); diff --git a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs index f0b04b2..d1abd42 100644 --- a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs @@ -6,7 +6,7 @@ using Foxnouns.Backend.Services; namespace Foxnouns.Backend.Jobs; -public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorage, ILogger logger) +public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger) : IInvocable, IInvocableWithPayload { private readonly ILogger _logger = logger.ForContext(); @@ -36,13 +36,13 @@ public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService image.Seek(0, SeekOrigin.Begin); 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; await db.SaveChangesAsync(); 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); } @@ -69,7 +69,7 @@ public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService return; } - await objectStorage.RemoveObjectAsync(Path(user.Id, user.Avatar)); + await objectStorageService.RemoveObjectAsync(Path(user.Id, user.Avatar)); user.Avatar = null; await db.SaveChangesAsync(); diff --git a/Foxnouns.Backend/Services/MetricsCollectionService.cs b/Foxnouns.Backend/Services/MetricsCollectionService.cs index f860650..c5c924e 100644 --- a/Foxnouns.Backend/Services/MetricsCollectionService.cs +++ b/Foxnouns.Backend/Services/MetricsCollectionService.cs @@ -47,7 +47,7 @@ public class MetricsCollectionService( } } -public class BackgroundMetricsCollectionService(ILogger logger, MetricsCollectionService innerService) +public class BackgroundMetricsCollectionService(ILogger logger, MetricsCollectionService metricsCollectionService) : BackgroundService { private readonly ILogger _logger = logger.ForContext(); @@ -60,7 +60,7 @@ public class BackgroundMetricsCollectionService(ILogger logger, MetricsCollectio while (await timer.WaitForNextTickAsync(ct)) { _logger.Debug("Collecting metrics"); - await innerService.CollectMetricsAsync(ct); + await metricsCollectionService.CollectMetricsAsync(ct); } } } \ No newline at end of file diff --git a/Foxnouns.Backend/Services/ObjectStorageService.cs b/Foxnouns.Backend/Services/ObjectStorageService.cs index 45e9678..e31a270 100644 --- a/Foxnouns.Backend/Services/ObjectStorageService.cs +++ b/Foxnouns.Backend/Services/ObjectStorageService.cs @@ -4,7 +4,7 @@ using Minio.Exceptions; 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(); @@ -13,7 +13,8 @@ public class ObjectStorageService(ILogger logger, Config config, IMinioClient mi _logger.Debug("Deleting object at path {Path}", path); 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) @@ -27,7 +28,7 @@ public class ObjectStorageService(ILogger logger, Config config, IMinioClient mi _logger.Debug("Putting object at path {Path} with length {Length} and content type {ContentType}", path, data.Length, contentType); - await minio.PutObjectAsync(new PutObjectArgs() + await minioClient.PutObjectAsync(new PutObjectArgs() .WithBucket(config.Storage.Bucket) .WithObject(path) .WithObjectSize(data.Length) diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index 4449488..07bdb8b 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -7,7 +7,7 @@ using NodaTime; 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 RenderUserAsync(User user, User? selfUser = null, Token? token = null, @@ -39,7 +39,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe return new UserResponse( user.Id, user.Username, user.DisplayName, user.Bio, user.MemberTitle, AvatarUrlFor(user), user.Links, user.Names, user.Pronouns, user.Fields, user.CustomPreferences, - renderMembers ? members.Select(memberRendererService.RenderPartialMember) : null, + renderMembers ? members.Select(memberRenderer.RenderPartialMember) : null, renderAuthMethods ? authMethods.Select(a => new AuthenticationMethodResponse( a.Id, a.AuthType, a.RemoteId, diff --git a/STYLE.md b/STYLE.md index 6acdab8..28fc9ce 100644 --- a/STYLE.md +++ b/STYLE.md @@ -2,10 +2,22 @@ ## 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` instead of a `List`, or if a variable is initialized as `null`). +- 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` + instead of a `List`, 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