diff --git a/Directory.Build.props b/Directory.Build.props
index 0751e1d..1afa3d5 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,6 +1,6 @@
- 0.1.31
+ 0.1.25
diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs
index 9c44b04..e71481d 100644
--- a/Femto.Api/Auth/SessionAuthenticationHandler.cs
+++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs
@@ -3,11 +3,11 @@ 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.Interface.ValidateSession;
+using Femto.Modules.Auth.Errors;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
+using Microsoft.OpenApi.Extensions;
namespace Femto.Api.Auth;
@@ -15,115 +15,48 @@ internal class SessionAuthenticationHandler(
IOptionsMonitor options,
ILoggerFactory logger,
UrlEncoder encoder,
- IAuthService authService,
+ IAuthModule authModule,
CurrentUserContext currentUserContext
) : AuthenticationHandler(options, logger, encoder)
{
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.Request.Cookies["session"];
+ if (string.IsNullOrWhiteSpace(sessionId))
return AuthenticateResult.NoResult();
- var claims = new List
+ try
{
- new(ClaimTypes.Name, user.Username),
- new("sub", user.Id.ToString()),
- new("user_id", user.Id.ToString()),
- };
+ var result = await authModule.Command(new ValidateSessionCommand(sessionId));
- claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role.ToString())));
+ var claims = new List
+ {
+ new(ClaimTypes.Name, result.User.Username),
+ new("sub", result.User.Id.ToString()),
+ new("user_id", result.User.Id.ToString()),
+ };
- 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)
- );
+ claims.AddRange(
+ result.User.Roles.Select(role => new Claim(ClaimTypes.Role, role.ToString()))
+ );
- return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name));
- }
+ var identity = new ClaimsIdentity(claims, this.Scheme.Name);
+ var principal = new ClaimsPrincipal(identity);
- private async Task TryAuthenticateWithSession()
- {
- var sessionId = this.Context.GetSessionId();
+ this.Context.SetSession(result.Session, result.User, Logger);
+ currentUserContext.CurrentUser = new CurrentUser(
+ result.User.Id,
+ result.User.Username,
+ result.Session.SessionId
+ );
- if (sessionId is null)
- {
- Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier);
- return null;
+ return AuthenticateResult.Success(
+ new AuthenticationTicket(principal, this.Scheme.Name)
+ );
}
-
- var session = await authService.GetSession(sessionId);
-
- if (session is null)
+ catch (InvalidSessionError)
{
- Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier);
- return null;
+ return AuthenticateResult.Fail("Invalid session");
}
-
- 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;
}
}
diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs
index c9108da..b567d1d 100644
--- a/Femto.Api/Controllers/Auth/AuthController.cs
+++ b/Femto.Api/Controllers/Auth/AuthController.cs
@@ -1,174 +1,95 @@
+using Femto.Api.Auth;
using Femto.Api.Sessions;
using Femto.Common;
using Femto.Modules.Auth.Application;
+using Femto.Modules.Auth.Application.Dto;
+using Femto.Modules.Auth.Application.Interface.CreateSignupCode;
+using Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery;
+using Femto.Modules.Auth.Application.Interface.Login;
+using Femto.Modules.Auth.Application.Interface.RefreshUserSession;
+using Femto.Modules.Auth.Application.Interface.Register;
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
+) : ControllerBase
{
[HttpPost("login")]
- public async Task> Login(
- [FromBody] LoginRequest request,
- CancellationToken cancellationToken
- )
+ public async Task> Login([FromBody] LoginRequest request)
{
- var result = await authService.AuthenticateUserCredentials(
- request.Username,
- request.Password,
- cancellationToken
+ var result = await authModule.Command(new LoginCommand(request.Username, request.Password));
+
+ HttpContext.SetSession(result.Session, result.User, logger);
+
+ return new LoginResponse(
+ result.User.Id,
+ result.User.Username,
+ result.User.Roles.Any(r => r == Role.SuperUser)
);
-
- if (result is null)
- return this.BadRequest();
-
- var (user, session) = result;
-
- 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 result = await authModule.Command(
+ new RegisterCommand(request.Username, request.Password, request.SignupCode)
);
- HttpContext.SetSession(session, user);
-
- if (request.RememberMe)
- {
- var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
- HttpContext.SetRememberMeToken(newRememberMeToken);
- }
+ HttpContext.SetSession(result.Session, result.User, logger);
return new RegisterResponse(
- user.Id,
- user.Username,
- user.Roles.Any(r => r == Role.SuperUser)
+ result.User.Id,
+ result.User.Username,
+ result.User.Roles.Any(r => r == Role.SuperUser)
);
}
- [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()
{
- 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();
- }
-
+ HttpContext.DeleteSession();
return Ok(new { });
}
[HttpGet("user/{userId}")]
[Authorize]
- public async Task> GetUserInfo(
+ public async Task> RefreshUser(
Guid userId,
CancellationToken cancellationToken
)
{
- var currentUser = currentUserContext.CurrentUser;
+ var currentUser = currentUserContext.CurrentUser!;
- if (currentUser is null || currentUser.Id != userId)
- return this.BadRequest();
+ try
+ {
+ var result = await authModule.Command(
+ new RefreshUserSessionCommand(userId, currentUser),
+ cancellationToken
+ );
- var user = await authService.GetUserWithId(userId, cancellationToken);
-
- if (user is null)
- return this.BadRequest();
-
- return new GetUserInfoResult(
- user.Id,
- user.Username,
- user.Roles.Any(r => r == Role.SuperUser)
- );
+ return new RefreshUserResult(
+ result.User.Id,
+ result.User.Username,
+ result.User.Roles.Any(r => r == Role.SuperUser)
+ );
+ }
+ catch (Exception)
+ {
+ HttpContext.DeleteSession();
+ return this.Forbid();
+ }
}
- [Obsolete("use POST /auth/create-signup-code")]
[HttpPost("signup-codes")]
[Authorize(Roles = "SuperUser")]
public async Task CreateSignupCode(
@@ -176,51 +97,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/GetUserInfoResult.cs b/Femto.Api/Controllers/Auth/GetUserInfoResult.cs
deleted file mode 100644
index 0212f32..0000000
--- a/Femto.Api/Controllers/Auth/GetUserInfoResult.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace Femto.Api.Controllers.Auth;
-
-public record GetUserInfoResult(Guid UserId, string Username, bool IsSuperUser);
\ 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/RefreshUserResult.cs b/Femto.Api/Controllers/Auth/RefreshUserResult.cs
new file mode 100644
index 0000000..8dbdee8
--- /dev/null
+++ b/Femto.Api/Controllers/Auth/RefreshUserResult.cs
@@ -0,0 +1,3 @@
+namespace Femto.Api.Controllers.Auth;
+
+public record RefreshUserResult(Guid UserId, string Username, bool IsSuperUser);
\ 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..f5e5d25 100644
--- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs
+++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs
@@ -1,52 +1,60 @@
using System.Text.Json;
+using System.Text.Json.Serialization;
using Femto.Api.Auth;
using Femto.Modules.Auth.Application.Dto;
-using Femto.Modules.Auth.Models;
using Microsoft.Extensions.Options;
namespace Femto.Api.Sessions;
-internal record SessionInfo(string? SessionId, Guid? UserId);
-
internal static class HttpContextSessionExtensions
{
- private static readonly JsonSerializerOptions JsonOptions = new()
+ public static void SetSession(this HttpContext httpContext, Session session, UserInfo user, ILogger logger)
{
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- };
+ var cookieSettings = httpContext.RequestServices.GetService>();
+
+ var secure = cookieSettings?.Value.Secure ?? true;
+ var sameSite = cookieSettings?.Value.SameSite ?? SameSiteMode.Strict;
+ var domain = cookieSettings?.Value.Domain;
+ var expires = session.Expires;
- public static string? GetSessionId(this HttpContext httpContext) =>
- httpContext.Request.Cookies["sid"];
+ logger.LogInformation(
+ "cookie settings: Secure={Secure}, SameSite={SameSite}, domain={Domain}, Expires={Expires}",
+ secure,
+ sameSite,
+ domain,
+ expires
+ );
- public static void SetSession(this HttpContext context, Session session, UserInfo user)
- {
- var cookieSettings = context.RequestServices.GetRequiredService>();
-
- context.Response.Cookies.Append(
- "sid",
- session.Id,
+ httpContext.Response.Cookies.Append(
+ "session",
+ session.SessionId,
new CookieOptions
{
- Path = "/",
IsEssential = true,
- Domain = cookieSettings.Value.Domain,
+ Domain = domain,
HttpOnly = true,
- Secure = cookieSettings.Value.Secure,
- SameSite = cookieSettings.Value.SameSite,
- Expires = session.Expires,
+ Secure = secure,
+ SameSite = sameSite,
+ Expires = expires,
}
);
- context.Response.Cookies.Append(
+ httpContext.Response.Cookies.Append(
"user",
- JsonSerializer.Serialize(user, JsonOptions),
+ JsonSerializer.Serialize(
+ user,
+ new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Converters = { new JsonStringEnumConverter() },
+ }
+ ),
new CookieOptions
{
- Path = "/",
- Domain = cookieSettings.Value.Domain,
+ Domain = domain,
IsEssential = true,
- Secure = cookieSettings.Value.Secure,
- SameSite = cookieSettings.Value.SameSite,
+ Secure = secure,
+ SameSite = sameSite,
Expires = session.Expires,
}
);
@@ -54,78 +62,28 @@ internal static class HttpContextSessionExtensions
public static void DeleteSession(this HttpContext httpContext)
{
- var cookieSettings = httpContext.RequestServices.GetRequiredService<
- IOptions
- >();
-
- httpContext.Response.Cookies.Delete(
- "sid",
- new CookieOptions
- {
- Path = "/",
- HttpOnly = true,
- Domain = cookieSettings.Value.Domain,
- IsEssential = true,
- Secure = cookieSettings.Value.Secure,
- SameSite = cookieSettings.Value.SameSite,
- Expires = DateTimeOffset.UtcNow.AddDays(-1),
- }
- );
-
- httpContext.Response.Cookies.Delete(
- "user",
- new CookieOptions
- {
- Path = "/",
- Domain = cookieSettings.Value.Domain,
- IsEssential = true,
- Secure = cookieSettings.Value.Secure,
- SameSite = cookieSettings.Value.SameSite,
- Expires = DateTimeOffset.UtcNow.AddDays(-1),
- }
- );
- }
-
-
- 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),
- }
- );
+ var cookieSettings = httpContext.RequestServices.GetService>();
+
+ var secure = cookieSettings?.Value.Secure ?? true;
+ var sameSite = secure ? SameSiteMode.None : SameSiteMode.Unspecified;
+ var domain = cookieSettings?.Value.Domain;
+
+ httpContext.Response.Cookies.Delete("session", new CookieOptions
+ {
+ HttpOnly = true,
+ Domain = domain,
+ IsEssential = true,
+ Secure = secure,
+ SameSite = sameSite,
+ Expires = DateTimeOffset.UtcNow.AddDays(-1),
+ });
+ httpContext.Response.Cookies.Delete("user", new CookieOptions
+ {
+ Domain = domain,
+ IsEssential = true,
+ Secure = secure,
+ SameSite = sameSite,
+ Expires = DateTimeOffset.UtcNow.AddDays(-1),
+ });
}
}
diff --git a/Femto.Common/ICurrentUserContext.cs b/Femto.Common/ICurrentUserContext.cs
index 629b2d2..a7233e0 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, string SessionId);
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 69%
rename from Femto.Common/Infrastructure/DDDPipelineBehaviour.cs
rename to Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs
index e5f338f..d9aaf03 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
{
@@ -18,12 +18,7 @@ public class DDDPipelineBehaviour(
CancellationToken cancellationToken
)
{
- logger.LogDebug("handling request {Type}", typeof(TRequest).Name);
var response = await next(cancellationToken);
-
- var hasChanges = context.ChangeTracker.HasChanges();
- logger.LogDebug("request handled. Changes? {HasChanges}", hasChanges);
-
if (context.ChangeTracker.HasChanges())
{
await context.EmitDomainEvents(logger, publisher, cancellationToken);
diff --git a/Femto.Common/ScopeBinding.cs b/Femto.Common/ScopeBinding.cs
index da58589..c78408e 100644
--- a/Femto.Common/ScopeBinding.cs
+++ b/Femto.Common/ScopeBinding.cs
@@ -3,24 +3,19 @@ using Microsoft.Extensions.Logging;
namespace Femto.Common;
-
///
/// We use this to bind a scope to the request scope in the composition root
/// Any scoped services provided by this subcontainer should be accessed via a ScopeBinding injected in the host
///
///
-public class ScopeBinding(IServiceScope scope) : IDisposable
+public class ScopeBinding(IServiceScope scope) : IDisposable
+ where T : notnull
{
- private IServiceScope Scope { get; } = scope;
-
- public T GetService()
- where T : notnull
- {
- return this.Scope.ServiceProvider.GetRequiredService();
+ public T GetService() {
+ return scope.ServiceProvider.GetRequiredService();
}
- public virtual void Dispose()
- {
- this.Scope.Dispose();
+ public void Dispose() {
+ scope.Dispose();
}
}
diff --git a/Femto.Database/Migrations/20250529101346_SessionsRework.sql b/Femto.Database/Migrations/20250529101346_SessionsRework.sql
deleted file mode 100644
index 11cb84e..0000000
--- a/Femto.Database/Migrations/20250529101346_SessionsRework.sql
+++ /dev/null
@@ -1,13 +0,0 @@
--- Migration: addLongTermSessions
--- Created at: 29/05/2025 10:13:46
-
-DROP TABLE authn.user_session;
-
-CREATE TABLE authn.long_term_session
-(
- id serial PRIMARY KEY,
- selector varchar(16) NOT NULL,
- hashed_verifier bytea NOT NULL,
- expires timestamptz not null,
- user_id uuid REFERENCES authn.user_identity (id)
-);
\ No newline at end of file
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.Docs/Design/Auth/RememberMe.md b/Femto.Docs/Design/Auth/RememberMe.md
deleted file mode 100644
index 0ff9ec2..0000000
--- a/Femto.Docs/Design/Auth/RememberMe.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# Remember me
-
-We want to implement long lived sessions
-
-we will do this with a remember me cookie
-
-this should be implemented as so:
-
-
-logging or registering and including a "rememberMe" flag with the request will generate a new remember me token, which can be stored as a cookie .
-
-the remember me token should live until:
-* the user changes password anywhere
-* the user logs out on that device
-* the user logs in with an expired session, in which case the remember me token will be used to refresh the session, and then it will be swapped out for a new one
-
-that means we need to implement three spots:
-- [ ] login
-- [ ] register
-- [ ] validate session
-
-we will implement it as described [here](https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence)
-
-we will only check the remember me token in "validate session".
-
-"refresh session" is only called with valid sessions so we do not need to check it here, as the session should already have been validated
-
diff --git a/Femto.Docs/Design/Auth/strong_vs_weak_session.md b/Femto.Docs/Design/Auth/strong_vs_weak_session.md
deleted file mode 100644
index 5a45a7d..0000000
--- a/Femto.Docs/Design/Auth/strong_vs_weak_session.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# Strong vs weak sessions
-
-a **strong** session is one that should have the power to do account level admin tasks like change password
-
-
-a **weak** session has strictly fewer privileges than a strong session
-
-## where to get a strong session
-
-a strong session is created when a user provides a username and a password. a session remains strong until it is refreshed, at which point it becomes weak.
-
-## where to get a weak session
-
-A weak session is any session that has not been directly created by user credentials, i.e.:
-* short-term session refresh
-* long-term session refresh
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/AuthModule.cs b/Femto.Modules.Auth/Application/AuthModule.cs
new file mode 100644
index 0000000..d289d9e
--- /dev/null
+++ b/Femto.Modules.Auth/Application/AuthModule.cs
@@ -0,0 +1,22 @@
+using Femto.Common.Domain;
+using MediatR;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace Femto.Modules.Auth.Application;
+
+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/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..b9e6132 100644
--- a/Femto.Modules.Auth/Application/AuthStartup.cs
+++ b/Femto.Modules.Auth/Application/AuthStartup.cs
@@ -20,29 +20,20 @@ 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();
- rootContainer.AddKeyedScoped(
- "AuthServiceScope",
- (s, o) =>
- {
- var scope = host.Services.CreateScope();
- return new ScopeBinding(scope);
- }
+ rootContainer.AddScoped(_ => new ScopeBinding(host.Services.CreateScope()));
+ rootContainer.AddScoped(services =>
+ services.GetRequiredService>().GetService()
);
- rootContainer.ExposeScopedService();
-
rootContainer.AddHostedService(services => new AuthApplication(host));
eventBus.Subscribe(
(evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)
@@ -53,12 +44,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 =>
@@ -78,7 +66,7 @@ public static class AuthStartup
{
options.WaitForJobsToComplete = true;
});
- // #endif
+
services.AddOutbox();
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly));
@@ -86,11 +74,8 @@ public static class AuthStartup
services.ConfigureDomainServices();
services.AddSingleton(publisher);
- services.AddSingleton();
- services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>));
-
- services.AddScoped();
+ services.AddScoped();
}
private static async Task EventSubscriber(
@@ -122,14 +107,3 @@ public static class AuthStartup
}
}
}
-
-internal static class AuthServiceCollectionExtensions
-{
- public static void ExposeScopedService(this IServiceCollection container)
- where T : class
- {
- container.AddScoped(services =>
- services.GetRequiredKeyedService("AuthServiceScope").GetService()
- );
- }
-}
diff --git a/Femto.Modules.Auth/Application/Dto/LoginResult.cs b/Femto.Modules.Auth/Application/Dto/LoginResult.cs
index c9048ad..1405a28 100644
--- a/Femto.Modules.Auth/Application/Dto/LoginResult.cs
+++ b/Femto.Modules.Auth/Application/Dto/LoginResult.cs
@@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto;
-public record LoginResult(SessionDto SessionDto, UserInfo User);
\ No newline at end of file
+public record LoginResult(Session Session, UserInfo User);
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs b/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs
index 19f1d17..ac1bbc3 100644
--- a/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs
+++ b/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs
@@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto;
-public record RefreshUserSessionResult(SessionDto SessionDto, UserInfo User);
\ No newline at end of file
+public record RefreshUserSessionResult(Session Session, UserInfo User);
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Dto/RegisterResult.cs b/Femto.Modules.Auth/Application/Dto/RegisterResult.cs
index e0a1243..13e1d12 100644
--- a/Femto.Modules.Auth/Application/Dto/RegisterResult.cs
+++ b/Femto.Modules.Auth/Application/Dto/RegisterResult.cs
@@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto;
-public record RegisterResult(SessionDto SessionDto, UserInfo User);
\ No newline at end of file
+public record RegisterResult(Session Session, UserInfo User);
\ No newline at end of file
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/Dto/Session.cs b/Femto.Modules.Auth/Application/Dto/Session.cs
index 7f422eb..9e87ca8 100644
--- a/Femto.Modules.Auth/Application/Dto/Session.cs
+++ b/Femto.Modules.Auth/Application/Dto/Session.cs
@@ -2,16 +2,9 @@ using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Dto;
-public record SessionDto(
- string SessionId,
- DateTimeOffset Expires,
- bool Weak,
- string? RememberMe = null
-)
+public record Session(string SessionId, DateTimeOffset Expires)
{
- internal SessionDto(Session session)
- : this(session.Id, session.Expires, !session.IsStronglyAuthenticated) { }
-
- internal SessionDto(Session session, string? rememberMe)
- : this(session.Id, session.Expires, !session.IsStronglyAuthenticated, rememberMe) { }
-}
+ internal Session(UserSession session) : this(session.Id, session.Expires)
+ {
+ }
+}
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs b/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs
index e29c84a..7fb022f 100644
--- a/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs
+++ b/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs
@@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto;
-public record ValidateSessionResult(SessionDto SessionDto);
\ No newline at end of file
+public record ValidateSessionResult(Session Session, UserInfo User);
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/IAuthModule.cs b/Femto.Modules.Auth/Application/IAuthModule.cs
new file mode 100644
index 0000000..4559161
--- /dev/null
+++ b/Femto.Modules.Auth/Application/IAuthModule.cs
@@ -0,0 +1,10 @@
+using Femto.Common.Domain;
+
+namespace Femto.Modules.Auth.Application;
+
+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/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/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/Login/LoginCommand.cs b/Femto.Modules.Auth/Application/Interface/Login/LoginCommand.cs
new file mode 100644
index 0000000..8252e2e
--- /dev/null
+++ b/Femto.Modules.Auth/Application/Interface/Login/LoginCommand.cs
@@ -0,0 +1,6 @@
+using Femto.Common.Domain;
+using Femto.Modules.Auth.Application.Dto;
+
+namespace Femto.Modules.Auth.Application.Interface.Login;
+
+public record LoginCommand(string Username, string Password) : ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/Login/LoginCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/Login/LoginCommandHandler.cs
new file mode 100644
index 0000000..45b1ae4
--- /dev/null
+++ b/Femto.Modules.Auth/Application/Interface/Login/LoginCommandHandler.cs
@@ -0,0 +1,28 @@
+using Femto.Common.Domain;
+using Femto.Modules.Auth.Application.Dto;
+using Femto.Modules.Auth.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Femto.Modules.Auth.Application.Interface.Login;
+
+internal class LoginCommandHandler(AuthContext context)
+ : ICommandHandler
+{
+ public async Task Handle(LoginCommand request, CancellationToken cancellationToken)
+ {
+ var user = await context.Users.SingleOrDefaultAsync(
+ u => u.Username == request.Username,
+ cancellationToken
+ );
+
+ if (user is null)
+ throw new DomainError("invalid credentials");
+
+ if (!user.HasPassword(request.Password))
+ throw new DomainError("invalid credentials");
+
+ var session = user.StartNewSession();
+
+ return new(new Session(session.Id, session.Expires), new UserInfo(user));
+ }
+}
diff --git a/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs b/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs
new file mode 100644
index 0000000..f04fa82
--- /dev/null
+++ b/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs
@@ -0,0 +1,7 @@
+using Femto.Common;
+using Femto.Common.Domain;
+using Femto.Modules.Auth.Application.Dto;
+
+namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession;
+
+public record RefreshUserSessionCommand(Guid ForUser, CurrentUser CurrentUser) : ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs
new file mode 100644
index 0000000..f0c6dc1
--- /dev/null
+++ b/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs
@@ -0,0 +1,32 @@
+using Femto.Common.Domain;
+using Femto.Common.Infrastructure.DbConnection;
+using Femto.Modules.Auth.Application.Dto;
+using Femto.Modules.Auth.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession;
+
+internal class RefreshUserSessionCommandHandler(AuthContext context)
+ : ICommandHandler
+{
+ public async Task Handle(
+ RefreshUserSessionCommand request,
+ CancellationToken cancellationToken
+ )
+ {
+ if (request.CurrentUser.Id != request.ForUser)
+ throw new DomainError("invalid request");
+
+ var user = await context.Users.SingleOrDefaultAsync(
+ u => u.Id == request.ForUser,
+ cancellationToken
+ );
+
+ if (user is null)
+ throw new DomainError("invalid request");
+
+ var session = user.PossiblyRefreshSession(request.CurrentUser.SessionId);
+
+ return new(new Session(session), 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..dd3c186
--- /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..9e29be6
--- /dev/null
+++ b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs
@@ -0,0 +1,35 @@
+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 user = new UserIdentity(request.Username);
+
+ user.SetPassword(request.Password);
+
+ var session = user.StartNewSession();
+
+ await context.AddAsync(user, cancellationToken);
+
+ code.Redeem(user.Id);
+
+ return new(new Session(session.Id, session.Expires), new UserInfo(user));
+ }
+}
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs
new file mode 100644
index 0000000..40d5417
--- /dev/null
+++ b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs
@@ -0,0 +1,10 @@
+using Femto.Common.Domain;
+using Femto.Modules.Auth.Application.Dto;
+
+namespace Femto.Modules.Auth.Application.Interface.ValidateSession;
+
+///
+/// Validate an existing session, and then return either the current session, or a new one in case the expiry is further in the future
+///
+///
+public record ValidateSessionCommand(string SessionId) : ICommand;
diff --git a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs
new file mode 100644
index 0000000..f79552c
--- /dev/null
+++ b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs
@@ -0,0 +1,34 @@
+using Femto.Common.Domain;
+using Femto.Modules.Auth.Application.Dto;
+using Femto.Modules.Auth.Data;
+using Femto.Modules.Auth.Errors;
+using Microsoft.EntityFrameworkCore;
+
+namespace Femto.Modules.Auth.Application.Interface.ValidateSession;
+
+internal class ValidateSessionCommandHandler(AuthContext context)
+ : ICommandHandler
+{
+ public async Task Handle(
+ ValidateSessionCommand request,
+ CancellationToken cancellationToken
+ )
+ {
+ var now = DateTimeOffset.UtcNow;
+
+ var user = await context.Users.SingleOrDefaultAsync(
+ u => u.Sessions.Any(s => s.Id == request.SessionId && s.Expires > now),
+ cancellationToken
+ );
+
+ if (user is null)
+ throw new InvalidSessionError();
+
+ var session = user.PossiblyRefreshSession(request.SessionId);
+
+ return new ValidateSessionResult(
+ new Session(session.Id, session.Expires),
+ new UserInfo(user)
+ );
+ }
+}
diff --git a/Femto.Modules.Auth/Data/AuthContext.cs b/Femto.Modules.Auth/Data/AuthContext.cs
index ac395ba..e850eb8 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;
@@ -12,7 +8,6 @@ internal class AuthContext(DbContextOptions options) : DbContext(op
{
public virtual DbSet Users { get; set; }
public virtual DbSet SignupCodes { get; set; }
- public virtual DbSet LongTermSessions { get; set; }
public virtual DbSet Outbox { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
@@ -21,43 +16,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/Data/Configurations/UserIdentityTypeConfiguration.cs b/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs
index 1921451..2e5086b 100644
--- a/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs
+++ b/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs
@@ -19,6 +19,8 @@ internal class UserIdentityTypeConfiguration : IEntityTypeConfiguration u.Sessions).WithOwner().HasForeignKey("user_id");
+
builder
.OwnsMany(u => u.Roles, entity =>
{
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
deleted file mode 100644
index e331396..0000000
--- a/Femto.Modules.Auth/Infrastructure/SessionStorage.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-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)
-{
- private readonly IMemoryCache _storage = new MemoryCache(new MemoryCacheOptions());
-
- public async 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;
- }
-
- public Task AddSession(Session session)
- {
- 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($"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
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
deleted file mode 100644
index eba3d9d..0000000
--- a/Femto.Modules.Auth/Models/LongTermSession.cs
+++ /dev/null
@@ -1,73 +0,0 @@
-using System.ComponentModel.DataAnnotations.Schema;
-using System.Text;
-using static System.Security.Cryptography.RandomNumberGenerator;
-
-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() { }
-
- public static (LongTermSession, string) Create(Guid userId)
- {
- var selector = GetString(
- "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
- 12
- );
-
- var verifier = GetString("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 32);
-
- using var sha256 = System.Security.Cryptography.SHA256.Create();
-
- var longTermSession = new LongTermSession
- {
- Selector = selector,
- HashedVerifier = ComputeHash(verifier),
- UserId = userId,
- Expires = DateTimeOffset.UtcNow + TokenTimeout,
- };
-
- return (longTermSession, verifier);
- }
-
- public bool CheckVerifier(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;
- }
-
- public void Invalidate()
- {
- this.IsInvalidated = true;
- }
-}
diff --git a/Femto.Modules.Auth/Models/Session.cs b/Femto.Modules.Auth/Models/Session.cs
deleted file mode 100644
index e641a61..0000000
--- a/Femto.Modules.Auth/Models/Session.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using static System.Security.Cryptography.RandomNumberGenerator;
-
-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 bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer;
- 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..756be41 100644
--- a/Femto.Modules.Auth/Models/UserIdentity.cs
+++ b/Femto.Modules.Auth/Models/UserIdentity.cs
@@ -1,6 +1,9 @@
+using System.Text;
+using System.Text.Unicode;
using Femto.Common.Domain;
using Femto.Modules.Auth.Contracts;
using Femto.Modules.Auth.Models.Events;
+using Geralt;
namespace Femto.Modules.Auth.Models;
@@ -12,6 +15,8 @@ internal class UserIdentity : Entity
public Password? Password { get; private set; }
+ public ICollection Sessions { get; private set; } = [];
+
public ICollection Roles { get; private set; } = [];
private UserIdentity() { }
@@ -26,10 +31,14 @@ internal class UserIdentity : Entity
this.AddDomainEvent(new UserWasCreatedEvent(this));
}
+ public UserIdentity WithPassword(string password)
+ {
+ this.SetPassword(password);
+ return this;
+ }
+
public void SetPassword(string password)
{
- if (this.Password is not null)
- this.AddDomainEvent(new UserPasswordChangedDomainEvent(this));
this.Password = new Password(password);
}
@@ -42,6 +51,25 @@ internal class UserIdentity : Entity
return this.Password.Check(requestPassword);
}
+
+ public UserSession PossiblyRefreshSession(string sessionId)
+ {
+ var session = this.Sessions.Single(s => s.Id == sessionId);
+
+ if (session.ExpiresSoon)
+ return this.StartNewSession();
+
+ return session;
+ }
+
+ public UserSession StartNewSession()
+ {
+ var session = UserSession.Create();
+
+ this.Sessions.Add(session);
+
+ return session;
+ }
}
public class SetPasswordError(string message, Exception inner) : DomainError(message, inner);
diff --git a/Femto.Modules.Auth/Models/UserSession.cs b/Femto.Modules.Auth/Models/UserSession.cs
new file mode 100644
index 0000000..7deb251
--- /dev/null
+++ b/Femto.Modules.Auth/Models/UserSession.cs
@@ -0,0 +1,21 @@
+namespace Femto.Modules.Auth.Models;
+
+internal class UserSession
+{
+ private static TimeSpan SessionTimeout { get; } = TimeSpan.FromMinutes(30);
+ private static TimeSpan ExpiryBuffer { get; } = TimeSpan.FromMinutes(5);
+ public string Id { get; private set; }
+ public DateTimeOffset Expires { get; private set; }
+ public bool ExpiresSoon => Expires < DateTimeOffset.UtcNow + ExpiryBuffer;
+
+ private UserSession() {}
+
+ public static UserSession Create()
+ {
+ return new()
+ {
+ Id = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)),
+ Expires = DateTimeOffset.UtcNow + SessionTimeout
+ };
+ }
+}
\ No newline at end of file
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/BlogStartup.cs b/Femto.Modules.Blog/Application/BlogStartup.cs
index afd18b5..b134f4c 100644
--- a/Femto.Modules.Blog/Application/BlogStartup.cs
+++ b/Femto.Modules.Blog/Application/BlogStartup.cs
@@ -35,13 +35,9 @@ public static class BlogStartup
rootContainer.AddHostedService(_ => new BlogApplication(host));
- rootContainer.AddKeyedScoped(
- "BlogService",
- (_, o) => new ScopeBinding(host.Services.CreateScope())
- );
-
+ rootContainer.AddScoped(_ => new ScopeBinding(host.Services.CreateScope()));
rootContainer.AddScoped(services =>
- services.GetRequiredKeyedService("BlogService").GetService()
+ services.GetRequiredService>().GetService()
);
bus.Subscribe(
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
+
+
+