Compare commits
8 commits
Author | SHA1 | Date | |
---|---|---|---|
3de79728a8 | |||
ce3888f1ab | |||
cbf67bf5f1 | |||
31d3de7bf3 | |||
c49267e6c4 | |||
e8d5d50ae5 | |||
5379d29c5f | |||
2519fc77d2 |
46 changed files with 501 additions and 212 deletions
|
@ -1,6 +1,6 @@
|
||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.1.28</Version>
|
<Version>0.1.31</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ using Femto.Api.Sessions;
|
||||||
using Femto.Common;
|
using Femto.Common;
|
||||||
using Femto.Modules.Auth.Application;
|
using Femto.Modules.Auth.Application;
|
||||||
using Femto.Modules.Auth.Application.Dto;
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
|
using Femto.Modules.Auth.Contracts;
|
||||||
using Femto.Modules.Auth.Models;
|
using Femto.Modules.Auth.Models;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
@ -26,10 +27,10 @@ internal class SessionAuthenticationHandler(
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
user = await this.TryAuthenticateWithRememberMeToken();
|
user = await this.TryAuthenticateWithRememberMeToken();
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
return AuthenticateResult.NoResult();
|
return AuthenticateResult.NoResult();
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(ClaimTypes.Name, user.Username),
|
new(ClaimTypes.Name, user.Username),
|
||||||
|
@ -41,7 +42,11 @@ internal class SessionAuthenticationHandler(
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, this.Scheme.Name);
|
var identity = new ClaimsIdentity(claims, this.Scheme.Name);
|
||||||
var principal = new ClaimsPrincipal(identity);
|
var principal = new ClaimsPrincipal(identity);
|
||||||
currentUserContext.CurrentUser = new CurrentUser(user.Id, user.Username);
|
currentUserContext.CurrentUser = new CurrentUser(
|
||||||
|
user.Id,
|
||||||
|
user.Username,
|
||||||
|
user.Roles.Contains(Role.SuperUser)
|
||||||
|
);
|
||||||
|
|
||||||
return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name));
|
return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name));
|
||||||
}
|
}
|
||||||
|
@ -99,21 +104,23 @@ internal class SessionAuthenticationHandler(
|
||||||
* if it is valid, create a new weak session, return the user
|
* if it is valid, create a new weak session, return the user
|
||||||
* if it is almost expired, refresh it
|
* if it is almost expired, refresh it
|
||||||
*/
|
*/
|
||||||
|
|
||||||
var rememberMeToken = this.Context.GetRememberMeToken();
|
var rememberMeToken = this.Context.GetRememberMeToken();
|
||||||
|
|
||||||
if (rememberMeToken is null)
|
if (rememberMeToken is null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var (user, newRememberMeToken) = await authService.GetUserWithRememberMeToken(rememberMeToken);
|
var (user, newRememberMeToken) = await authService.GetUserWithRememberMeToken(
|
||||||
|
rememberMeToken
|
||||||
|
);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var session = await authService.CreateWeakSession(user.Id);
|
var session = await authService.CreateWeakSession(user.Id);
|
||||||
|
|
||||||
this.Context.SetSession(session, user);
|
this.Context.SetSession(session, user);
|
||||||
|
|
||||||
if (newRememberMeToken is not null)
|
if (newRememberMeToken is not null)
|
||||||
this.Context.SetRememberMeToken(newRememberMeToken);
|
this.Context.SetRememberMeToken(newRememberMeToken);
|
||||||
|
|
||||||
|
|
|
@ -28,9 +28,9 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
|
||||||
return this.BadRequest();
|
return this.BadRequest();
|
||||||
|
|
||||||
var (user, session) = result;
|
var (user, session) = result;
|
||||||
|
|
||||||
HttpContext.SetSession(session, user);
|
HttpContext.SetSession(session, user);
|
||||||
|
|
||||||
if (request.RememberMe)
|
if (request.RememberMe)
|
||||||
{
|
{
|
||||||
var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
|
var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
|
||||||
|
@ -41,7 +41,10 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<ActionResult<RegisterResponse>> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
|
public async Task<ActionResult<RegisterResponse>> Register(
|
||||||
|
[FromBody] RegisterRequest request,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var (user, session) = await authService.CreateUserWithCredentials(
|
var (user, session) = await authService.CreateUserWithCredentials(
|
||||||
request.Username,
|
request.Username,
|
||||||
|
@ -51,7 +54,7 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
|
||||||
);
|
);
|
||||||
|
|
||||||
HttpContext.SetSession(session, user);
|
HttpContext.SetSession(session, user);
|
||||||
|
|
||||||
if (request.RememberMe)
|
if (request.RememberMe)
|
||||||
{
|
{
|
||||||
var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
|
var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
|
||||||
|
@ -65,6 +68,60 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("change-password")]
|
||||||
|
public async Task<ActionResult> 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<ActionResult> 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")]
|
[HttpDelete("session")]
|
||||||
public async Task<ActionResult> DeleteSession()
|
public async Task<ActionResult> DeleteSession()
|
||||||
{
|
{
|
||||||
|
@ -111,6 +168,7 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Obsolete("use POST /auth/create-signup-code")]
|
||||||
[HttpPost("signup-codes")]
|
[HttpPost("signup-codes")]
|
||||||
[Authorize(Roles = "SuperUser")]
|
[Authorize(Roles = "SuperUser")]
|
||||||
public async Task<ActionResult> CreateSignupCode(
|
public async Task<ActionResult> CreateSignupCode(
|
||||||
|
@ -123,6 +181,7 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
|
||||||
return Ok(new { });
|
return Ok(new { });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Obsolete("use GET /auth/list-signup-codes")]
|
||||||
[HttpGet("signup-codes")]
|
[HttpGet("signup-codes")]
|
||||||
[Authorize(Roles = "SuperUser")]
|
[Authorize(Roles = "SuperUser")]
|
||||||
public async Task<ActionResult<ListSignupCodesResult>> ListSignupCodes(
|
public async Task<ActionResult<ListSignupCodesResult>> ListSignupCodes(
|
||||||
|
@ -142,4 +201,36 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("create-signup-code")]
|
||||||
|
[Authorize(Roles = "SuperUser")]
|
||||||
|
public async Task<ActionResult> 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<ActionResult<ListSignupCodesResult>> ListSignupCodesV2(
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var codes = await authService.GetSignupCodes(cancellationToken);
|
||||||
|
|
||||||
|
return new ListSignupCodesResult(
|
||||||
|
codes.Select(c => new SignupCodeDto(
|
||||||
|
c.Code,
|
||||||
|
c.Email,
|
||||||
|
c.Name,
|
||||||
|
c.RedeemedByUserId,
|
||||||
|
c.RedeemedByUsername,
|
||||||
|
c.ExpiresOn
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
3
Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs
Normal file
3
Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Auth;
|
||||||
|
|
||||||
|
public record ChangePasswordRequestBody(Guid UserId, string NewPassword);
|
3
Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs
Normal file
3
Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
|
public record AddPostCommentRequest(Guid AuthorId, string Content);
|
3
Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs
Normal file
3
Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
|
public record GetPostResponse(PostDto Post);
|
|
@ -3,4 +3,4 @@ using JetBrains.Annotations;
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public record GetPublicPostsSearchParams(Guid? From, int? Amount, Guid? AuthorId, string? Author);
|
public record GetPublicPostsSearchParams(Guid? After, int? Amount, Guid? AuthorId, string? Author);
|
|
@ -3,4 +3,4 @@ using JetBrains.Annotations;
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public record LoadPostsResponse(IEnumerable<PostDto> Posts, Guid? Next);
|
public record LoadPostsResponse(IEnumerable<PostDto> Posts);
|
3
Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs
Normal file
3
Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
|
public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);
|
|
@ -10,5 +10,19 @@ public record PostDto(
|
||||||
IEnumerable<PostMediaDto> Media,
|
IEnumerable<PostMediaDto> Media,
|
||||||
IEnumerable<PostReactionDto> Reactions,
|
IEnumerable<PostReactionDto> Reactions,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
IEnumerable<string> PossibleReactions
|
IEnumerable<string> PossibleReactions,
|
||||||
);
|
IEnumerable<PostCommentDto> 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))
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,3 +1,3 @@
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
public record PostReactionDto(string Emoji, int Count, bool DidReact);
|
public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);
|
|
@ -1,6 +1,7 @@
|
||||||
using Femto.Api.Controllers.Posts.Dto;
|
using Femto.Api.Controllers.Posts.Dto;
|
||||||
using Femto.Common;
|
using Femto.Common;
|
||||||
using Femto.Modules.Blog.Application;
|
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.AddPostReaction;
|
||||||
using Femto.Modules.Blog.Application.Commands.ClearPostReaction;
|
using Femto.Modules.Blog.Application.Commands.ClearPostReaction;
|
||||||
using Femto.Modules.Blog.Application.Commands.CreatePost;
|
using Femto.Modules.Blog.Application.Commands.CreatePost;
|
||||||
|
@ -13,7 +14,7 @@ namespace Femto.Api.Controllers.Posts;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("posts")]
|
[Route("posts")]
|
||||||
public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext)
|
public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext, IAuthorizationService auth)
|
||||||
: ControllerBase
|
: ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
@ -25,7 +26,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
|
||||||
var res = await blogModule.Query(
|
var res = await blogModule.Query(
|
||||||
new GetPostsQuery(currentUserContext.CurrentUser?.Id)
|
new GetPostsQuery(currentUserContext.CurrentUser?.Id)
|
||||||
{
|
{
|
||||||
From = searchParams.From,
|
After = searchParams.After,
|
||||||
Amount = searchParams.Amount ?? 20,
|
Amount = searchParams.Amount ?? 20,
|
||||||
AuthorId = searchParams.AuthorId,
|
AuthorId = searchParams.AuthorId,
|
||||||
Author = searchParams.Author,
|
Author = searchParams.Author,
|
||||||
|
@ -33,18 +34,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
return new LoadPostsResponse(
|
return new LoadPostsResponse(res.Posts.Select(PostDto.FromModel));
|
||||||
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]
|
[HttpPost]
|
||||||
|
@ -75,17 +65,26 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
return new CreatePostResponse(
|
return new CreatePostResponse(PostDto.FromModel(post));
|
||||||
new PostDto(
|
}
|
||||||
new PostAuthorDto(post.Author.AuthorId, post.Author.Username),
|
|
||||||
post.PostId,
|
[HttpGet("{postId}")]
|
||||||
post.Text,
|
public async Task<ActionResult<GetPostResponse>> GetPost(
|
||||||
post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)),
|
Guid postId,
|
||||||
post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.Count, r.DidReact)).ToList(),
|
CancellationToken cancellationToken
|
||||||
post.CreatedAt,
|
)
|
||||||
post.PossibleReactions
|
{
|
||||||
)
|
var result = await blogModule.Query(
|
||||||
|
new GetPostsQuery(postId, currentUserContext.CurrentUser?.Id),
|
||||||
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
var post = result.Posts.SingleOrDefault();
|
||||||
|
|
||||||
|
if (post is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
return new GetPostResponse(PostDto.FromModel(post));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{postId}")]
|
[HttpDelete("{postId}")]
|
||||||
|
@ -100,24 +99,56 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
|
||||||
|
|
||||||
[HttpPost("{postId}/reactions")]
|
[HttpPost("{postId}/reactions")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult> AddPostReaction(Guid postId, [FromBody] AddPostReactionRequest request, CancellationToken cancellationToken)
|
public async Task<ActionResult> AddPostReaction(
|
||||||
|
Guid postId,
|
||||||
|
[FromBody] AddPostReactionRequest request,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var currentUser = currentUserContext.CurrentUser!;
|
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();
|
return this.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{postId}/reactions")]
|
[HttpDelete("{postId}/reactions")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult> DeletePostReaction(Guid postId, [FromBody] DeletePostReactionRequest request, CancellationToken cancellationToken)
|
public async Task<ActionResult> DeletePostReaction(
|
||||||
|
Guid postId,
|
||||||
|
[FromBody] DeletePostReactionRequest request,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var currentUser = currentUserContext.CurrentUser!;
|
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();
|
return this.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("{postId}/comments")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult> 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,7 @@ builder.Services.AddHostedService(_ => eventBus);
|
||||||
|
|
||||||
builder.Services.InitializeBlogModule(connectionString, eventBus, loggerFactory);
|
builder.Services.InitializeBlogModule(connectionString, eventBus, loggerFactory);
|
||||||
builder.Services.InitializeMediaModule(connectionString, blobStorageRoot);
|
builder.Services.InitializeMediaModule(connectionString, blobStorageRoot);
|
||||||
builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory);
|
builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory, TimeProvider.System);
|
||||||
|
|
||||||
builder.Services.AddScoped<CurrentUserContext, CurrentUserContext>();
|
builder.Services.AddScoped<CurrentUserContext, CurrentUserContext>();
|
||||||
builder.Services.AddScoped<ICurrentUserContext>(s => s.GetRequiredService<CurrentUserContext>());
|
builder.Services.AddScoped<ICurrentUserContext>(s => s.GetRequiredService<CurrentUserContext>());
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "https://stinkpad:7269;http://0.0.0.0:5181",
|
"applicationUrl": "https://localhost:7269",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,4 +5,4 @@ public interface ICurrentUserContext
|
||||||
CurrentUser? CurrentUser { get; }
|
CurrentUser? CurrentUser { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CurrentUser(Guid Id, string Username);
|
public record CurrentUser(Guid Id, string Username, bool IsSuperUser);
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- Migration: AddInvalidateToLongTermSession
|
||||||
|
-- Created at: 19/07/2025 10:42:00
|
||||||
|
|
||||||
|
ALTER TABLE authn.long_term_session
|
||||||
|
ADD COLUMN is_invalidated BOOLEAN NOT NULL DEFAULT FALSE;
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- Migration: AddTimestampToReaction
|
||||||
|
-- Created at: 10/08/2025 15:21:32
|
||||||
|
alter table blog.post_reaction
|
||||||
|
add column created_at timestamptz;
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Migration: AddCommentToPost
|
||||||
|
-- Created at: 10/08/2025 17:22:42
|
||||||
|
|
||||||
|
CREATE TABLE blog.post_comment
|
||||||
|
(
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
post_id uuid REFERENCES blog.post(id),
|
||||||
|
author_id uuid REFERENCES blog.author(id),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
|
@ -43,6 +43,7 @@ public static class TestDataSeeder
|
||||||
('019691a0-4dd3-7e89-909e-94a6fd19a05e', @id, '["🍆", "🧢", "🧑🏾🎓", "🥕", "🕗"]', 'Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.')
|
('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
|
INSERT INTO blog.post_media
|
||||||
(id, post_id, url, ordering)
|
(id, post_id, url, ordering)
|
||||||
VALUES
|
VALUES
|
||||||
|
@ -63,6 +64,12 @@ public static class TestDataSeeder
|
||||||
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '🕗')
|
('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
|
INSERT INTO authn.user_identity
|
||||||
(id, username, password_hash, password_salt)
|
(id, username, password_hash, password_salt)
|
||||||
VALUES
|
VALUES
|
||||||
|
|
|
@ -11,7 +11,7 @@ namespace Femto.Modules.Auth.Application;
|
||||||
|
|
||||||
internal class AuthService(
|
internal class AuthService(
|
||||||
AuthContext context,
|
AuthContext context,
|
||||||
SessionStorage storage,
|
SessionStorage sessionStorage,
|
||||||
IDbConnectionFactory connectionFactory
|
IDbConnectionFactory connectionFactory
|
||||||
) : IAuthService
|
) : IAuthService
|
||||||
{
|
{
|
||||||
|
@ -33,7 +33,7 @@ internal class AuthService(
|
||||||
|
|
||||||
var session = new Session(user.Id, true);
|
var session = new Session(user.Id, true);
|
||||||
|
|
||||||
await storage.AddSession(session);
|
await sessionStorage.AddSession(session);
|
||||||
|
|
||||||
return new(
|
return new(
|
||||||
new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()),
|
new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()),
|
||||||
|
@ -53,7 +53,7 @@ internal class AuthService(
|
||||||
{
|
{
|
||||||
var session = new Session(userId, true);
|
var session = new Session(userId, true);
|
||||||
|
|
||||||
await storage.AddSession(session);
|
await sessionStorage.AddSession(session);
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
@ -62,19 +62,19 @@ internal class AuthService(
|
||||||
{
|
{
|
||||||
var session = new Session(userId, false);
|
var session = new Session(userId, false);
|
||||||
|
|
||||||
await storage.AddSession(session);
|
await sessionStorage.AddSession(session);
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Session?> GetSession(string sessionId)
|
public Task<Session?> GetSession(string sessionId)
|
||||||
{
|
{
|
||||||
return storage.GetSession(sessionId);
|
return sessionStorage.GetSession(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteSession(string sessionId)
|
public async Task DeleteSession(string sessionId)
|
||||||
{
|
{
|
||||||
await storage.DeleteSession(sessionId);
|
await sessionStorage.DeleteSession(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UserAndSession> CreateUserWithCredentials(
|
public async Task<UserAndSession> CreateUserWithCredentials(
|
||||||
|
@ -113,7 +113,7 @@ internal class AuthService(
|
||||||
|
|
||||||
var session = new Session(user.Id, true);
|
var session = new Session(user.Id, true);
|
||||||
|
|
||||||
await storage.AddSession(session);
|
await sessionStorage.AddSession(session);
|
||||||
|
|
||||||
await context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
@ -189,7 +189,7 @@ internal class AuthService(
|
||||||
if (token is null)
|
if (token is null)
|
||||||
return (null, null);
|
return (null, null);
|
||||||
|
|
||||||
if (!token.Validate(rememberMeToken.Verifier))
|
if (!token.CheckVerifier(rememberMeToken.Verifier))
|
||||||
return (null, null);
|
return (null, null);
|
||||||
|
|
||||||
var user = await context.Users.SingleOrDefaultAsync(u => u.Id == token.UserId);
|
var user = await context.Users.SingleOrDefaultAsync(u => u.Id == token.UserId);
|
||||||
|
@ -218,13 +218,34 @@ internal class AuthService(
|
||||||
if (session is null)
|
if (session is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!session.Validate(rememberMeToken.Verifier))
|
if (!session.CheckVerifier(rememberMeToken.Verifier))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
context.Remove(session);
|
context.Remove(session);
|
||||||
await context.SaveChangesAsync();
|
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
|
private class GetSignupCodesQueryResultRow
|
||||||
{
|
{
|
||||||
public string Code { get; set; }
|
public string Code { get; set; }
|
||||||
|
|
|
@ -20,13 +20,14 @@ public static class AuthStartup
|
||||||
this IServiceCollection rootContainer,
|
this IServiceCollection rootContainer,
|
||||||
string connectionString,
|
string connectionString,
|
||||||
IEventBus eventBus,
|
IEventBus eventBus,
|
||||||
ILoggerFactory loggerFactory
|
ILoggerFactory loggerFactory,
|
||||||
|
TimeProvider timeProvider
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var hostBuilder = Host.CreateDefaultBuilder();
|
var hostBuilder = Host.CreateDefaultBuilder();
|
||||||
|
|
||||||
hostBuilder.ConfigureServices(services =>
|
hostBuilder.ConfigureServices(services =>
|
||||||
ConfigureServices(services, connectionString, eventBus, loggerFactory)
|
ConfigureServices(services, connectionString, eventBus, loggerFactory, timeProvider)
|
||||||
);
|
);
|
||||||
|
|
||||||
var host = hostBuilder.Build();
|
var host = hostBuilder.Build();
|
||||||
|
@ -52,9 +53,12 @@ public static class AuthStartup
|
||||||
IServiceCollection services,
|
IServiceCollection services,
|
||||||
string connectionString,
|
string connectionString,
|
||||||
IEventPublisher publisher,
|
IEventPublisher publisher,
|
||||||
ILoggerFactory loggerFactory
|
ILoggerFactory loggerFactory,
|
||||||
|
TimeProvider timeProvider
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
services.AddSingleton(timeProvider);
|
||||||
|
|
||||||
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
|
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
|
||||||
|
|
||||||
services.AddDbContext<AuthContext>(builder =>
|
services.AddDbContext<AuthContext>(builder =>
|
||||||
|
@ -83,11 +87,8 @@ public static class AuthStartup
|
||||||
|
|
||||||
services.AddSingleton(publisher);
|
services.AddSingleton(publisher);
|
||||||
services.AddSingleton<SessionStorage>();
|
services.AddSingleton<SessionStorage>();
|
||||||
|
|
||||||
services.AddScoped(
|
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>));
|
||||||
typeof(IPipelineBehavior<,>),
|
|
||||||
typeof(SaveChangesPipelineBehaviour<,>)
|
|
||||||
);
|
|
||||||
|
|
||||||
services.AddScoped<IAuthService, AuthService>();
|
services.AddScoped<IAuthService, AuthService>();
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,12 +3,6 @@ using Femto.Modules.Auth.Models;
|
||||||
|
|
||||||
namespace Femto.Modules.Auth.Application;
|
namespace Femto.Modules.Auth.Application;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// I broke off IAuthService from IAuthModule because the CQRS distinction is cumbersome when doing auth handling,
|
|
||||||
/// particularly in regards to session management. I may or may not bother to move the commands and queries here also,
|
|
||||||
/// but for controller actions I do quite like having the abstraction, and there is less drive within me to bother.
|
|
||||||
/// It just seems redundant to expose them both, and it's a bit confusin'
|
|
||||||
/// </summary>
|
|
||||||
public interface IAuthService
|
public interface IAuthService
|
||||||
{
|
{
|
||||||
public Task<UserAndSession?> AuthenticateUserCredentials(
|
public Task<UserAndSession?> AuthenticateUserCredentials(
|
||||||
|
@ -43,6 +37,9 @@ public interface IAuthService
|
||||||
Task<NewRememberMeToken> CreateRememberMeToken(Guid userId);
|
Task<NewRememberMeToken> CreateRememberMeToken(Guid userId);
|
||||||
Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken(RememberMeToken rememberMeToken);
|
Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken(RememberMeToken rememberMeToken);
|
||||||
Task DeleteRememberMeToken(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);
|
public record UserAndSession(UserInfo User, Session Session);
|
|
@ -1,29 +1,55 @@
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using Femto.Modules.Auth.Models;
|
using Femto.Modules.Auth.Models;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
namespace Femto.Modules.Auth.Infrastructure;
|
namespace Femto.Modules.Auth.Infrastructure;
|
||||||
|
|
||||||
internal class SessionStorage(MemoryCacheOptions? options = null)
|
internal class SessionStorage(TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
private readonly IMemoryCache _storage = new MemoryCache(options ?? new MemoryCacheOptions());
|
private readonly IMemoryCache _storage = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
|
||||||
public Task<Session?> GetSession(string id)
|
public async Task<Session?> GetSession(string id)
|
||||||
{
|
{
|
||||||
return Task.FromResult(this._storage.Get<Session>(id));
|
var session = this._storage.Get<Session>($"session:{id}");
|
||||||
|
|
||||||
|
if (session is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var invalidUntil = this._storage.Get<DateTimeOffset?>(
|
||||||
|
$"user:invalid_until:{session.UserId}"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invalidUntil is not null && invalidUntil > session.Expires)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task AddSession(Session session)
|
public Task AddSession(Session session)
|
||||||
{
|
{
|
||||||
using var entry = this._storage.CreateEntry(session.Id);
|
using var sessionEntry = this._storage.CreateEntry($"session:{session.Id}");
|
||||||
entry.Value = session;
|
sessionEntry.Value = session;
|
||||||
entry.SetAbsoluteExpiration(session.Expires);
|
sessionEntry.SetAbsoluteExpiration(session.Expires);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task DeleteSession(string id)
|
public Task DeleteSession(string id)
|
||||||
{
|
{
|
||||||
this._storage.Remove(id);
|
this._storage.Remove($"session:{id}");
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InvalidateUserSessions(Guid userId)
|
||||||
|
{
|
||||||
|
var invalidUntil = timeProvider.GetUtcNow() + Session.ValidityPeriod;
|
||||||
|
|
||||||
|
// invalidate sessions who are currently valid
|
||||||
|
// any sessions created after this will have a validity period that extends past invalid_until
|
||||||
|
// this cache entry doesn't need to live longer than that point in time
|
||||||
|
this._storage.Set($"user:invalid_until:{userId}", invalidUntil, invalidUntil);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
using Femto.Modules.Auth.Data;
|
||||||
|
using Femto.Modules.Auth.Models.Events;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Models.DomainEventHandlers;
|
||||||
|
|
||||||
|
internal class UserPasswordChangedHandler(AuthContext context)
|
||||||
|
: INotificationHandler<UserWasCreatedEvent>
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Models.Events;
|
||||||
|
|
||||||
|
internal record UserPasswordChangedDomainEvent(UserIdentity User) : DomainEvent;
|
|
@ -18,6 +18,8 @@ public class LongTermSession
|
||||||
public DateTimeOffset Expires { get; private set; }
|
public DateTimeOffset Expires { get; private set; }
|
||||||
|
|
||||||
public Guid UserId { get; private set; }
|
public Guid UserId { get; private set; }
|
||||||
|
|
||||||
|
public bool IsInvalidated { get; private set; }
|
||||||
|
|
||||||
[NotMapped]
|
[NotMapped]
|
||||||
public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer;
|
public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer;
|
||||||
|
@ -46,8 +48,11 @@ public class LongTermSession
|
||||||
return (longTermSession, verifier);
|
return (longTermSession, verifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Validate(string verifier)
|
public bool CheckVerifier(string verifier)
|
||||||
{
|
{
|
||||||
|
if (this.IsInvalidated)
|
||||||
|
return false;
|
||||||
|
|
||||||
if (this.Expires < DateTimeOffset.UtcNow)
|
if (this.Expires < DateTimeOffset.UtcNow)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
@ -60,4 +65,9 @@ public class LongTermSession
|
||||||
var hashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier));
|
var hashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier));
|
||||||
return hashedVerifier;
|
return hashedVerifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Invalidate()
|
||||||
|
{
|
||||||
|
this.IsInvalidated = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ namespace Femto.Modules.Auth.Models;
|
||||||
|
|
||||||
public class Session(Guid userId, bool isStrong)
|
public class Session(Guid userId, bool isStrong)
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan ValidityPeriod = TimeSpan.FromMinutes(15);
|
public static readonly TimeSpan ValidityPeriod = TimeSpan.FromMinutes(15);
|
||||||
private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMinutes(5);
|
||||||
public string Id { get; } = Convert.ToBase64String(GetBytes(32));
|
public string Id { get; } = Convert.ToBase64String(GetBytes(32));
|
||||||
public Guid UserId { get; } = userId;
|
public Guid UserId { get; } = userId;
|
||||||
|
|
|
@ -28,6 +28,8 @@ internal class UserIdentity : Entity
|
||||||
|
|
||||||
public void SetPassword(string password)
|
public void SetPassword(string password)
|
||||||
{
|
{
|
||||||
|
if (this.Password is not null)
|
||||||
|
this.AddDomainEvent(new UserPasswordChangedDomainEvent(this));
|
||||||
this.Password = new Password(password);
|
this.Password = new Password(password);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
namespace Femto.Modules.Blog.Data;
|
|
||||||
|
|
||||||
public class Class1
|
|
||||||
{
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
|
@ -1,24 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="MediatR" Version="12.5.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Femto.Common\Femto.Common.csproj" />
|
|
||||||
<ProjectReference Include="..\Femto.Modules.Blog.Contracts\Femto.Modules.Blog.Contracts.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="Microsoft.EntityFrameworkCore">
|
|
||||||
<HintPath>..\..\..\..\.nuget\packages\microsoft.entityframeworkcore\9.0.4\lib\net8.0\Microsoft.EntityFrameworkCore.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Application.Commands.AddPostComment;
|
||||||
|
|
||||||
|
public record AddPostCommentCommand(Guid PostId, Guid AuthorId, string Content) : ICommand;
|
|
@ -0,0 +1,20 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Blog.Application.Commands.AddPostComment;
|
||||||
|
|
||||||
|
internal class AddPostCommentCommandHandler(BlogContext context) : ICommandHandler<AddPostCommentCommand>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,11 +24,9 @@ internal class CreatePostCommandHandler(BlogContext context)
|
||||||
media.Width,
|
media.Width,
|
||||||
media.Height
|
media.Height
|
||||||
))
|
))
|
||||||
.ToList()
|
.ToList(),
|
||||||
)
|
request.IsPublic is true
|
||||||
{
|
);
|
||||||
IsPublic = request.IsPublic is true
|
|
||||||
};
|
|
||||||
|
|
||||||
await context.AddAsync(post, cancellationToken);
|
await context.AddAsync(post, cancellationToken);
|
||||||
|
|
||||||
|
@ -39,7 +37,8 @@ internal class CreatePostCommandHandler(BlogContext context)
|
||||||
post.PostedOn,
|
post.PostedOn,
|
||||||
new PostAuthorDto(post.AuthorId, request.CurrentUser.Username),
|
new PostAuthorDto(post.AuthorId, request.CurrentUser.Username),
|
||||||
[],
|
[],
|
||||||
post.PossibleReactions
|
post.PossibleReactions,
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,8 @@ internal class PostConfiguration : IEntityTypeConfiguration<Post>
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
table.OwnsMany(p => p.Comments).WithOwner();
|
||||||
|
|
||||||
table.Property<string>("PossibleReactionsJson").HasColumnName("possible_reactions");
|
table.Property<string>("PossibleReactionsJson").HasColumnName("possible_reactions");
|
||||||
|
|
||||||
table.Ignore(e => e.PossibleReactions);
|
table.Ignore(e => e.PossibleReactions);
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
||||||
|
|
||||||
public record GetPostsQueryResult(IList<PostDto> Posts, Guid? Next);
|
public record GetPostsQueryResult(IList<PostDto> Posts);
|
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
||||||
|
|
||||||
|
public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);
|
|
@ -7,5 +7,6 @@ public record PostDto(
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
PostAuthorDto Author,
|
PostAuthorDto Author,
|
||||||
IList<PostReactionDto> Reactions,
|
IList<PostReactionDto> Reactions,
|
||||||
IEnumerable<string> PossibleReactions
|
IEnumerable<string> PossibleReactions,
|
||||||
);
|
IList<PostCommentDto> Comments
|
||||||
|
);
|
|
@ -1,3 +1,3 @@
|
||||||
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
||||||
|
|
||||||
public record PostReactionDto(string Emoji, int Count, bool DidReact);
|
public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);
|
||||||
|
|
|
@ -3,22 +3,27 @@ using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Application.Queries.GetPosts;
|
namespace Femto.Modules.Blog.Application.Queries.GetPosts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get posts in reverse chronological order
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="CurrentUserId"></param>
|
||||||
public record GetPostsQuery(Guid? CurrentUserId) : IQuery<GetPostsQueryResult>
|
public record GetPostsQuery(Guid? CurrentUserId) : IQuery<GetPostsQueryResult>
|
||||||
{
|
{
|
||||||
public Guid? From { get; init; }
|
/// <summary>
|
||||||
|
/// Id of the specific post to load. If specified, After and Amount are ignored
|
||||||
|
/// </summary>
|
||||||
|
public Guid? PostId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If specified, loads posts from after the given Id. Used for paging
|
||||||
|
/// </summary>
|
||||||
|
public Guid? After { get; init; }
|
||||||
public int Amount { get; init; } = 20;
|
public int Amount { get; init; } = 20;
|
||||||
public Guid? AuthorId { get; init; }
|
public Guid? AuthorId { get; init; }
|
||||||
public string? Author { get; init; }
|
public string? Author { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
public GetPostsQuery(Guid postId, Guid? currentUserId) : this(currentUserId)
|
||||||
/// Default is to load in reverse chronological order
|
{
|
||||||
/// TODO this is not exposed on the client as it probably wouldn't work that well
|
this.PostId = postId;
|
||||||
/// </summary>
|
}
|
||||||
public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public enum GetPostsDirection
|
|
||||||
{
|
|
||||||
Forward,
|
|
||||||
Backward,
|
|
||||||
}
|
|
|
@ -18,7 +18,7 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
|
|
||||||
var username = query.Author;
|
var username = query.Author;
|
||||||
var authorGuid = query.AuthorId;
|
var authorGuid = query.AuthorId;
|
||||||
var cursor = query.From;
|
var cursor = query.After;
|
||||||
var showPrivate = query.CurrentUserId is not null;
|
var showPrivate = query.CurrentUserId is not null;
|
||||||
|
|
||||||
var loadPostsResult = await conn.QueryAsync<LoadPostRow>(
|
var loadPostsResult = await conn.QueryAsync<LoadPostRow>(
|
||||||
|
@ -33,9 +33,10 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
from blog.post
|
from blog.post
|
||||||
inner join blog.author on blog.author.id = blog.post.author_id
|
inner join blog.author on blog.author.id = blog.post.author_id
|
||||||
where (@username is null or blog.author.username = @username)
|
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 (@showPrivate or blog.post.is_public = true)
|
||||||
and (@authorGuid is null or blog.author.id = @authorGuid)
|
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
|
order by blog.post.id desc
|
||||||
limit @amount
|
limit @amount
|
||||||
""",
|
""",
|
||||||
|
@ -44,15 +45,13 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
username,
|
username,
|
||||||
authorGuid,
|
authorGuid,
|
||||||
cursor,
|
cursor,
|
||||||
// load an extra one to take for the cursor
|
amount = query.PostId is not null ? 1 : query.Amount,
|
||||||
amount = query.Amount + 1,
|
|
||||||
showPrivate,
|
showPrivate,
|
||||||
|
postId = query.PostId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
var loadedPosts = loadPostsResult.ToList();
|
var posts = loadPostsResult.ToList();
|
||||||
var posts = loadedPosts.Take(query.Amount).ToList();
|
|
||||||
var next = loadedPosts.LastOrDefault()?.PostId;
|
|
||||||
|
|
||||||
var postIds = posts.Select(p => p.PostId).ToList();
|
var postIds = posts.Select(p => p.PostId).ToList();
|
||||||
|
|
||||||
|
@ -70,69 +69,69 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
new { postIds }
|
new { postIds }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
var media = loadMediaResult.ToList();
|
||||||
|
|
||||||
var loadReactionsResult = await conn.QueryAsync<LoadReactionRow>(
|
var loadReactionsResult = await conn.QueryAsync<LoadReactionRow>(
|
||||||
"""
|
"""
|
||||||
select
|
select
|
||||||
pr.post_id as PostId,
|
pr.post_id as PostId,
|
||||||
pr.author_id as AuthorId,
|
a.username as AuthorName,
|
||||||
pr.emoji as Emoji
|
pr.emoji as Emoji,
|
||||||
|
pr.created_at as CreatedOn
|
||||||
from blog.post_reaction pr
|
from blog.post_reaction pr
|
||||||
|
join blog.author a on a.id = pr.author_id
|
||||||
where pr.post_id = ANY (@postIds)
|
where pr.post_id = ANY (@postIds)
|
||||||
""",
|
""",
|
||||||
new { postIds }
|
new { postIds }
|
||||||
);
|
);
|
||||||
|
|
||||||
var reactionsByPostId = loadReactionsResult
|
var reactions = loadReactionsResult.ToList();
|
||||||
.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 mediaByPostId = loadMediaResult
|
var loadCommentsResult = await conn.QueryAsync<LoadCommentRow>(
|
||||||
.GroupBy(m => m.PostId)
|
"""
|
||||||
.ToDictionary(
|
select
|
||||||
g => g.Key,
|
pc.id as CommentId,
|
||||||
g =>
|
pc.post_id as PostId,
|
||||||
g.Select(m => new PostMediaDto(
|
pc.content as Content,
|
||||||
new Uri(m.MediaUrl),
|
pc.created_at as PostedOn,
|
||||||
m.MediaWidth,
|
a.username as AuthorName
|
||||||
m.MediaHeight
|
from blog.post_comment pc
|
||||||
))
|
join blog.author a on pc.author_id = a.id
|
||||||
.ToList()
|
where pc.post_id = ANY (@postIds)
|
||||||
);
|
""",
|
||||||
|
new { postIds }
|
||||||
|
);
|
||||||
|
|
||||||
|
var comments = loadCommentsResult.ToList();
|
||||||
|
|
||||||
return new GetPostsQueryResult(
|
return new GetPostsQueryResult(
|
||||||
posts
|
posts
|
||||||
.Select(p => new PostDto(
|
.Select(p => new PostDto(
|
||||||
p.PostId,
|
p.PostId,
|
||||||
p.Content,
|
p.Content,
|
||||||
mediaByPostId.TryGetValue(p.PostId, out var mediaDtos) ? mediaDtos : [],
|
media
|
||||||
|
.Where(m => m.PostId == p.PostId)
|
||||||
|
.Select(m => new PostMediaDto(
|
||||||
|
new Uri(m.MediaUrl),
|
||||||
|
m.MediaWidth,
|
||||||
|
m.MediaHeight
|
||||||
|
))
|
||||||
|
.ToList(),
|
||||||
p.PostedOn,
|
p.PostedOn,
|
||||||
new PostAuthorDto(p.AuthorId, p.Username),
|
new PostAuthorDto(p.AuthorId, p.Username),
|
||||||
reactionsByPostId.TryGetValue(p.PostId, out var reactionDtos)
|
reactions
|
||||||
? reactionDtos.ToList()
|
.Where(r => r.PostId == p.PostId)
|
||||||
: [],
|
.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.CreatedAt))
|
||||||
|
.ToList(),
|
||||||
!string.IsNullOrEmpty(p.PossibleReactions)
|
!string.IsNullOrEmpty(p.PossibleReactions)
|
||||||
? JsonSerializer.Deserialize<IEnumerable<string>>(p.PossibleReactions)!
|
? JsonSerializer.Deserialize<IEnumerable<string>>(p.PossibleReactions)!
|
||||||
: []
|
: [],
|
||||||
|
comments
|
||||||
|
.Where(c => c.PostId == p.PostId)
|
||||||
|
.Select(c => new PostCommentDto(c.AuthorName, c.Content, c.PostedOn))
|
||||||
|
.ToList()
|
||||||
))
|
))
|
||||||
.ToList(),
|
.ToList()
|
||||||
next
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -158,7 +157,17 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
internal record LoadReactionRow
|
internal record LoadReactionRow
|
||||||
{
|
{
|
||||||
public Guid PostId { get; init; }
|
public Guid PostId { get; init; }
|
||||||
public Guid AuthorId { get; init; }
|
public string AuthorName { get; init; }
|
||||||
public string Emoji { 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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,9 @@ internal class Post : Entity
|
||||||
public IList<PostMedia> Media { get; private set; }
|
public IList<PostMedia> Media { get; private set; }
|
||||||
|
|
||||||
public IList<PostReaction> Reactions { get; private set; } = [];
|
public IList<PostReaction> Reactions { get; private set; } = [];
|
||||||
public bool IsPublic { get; set; }
|
|
||||||
|
public IList<PostComment> Comments { get; private set; } = [];
|
||||||
|
public bool IsPublic { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset PostedOn { get; private set; }
|
public DateTimeOffset PostedOn { get; private set; }
|
||||||
|
|
||||||
|
@ -27,7 +29,7 @@ internal class Post : Entity
|
||||||
|
|
||||||
private Post() { }
|
private Post() { }
|
||||||
|
|
||||||
public Post(Guid authorId, string content, IList<PostMedia> media)
|
public Post(Guid authorId, string content, IList<PostMedia> media, bool isPublic)
|
||||||
{
|
{
|
||||||
this.Id = Guid.CreateVersion7();
|
this.Id = Guid.CreateVersion7();
|
||||||
this.AuthorId = authorId;
|
this.AuthorId = authorId;
|
||||||
|
@ -35,6 +37,7 @@ internal class Post : Entity
|
||||||
this.Media = media;
|
this.Media = media;
|
||||||
this.PossibleReactions = AllEmoji.GetRandomEmoji(5);
|
this.PossibleReactions = AllEmoji.GetRandomEmoji(5);
|
||||||
this.PostedOn = DateTimeOffset.UtcNow;
|
this.PostedOn = DateTimeOffset.UtcNow;
|
||||||
|
this.IsPublic = isPublic;
|
||||||
|
|
||||||
this.AddDomainEvent(new PostCreated(this));
|
this.AddDomainEvent(new PostCreated(this));
|
||||||
}
|
}
|
||||||
|
@ -56,4 +59,14 @@ internal class Post : Entity
|
||||||
.Reactions.Where(r => r.AuthorId != reactorId || r.Emoji != emoji)
|
.Reactions.Where(r => r.AuthorId != reactorId || r.Emoji != emoji)
|
||||||
.ToList();
|
.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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
19
Femto.Modules.Blog/Domain/Posts/PostComment.cs
Normal file
19
Femto.Modules.Blog/Domain/Posts/PostComment.cs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
namespace Femto.Modules.Blog.Domain.Posts;
|
||||||
|
|
||||||
|
internal class PostComment
|
||||||
|
{
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
public Guid AuthorId { get; private set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
public string Content { get; private set; }
|
||||||
|
|
||||||
|
private PostComment() {}
|
||||||
|
|
||||||
|
public PostComment(Guid authorId, string content)
|
||||||
|
{
|
||||||
|
this.Id = Guid.CreateVersion7();
|
||||||
|
this.AuthorId = authorId;
|
||||||
|
this.Content = content;
|
||||||
|
this.CreatedAt = TimeProvider.System.GetUtcNow();
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,11 +5,13 @@ public class PostReaction
|
||||||
public Guid AuthorId { get; private set; }
|
public Guid AuthorId { get; private set; }
|
||||||
public Guid PostId { get; private set; }
|
public Guid PostId { get; private set; }
|
||||||
public string Emoji { get; private set; } = null!;
|
public string Emoji { get; private set; } = null!;
|
||||||
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
public PostReaction(Guid authorId, Guid postId, string emoji)
|
public PostReaction(Guid authorId, Guid postId, string emoji)
|
||||||
{
|
{
|
||||||
this.AuthorId = authorId;
|
this.AuthorId = authorId;
|
||||||
this.PostId = postId;
|
this.PostId = postId;
|
||||||
this.Emoji = emoji;
|
this.Emoji = emoji;
|
||||||
|
this.CreatedAt = TimeProvider.System.GetUtcNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
private PostReaction() { }
|
private PostReaction() { }
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
namespace Femto.Modules.Files.Domain.Files;
|
|
||||||
|
|
||||||
public class File
|
|
||||||
{
|
|
||||||
Guid Id { get; set; }
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
Loading…
Add table
Add a link
Reference in a new issue