diff --git a/Directory.Build.props b/Directory.Build.props index 727ce06..0751e1d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.28 + 0.1.31 diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs index efe0eed..9c44b04 100644 --- a/Femto.Api/Auth/SessionAuthenticationHandler.cs +++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs @@ -4,6 +4,7 @@ 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 Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; @@ -26,10 +27,10 @@ internal class SessionAuthenticationHandler( if (user is null) user = await this.TryAuthenticateWithRememberMeToken(); - + if (user is null) return AuthenticateResult.NoResult(); - + var claims = new List { new(ClaimTypes.Name, user.Username), @@ -41,7 +42,11 @@ internal class SessionAuthenticationHandler( var identity = new ClaimsIdentity(claims, this.Scheme.Name); var principal = new ClaimsPrincipal(identity); - currentUserContext.CurrentUser = new CurrentUser(user.Id, user.Username); + currentUserContext.CurrentUser = new CurrentUser( + user.Id, + user.Username, + user.Roles.Contains(Role.SuperUser) + ); return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name)); } @@ -99,21 +104,23 @@ internal class SessionAuthenticationHandler( * 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); + + 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); diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs index 9fe4b85..c9108da 100644 --- a/Femto.Api/Controllers/Auth/AuthController.cs +++ b/Femto.Api/Controllers/Auth/AuthController.cs @@ -28,9 +28,9 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService return this.BadRequest(); var (user, session) = result; - + HttpContext.SetSession(session, user); - + if (request.RememberMe) { var newRememberMeToken = await authService.CreateRememberMeToken(user.Id); @@ -41,7 +41,10 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService } [HttpPost("register")] - public async Task> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken) + public async Task> Register( + [FromBody] RegisterRequest request, + CancellationToken cancellationToken + ) { var (user, session) = await authService.CreateUserWithCredentials( request.Username, @@ -51,7 +54,7 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService ); HttpContext.SetSession(session, user); - + if (request.RememberMe) { var newRememberMeToken = await authService.CreateRememberMeToken(user.Id); @@ -65,6 +68,60 @@ 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() { @@ -111,6 +168,7 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService ); } + [Obsolete("use POST /auth/create-signup-code")] [HttpPost("signup-codes")] [Authorize(Roles = "SuperUser")] public async Task CreateSignupCode( @@ -123,6 +181,7 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService return Ok(new { }); } + [Obsolete("use GET /auth/list-signup-codes")] [HttpGet("signup-codes")] [Authorize(Roles = "SuperUser")] public async Task> ListSignupCodes( @@ -142,4 +201,36 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService )) ); } + + [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); + + return new ListSignupCodesResult( + codes.Select(c => new SignupCodeDto( + c.Code, + c.Email, + c.Name, + c.RedeemedByUserId, + c.RedeemedByUsername, + c.ExpiresOn + )) + ); + } } diff --git a/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs b/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs new file mode 100644 index 0000000..77f1dcd --- /dev/null +++ b/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs @@ -0,0 +1,3 @@ +namespace Femto.Api.Controllers.Auth; + +public record ChangePasswordRequestBody(Guid UserId, string NewPassword); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs b/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs new file mode 100644 index 0000000..7546af0 --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..b44740c --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs @@ -0,0 +1,3 @@ +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 e5155f6..3705456 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? From, int? Amount, Guid? AuthorId, string? Author); \ No newline at end of file +public record GetPublicPostsSearchParams(Guid? After, 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 7efdeee..54b9df7 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, Guid? Next); \ No newline at end of file +public record LoadPostsResponse(IEnumerable Posts); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs b/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs new file mode 100644 index 0000000..04e180a --- /dev/null +++ b/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs @@ -0,0 +1,3 @@ +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 a00c8c1..c9af7c6 100644 --- a/Femto.Api/Controllers/Posts/Dto/PostDto.cs +++ b/Femto.Api/Controllers/Posts/Dto/PostDto.cs @@ -10,5 +10,19 @@ public record PostDto( IEnumerable Media, IEnumerable Reactions, DateTimeOffset CreatedAt, - IEnumerable PossibleReactions -); \ No newline at end of file + 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 diff --git a/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs index 81e3a95..f9934c6 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, int Count, bool DidReact); \ No newline at end of file +public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn); \ No newline at end of file diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs index 6036767..ed882f7 100644 --- a/Femto.Api/Controllers/Posts/PostsController.cs +++ b/Femto.Api/Controllers/Posts/PostsController.cs @@ -1,6 +1,7 @@ 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; @@ -13,7 +14,7 @@ namespace Femto.Api.Controllers.Posts; [ApiController] [Route("posts")] -public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) +public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext, IAuthorizationService auth) : ControllerBase { [HttpGet] @@ -25,7 +26,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current var res = await blogModule.Query( new GetPostsQuery(currentUserContext.CurrentUser?.Id) { - From = searchParams.From, + After = searchParams.After, Amount = searchParams.Amount ?? 20, AuthorId = searchParams.AuthorId, Author = searchParams.Author, @@ -33,18 +34,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current cancellationToken ); - 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 - ); + return new LoadPostsResponse(res.Posts.Select(PostDto.FromModel)); } [HttpPost] @@ -75,17 +65,26 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current 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 - ) + 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 ); + + var post = result.Posts.SingleOrDefault(); + + if (post is null) + return NotFound(); + + return new GetPostResponse(PostDto.FromModel(post)); } [HttpDelete("{postId}")] @@ -100,24 +99,56 @@ 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 08a40b8..ebb81fd 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); +builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory, TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddScoped(s => s.GetRequiredService()); diff --git a/Femto.Api/Properties/launchSettings.json b/Femto.Api/Properties/launchSettings.json index 9a9026a..b024278 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://stinkpad:7269;http://0.0.0.0:5181", + "applicationUrl": "https://localhost:7269", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/Femto.Common/ICurrentUserContext.cs b/Femto.Common/ICurrentUserContext.cs index 3e7dae6..629b2d2 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); +public record CurrentUser(Guid Id, string Username, bool IsSuperUser); \ No newline at end of file diff --git a/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql b/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql new file mode 100644 index 0000000..15d0323 --- /dev/null +++ b/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql @@ -0,0 +1,5 @@ +-- 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 new file mode 100644 index 0000000..4557156 --- /dev/null +++ b/Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql @@ -0,0 +1,4 @@ +-- 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 new file mode 100644 index 0000000..44e0086 --- /dev/null +++ b/Femto.Database/Migrations/20250810172242_AddCommentToPost.sql @@ -0,0 +1,11 @@ +-- 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 433f73c..2c8efcc 100644 --- a/Femto.Database/Seed/TestDataSeeder.cs +++ b/Femto.Database/Seed/TestDataSeeder.cs @@ -43,6 +43,7 @@ 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 @@ -63,6 +64,12 @@ 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 index d81eb42..eb6215f 100644 --- a/Femto.Modules.Auth/Application/AuthService.cs +++ b/Femto.Modules.Auth/Application/AuthService.cs @@ -11,7 +11,7 @@ namespace Femto.Modules.Auth.Application; internal class AuthService( AuthContext context, - SessionStorage storage, + SessionStorage sessionStorage, IDbConnectionFactory connectionFactory ) : IAuthService { @@ -33,7 +33,7 @@ internal class AuthService( var session = new Session(user.Id, true); - await storage.AddSession(session); + await sessionStorage.AddSession(session); return new( new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()), @@ -53,7 +53,7 @@ internal class AuthService( { var session = new Session(userId, true); - await storage.AddSession(session); + await sessionStorage.AddSession(session); return session; } @@ -62,19 +62,19 @@ internal class AuthService( { var session = new Session(userId, false); - await storage.AddSession(session); + await sessionStorage.AddSession(session); return session; } public Task GetSession(string sessionId) { - return storage.GetSession(sessionId); + return sessionStorage.GetSession(sessionId); } public async Task DeleteSession(string sessionId) { - await storage.DeleteSession(sessionId); + await sessionStorage.DeleteSession(sessionId); } public async Task CreateUserWithCredentials( @@ -113,7 +113,7 @@ internal class AuthService( var session = new Session(user.Id, true); - await storage.AddSession(session); + await sessionStorage.AddSession(session); await context.SaveChangesAsync(cancellationToken); @@ -189,7 +189,7 @@ internal class AuthService( if (token is null) return (null, null); - if (!token.Validate(rememberMeToken.Verifier)) + if (!token.CheckVerifier(rememberMeToken.Verifier)) return (null, null); var user = await context.Users.SingleOrDefaultAsync(u => u.Id == token.UserId); @@ -218,13 +218,34 @@ internal class AuthService( if (session is null) return; - if (!session.Validate(rememberMeToken.Verifier)) + 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; } diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs index 1d20668..362e600 100644 --- a/Femto.Modules.Auth/Application/AuthStartup.cs +++ b/Femto.Modules.Auth/Application/AuthStartup.cs @@ -20,13 +20,14 @@ public static class AuthStartup this IServiceCollection rootContainer, string connectionString, IEventBus eventBus, - ILoggerFactory loggerFactory + ILoggerFactory loggerFactory, + TimeProvider timeProvider ) { var hostBuilder = Host.CreateDefaultBuilder(); hostBuilder.ConfigureServices(services => - ConfigureServices(services, connectionString, eventBus, loggerFactory) + ConfigureServices(services, connectionString, eventBus, loggerFactory, timeProvider) ); var host = hostBuilder.Build(); @@ -52,9 +53,12 @@ public static class AuthStartup IServiceCollection services, string connectionString, IEventPublisher publisher, - ILoggerFactory loggerFactory + ILoggerFactory loggerFactory, + TimeProvider timeProvider ) { + services.AddSingleton(timeProvider); + services.AddTransient(_ => new DbConnectionFactory(connectionString)); services.AddDbContext(builder => @@ -83,11 +87,8 @@ public static class AuthStartup services.AddSingleton(publisher); services.AddSingleton(); - - services.AddScoped( - typeof(IPipelineBehavior<,>), - typeof(SaveChangesPipelineBehaviour<,>) - ); + + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>)); services.AddScoped(); } diff --git a/Femto.Modules.Auth/Application/IAuthService.cs b/Femto.Modules.Auth/Application/IAuthService.cs index 9fd517d..f656d95 100644 --- a/Femto.Modules.Auth/Application/IAuthService.cs +++ b/Femto.Modules.Auth/Application/IAuthService.cs @@ -3,12 +3,6 @@ using Femto.Modules.Auth.Models; namespace Femto.Modules.Auth.Application; -/// -/// 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 AuthenticateUserCredentials( @@ -43,6 +37,9 @@ public interface IAuthService 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/Infrastructure/SessionStorage.cs b/Femto.Modules.Auth/Infrastructure/SessionStorage.cs index 0e1b3dd..e331396 100644 --- a/Femto.Modules.Auth/Infrastructure/SessionStorage.cs +++ b/Femto.Modules.Auth/Infrastructure/SessionStorage.cs @@ -1,29 +1,55 @@ +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(MemoryCacheOptions? options = null) +internal class SessionStorage(TimeProvider timeProvider) { - private readonly IMemoryCache _storage = new MemoryCache(options ?? new MemoryCacheOptions()); + private readonly IMemoryCache _storage = new MemoryCache(new MemoryCacheOptions()); - public Task GetSession(string id) + public async Task GetSession(string id) { - return Task.FromResult(this._storage.Get(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; } public Task AddSession(Session session) { - using var entry = this._storage.CreateEntry(session.Id); - entry.Value = session; - entry.SetAbsoluteExpiration(session.Expires); + using var sessionEntry = this._storage.CreateEntry($"session:{session.Id}"); + sessionEntry.Value = session; + sessionEntry.SetAbsoluteExpiration(session.Expires); return Task.CompletedTask; } public Task DeleteSession(string id) { - this._storage.Remove(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); return Task.CompletedTask; } diff --git a/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs b/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs new file mode 100644 index 0000000..e09c66e --- /dev/null +++ b/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..70c9a73 --- /dev/null +++ b/Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs @@ -0,0 +1,5 @@ +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 113a3eb..eba3d9d 100644 --- a/Femto.Modules.Auth/Models/LongTermSession.cs +++ b/Femto.Modules.Auth/Models/LongTermSession.cs @@ -18,6 +18,8 @@ public class LongTermSession 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; @@ -46,8 +48,11 @@ public class LongTermSession return (longTermSession, verifier); } - public bool Validate(string verifier) + public bool CheckVerifier(string verifier) { + if (this.IsInvalidated) + return false; + if (this.Expires < DateTimeOffset.UtcNow) return false; @@ -60,4 +65,9 @@ public class LongTermSession var hashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier)); return hashedVerifier; } + + public void Invalidate() + { + this.IsInvalidated = true; + } } diff --git a/Femto.Modules.Auth/Models/Session.cs b/Femto.Modules.Auth/Models/Session.cs index c4f8a60..e641a61 100644 --- a/Femto.Modules.Auth/Models/Session.cs +++ b/Femto.Modules.Auth/Models/Session.cs @@ -4,7 +4,7 @@ namespace Femto.Modules.Auth.Models; public class Session(Guid userId, bool isStrong) { - private static readonly TimeSpan ValidityPeriod = TimeSpan.FromMinutes(15); + 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; diff --git a/Femto.Modules.Auth/Models/UserIdentity.cs b/Femto.Modules.Auth/Models/UserIdentity.cs index a7e3ddd..bd0288f 100644 --- a/Femto.Modules.Auth/Models/UserIdentity.cs +++ b/Femto.Modules.Auth/Models/UserIdentity.cs @@ -28,6 +28,8 @@ 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 deleted file mode 100644 index 3be8b2a..0000000 --- a/Femto.Modules.Blog.Data/Class1.cs +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 17b910f..0000000 --- a/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - 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 deleted file mode 100644 index 6ae6742..0000000 --- a/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - 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 new file mode 100644 index 0000000..445c59e --- /dev/null +++ b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..6e52877 --- /dev/null +++ b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs @@ -0,0 +1,20 @@ +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 2d9c713..25bab45 100644 --- a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs +++ b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs @@ -24,11 +24,9 @@ internal class CreatePostCommandHandler(BlogContext context) media.Width, media.Height )) - .ToList() - ) - { - IsPublic = request.IsPublic is true - }; + .ToList(), + request.IsPublic is true + ); await context.AddAsync(post, cancellationToken); @@ -39,7 +37,8 @@ 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 b1defec..630cbe2 100644 --- a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs +++ b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs @@ -24,6 +24,8 @@ 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 be8157a..8b75d6e 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, Guid? Next); \ No newline at end of file +public record GetPostsQueryResult(IList Posts); \ 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 new file mode 100644 index 0000000..55ea5e8 --- /dev/null +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs @@ -0,0 +1,3 @@ +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 b8b6a3d..63efede 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs @@ -7,5 +7,6 @@ public record PostDto( DateTimeOffset CreatedAt, PostAuthorDto Author, IList Reactions, - IEnumerable PossibleReactions -); + IEnumerable PossibleReactions, + IList Comments +); \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs index 9ea33dd..60349b9 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, int Count, bool DidReact); \ No newline at end of file +public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn); diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs index f8af9d2..1bb1d4c 100644 --- a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs +++ b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs @@ -3,22 +3,27 @@ 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 { - public Guid? From { get; init; } + /// + /// 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 int Amount { get; init; } = 20; public Guid? AuthorId { get; init; } public string? Author { get; init; } - /// - /// 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, -} + public GetPostsQuery(Guid postId, Guid? currentUserId) : this(currentUserId) + { + this.PostId = postId; + } +} \ No newline at end of file diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs index 26ae43a..c57627f 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.From; + var cursor = query.After; var showPrivate = query.CurrentUserId is not null; var loadPostsResult = await conn.QueryAsync( @@ -33,9 +33,10 @@ 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 """, @@ -44,15 +45,13 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) username, authorGuid, cursor, - // load an extra one to take for the cursor - amount = query.Amount + 1, + amount = query.PostId is not null ? 1 : query.Amount, showPrivate, + postId = query.PostId, } ); - var loadedPosts = loadPostsResult.ToList(); - var posts = loadedPosts.Take(query.Amount).ToList(); - var next = loadedPosts.LastOrDefault()?.PostId; + var posts = loadPostsResult.ToList(); var postIds = posts.Select(p => p.PostId).ToList(); @@ -70,69 +69,69 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) new { postIds } ); + var media = loadMediaResult.ToList(); + var loadReactionsResult = await conn.QueryAsync( """ select pr.post_id as PostId, - pr.author_id as AuthorId, - pr.emoji as Emoji + a.username as AuthorName, + pr.emoji as Emoji, + pr.created_at as CreatedOn from blog.post_reaction pr + join blog.author a on a.id = pr.author_id where pr.post_id = ANY (@postIds) """, new { postIds } ); - 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 reactions = loadReactionsResult.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() - ); + 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(); return new GetPostsQueryResult( posts .Select(p => new PostDto( p.PostId, p.Content, - mediaByPostId.TryGetValue(p.PostId, out var mediaDtos) ? mediaDtos : [], + media + .Where(m => m.PostId == p.PostId) + .Select(m => new PostMediaDto( + new Uri(m.MediaUrl), + m.MediaWidth, + m.MediaHeight + )) + .ToList(), p.PostedOn, new PostAuthorDto(p.AuthorId, p.Username), - reactionsByPostId.TryGetValue(p.PostId, out var reactionDtos) - ? reactionDtos.ToList() - : [], + reactions + .Where(r => r.PostId == p.PostId) + .Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.CreatedAt)) + .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(), - next + .ToList() ); } @@ -158,7 +157,17 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory) internal record LoadReactionRow { public Guid PostId { get; init; } - public Guid AuthorId { get; init; } + public string AuthorName { 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 dc4d937..b5a9b2d 100644 --- a/Femto.Modules.Blog/Domain/Posts/Post.cs +++ b/Femto.Modules.Blog/Domain/Posts/Post.cs @@ -13,7 +13,9 @@ internal class Post : Entity public IList Media { get; private set; } public IList Reactions { get; private set; } = []; - public bool IsPublic { get; set; } + + public IList Comments { get; private set; } = []; + public bool IsPublic { get; private set; } public DateTimeOffset PostedOn { get; private set; } @@ -27,7 +29,7 @@ internal class Post : Entity private Post() { } - public Post(Guid authorId, string content, IList media) + public Post(Guid authorId, string content, IList media, bool isPublic) { this.Id = Guid.CreateVersion7(); this.AuthorId = authorId; @@ -35,6 +37,7 @@ internal class Post : Entity this.Media = media; this.PossibleReactions = AllEmoji.GetRandomEmoji(5); this.PostedOn = DateTimeOffset.UtcNow; + this.IsPublic = isPublic; this.AddDomainEvent(new PostCreated(this)); } @@ -56,4 +59,14 @@ 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 new file mode 100644 index 0000000..6f658a8 --- /dev/null +++ b/Femto.Modules.Blog/Domain/Posts/PostComment.cs @@ -0,0 +1,19 @@ +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 ea8ab16..38e33b8 100644 --- a/Femto.Modules.Blog/Domain/Posts/PostReaction.cs +++ b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs @@ -5,11 +5,13 @@ 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 deleted file mode 100644 index 9600ceb..0000000 --- a/Femto.Modules.Files/Domain/Files/File.cs +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 17b910f..0000000 --- a/Femto.Modules.Files/Femto.Modules.Files.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net9.0 - enable - enable - - -