diff --git a/Directory.Build.props b/Directory.Build.props index 0751e1d..60ef2bc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.31 + 0.1.27 diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index 9c44b04..f98ae8d 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -3,9 +3,7 @@ using System.Text.Encodings.Web; using Femto.Api.Sessions; using Femto.Common; using Femto.Modules.Auth.Application; -using Femto.Modules.Auth.Application.Dto; -using Femto.Modules.Auth.Contracts; -using Femto.Modules.Auth.Models; +using Femto.Modules.Auth.Application.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; @@ -22,15 +20,42 @@ internal class SessionAuthenticationHandler( protected override async Task HandleAuthenticateAsync() { Logger.LogDebug("{TraceId} Authenticating session", this.Context.TraceIdentifier); - - var user = await this.TryAuthenticateWithSession(); - - if (user is null) - user = await this.TryAuthenticateWithRememberMeToken(); - - if (user is null) + + var sessionId = this.Context.GetSessionId(); + + if (sessionId is null) + { + Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier); return AuthenticateResult.NoResult(); + } + + var session = await authService.GetSession(sessionId); + if (session is null) + { + Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier); + return await FailAndDeleteSession(sessionId); + } + + if (session.IsExpired) + { + Logger.LogDebug("{TraceId} Loaded session was expired ", this.Context.TraceIdentifier); + return await FailAndDeleteSession(sessionId); + } + + var user = await authService.GetUserWithId(session.UserId); + + if (user is null) + { + return await FailAndDeleteSession(sessionId); + } + + if (session.ExpiresSoon) + { + session = await authService.CreateWeakSession(session.UserId); + this.Context.SetSession(session, user); + } + var claims = new List { new(ClaimTypes.Name, user.Username), @@ -42,88 +67,15 @@ internal class SessionAuthenticationHandler( var identity = new ClaimsIdentity(claims, this.Scheme.Name); var principal = new ClaimsPrincipal(identity); - currentUserContext.CurrentUser = new CurrentUser( - user.Id, - user.Username, - user.Roles.Contains(Role.SuperUser) - ); + currentUserContext.CurrentUser = new CurrentUser(user.Id, user.Username); return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name)); } - private async Task TryAuthenticateWithSession() + private async Task FailAndDeleteSession(string sessionId) { - var sessionId = this.Context.GetSessionId(); - - if (sessionId is null) - { - Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier); - return null; - } - - var session = await authService.GetSession(sessionId); - - if (session is null) - { - Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier); - return null; - } - - if (session.IsExpired) - { - Logger.LogDebug("{TraceId} Loaded session was expired ", this.Context.TraceIdentifier); - await authService.DeleteSession(sessionId); - this.Context.DeleteSession(); - return null; - } - - var user = await authService.GetUserWithId(session.UserId); - - if (user is null) - { - await authService.DeleteSession(sessionId); - this.Context.DeleteSession(); - return null; - } - - if (session.ExpiresSoon) - { - session = await authService.CreateWeakSession(session.UserId); - this.Context.SetSession(session, user); - } - - return user; - } - - private async Task TryAuthenticateWithRememberMeToken() - { - /* - * load remember me from token - * if it is null, return null - * if it exists, validate it - * if it is valid, create a new weak session, return the user - * if it is almost expired, refresh it - */ - - var rememberMeToken = this.Context.GetRememberMeToken(); - - if (rememberMeToken is null) - return null; - - var (user, newRememberMeToken) = await authService.GetUserWithRememberMeToken( - rememberMeToken - ); - - if (user is null) - return null; - - var session = await authService.CreateWeakSession(user.Id); - - this.Context.SetSession(session, user); - - if (newRememberMeToken is not null) - this.Context.SetRememberMeToken(newRememberMeToken); - - return user; + await authService.DeleteSession(sessionId); + this.Context.DeleteSession(); + return AuthenticateResult.Fail("invalid session"); } } diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index c9108da..e45e73c 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -1,16 +1,26 @@ +using Femto.Api.Auth; using Femto.Api.Sessions; using Femto.Common; -using Femto.Modules.Auth.Application; +using Femto.Modules.Auth.Application.Interface.CreateSignupCode; +using Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery; +using Femto.Modules.Auth.Application.Interface.Register; +using Femto.Modules.Auth.Application.Services; using Femto.Modules.Auth.Contracts; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; namespace Femto.Api.Controllers.Auth; [ApiController] [Route("auth")] -public class AuthController(ICurrentUserContext currentUserContext, IAuthService authService) - : ControllerBase +public class AuthController( + IAuthModule authModule, + IOptions cookieSettings, + ICurrentUserContext currentUserContext, + ILogger logger, + IAuthService authService +) : ControllerBase { [HttpPost("login")] public async Task> Login( @@ -18,49 +28,32 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService CancellationToken cancellationToken ) { - var result = await authService.AuthenticateUserCredentials( + var user = await authService.GetUserWithCredentials( request.Username, request.Password, cancellationToken ); - - if (result is null) + + if (user is null) return this.BadRequest(); - - var (user, session) = result; + + var session = await authService.CreateStrongSession(user.Id); HttpContext.SetSession(session, user); - if (request.RememberMe) - { - var newRememberMeToken = await authService.CreateRememberMeToken(user.Id); - HttpContext.SetRememberMeToken(newRememberMeToken); - } - return new LoginResponse(user.Id, user.Username, user.Roles.Any(r => r == Role.SuperUser)); } [HttpPost("register")] - public async Task> Register( - [FromBody] RegisterRequest request, - CancellationToken cancellationToken - ) + public async Task> Register([FromBody] RegisterRequest request) { - var (user, session) = await authService.CreateUserWithCredentials( - request.Username, - request.Password, - request.SignupCode, - cancellationToken + var user = await authModule.Command( + new RegisterCommand(request.Username, request.Password, request.SignupCode) ); + var session = await authService.CreateStrongSession(user.Id); + HttpContext.SetSession(session, user); - - if (request.RememberMe) - { - var newRememberMeToken = await authService.CreateRememberMeToken(user.Id); - HttpContext.SetRememberMeToken(newRememberMeToken); - } - return new RegisterResponse( user.Id, user.Username, @@ -68,60 +61,6 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService ); } - [HttpPost("change-password")] - public async Task ChangePassword( - [FromBody] ChangePasswordRequestBody req, - CancellationToken cancellationToken - ) - { - if (currentUserContext.CurrentUser is not {} user) - return this.BadRequest(); - - // superuser do what superuser want - if (!user.IsSuperUser) - { - if (user.Id != req.UserId) - return this.BadRequest(); - - var session = await authService.GetSession(this.HttpContext.GetSessionId()!); - - // require strong authentication to change password - // the user can re-enter their password - if (session is null || !session.IsStronglyAuthenticated) - return this.BadRequest(); - } - - await authService.ChangePassword(req.UserId, req.NewPassword, cancellationToken); - - // TODO would be better do handle this from inside the auth service. maybe just have it happen in a post-save event handler? - await authService.InvalidateUserSessions(req.UserId, cancellationToken); - - return this.Ok(new {}); - } - - [HttpPost("delete-current-session")] - public async Task DeleteSessionV2() - { - var sessionId = HttpContext.GetSessionId(); - - if (sessionId is not null) - { - await authService.DeleteSession(sessionId); - HttpContext.DeleteSession(); - } - - var rememberMeToken = HttpContext.GetRememberMeToken(); - - if (rememberMeToken is not null) - { - await authService.DeleteRememberMeToken(rememberMeToken); - HttpContext.DeleteRememberMeToken(); - } - - return Ok(new { }); - } - - [Obsolete("use POST /auth/delete-current-session")] [HttpDelete("session")] public async Task DeleteSession() { @@ -133,14 +72,6 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService HttpContext.DeleteSession(); } - var rememberMeToken = HttpContext.GetRememberMeToken(); - - if (rememberMeToken is not null) - { - await authService.DeleteRememberMeToken(rememberMeToken); - HttpContext.DeleteRememberMeToken(); - } - return Ok(new { }); } @@ -168,7 +99,6 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService ); } - [Obsolete("use POST /auth/create-signup-code")] [HttpPost("signup-codes")] [Authorize(Roles = "SuperUser")] public async Task CreateSignupCode( @@ -176,51 +106,21 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService CancellationToken cancellationToken ) { - await authService.AddSignupCode(request.Code, request.Name, cancellationToken); + await authModule.Command( + new CreateSignupCodeCommand(request.Code, request.Email, request.Name), + cancellationToken + ); return Ok(new { }); } - [Obsolete("use GET /auth/list-signup-codes")] [HttpGet("signup-codes")] [Authorize(Roles = "SuperUser")] public async Task> ListSignupCodes( CancellationToken cancellationToken ) { - var codes = await authService.GetSignupCodes(cancellationToken); - - return new ListSignupCodesResult( - codes.Select(c => new SignupCodeDto( - c.Code, - c.Email, - c.Name, - c.RedeemedByUserId, - c.RedeemedByUsername, - c.ExpiresOn - )) - ); - } - - [HttpPost("create-signup-code")] - [Authorize(Roles = "SuperUser")] - public async Task CreateSignupCodeV2( - [FromBody] CreateSignupCodeRequest request, - CancellationToken cancellationToken - ) - { - await authService.AddSignupCode(request.Code, request.Name, cancellationToken); - - return Ok(new { }); - } - - [HttpGet("list-signup-codes")] - [Authorize(Roles = "SuperUser")] - public async Task> ListSignupCodesV2( - CancellationToken cancellationToken - ) - { - var codes = await authService.GetSignupCodes(cancellationToken); + var codes = await authModule.Query(new GetSignupCodesQuery(), cancellationToken); return new ListSignupCodesResult( codes.Select(c => new SignupCodeDto( diff --git a/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs b/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs deleted file mode 100644 index 77f1dcd..0000000 --- a/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Femto.Api.Controllers.Auth; - -public record ChangePasswordRequestBody(Guid UserId, string NewPassword); \ No newline at end of file diff --git a/Femto.Api/Controllers/Auth/LoginRequest.cs b/Femto.Api/Controllers/Auth/LoginRequest.cs index 6c09e64..8366d14 100644 --- a/Femto.Api/Controllers/Auth/LoginRequest.cs +++ b/Femto.Api/Controllers/Auth/LoginRequest.cs @@ -1,3 +1,3 @@ namespace Femto.Api.Controllers.Auth; -public record LoginRequest(string Username, string Password, bool RememberMe); \ No newline at end of file +public record LoginRequest(string Username, string Password); \ No newline at end of file diff --git a/Femto.Api/Controllers/Auth/RegisterRequest.cs b/Femto.Api/Controllers/Auth/RegisterRequest.cs index ee21297..f386198 100644 --- a/Femto.Api/Controllers/Auth/RegisterRequest.cs +++ b/Femto.Api/Controllers/Auth/RegisterRequest.cs @@ -1,3 +1,3 @@ namespace Femto.Api.Controllers.Auth; -public record RegisterRequest(string Username, string Password, string SignupCode, bool RememberMe); \ No newline at end of file +public record RegisterRequest(string Username, string Password, string SignupCode, string? Email); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs b/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs deleted file mode 100644 index 7546af0..0000000 --- a/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Femto.Api.Controllers.Posts.Dto; - -public record AddPostCommentRequest(Guid AuthorId, string Content); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs b/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs deleted file mode 100644 index b44740c..0000000 --- a/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Femto.Api.Controllers.Posts.Dto; - -public record GetPostResponse(PostDto Post); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs b/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs index 3705456..e5155f6 100644 --- a/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs +++ b/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs @@ -3,4 +3,4 @@ using JetBrains.Annotations; namespace Femto.Api.Controllers.Posts.Dto; [PublicAPI] -public record GetPublicPostsSearchParams(Guid? After, int? Amount, Guid? AuthorId, string? Author); \ No newline at end of file +public record GetPublicPostsSearchParams(Guid? From, int? Amount, Guid? AuthorId, string? Author); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs b/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs index 54b9df7..7efdeee 100644 --- a/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs +++ b/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs @@ -3,4 +3,4 @@ using JetBrains.Annotations; namespace Femto.Api.Controllers.Posts.Dto; [PublicAPI] -public record LoadPostsResponse(IEnumerable Posts); \ No newline at end of file +public record LoadPostsResponse(IEnumerable Posts, Guid? Next); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs b/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs deleted file mode 100644 index 04e180a..0000000 --- a/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Femto.Api.Controllers.Posts.Dto; - -public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/PostDto.cs b/Femto.Api/Controllers/Posts/Dto/PostDto.cs index c9af7c6..a00c8c1 100644 --- a/Femto.Api/Controllers/Posts/Dto/PostDto.cs +++ b/Femto.Api/Controllers/Posts/Dto/PostDto.cs @@ -10,19 +10,5 @@ public record PostDto( IEnumerable Media, IEnumerable Reactions, DateTimeOffset CreatedAt, - IEnumerable PossibleReactions, - IEnumerable Comments -) -{ - public static PostDto FromModel(Modules.Blog.Application.Queries.GetPosts.Dto.PostDto post) => - new( - new PostAuthorDto(post.Author.AuthorId, post.Author.Username), - post.PostId, - post.Text, - post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), - post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.ReactedOn)), - post.CreatedAt, - post.PossibleReactions, - post.Comments.Select(c => new PostCommentDto(c.Author, c.Content, c.PostedOn)) - ); -} \ No newline at end of file + IEnumerable PossibleReactions +); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs index f9934c6..81e3a95 100644 --- a/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs +++ b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs @@ -1,3 +1,3 @@ namespace Femto.Api.Controllers.Posts.Dto; -public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn); \ No newline at end of file +public record PostReactionDto(string Emoji, int Count, bool DidReact); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index ed882f7..6036767 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -1,7 +1,6 @@ using Femto.Api.Controllers.Posts.Dto; using Femto.Common; using Femto.Modules.Blog.Application; -using Femto.Modules.Blog.Application.Commands.AddPostComment; using Femto.Modules.Blog.Application.Commands.AddPostReaction; using Femto.Modules.Blog.Application.Commands.ClearPostReaction; using Femto.Modules.Blog.Application.Commands.CreatePost; @@ -14,7 +13,7 @@ namespace Femto.Api.Controllers.Posts; [ApiController] [Route("posts")] -public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext, IAuthorizationService auth) +public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) : ControllerBase { [HttpGet] @@ -26,7 +25,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current var res = await blogModule.Query( new GetPostsQuery(currentUserContext.CurrentUser?.Id) { - After = searchParams.After, + From = searchParams.From, Amount = searchParams.Amount ?? 20, AuthorId = searchParams.AuthorId, Author = searchParams.Author, @@ -34,7 +33,18 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current cancellationToken ); - return new LoadPostsResponse(res.Posts.Select(PostDto.FromModel)); + return new LoadPostsResponse( + res.Posts.Select(p => new PostDto( + new PostAuthorDto(p.Author.AuthorId, p.Author.Username), + p.PostId, + p.Text, + p.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), + p.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)), + p.CreatedAt, + p.PossibleReactions + )), + res.Next + ); } [HttpPost] @@ -65,26 +75,17 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current cancellationToken ); - return new CreatePostResponse(PostDto.FromModel(post)); - } - - [HttpGet("{postId}")] - public async Task> GetPost( - Guid postId, - CancellationToken cancellationToken - ) - { - var result = await blogModule.Query( - new GetPostsQuery(postId, currentUserContext.CurrentUser?.Id), - cancellationToken + return new CreatePostResponse( + new PostDto( + new PostAuthorDto(post.Author.AuthorId, post.Author.Username), + post.PostId, + post.Text, + post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)), + post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)).ToList(), + post.CreatedAt, + post.PossibleReactions + ) ); - - var post = result.Posts.SingleOrDefault(); - - if (post is null) - return NotFound(); - - return new GetPostResponse(PostDto.FromModel(post)); } [HttpDelete("{postId}")] @@ -99,56 +100,24 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current [HttpPost("{postId}/reactions")] [Authorize] - public async Task AddPostReaction( - Guid postId, - [FromBody] AddPostReactionRequest request, - CancellationToken cancellationToken - ) + public async Task AddPostReaction(Guid postId, [FromBody] AddPostReactionRequest request, CancellationToken cancellationToken) { var currentUser = currentUserContext.CurrentUser!; - await blogModule.Command( - new AddPostReactionCommand(postId, request.Emoji, currentUser.Id), - cancellationToken - ); + await blogModule.Command(new AddPostReactionCommand(postId, request.Emoji, currentUser.Id), cancellationToken); return this.Ok(); } - + [HttpDelete("{postId}/reactions")] [Authorize] - public async Task DeletePostReaction( - Guid postId, - [FromBody] DeletePostReactionRequest request, - CancellationToken cancellationToken - ) + public async Task DeletePostReaction(Guid postId, [FromBody] DeletePostReactionRequest request, CancellationToken cancellationToken) { var currentUser = currentUserContext.CurrentUser!; - await blogModule.Command( - new ClearPostReactionCommand(postId, request.Emoji, currentUser.Id), - cancellationToken - ); + await blogModule.Command(new ClearPostReactionCommand(postId, request.Emoji, currentUser.Id), cancellationToken); return this.Ok(); } - [HttpPost("{postId}/comments")] - [Authorize] - public async Task AddPostComment( - Guid postId, - [FromBody] AddPostCommentRequest request, - CancellationToken cancellationToken - ) - { - if (currentUserContext.CurrentUser?.Id != request.AuthorId) - return this.BadRequest(); - - await blogModule.Command( - new AddPostCommentCommand(postId, request.AuthorId, request.Content), - cancellationToken - ); - - return this.Ok(); - } } diff --git a/Femto.Api/Program.cs b/Femto.Api/Program.cs index ebb81fd..08a40b8 100644 --- a/Femto.Api/Program.cs +++ b/Femto.Api/Program.cs @@ -46,7 +46,7 @@ builder.Services.AddHostedService(_ => eventBus); builder.Services.InitializeBlogModule(connectionString, eventBus, loggerFactory); builder.Services.InitializeMediaModule(connectionString, blobStorageRoot); -builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory, TimeProvider.System); +builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory); builder.Services.AddScoped(); builder.Services.AddScoped(s => s.GetRequiredService()); diff --git a/Femto.Api/Properties/launchSettings.json b/Femto.Api/Properties/launchSettings.json index b024278..9a9026a 100644 --- a/Femto.Api/Properties/launchSettings.json +++ b/Femto.Api/Properties/launchSettings.json @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://localhost:7269", + "applicationUrl": "https://stinkpad:7269;http://0.0.0.0:5181", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs index 2b8ee96..e693180 100644 --- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs +++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs @@ -15,12 +15,18 @@ internal static class HttpContextSessionExtensions PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; - public static string? GetSessionId(this HttpContext httpContext) => - httpContext.Request.Cookies["sid"]; + public static string? GetSessionId(this HttpContext httpContext) + { + var sessionId = httpContext.Request.Cookies["sid"]; + + return sessionId; + } public static void SetSession(this HttpContext context, Session session, UserInfo user) { - var cookieSettings = context.RequestServices.GetRequiredService>(); + var cookieSettings = context.RequestServices.GetRequiredService< + IOptions + >(); context.Response.Cookies.Append( "sid", @@ -51,7 +57,7 @@ internal static class HttpContextSessionExtensions } ); } - + public static void DeleteSession(this HttpContext httpContext) { var cookieSettings = httpContext.RequestServices.GetRequiredService< @@ -85,47 +91,4 @@ internal static class HttpContextSessionExtensions } ); } - - - public static RememberMeToken? GetRememberMeToken(this HttpContext httpContext) => - httpContext.Request.Cookies["rid"] is { } code ? RememberMeToken.FromCode(code) : null; - - public static void SetRememberMeToken(this HttpContext context, NewRememberMeToken token) - { - var cookieSettings = context.RequestServices.GetRequiredService>(); - - context.Response.Cookies.Append( - "rid", - token.Code, - new CookieOptions - { - Path = "/", - IsEssential = true, - Domain = cookieSettings.Value.Domain, - HttpOnly = true, - Secure = cookieSettings.Value.Secure, - SameSite = cookieSettings.Value.SameSite, - Expires = token.Expires, - } - ); - } - - public static void DeleteRememberMeToken(this HttpContext context) - { - var cookieSettings = context.RequestServices.GetRequiredService>(); - - context.Response.Cookies.Delete( - "rid", - new CookieOptions - { - Path = "/", - HttpOnly = true, - Domain = cookieSettings.Value.Domain, - IsEssential = true, - Secure = cookieSettings.Value.Secure, - SameSite = cookieSettings.Value.SameSite, - Expires = DateTimeOffset.UtcNow.AddDays(-1), - } - ); - } } diff --git a/Femto.Common/ICurrentUserContext.cs b/Femto.Common/ICurrentUserContext.cs index 629b2d2..3e7dae6 100644 --- a/Femto.Common/ICurrentUserContext.cs +++ b/Femto.Common/ICurrentUserContext.cs @@ -5,4 +5,4 @@ public interface ICurrentUserContext CurrentUser? CurrentUser { get; } } -public record CurrentUser(Guid Id, string Username, bool IsSuperUser); \ No newline at end of file +public record CurrentUser(Guid Id, string Username); diff --git a/Femto.Common/Infrastructure/DomainServiceExtensions.cs b/Femto.Common/Infrastructure/DomainServiceExtensions.cs index 9812c93..e83469e 100644 --- a/Femto.Common/Infrastructure/DomainServiceExtensions.cs +++ b/Femto.Common/Infrastructure/DomainServiceExtensions.cs @@ -12,7 +12,7 @@ public static class DomainServiceExtensions services.AddScoped(s => s.GetRequiredService()); services.AddTransient( typeof(IPipelineBehavior<,>), - typeof(DDDPipelineBehaviour<,>) + typeof(SaveChangesPipelineBehaviour<,>) ); } diff --git a/Femto.Common/Infrastructure/DDDPipelineBehaviour.cs b/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs similarity index 88% rename from Femto.Common/Infrastructure/DDDPipelineBehaviour.cs rename to Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs index e5f338f..b86a7e4 100644 --- a/Femto.Common/Infrastructure/DDDPipelineBehaviour.cs +++ b/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs @@ -5,10 +5,10 @@ using Microsoft.Extensions.Logging; namespace Femto.Common.Infrastructure; -public class DDDPipelineBehaviour( +public class SaveChangesPipelineBehaviour( DbContext context, IPublisher publisher, - ILogger> logger + ILogger> logger ) : IPipelineBehavior where TRequest : notnull { diff --git a/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql b/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql deleted file mode 100644 index 15d0323..0000000 --- a/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Migration: AddInvalidateToLongTermSession --- Created at: 19/07/2025 10:42:00 - -ALTER TABLE authn.long_term_session -ADD COLUMN is_invalidated BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql b/Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql deleted file mode 100644 index 4557156..0000000 --- a/Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Migration: AddTimestampToReaction --- Created at: 10/08/2025 15:21:32 -alter table blog.post_reaction -add column created_at timestamptz; \ No newline at end of file diff --git a/Femto.Database/Migrations/20250810172242_AddCommentToPost.sql b/Femto.Database/Migrations/20250810172242_AddCommentToPost.sql deleted file mode 100644 index 44e0086..0000000 --- a/Femto.Database/Migrations/20250810172242_AddCommentToPost.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Migration: AddCommentToPost --- Created at: 10/08/2025 17:22:42 - -CREATE TABLE blog.post_comment -( - id uuid PRIMARY KEY, - post_id uuid REFERENCES blog.post(id), - author_id uuid REFERENCES blog.author(id), - content TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -) \ No newline at end of file diff --git a/Femto.Database/Seed/TestDataSeeder.cs b/Femto.Database/Seed/TestDataSeeder.cs index 2c8efcc..433f73c 100644 --- a/Femto.Database/Seed/TestDataSeeder.cs +++ b/Femto.Database/Seed/TestDataSeeder.cs @@ -43,7 +43,6 @@ public static class TestDataSeeder ('019691a0-4dd3-7e89-909e-94a6fd19a05e', @id, '["🍆", "🧢", "🧑🏾‍🎓", "🥕", "🕗"]', 'Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.') ; - INSERT INTO blog.post_media (id, post_id, url, ordering) VALUES @@ -64,12 +63,6 @@ public static class TestDataSeeder ('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '🕗') ; - INSERT INTO blog.post_comment - (id, post_id, author_id, content) - VALUES - ('9116da05-49eb-4053-9199-57f54f92e73a', '019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, 'this is a comment!') - ; - INSERT INTO authn.user_identity (id, username, password_hash, password_salt) VALUES diff --git a/Femto.Modules.Auth/Application/AuthService.cs b/Femto.Modules.Auth/Application/AuthService.cs deleted file mode 100644 index eb6215f..0000000 --- a/Femto.Modules.Auth/Application/AuthService.cs +++ /dev/null @@ -1,258 +0,0 @@ -using Dapper; -using Femto.Common.Domain; -using Femto.Common.Infrastructure.DbConnection; -using Femto.Modules.Auth.Application.Dto; -using Femto.Modules.Auth.Data; -using Femto.Modules.Auth.Infrastructure; -using Femto.Modules.Auth.Models; -using Microsoft.EntityFrameworkCore; - -namespace Femto.Modules.Auth.Application; - -internal class AuthService( - AuthContext context, - SessionStorage sessionStorage, - IDbConnectionFactory connectionFactory -) : IAuthService -{ - public async Task AuthenticateUserCredentials( - string username, - string password, - CancellationToken cancellationToken = default - ) - { - var user = await context - .Users.Where(u => u.Username == username) - .SingleOrDefaultAsync(cancellationToken); - - if (user is null) - return null; - - if (!user.HasPassword(password)) - return null; - - var session = new Session(user.Id, true); - - await sessionStorage.AddSession(session); - - return new( - new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()), - session - ); - } - - public Task GetUserWithId(Guid? userId, CancellationToken cancellationToken) - { - return context - .Users.Where(u => u.Id == userId) - .Select(u => new UserInfo(u.Id, u.Username, u.Roles.Select(r => r.Role).ToList())) - .SingleOrDefaultAsync(cancellationToken); - } - - public async Task CreateNewSession(Guid userId) - { - var session = new Session(userId, true); - - await sessionStorage.AddSession(session); - - return session; - } - - public async Task CreateWeakSession(Guid userId) - { - var session = new Session(userId, false); - - await sessionStorage.AddSession(session); - - return session; - } - - public Task GetSession(string sessionId) - { - return sessionStorage.GetSession(sessionId); - } - - public async Task DeleteSession(string sessionId) - { - await sessionStorage.DeleteSession(sessionId); - } - - public async Task CreateUserWithCredentials( - string username, - string password, - string signupCode, - CancellationToken cancellationToken = default - ) - { - var now = DateTimeOffset.UtcNow; - - var code = await context - .SignupCodes.Where(c => c.Code == signupCode) - .Where(c => c.ExpiresAt == null || c.ExpiresAt > now) - .Where(c => c.RedeemingUserId == null) - .SingleOrDefaultAsync(cancellationToken); - - if (code is null) - throw new DomainError("invalid signup code"); - - var usernameTaken = await context.Users.AnyAsync( - u => u.Username == username, - cancellationToken - ); - - if (usernameTaken) - throw new DomainError("username taken"); - - var user = new UserIdentity(username); - - await context.AddAsync(user, cancellationToken); - - user.SetPassword(password); - - code.Redeem(user.Id); - - var session = new Session(user.Id, true); - - await sessionStorage.AddSession(session); - - await context.SaveChangesAsync(cancellationToken); - - return new(new UserInfo(user), session); - } - - public async Task AddSignupCode( - string code, - string recipientName, - CancellationToken cancellationToken - ) - { - await context.SignupCodes.AddAsync( - new SignupCode("", recipientName, code), - cancellationToken - ); - - await context.SaveChangesAsync(cancellationToken); - } - - public async Task> GetSignupCodes( - CancellationToken cancellationToken = default - ) - { - using var conn = connectionFactory.GetConnection(); - - // lang=sql - const string sql = """ - SELECT - sc.code as Code, - sc.recipient_email as Email, - sc.recipient_name as Name, - sc.redeeming_user_id as RedeemedByUserId, - u.username as RedeemedByUsername, - sc.expires_at as ExpiresOn - FROM authn.signup_code sc - LEFT JOIN authn.user_identity u ON u.id = sc.redeeming_user_id - ORDER BY sc.created_at DESC - """; - - var result = await conn.QueryAsync(sql, cancellationToken); - - return result - .Select(row => new SignupCodeDto( - row.Code, - row.Email, - row.Name, - row.RedeemedByUserId, - row.RedeemedByUsername, - row.ExpiresOn - )) - .ToList(); - } - - public async Task CreateRememberMeToken(Guid userId) - { - var (rememberMeToken, verifier) = LongTermSession.Create(userId); - - await context.AddAsync(rememberMeToken); - await context.SaveChangesAsync(); - - return new(rememberMeToken.Selector, verifier, rememberMeToken.Expires); - } - - public async Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken( - RememberMeToken rememberMeToken - ) - { - var token = await context.LongTermSessions.SingleOrDefaultAsync(t => - t.Selector == rememberMeToken.Selector - ); - - if (token is null) - return (null, null); - - if (!token.CheckVerifier(rememberMeToken.Verifier)) - return (null, null); - - var user = await context.Users.SingleOrDefaultAsync(u => u.Id == token.UserId); - - if (user is null) - return (null, null); - - if (token.ExpiresSoon) - { - var (newToken, verifier) = LongTermSession.Create(user.Id); - await context.AddAsync(newToken); - await context.SaveChangesAsync(); - - return (new(user), new(newToken.Selector, verifier, newToken.Expires)); - } - - return (new(user), null); - } - - public async Task DeleteRememberMeToken(RememberMeToken rememberMeToken) - { - var session = await context.LongTermSessions.SingleOrDefaultAsync(s => - s.Selector == rememberMeToken.Selector - ); - - if (session is null) - return; - - if (!session.CheckVerifier(rememberMeToken.Verifier)) - return; - - context.Remove(session); - await context.SaveChangesAsync(); - } - - public async Task ChangePassword(Guid userId, string password, CancellationToken cancellationToken) - { - // change the password - // invalidate long term sessions - // invalidate sessions - - var user = await context.Users.SingleOrDefaultAsync(u => u.Id == userId,cancellationToken); - - if (user is null) - throw new DomainError("invalid user"); - - user.SetPassword(password); - - await context.SaveChangesAsync(cancellationToken); - } - - public async Task InvalidateUserSessions(Guid userId, CancellationToken cancellationToken) - { - await sessionStorage.InvalidateUserSessions(userId); - } - - private class GetSignupCodesQueryResultRow - { - public string Code { get; set; } - public string Email { get; set; } - public string Name { get; set; } - public Guid? RedeemedByUserId { get; set; } - public string? RedeemedByUsername { get; set; } - public DateTimeOffset? ExpiresOn { get; set; } - } -} diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs index 362e600..c78e923 100644 --- a/Femto.Modules.Auth/Application/AuthStartup.cs +++ b/Femto.Modules.Auth/Application/AuthStartup.cs @@ -3,6 +3,7 @@ using Femto.Common.Infrastructure; using Femto.Common.Infrastructure.DbConnection; using Femto.Common.Infrastructure.Outbox; using Femto.Common.Integration; +using Femto.Modules.Auth.Application.Services; using Femto.Modules.Auth.Data; using Femto.Modules.Auth.Infrastructure; using MediatR; @@ -20,14 +21,13 @@ public static class AuthStartup this IServiceCollection rootContainer, string connectionString, IEventBus eventBus, - ILoggerFactory loggerFactory, - TimeProvider timeProvider + ILoggerFactory loggerFactory ) { var hostBuilder = Host.CreateDefaultBuilder(); hostBuilder.ConfigureServices(services => - ConfigureServices(services, connectionString, eventBus, loggerFactory, timeProvider) + ConfigureServices(services, connectionString, eventBus, loggerFactory) ); var host = hostBuilder.Build(); @@ -41,6 +41,7 @@ public static class AuthStartup } ); + rootContainer.ExposeScopedService(); rootContainer.ExposeScopedService(); rootContainer.AddHostedService(services => new AuthApplication(host)); @@ -53,12 +54,9 @@ public static class AuthStartup IServiceCollection services, string connectionString, IEventPublisher publisher, - ILoggerFactory loggerFactory, - TimeProvider timeProvider + ILoggerFactory loggerFactory ) { - services.AddSingleton(timeProvider); - services.AddTransient(_ => new DbConnectionFactory(connectionString)); services.AddDbContext(builder => @@ -88,8 +86,7 @@ public static class AuthStartup services.AddSingleton(publisher); services.AddSingleton(); - services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>)); - + services.AddScoped(); services.AddScoped(); } diff --git a/Femto.Modules.Auth/Application/Dto/RememberMeToken.cs b/Femto.Modules.Auth/Application/Dto/RememberMeToken.cs deleted file mode 100644 index 5750b8d..0000000 --- a/Femto.Modules.Auth/Application/Dto/RememberMeToken.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Femto.Modules.Auth.Models; - -namespace Femto.Modules.Auth.Application.Dto; - -public record RememberMeToken(string Selector, string Verifier) -{ - public static RememberMeToken FromCode(string code) - { - var parts = code.Split('.'); - return new RememberMeToken(parts[0], parts[1]); - } - -}; - -public record NewRememberMeToken(string Selector, string Verifier, DateTimeOffset Expires) -{ - public string Code => $"{Selector}.{Verifier}"; -} diff --git a/Femto.Modules.Auth/Application/IAuthService.cs b/Femto.Modules.Auth/Application/IAuthService.cs deleted file mode 100644 index f656d95..0000000 --- a/Femto.Modules.Auth/Application/IAuthService.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Femto.Modules.Auth.Application.Dto; -using Femto.Modules.Auth.Models; - -namespace Femto.Modules.Auth.Application; - -public interface IAuthService -{ - public Task AuthenticateUserCredentials( - string username, - string password, - CancellationToken cancellationToken = default - ); - public Task GetUserWithId( - Guid? userId, - CancellationToken cancellationToken = default - ); - public Task CreateNewSession(Guid userId); - public Task CreateWeakSession(Guid userId); - public Task GetSession(string sessionId); - public Task DeleteSession(string sessionId); - - public Task CreateUserWithCredentials(string username, - string password, - string signupCode, - CancellationToken cancellationToken = default); - - public Task AddSignupCode( - string code, - string recipientName, - CancellationToken cancellationToken = default - ); - - public Task> GetSignupCodes( - CancellationToken cancellationToken = default - ); - - Task CreateRememberMeToken(Guid userId); - Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken(RememberMeToken rememberMeToken); - Task DeleteRememberMeToken(RememberMeToken rememberMeToken); - - Task ChangePassword(Guid userId, string password, CancellationToken cancellationToken = default); - Task InvalidateUserSessions(Guid userId, CancellationToken cancellationToken = default); -} - -public record UserAndSession(UserInfo User, Session Session); \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommand.cs b/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommand.cs new file mode 100644 index 0000000..be24aa9 --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommand.cs @@ -0,0 +1,5 @@ +using Femto.Common.Domain; + +namespace Femto.Modules.Auth.Application.Interface.CreateSignupCode; + +public record CreateSignupCodeCommand(string Code, string RecipientEmail, string RecipientName): ICommand; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommandHandler.cs new file mode 100644 index 0000000..cfbb44a --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommandHandler.cs @@ -0,0 +1,15 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Data; +using Femto.Modules.Auth.Models; + +namespace Femto.Modules.Auth.Application.Interface.CreateSignupCode; + +internal class CreateSignupCodeCommandHandler(AuthContext context) : ICommandHandler +{ + public async Task Handle(CreateSignupCodeCommand command, CancellationToken cancellationToken) + { + var code = new SignupCode(command.RecipientEmail, command.RecipientName, command.Code); + + await context.SignupCodes.AddAsync(code, cancellationToken); + } +} diff --git a/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommand.cs b/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommand.cs new file mode 100644 index 0000000..44c346f --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommand.cs @@ -0,0 +1,6 @@ + +using Femto.Common.Domain; + +namespace Femto.Modules.Auth.Application.Interface.Deauthenticate; + +public record DeauthenticateCommand(Guid UserId, string SessionId, string? RememberMeToken) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs new file mode 100644 index 0000000..435718c --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/Deauthenticate/DeauthenticateCommandHandler.cs @@ -0,0 +1,12 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Data; + +namespace Femto.Modules.Auth.Application.Interface.Deauthenticate; + +internal class DeauthenticateCommandHandler(AuthContext context) : ICommandHandler +{ + public async Task Handle(DeauthenticateCommand request, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQuery.cs b/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQuery.cs new file mode 100644 index 0000000..422a09d --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQuery.cs @@ -0,0 +1,6 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Application.Dto; + +namespace Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery; + +public record GetSignupCodesQuery: IQuery>; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQueryHandler.cs b/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQueryHandler.cs new file mode 100644 index 0000000..201fdce --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQueryHandler.cs @@ -0,0 +1,55 @@ +using Dapper; +using Femto.Common.Domain; +using Femto.Common.Infrastructure.DbConnection; +using Femto.Modules.Auth.Application.Dto; + +namespace Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery; + +public class GetSignupCodesQueryHandler(IDbConnectionFactory connectionFactory) + : IQueryHandler> +{ + public async Task> Handle( + GetSignupCodesQuery request, + CancellationToken cancellationToken + ) + { + using var conn = connectionFactory.GetConnection(); + + // lang=sql + const string sql = """ + SELECT + sc.code as Code, + sc.recipient_email as Email, + sc.recipient_name as Name, + sc.redeeming_user_id as RedeemedByUserId, + u.username as RedeemedByUsername, + sc.expires_at as ExpiresOn + FROM authn.signup_code sc + LEFT JOIN authn.user_identity u ON u.id = sc.redeeming_user_id + ORDER BY sc.created_at DESC + """; + + var result = await conn.QueryAsync(sql); + + return result + .Select(row => new SignupCodeDto( + row.Code, + row.Email, + row.Name, + row.RedeemedByUserId, + row.RedeemedByUsername, + row.ExpiresOn + )) + .ToList(); + } + + private class QueryResultRow + { + public string Code { get; set; } + public string Email { get; set; } + public string Name { get; set; } + public Guid? RedeemedByUserId { get; set; } + public string? RedeemedByUsername { get; set; } + public DateTimeOffset? ExpiresOn { get; set; } + } +} diff --git a/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs b/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs new file mode 100644 index 0000000..430b0d3 --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommand.cs @@ -0,0 +1,6 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Application.Dto; + +namespace Femto.Modules.Auth.Application.Interface.GetUserInfo; + +public record GetUserInfoCommand(Guid ForUser) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommandHandler.cs new file mode 100644 index 0000000..72c5f20 --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/GetUserInfo/GetUserInfoCommandHandler.cs @@ -0,0 +1,27 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Application.Dto; +using Femto.Modules.Auth.Data; +using Microsoft.EntityFrameworkCore; + +namespace Femto.Modules.Auth.Application.Interface.GetUserInfo; + +internal class GetUserInfoCommandHandler(AuthContext context) + : ICommandHandler +{ + public async Task Handle( + GetUserInfoCommand request, + CancellationToken cancellationToken + ) + { + + var user = await context.Users.SingleOrDefaultAsync( + u => u.Id == request.ForUser, + cancellationToken + ); + + if (user is null) + return null; + + return new UserInfo(user); + } +} diff --git a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs new file mode 100644 index 0000000..87332cb --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs @@ -0,0 +1,6 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Application.Dto; + +namespace Femto.Modules.Auth.Application.Interface.Register; + +public record RegisterCommand(string Username, string Password, string SignupCode) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs new file mode 100644 index 0000000..7bb17be --- /dev/null +++ b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs @@ -0,0 +1,43 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Application.Dto; +using Femto.Modules.Auth.Data; +using Femto.Modules.Auth.Models; +using Microsoft.EntityFrameworkCore; + +namespace Femto.Modules.Auth.Application.Interface.Register; + +internal class RegisterCommandHandler(AuthContext context) + : ICommandHandler +{ + public async Task Handle(RegisterCommand request, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + + var code = await context + .SignupCodes.Where(c => c.Code == request.SignupCode) + .Where(c => c.ExpiresAt == null || c.ExpiresAt > now) + .Where(c => c.RedeemingUserId == null) + .SingleOrDefaultAsync(cancellationToken); + + if (code is null) + throw new DomainError("invalid signup code"); + + var usernameTaken = await context.Users.AnyAsync( + u => u.Username == request.Username, + cancellationToken + ); + + if (usernameTaken) + throw new DomainError("username taken"); + + var user = new UserIdentity(request.Username); + + await context.AddAsync(user, cancellationToken); + + user.SetPassword(request.Password); + + code.Redeem(user.Id); + + return new UserInfo(user); + } +} diff --git a/Femto.Modules.Auth/Application/Services/AuthModule.cs b/Femto.Modules.Auth/Application/Services/AuthModule.cs new file mode 100644 index 0000000..f64d78f --- /dev/null +++ b/Femto.Modules.Auth/Application/Services/AuthModule.cs @@ -0,0 +1,20 @@ +using Femto.Common.Domain; +using MediatR; + +namespace Femto.Modules.Auth.Application.Services; + +internal class AuthModule(IMediator mediator) : IAuthModule +{ + public async Task Command(ICommand command, CancellationToken cancellationToken = default) => + await mediator.Send(command, cancellationToken); + + public async Task Command( + ICommand command, + CancellationToken cancellationToken = default + ) => await mediator.Send(command, cancellationToken); + + public async Task Query( + IQuery query, + CancellationToken cancellationToken = default + ) => await mediator.Send(query, cancellationToken); +} diff --git a/Femto.Modules.Auth/Application/Services/AuthService.cs b/Femto.Modules.Auth/Application/Services/AuthService.cs new file mode 100644 index 0000000..1a9f868 --- /dev/null +++ b/Femto.Modules.Auth/Application/Services/AuthService.cs @@ -0,0 +1,86 @@ +using Femto.Common.Domain; +using Femto.Modules.Auth.Application.Dto; +using Femto.Modules.Auth.Data; +using Femto.Modules.Auth.Infrastructure; +using Femto.Modules.Auth.Models; +using Microsoft.EntityFrameworkCore; + +namespace Femto.Modules.Auth.Application.Services; + +internal class AuthService(AuthContext context, SessionStorage storage) : IAuthService +{ + public async Task GetUserWithCredentials( + string username, + string password, + CancellationToken cancellationToken = default + ) + { + var user = await context + .Users.Where(u => u.Username == username) + .SingleOrDefaultAsync(cancellationToken); + + if (user is null) + return null; + + if (!user.HasPassword(password)) + return null; + + return new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()); + } + + public Task GetUserWithId(Guid? userId, CancellationToken cancellationToken) + { + return context + .Users.Where(u => u.Id == userId) + .Select(u => new UserInfo(u.Id, u.Username, u.Roles.Select(r => r.Role).ToList())) + .SingleOrDefaultAsync(cancellationToken); + } + + public async Task CreateStrongSession(Guid userId) + { + var session = new Session(userId, true); + + await storage.AddSession(session); + + return session; + } + + public async Task CreateWeakSession(Guid userId) + { + var session = new Session(userId, false); + + await storage.AddSession(session); + + return session; + } + + public Task GetSession(string sessionId) + { + return storage.GetSession(sessionId); + } + + public async Task DeleteSession(string sessionId) + { + await storage.DeleteSession(sessionId); + } + + public async Task CreateLongTermSession(Guid userId, bool isStrong) + { + throw new NotImplementedException(); + } + + public async Task DeleteLongTermSession(string sessionId) + { + throw new NotImplementedException(); + } + + public async Task RefreshLongTermSession(string sessionId) + { + throw new NotImplementedException(); + } + + public async Task ValidateLongTermSession(string sessionId) + { + throw new NotImplementedException(); + } +} diff --git a/Femto.Modules.Auth/Application/Services/IAuthModule.cs b/Femto.Modules.Auth/Application/Services/IAuthModule.cs new file mode 100644 index 0000000..df34366 --- /dev/null +++ b/Femto.Modules.Auth/Application/Services/IAuthModule.cs @@ -0,0 +1,10 @@ +using Femto.Common.Domain; + +namespace Femto.Modules.Auth.Application.Services; + +public interface IAuthModule +{ + Task Command(ICommand command, CancellationToken cancellationToken = default); + Task Command(ICommand command, CancellationToken cancellationToken = default); + Task Query(IQuery query, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Femto.Modules.Auth/Application/Services/IAuthService.cs b/Femto.Modules.Auth/Application/Services/IAuthService.cs new file mode 100644 index 0000000..2858053 --- /dev/null +++ b/Femto.Modules.Auth/Application/Services/IAuthService.cs @@ -0,0 +1,20 @@ +using Femto.Modules.Auth.Application.Dto; +using Femto.Modules.Auth.Models; + +namespace Femto.Modules.Auth.Application.Services; + +/// +/// I broke off IAuthService from IAuthModule because the CQRS distinction is cumbersome when doing auth handling, +/// particularly in regards to session management. I may or may not bother to move the commands and queries here also, +/// but for controller actions I do quite like having the abstraction, and there is less drive within me to bother. +/// It just seems redundant to expose them both, and it's a bit confusin' +/// +public interface IAuthService +{ + public Task GetUserWithCredentials(string username, string password, CancellationToken cancellationToken = default); + public Task GetUserWithId(Guid? userId, CancellationToken cancellationToken = default); + public Task CreateStrongSession(Guid userId); + public Task CreateWeakSession(Guid userId); + public Task GetSession(string sessionId); + public Task DeleteSession(string sessionId); +} \ No newline at end of file diff --git a/Femto.Modules.Auth/Data/AuthContext.cs b/Femto.Modules.Auth/Data/AuthContext.cs index ac395ba..e4488e4 100644 --- a/Femto.Modules.Auth/Data/AuthContext.cs +++ b/Femto.Modules.Auth/Data/AuthContext.cs @@ -1,10 +1,6 @@ -using Femto.Common.Domain; using Femto.Common.Infrastructure.Outbox; using Femto.Modules.Auth.Models; -using MediatR; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.Extensions.Logging; namespace Femto.Modules.Auth.Data; @@ -21,43 +17,4 @@ internal class AuthContext(DbContextOptions options) : DbContext(op builder.HasDefaultSchema("authn"); builder.ApplyConfigurationsFromAssembly(typeof(AuthContext).Assembly); } - - public override int SaveChanges() - { - throw new InvalidOperationException("Use SaveChangesAsync instead"); - } - - public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - await EmitDomainEvents(cancellationToken); - - return await base.SaveChangesAsync(cancellationToken); - } - - private async Task EmitDomainEvents(CancellationToken cancellationToken) - { - var logger = this.GetService>(); - var publisher = this.GetService(); - var domainEvents = this - .ChangeTracker.Entries() - .SelectMany(e => - { - var events = e.Entity.DomainEvents; - e.Entity.ClearDomainEvents(); - return events; - }) - .ToList(); - - logger.LogTrace("loaded {Count} domain events", domainEvents.Count); - - foreach (var domainEvent in domainEvents) - { - logger.LogTrace( - "publishing {Type} domain event {Id}", - domainEvent.GetType().Name, - domainEvent.EventId - ); - await publisher.Publish(domainEvent, cancellationToken); - } - } } \ No newline at end of file diff --git a/Femto.Modules.Auth/Data/Configurations/LongTermSessionConfiguration.cs b/Femto.Modules.Auth/Data/Configurations/LongTermSessionConfiguration.cs deleted file mode 100644 index 00f2a13..0000000 --- a/Femto.Modules.Auth/Data/Configurations/LongTermSessionConfiguration.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Femto.Modules.Auth.Models; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Femto.Modules.Auth.Data.Configurations; - -public class LongTermSessionConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("long_term_session"); - } -} \ No newline at end of file diff --git a/Femto.Modules.Auth/Infrastructure/SaveChangesPipelineBehaviour.cs b/Femto.Modules.Auth/Infrastructure/SaveChangesPipelineBehaviour.cs deleted file mode 100644 index cc4f983..0000000 --- a/Femto.Modules.Auth/Infrastructure/SaveChangesPipelineBehaviour.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Femto.Modules.Auth.Data; -using MediatR; - -namespace Femto.Modules.Auth.Infrastructure; - -internal class SaveChangesPipelineBehaviour(AuthContext context) - : IPipelineBehavior - where TRequest : notnull -{ - public async Task Handle( - TRequest request, - RequestHandlerDelegate next, - CancellationToken cancellationToken - ) - { - var response = await next(cancellationToken); - - if (context.ChangeTracker.HasChanges()) - await context.SaveChangesAsync(cancellationToken); - - return response; - } -} diff --git a/Femto.Modules.Auth/Infrastructure/SessionStorage.cs b/Femto.Modules.Auth/Infrastructure/SessionStorage.cs index e331396..0e1b3dd 100644 --- a/Femto.Modules.Auth/Infrastructure/SessionStorage.cs +++ b/Femto.Modules.Auth/Infrastructure/SessionStorage.cs @@ -1,55 +1,29 @@ -using System.Collections; -using System.Collections.Concurrent; using Femto.Modules.Auth.Models; using Microsoft.Extensions.Caching.Memory; namespace Femto.Modules.Auth.Infrastructure; -internal class SessionStorage(TimeProvider timeProvider) +internal class SessionStorage(MemoryCacheOptions? options = null) { - private readonly IMemoryCache _storage = new MemoryCache(new MemoryCacheOptions()); + private readonly IMemoryCache _storage = new MemoryCache(options ?? new MemoryCacheOptions()); - public async Task GetSession(string id) + public Task GetSession(string id) { - var session = this._storage.Get($"session:{id}"); - - if (session is null) - return null; - - var invalidUntil = this._storage.Get( - $"user:invalid_until:{session.UserId}" - ); - - if (invalidUntil is not null && invalidUntil > session.Expires) - return null; - - return session; + return Task.FromResult(this._storage.Get(id)); } public Task AddSession(Session session) { - using var sessionEntry = this._storage.CreateEntry($"session:{session.Id}"); - sessionEntry.Value = session; - sessionEntry.SetAbsoluteExpiration(session.Expires); + using var entry = this._storage.CreateEntry(session.Id); + entry.Value = session; + entry.SetAbsoluteExpiration(session.Expires); return Task.CompletedTask; } public Task DeleteSession(string id) { - this._storage.Remove($"session:{id}"); - - return Task.CompletedTask; - } - - public Task InvalidateUserSessions(Guid userId) - { - var invalidUntil = timeProvider.GetUtcNow() + Session.ValidityPeriod; - - // invalidate sessions who are currently valid - // any sessions created after this will have a validity period that extends past invalid_until - // this cache entry doesn't need to live longer than that point in time - this._storage.Set($"user:invalid_until:{userId}", invalidUntil, invalidUntil); + this._storage.Remove(id); return Task.CompletedTask; } diff --git a/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs b/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs deleted file mode 100644 index e09c66e..0000000 --- a/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Femto.Modules.Auth.Data; -using Femto.Modules.Auth.Models.Events; -using MediatR; -using Microsoft.EntityFrameworkCore; - -namespace Femto.Modules.Auth.Models.DomainEventHandlers; - -internal class UserPasswordChangedHandler(AuthContext context) - : INotificationHandler -{ - public async Task Handle(UserWasCreatedEvent notification, CancellationToken cancellationToken) - { - var longTermSessions = await context - .LongTermSessions.Where(s => s.UserId == notification.User.Id) - .ToListAsync(cancellationToken); - - foreach (var session in longTermSessions) - { - session.Invalidate(); - } - } -} diff --git a/Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs b/Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs deleted file mode 100644 index 70c9a73..0000000 --- a/Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs +++ /dev/null @@ -1,5 +0,0 @@ -using Femto.Common.Domain; - -namespace Femto.Modules.Auth.Models.Events; - -internal record UserPasswordChangedDomainEvent(UserIdentity User) : DomainEvent; \ No newline at end of file diff --git a/Femto.Modules.Auth/Models/LongTermSession.cs b/Femto.Modules.Auth/Models/LongTermSession.cs index eba3d9d..06bd19d 100644 --- a/Femto.Modules.Auth/Models/LongTermSession.cs +++ b/Femto.Modules.Auth/Models/LongTermSession.cs @@ -1,4 +1,3 @@ -using System.ComponentModel.DataAnnotations.Schema; using System.Text; using static System.Security.Cryptography.RandomNumberGenerator; @@ -7,32 +6,22 @@ namespace Femto.Modules.Auth.Models; public class LongTermSession { private static TimeSpan TokenTimeout { get; } = TimeSpan.FromDays(90); - private static TimeSpan RefreshBuffer { get; } = TimeSpan.FromDays(5); - + public int Id { get; private set; } - + public string Selector { get; private set; } - + public byte[] HashedVerifier { get; private set; } - + public DateTimeOffset Expires { get; private set; } - + public Guid UserId { get; private set; } - public bool IsInvalidated { get; private set; } - - [NotMapped] - public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer; - - private LongTermSession() { } - + private LongTermSession() {} + public static (LongTermSession, string) Create(Guid userId) { - var selector = GetString( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", - 12 - ); - + var selector = GetString("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 12); var verifier = GetString("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 32); using var sha256 = System.Security.Cryptography.SHA256.Create(); @@ -40,34 +29,23 @@ public class LongTermSession var longTermSession = new LongTermSession { Selector = selector, - HashedVerifier = ComputeHash(verifier), + HashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier)), UserId = userId, - Expires = DateTimeOffset.UtcNow + TokenTimeout, + Expires = DateTimeOffset.UtcNow + TokenTimeout }; + + var rememberMeToken = $"{selector}.{verifier}"; - return (longTermSession, verifier); + return (longTermSession, rememberMeToken); } - public bool CheckVerifier(string verifier) + public bool Validate(string verifier) { - if (this.IsInvalidated) - return false; - if (this.Expires < DateTimeOffset.UtcNow) return false; - - return ComputeHash(verifier).SequenceEqual(this.HashedVerifier); - } - - private static byte[] ComputeHash(string verifier) - { + using var sha256 = System.Security.Cryptography.SHA256.Create(); var hashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier)); - return hashedVerifier; + return hashedVerifier.SequenceEqual(this.HashedVerifier); } - - public void Invalidate() - { - this.IsInvalidated = true; - } -} +} \ No newline at end of file diff --git a/Femto.Modules.Auth/Models/Session.cs b/Femto.Modules.Auth/Models/Session.cs index e641a61..c142fb8 100644 --- a/Femto.Modules.Auth/Models/Session.cs +++ b/Femto.Modules.Auth/Models/Session.cs @@ -4,13 +4,11 @@ namespace Femto.Modules.Auth.Models; public class Session(Guid userId, bool isStrong) { - public static readonly TimeSpan ValidityPeriod = TimeSpan.FromMinutes(15); - private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMinutes(5); public string Id { get; } = Convert.ToBase64String(GetBytes(32)); public Guid UserId { get; } = userId; - public DateTimeOffset Expires { get; } = DateTimeOffset.UtcNow + ValidityPeriod; + public DateTimeOffset Expires { get; } = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(15); - public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer; + public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + TimeSpan.FromMinutes(5); public bool IsStronglyAuthenticated { get; } = isStrong; public bool IsExpired => this.Expires < DateTimeOffset.UtcNow; } diff --git a/Femto.Modules.Auth/Models/UserIdentity.cs b/Femto.Modules.Auth/Models/UserIdentity.cs index bd0288f..a7e3ddd 100644 --- a/Femto.Modules.Auth/Models/UserIdentity.cs +++ b/Femto.Modules.Auth/Models/UserIdentity.cs @@ -28,8 +28,6 @@ internal class UserIdentity : Entity public void SetPassword(string password) { - if (this.Password is not null) - this.AddDomainEvent(new UserPasswordChangedDomainEvent(this)); this.Password = new Password(password); } diff --git a/Femto.Modules.Blog.Data/Class1.cs b/Femto.Modules.Blog.Data/Class1.cs new file mode 100644 index 0000000..3be8b2a --- /dev/null +++ b/Femto.Modules.Blog.Data/Class1.cs @@ -0,0 +1,5 @@ +namespace Femto.Modules.Blog.Data; + +public class Class1 +{ +} \ No newline at end of file diff --git a/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj b/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj new file mode 100644 index 0000000..17b910f --- /dev/null +++ b/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj b/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj new file mode 100644 index 0000000..6ae6742 --- /dev/null +++ b/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + ..\..\..\..\.nuget\packages\microsoft.entityframeworkcore\9.0.4\lib\net8.0\Microsoft.EntityFrameworkCore.dll + + + + diff --git a/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs deleted file mode 100644 index 445c59e..0000000 --- a/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs +++ /dev/null @@ -1,5 +0,0 @@ -using Femto.Common.Domain; - -namespace Femto.Modules.Blog.Application.Commands.AddPostComment; - -public record AddPostCommentCommand(Guid PostId, Guid AuthorId, string Content) : ICommand; \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs deleted file mode 100644 index 6e52877..0000000 --- a/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Femto.Common.Domain; -using Microsoft.EntityFrameworkCore; - -namespace Femto.Modules.Blog.Application.Commands.AddPostComment; - -internal class AddPostCommentCommandHandler(BlogContext context) : ICommandHandler -{ - public async Task Handle(AddPostCommentCommand request, CancellationToken cancellationToken) - { - var post = await context.Posts.SingleOrDefaultAsync( - p => p.Id == request.PostId, - cancellationToken - ); - - if (post is null) - return; - - post.AddComment(request.AuthorId, request.Content); - } -} \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs index 25bab45..2d9c713 100644 --- a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs +++ b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs @@ -24,9 +24,11 @@ internal class CreatePostCommandHandler(BlogContext context) media.Width, media.Height )) - .ToList(), - request.IsPublic is true - ); + .ToList() + ) + { + IsPublic = request.IsPublic is true + }; await context.AddAsync(post, cancellationToken); @@ -37,8 +39,7 @@ internal class CreatePostCommandHandler(BlogContext context) post.PostedOn, new PostAuthorDto(post.AuthorId, request.CurrentUser.Username), [], - post.PossibleReactions, - [] + post.PossibleReactions ); } } diff --git a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs index 630cbe2..b1defec 100644 --- a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs +++ b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs @@ -24,8 +24,6 @@ internal class PostConfiguration : IEntityTypeConfiguration } ); - table.OwnsMany(p => p.Comments).WithOwner(); - table.Property("PossibleReactionsJson").HasColumnName("possible_reactions"); table.Ignore(e => e.PossibleReactions); diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs index 8b75d6e..be8157a 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs @@ -1,3 +1,3 @@ namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto; -public record GetPostsQueryResult(IList Posts); \ No newline at end of file +public record GetPostsQueryResult(IList Posts, Guid? Next); \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs deleted file mode 100644 index 55ea5e8..0000000 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto; - -public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn); \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs index 63efede..b8b6a3d 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs @@ -7,6 +7,5 @@ public record PostDto( DateTimeOffset CreatedAt, PostAuthorDto Author, IList Reactions, - IEnumerable PossibleReactions, - IList Comments -); \ No newline at end of file + IEnumerable PossibleReactions +); diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs index 60349b9..9ea33dd 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs @@ -1,3 +1,3 @@ namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto; -public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn); +public record PostReactionDto(string Emoji, int Count, bool DidReact); \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs index 1bb1d4c..f8af9d2 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs @@ -3,27 +3,22 @@ using Femto.Modules.Blog.Application.Queries.GetPosts.Dto; namespace Femto.Modules.Blog.Application.Queries.GetPosts; -/// -/// Get posts in reverse chronological order -/// -/// public record GetPostsQuery(Guid? CurrentUserId) : IQuery { - /// - /// Id of the specific post to load. If specified, After and Amount are ignored - /// - public Guid? PostId { get; } - - /// - /// If specified, loads posts from after the given Id. Used for paging - /// - public Guid? After { get; init; } + public Guid? From { get; init; } public int Amount { get; init; } = 20; public Guid? AuthorId { get; init; } public string? Author { get; init; } - public GetPostsQuery(Guid postId, Guid? currentUserId) : this(currentUserId) - { - this.PostId = postId; - } -} \ No newline at end of file + /// + /// Default is to load in reverse chronological order + /// TODO this is not exposed on the client as it probably wouldn't work that well + /// + public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward; +} + +public enum GetPostsDirection +{ + Forward, + Backward, +} diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs index c57627f..26ae43a 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs @@ -18,7 +18,7 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) var username = query.Author; var authorGuid = query.AuthorId; - var cursor = query.After; + var cursor = query.From; var showPrivate = query.CurrentUserId is not null; var loadPostsResult = await conn.QueryAsync( @@ -33,10 +33,9 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) from blog.post inner join blog.author on blog.author.id = blog.post.author_id where (@username is null or blog.author.username = @username) - and (@postId is null or blog.post.id = @postId) and (@showPrivate or blog.post.is_public = true) and (@authorGuid is null or blog.author.id = @authorGuid) - and (@cursor is null or blog.post.id < @cursor) + and (@cursor is null or blog.post.id <= @cursor) order by blog.post.id desc limit @amount """, @@ -45,13 +44,15 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) username, authorGuid, cursor, - amount = query.PostId is not null ? 1 : query.Amount, + // load an extra one to take for the cursor + amount = query.Amount + 1, showPrivate, - postId = query.PostId, } ); - var posts = loadPostsResult.ToList(); + var loadedPosts = loadPostsResult.ToList(); + var posts = loadedPosts.Take(query.Amount).ToList(); + var next = loadedPosts.LastOrDefault()?.PostId; var postIds = posts.Select(p => p.PostId).ToList(); @@ -69,69 +70,69 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) new { postIds } ); - var media = loadMediaResult.ToList(); - var loadReactionsResult = await conn.QueryAsync( """ select pr.post_id as PostId, - a.username as AuthorName, - pr.emoji as Emoji, - pr.created_at as CreatedOn + pr.author_id as AuthorId, + pr.emoji as Emoji from blog.post_reaction pr - join blog.author a on a.id = pr.author_id where pr.post_id = ANY (@postIds) """, new { postIds } ); - var reactions = loadReactionsResult.ToList(); + var reactionsByPostId = loadReactionsResult + .GroupBy(r => r.PostId) + .ToDictionary( + group => group.Key, + group => + group + .GroupBy( + r => r.Emoji, + (key, g) => + { + var reactions = g.ToList(); + return new PostReactionDto( + key, + reactions.Count, + reactions.Any(r => r.AuthorId == query.CurrentUserId) + ); + } + ) + .ToList() + ); - var loadCommentsResult = await conn.QueryAsync( - """ - select - pc.id as CommentId, - pc.post_id as PostId, - pc.content as Content, - pc.created_at as PostedOn, - a.username as AuthorName - from blog.post_comment pc - join blog.author a on pc.author_id = a.id - where pc.post_id = ANY (@postIds) - """, - new { postIds } - ); - - var comments = loadCommentsResult.ToList(); + var mediaByPostId = loadMediaResult + .GroupBy(m => m.PostId) + .ToDictionary( + g => g.Key, + g => + g.Select(m => new PostMediaDto( + new Uri(m.MediaUrl), + m.MediaWidth, + m.MediaHeight + )) + .ToList() + ); return new GetPostsQueryResult( posts .Select(p => new PostDto( p.PostId, p.Content, - media - .Where(m => m.PostId == p.PostId) - .Select(m => new PostMediaDto( - new Uri(m.MediaUrl), - m.MediaWidth, - m.MediaHeight - )) - .ToList(), + mediaByPostId.TryGetValue(p.PostId, out var mediaDtos) ? mediaDtos : [], p.PostedOn, new PostAuthorDto(p.AuthorId, p.Username), - reactions - .Where(r => r.PostId == p.PostId) - .Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.CreatedAt)) - .ToList(), + reactionsByPostId.TryGetValue(p.PostId, out var reactionDtos) + ? reactionDtos.ToList() + : [], !string.IsNullOrEmpty(p.PossibleReactions) ? JsonSerializer.Deserialize>(p.PossibleReactions)! - : [], - comments - .Where(c => c.PostId == p.PostId) - .Select(c => new PostCommentDto(c.AuthorName, c.Content, c.PostedOn)) - .ToList() + : [] )) - .ToList() + .ToList(), + next ); } @@ -157,17 +158,7 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) internal record LoadReactionRow { public Guid PostId { get; init; } - public string AuthorName { get; init; } + public Guid AuthorId { get; init; } public string Emoji { get; init; } - public DateTimeOffset CreatedAt { get; init; } - } - - internal record LoadCommentRow - { - public Guid CommentId { get; init; } - public Guid PostId { get; init; } - public string Content { get; init; } - public DateTimeOffset PostedOn { get; init; } - public string AuthorName { get; init; } } } diff --git a/Femto.Modules.Blog/Domain/Posts/Post.cs b/Femto.Modules.Blog/Domain/Posts/Post.cs index b5a9b2d..dc4d937 100644 --- a/Femto.Modules.Blog/Domain/Posts/Post.cs +++ b/Femto.Modules.Blog/Domain/Posts/Post.cs @@ -13,9 +13,7 @@ internal class Post : Entity public IList Media { get; private set; } public IList Reactions { get; private set; } = []; - - public IList Comments { get; private set; } = []; - public bool IsPublic { get; private set; } + public bool IsPublic { get; set; } public DateTimeOffset PostedOn { get; private set; } @@ -29,7 +27,7 @@ internal class Post : Entity private Post() { } - public Post(Guid authorId, string content, IList media, bool isPublic) + public Post(Guid authorId, string content, IList media) { this.Id = Guid.CreateVersion7(); this.AuthorId = authorId; @@ -37,7 +35,6 @@ internal class Post : Entity this.Media = media; this.PossibleReactions = AllEmoji.GetRandomEmoji(5); this.PostedOn = DateTimeOffset.UtcNow; - this.IsPublic = isPublic; this.AddDomainEvent(new PostCreated(this)); } @@ -59,14 +56,4 @@ internal class Post : Entity .Reactions.Where(r => r.AuthorId != reactorId || r.Emoji != emoji) .ToList(); } - - public void AddComment(Guid authorId, string content) - { - // XXX just ignore empty comments for now. we may want to upgrade this to an error - // but it is probably suitable to just consider it a no-op - if (string.IsNullOrWhiteSpace(content)) - return; - - this.Comments.Add(new PostComment(authorId, content)); - } } diff --git a/Femto.Modules.Blog/Domain/Posts/PostComment.cs b/Femto.Modules.Blog/Domain/Posts/PostComment.cs deleted file mode 100644 index 6f658a8..0000000 --- a/Femto.Modules.Blog/Domain/Posts/PostComment.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Femto.Modules.Blog.Domain.Posts; - -internal class PostComment -{ - public Guid Id { get; private set; } - public Guid AuthorId { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - public string Content { get; private set; } - - private PostComment() {} - - public PostComment(Guid authorId, string content) - { - this.Id = Guid.CreateVersion7(); - this.AuthorId = authorId; - this.Content = content; - this.CreatedAt = TimeProvider.System.GetUtcNow(); - } -} \ No newline at end of file diff --git a/Femto.Modules.Blog/Domain/Posts/PostReaction.cs b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs index 38e33b8..ea8ab16 100644 --- a/Femto.Modules.Blog/Domain/Posts/PostReaction.cs +++ b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs @@ -5,13 +5,11 @@ public class PostReaction public Guid AuthorId { get; private set; } public Guid PostId { get; private set; } public string Emoji { get; private set; } = null!; - public DateTimeOffset CreatedAt { get; private set; } public PostReaction(Guid authorId, Guid postId, string emoji) { this.AuthorId = authorId; this.PostId = postId; this.Emoji = emoji; - this.CreatedAt = TimeProvider.System.GetUtcNow(); } private PostReaction() { } diff --git a/Femto.Modules.Files/Domain/Files/File.cs b/Femto.Modules.Files/Domain/Files/File.cs new file mode 100644 index 0000000..9600ceb --- /dev/null +++ b/Femto.Modules.Files/Domain/Files/File.cs @@ -0,0 +1,8 @@ +namespace Femto.Modules.Files.Domain.Files; + +public class File +{ + Guid Id { get; set; } + + +} \ No newline at end of file diff --git a/Femto.Modules.Files/Femto.Modules.Files.csproj b/Femto.Modules.Files/Femto.Modules.Files.csproj new file mode 100644 index 0000000..17b910f --- /dev/null +++ b/Femto.Modules.Files/Femto.Modules.Files.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + +