Compare commits

..

No commits in common. "main" and "v0.1.5" have entirely different histories.
main ... v0.1.5

109 changed files with 497 additions and 5829 deletions

View file

@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.1.31</Version> <Version>0.1.5</Version>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View file

@ -2,7 +2,6 @@ namespace Femto.Api.Auth;
public class CookieSettings public class CookieSettings
{ {
public SameSiteMode SameSite { get; set; } public bool SameSite { get; set; }
public bool Secure { get; set; } public bool Secure { get; set; }
public string? Domain { get; set; }
} }

View file

@ -3,9 +3,8 @@ using System.Text.Encodings.Web;
using Femto.Api.Sessions; 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.Commands.ValidateSession;
using Femto.Modules.Auth.Contracts; using Femto.Modules.Auth.Errors;
using Femto.Modules.Auth.Models;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -15,115 +14,41 @@ internal class SessionAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options, IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, ILoggerFactory logger,
UrlEncoder encoder, UrlEncoder encoder,
IAuthService authService, IAuthModule authModule,
CurrentUserContext currentUserContext CurrentUserContext currentUserContext,
IOptions<CookieSettings> cookieOptions
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) ) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{ {
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
Logger.LogDebug("{TraceId} Authenticating session", this.Context.TraceIdentifier); var sessionId = this.Request.Cookies["session"];
if (string.IsNullOrWhiteSpace(sessionId))
var user = await this.TryAuthenticateWithSession();
if (user is null)
user = await this.TryAuthenticateWithRememberMeToken();
if (user is null)
return AuthenticateResult.NoResult(); return AuthenticateResult.NoResult();
try
{
var result = await authModule.PostCommand(new ValidateSessionCommand(sessionId));
var claims = new List<Claim> var claims = new List<Claim>
{ {
new(ClaimTypes.Name, user.Username), new(ClaimTypes.Name, result.Username),
new("sub", user.Id.ToString()), new("sub", result.UserId.ToString()),
new("user_id", user.Id.ToString()), new("user_id", result.UserId.ToString()),
}; };
claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role.ToString())));
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, this.Context.SetSession(result.Session, cookieOptions.Value);
user.Username, currentUserContext.CurrentUser = new CurrentUser(result.UserId, result.Username);
user.Roles.Contains(Role.SuperUser)
return AuthenticateResult.Success(
new AuthenticationTicket(principal, this.Scheme.Name)
); );
return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name));
} }
catch (InvalidSessionError)
private async Task<UserInfo?> TryAuthenticateWithSession()
{ {
var sessionId = this.Context.GetSessionId(); return AuthenticateResult.Fail("Invalid session");
if (sessionId is null)
{
Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier);
return null;
} }
var session = await authService.GetSession(sessionId);
if (session is null)
{
Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier);
return null;
}
if (session.IsExpired)
{
Logger.LogDebug("{TraceId} Loaded session was expired ", this.Context.TraceIdentifier);
await authService.DeleteSession(sessionId);
this.Context.DeleteSession();
return null;
}
var user = await authService.GetUserWithId(session.UserId);
if (user is null)
{
await authService.DeleteSession(sessionId);
this.Context.DeleteSession();
return null;
}
if (session.ExpiresSoon)
{
session = await authService.CreateWeakSession(session.UserId);
this.Context.SetSession(session, user);
}
return user;
}
private async Task<UserInfo?> 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;
} }
} }

View file

@ -1,236 +1,45 @@
using Femto.Api.Auth;
using Femto.Api.Sessions; using Femto.Api.Sessions;
using Femto.Common;
using Femto.Modules.Auth.Application; using Femto.Modules.Auth.Application;
using Femto.Modules.Auth.Contracts; using Femto.Modules.Auth.Application.Commands.Login;
using Microsoft.AspNetCore.Authorization; using Femto.Modules.Auth.Application.Commands.Register;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace Femto.Api.Controllers.Auth; namespace Femto.Api.Controllers.Auth;
[ApiController] [ApiController]
[Route("auth")] [Route("auth")]
public class AuthController(ICurrentUserContext currentUserContext, IAuthService authService) public class AuthController(IAuthModule authModule, IOptions<CookieSettings> cookieSettings) : ControllerBase
: ControllerBase
{ {
[HttpPost("login")] [HttpPost("login")]
public async Task<ActionResult<LoginResponse>> Login( public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request)
[FromBody] LoginRequest request,
CancellationToken cancellationToken
)
{ {
var result = await authService.AuthenticateUserCredentials( var result = await authModule.PostCommand(
request.Username, new LoginCommand(request.Username, request.Password)
request.Password,
cancellationToken
); );
if (result is null) HttpContext.SetSession(result.Session, cookieSettings.Value);
return this.BadRequest();
var (user, session) = result; return new LoginResponse(result.UserId, result.Username);
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")] [HttpPost("register")]
public async Task<ActionResult<RegisterResponse>> Register( public async Task<ActionResult<RegisterResponse>> Register([FromBody] RegisterRequest request)
[FromBody] RegisterRequest request,
CancellationToken cancellationToken
)
{ {
var (user, session) = await authService.CreateUserWithCredentials( var result = await authModule.PostCommand(
request.Username, new RegisterCommand(request.Username, request.Password)
request.Password,
request.SignupCode,
cancellationToken
); );
HttpContext.SetSession(session, user); HttpContext.SetSession(result.Session, cookieSettings.Value);
if (request.RememberMe) return new RegisterResponse(result.UserId, result.Username);
{
var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
HttpContext.SetRememberMeToken(newRememberMeToken);
} }
return new RegisterResponse(
user.Id,
user.Username,
user.Roles.Any(r => r == Role.SuperUser)
);
}
[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()
{ {
var sessionId = HttpContext.GetSessionId(); HttpContext.Response.Cookies.Delete("session");
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 { }); return Ok(new { });
} }
[HttpGet("user/{userId}")]
[Authorize]
public async Task<ActionResult<GetUserInfoResult>> GetUserInfo(
Guid userId,
CancellationToken cancellationToken
)
{
var currentUser = currentUserContext.CurrentUser;
if (currentUser is null || currentUser.Id != userId)
return this.BadRequest();
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)
);
}
[Obsolete("use POST /auth/create-signup-code")]
[HttpPost("signup-codes")]
[Authorize(Roles = "SuperUser")]
public async Task<ActionResult> CreateSignupCode(
[FromBody] CreateSignupCodeRequest request,
CancellationToken cancellationToken
)
{
await authService.AddSignupCode(request.Code, request.Name, cancellationToken);
return Ok(new { });
}
[Obsolete("use GET /auth/list-signup-codes")]
[HttpGet("signup-codes")]
[Authorize(Roles = "SuperUser")]
public async Task<ActionResult<ListSignupCodesResult>> 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<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
))
);
}
} }

View file

@ -1,3 +0,0 @@
namespace Femto.Api.Controllers.Auth;
public record ChangePasswordRequestBody(Guid UserId, string NewPassword);

View file

@ -1,3 +0,0 @@
namespace Femto.Api.Controllers.Auth;
public record CreateSignupCodeRequest(string Code, string Email, string Name);

View file

@ -1,3 +0,0 @@
namespace Femto.Api.Controllers.Auth;
public record GetUserInfoResult(Guid UserId, string Username, bool IsSuperUser);

View file

@ -1,14 +0,0 @@
namespace Femto.Api.Controllers.Auth;
public record ListSignupCodesResult(
IEnumerable<SignupCodeDto> SignupCodes
);
public record SignupCodeDto(
string Code,
string Email,
string Name,
Guid? RedeemingUserId,
string? RedeemingUsername,
DateTimeOffset? ExpiresOn
);

View file

@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Auth; namespace Femto.Api.Controllers.Auth;
public record LoginRequest(string Username, string Password, bool RememberMe); public record LoginRequest(string Username, string Password);

View file

@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Auth; namespace Femto.Api.Controllers.Auth;
public record LoginResponse(Guid UserId, string Username, bool IsSuperUser); public record LoginResponse(Guid UserId, string Username);

View file

@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Auth; namespace Femto.Api.Controllers.Auth;
public record RegisterRequest(string Username, string Password, string SignupCode, bool RememberMe); public record RegisterRequest(string Username, string Password, string SignupCode, string? Email);

View file

@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Auth; namespace Femto.Api.Controllers.Auth;
public record RegisterResponse(Guid UserId, string Username, bool IsSuperUser); public record RegisterResponse(Guid UserId, string Username);

View file

@ -1,7 +1,9 @@
using Femto.Api.Controllers.Media.Dto; using Femto.Api.Controllers.Media.Dto;
using Femto.Modules.Media.Application; using Femto.Modules.Media.Application;
using Femto.Modules.Media.Contracts;
using Femto.Modules.Media.Contracts.LoadFile; using Femto.Modules.Media.Contracts.LoadFile;
using Femto.Modules.Media.Contracts.SaveFile; using Femto.Modules.Media.Contracts.SaveFile;
using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;

View file

@ -1,3 +0,0 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record AddPostCommentRequest(Guid AuthorId, string Content);

View file

@ -1,3 +0,0 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record AddPostReactionRequest(string Emoji);

View file

@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto; namespace Femto.Api.Controllers.Posts.Dto;
public record CreatePostResponse(PostDto Post); public record CreatePostResponse(Guid PostId);

View file

@ -1,3 +0,0 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record DeletePostReactionRequest(string Emoji);

View file

@ -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); public record GetAllPublicPostsResponse(IEnumerable<PostDto> Posts, Guid? Next);

View file

@ -1,3 +0,0 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record GetPostResponse(PostDto Post);

View file

@ -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? After, int? Amount, Guid? AuthorId, string? Author); public record GetPublicPostsSearchParams(Guid? From, int? Amount, Guid? AuthorId, string? Author);

View file

@ -1,3 +0,0 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);

View file

@ -8,21 +8,5 @@ public record PostDto(
Guid PostId, Guid PostId,
string Content, string Content,
IEnumerable<PostMediaDto> Media, IEnumerable<PostMediaDto> Media,
IEnumerable<PostReactionDto> Reactions, DateTimeOffset CreatedAt
DateTimeOffset CreatedAt, );
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))
);
}

View file

@ -1,3 +0,0 @@
namespace Femto.Api.Controllers.Posts.Dto;
public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);

View file

@ -1,11 +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.ClearPostReaction;
using Femto.Modules.Blog.Application.Commands.CreatePost; using Femto.Modules.Blog.Application.Commands.CreatePost;
using Femto.Modules.Blog.Application.Commands.DeletePost;
using Femto.Modules.Blog.Application.Queries.GetPosts; using Femto.Modules.Blog.Application.Queries.GetPosts;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -14,19 +10,18 @@ namespace Femto.Api.Controllers.Posts;
[ApiController] [ApiController]
[Route("posts")] [Route("posts")]
public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext, IAuthorizationService auth) public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) : ControllerBase
: ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult<LoadPostsResponse>> LoadPosts( public async Task<ActionResult<GetAllPublicPostsResponse>> LoadPosts(
[FromQuery] GetPublicPostsSearchParams searchParams, [FromQuery] GetPublicPostsSearchParams searchParams,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var res = await blogModule.Query( var res = await blogModule.PostQuery(
new GetPostsQuery(currentUserContext.CurrentUser?.Id) new GetPostsQuery(currentUserContext.CurrentUser?.Id)
{ {
After = searchParams.After, From = searchParams.From,
Amount = searchParams.Amount ?? 20, Amount = searchParams.Amount ?? 20,
AuthorId = searchParams.AuthorId, AuthorId = searchParams.AuthorId,
Author = searchParams.Author, Author = searchParams.Author,
@ -34,7 +29,16 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
cancellationToken cancellationToken
); );
return new LoadPostsResponse(res.Posts.Select(PostDto.FromModel)); return new GetAllPublicPostsResponse(
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.CreatedAt
)),
res.Next
);
} }
[HttpPost] [HttpPost]
@ -44,7 +48,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var post = await blogModule.Command( var guid = await blogModule.PostCommand(
new CreatePostCommand( new CreatePostCommand(
req.AuthorId, req.AuthorId,
req.Content, req.Content,
@ -59,96 +63,11 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
media.Height media.Height
) )
), ),
req.IsPublic, req.IsPublic
currentUserContext.CurrentUser!
), ),
cancellationToken cancellationToken
); );
return new CreatePostResponse(PostDto.FromModel(post)); return new CreatePostResponse(guid);
}
[HttpGet("{postId}")]
public async Task<ActionResult<GetPostResponse>> GetPost(
Guid postId,
CancellationToken cancellationToken
)
{
var result = await blogModule.Query(
new GetPostsQuery(postId, currentUserContext.CurrentUser?.Id),
cancellationToken
);
var post = result.Posts.SingleOrDefault();
if (post is null)
return NotFound();
return new GetPostResponse(PostDto.FromModel(post));
}
[HttpDelete("{postId}")]
[Authorize]
public async Task DeletePost(Guid postId, CancellationToken cancellationToken)
{
await blogModule.Command(
new DeletePostCommand(postId, currentUserContext.CurrentUser!.Id),
cancellationToken
);
}
[HttpPost("{postId}/reactions")]
[Authorize]
public async Task<ActionResult> AddPostReaction(
Guid postId,
[FromBody] AddPostReactionRequest request,
CancellationToken cancellationToken
)
{
var currentUser = currentUserContext.CurrentUser!;
await blogModule.Command(
new AddPostReactionCommand(postId, request.Emoji, currentUser.Id),
cancellationToken
);
return this.Ok();
}
[HttpDelete("{postId}/reactions")]
[Authorize]
public async Task<ActionResult> DeletePostReaction(
Guid postId,
[FromBody] DeletePostReactionRequest request,
CancellationToken cancellationToken
)
{
var currentUser = currentUserContext.CurrentUser!;
await blogModule.Command(
new ClearPostReactionCommand(postId, request.Emoji, currentUser.Id),
cancellationToken
);
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();
} }
} }

View file

@ -21,17 +21,6 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
var loggerFactory = LoggerFactory.Create(b =>
{
b.SetMinimumLevel(LogLevel.Information)
.AddConfiguration(builder.Configuration.GetSection("Logging"))
.AddConsole()
.AddDebug();
});
builder.Services.AddSingleton(loggerFactory);
builder.Services.AddSingleton(typeof(ILogger<>), typeof(Logger<>));
var connectionString = builder.Configuration.GetConnectionString("Database"); var connectionString = builder.Configuration.GetConnectionString("Database");
if (connectionString is null) if (connectionString is null)
throw new Exception("no database connection string found"); throw new Exception("no database connection string found");
@ -44,9 +33,9 @@ if (blobStorageRoot is null)
var eventBus = new EventBus(Channel.CreateUnbounded<IEvent>()); var eventBus = new EventBus(Channel.CreateUnbounded<IEvent>());
builder.Services.AddHostedService(_ => eventBus); builder.Services.AddHostedService(_ => eventBus);
builder.Services.InitializeBlogModule(connectionString, eventBus, loggerFactory); builder.Services.InitializeBlogModule(connectionString, eventBus);
builder.Services.InitializeMediaModule(connectionString, blobStorageRoot); builder.Services.InitializeMediaModule(connectionString, blobStorageRoot);
builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory, TimeProvider.System); builder.Services.InitializeAuthenticationModule(connectionString, eventBus);
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>());

View file

@ -14,7 +14,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": false, "launchBrowser": false,
"applicationUrl": "https://localhost:7269", "applicationUrl": "https://0.0.0.0:7269;http://0.0.0.0:5181",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }

View file

@ -1,131 +1,37 @@
using System.Text.Json;
using Femto.Api.Auth; using Femto.Api.Auth;
using Femto.Modules.Auth.Application.Dto; using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Models;
using Microsoft.Extensions.Options;
namespace Femto.Api.Sessions; namespace Femto.Api.Sessions;
internal record SessionInfo(string? SessionId, Guid? UserId);
internal static class HttpContextSessionExtensions internal static class HttpContextSessionExtensions
{ {
private static readonly JsonSerializerOptions JsonOptions = new() public static void SetSession(
this HttpContext httpContext,
Session session,
CookieSettings cookieSettings
)
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, httpContext.Response.Cookies.Append(
}; "session",
session.SessionId,
public static string? GetSessionId(this HttpContext httpContext) =>
httpContext.Request.Cookies["sid"];
public static void SetSession(this HttpContext context, Session session, UserInfo user)
{
var cookieSettings = context.RequestServices.GetRequiredService<IOptions<CookieSettings>>();
context.Response.Cookies.Append(
"sid",
session.Id,
new CookieOptions new CookieOptions
{ {
Path = "/",
IsEssential = true,
Domain = cookieSettings.Value.Domain,
HttpOnly = true, HttpOnly = true,
Secure = cookieSettings.Value.Secure, Secure = cookieSettings.Secure,
SameSite = cookieSettings.Value.SameSite, SameSite = cookieSettings.SameSite ? SameSiteMode.Strict : SameSiteMode.Unspecified,
Expires = session.Expires, Expires = session.Expires,
} }
); );
context.Response.Cookies.Append( httpContext.Response.Cookies.Append(
"user", "hasSession",
JsonSerializer.Serialize(user, JsonOptions), "true",
new CookieOptions new CookieOptions
{ {
Path = "/", Secure = cookieSettings.Secure,
Domain = cookieSettings.Value.Domain, SameSite = cookieSettings.SameSite ? SameSiteMode.Strict : SameSiteMode.Unspecified,
IsEssential = true,
Secure = cookieSettings.Value.Secure,
SameSite = cookieSettings.Value.SameSite,
Expires = session.Expires, Expires = session.Expires,
} }
); );
} }
public static void DeleteSession(this HttpContext httpContext)
{
var cookieSettings = httpContext.RequestServices.GetRequiredService<
IOptions<CookieSettings>
>();
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<IOptions<CookieSettings>>();
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<IOptions<CookieSettings>>();
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),
}
);
}
} }

View file

@ -3,9 +3,7 @@
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Femto": "Debug", "Microsoft.AspNetCore": "Warning"
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*"

View file

@ -5,4 +5,4 @@ public interface ICurrentUserContext
CurrentUser? CurrentUser { get; } CurrentUser? CurrentUser { get; }
} }
public record CurrentUser(Guid Id, string Username, bool IsSuperUser); public record CurrentUser(Guid Id, string Username);

View file

@ -12,7 +12,7 @@ public static class DomainServiceExtensions
services.AddScoped<DbContext>(s => s.GetRequiredService<TContext>()); services.AddScoped<DbContext>(s => s.GetRequiredService<TContext>());
services.AddTransient( services.AddTransient(
typeof(IPipelineBehavior<,>), typeof(IPipelineBehavior<,>),
typeof(DDDPipelineBehaviour<,>) typeof(SaveChangesPipelineBehaviour<,>)
); );
} }

View file

@ -5,10 +5,10 @@ using Microsoft.Extensions.Logging;
namespace Femto.Common.Infrastructure; namespace Femto.Common.Infrastructure;
public class DDDPipelineBehaviour<TRequest, TResponse>( public class SaveChangesPipelineBehaviour<TRequest, TResponse>(
DbContext context, DbContext context,
IPublisher publisher, IPublisher publisher,
ILogger<DDDPipelineBehaviour<TRequest, TResponse>> logger ILogger<SaveChangesPipelineBehaviour<TRequest, TResponse>> logger
) : IPipelineBehavior<TRequest, TResponse> ) : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull where TRequest : notnull
{ {
@ -18,12 +18,7 @@ public class DDDPipelineBehaviour<TRequest, TResponse>(
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
logger.LogDebug("handling request {Type}", typeof(TRequest).Name);
var response = await next(cancellationToken); var response = await next(cancellationToken);
var hasChanges = context.ChangeTracker.HasChanges();
logger.LogDebug("request handled. Changes? {HasChanges}", hasChanges);
if (context.ChangeTracker.HasChanges()) if (context.ChangeTracker.HasChanges())
{ {
await context.EmitDomainEvents(logger, publisher, cancellationToken); await context.EmitDomainEvents(logger, publisher, cancellationToken);

View file

@ -1,26 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Femto.Common;
/// <summary>
/// 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
/// </summary>
/// <param name="scope"></param>
public class ScopeBinding(IServiceScope scope) : IDisposable
{
private IServiceScope Scope { get; } = scope;
public T GetService<T>()
where T : notnull
{
return this.Scope.ServiceProvider.GetRequiredService<T>();
}
public virtual void Dispose()
{
this.Scope.Dispose();
}
}

View file

@ -1,14 +0,0 @@
-- Migration: InitSignupCode
-- Created at: 18/05/2025 16:30:39
CREATE TABLE authn.signup_code
(
code varchar(32) PRIMARY KEY,
recipient_email TEXT NOT NULL,
recipient_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
-- we don't make this a foreign key as we don't really need it for lookups,
-- and should the redeeming user delete be deleted, it's better that we keep the ID here
redeeming_user_id UUID
);

View file

@ -1,9 +0,0 @@
-- Migration: AddUserRole
-- Created at: 18/05/2025 19:31:13
CREATE TABLE authn.user_role
(
user_id uuid REFERENCES authn.user_identity(id),
role int,
primary key (user_id, role)
)

View file

@ -1,13 +0,0 @@
-- Migration: AddReactions
-- Created at: 26/05/2025 22:00:32
ALTER TABLE blog.post
ADD COLUMN possible_reactions TEXT;
CREATE TABLE blog.post_reaction
(
post_id uuid REFERENCES blog.post(id),
author_id uuid REFERENCES blog.author(id),
emoji text not null,
primary key (post_id, author_id, emoji)
);

View file

@ -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)
);

View file

@ -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;

View file

@ -1,4 +0,0 @@
-- Migration: AddTimestampToReaction
-- Created at: 10/08/2025 15:21:32
alter table blog.post_reaction
add column created_at timestamptz;

View file

@ -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()
)

View file

@ -14,7 +14,7 @@ public static class TestDataSeeder
var id = Guid.Parse("0196960c-6296-7532-ba66-8fabb38c6ae0"); var id = Guid.Parse("0196960c-6296-7532-ba66-8fabb38c6ae0");
var username = "johnbotris"; var username = "johnbotris";
var salt = new byte[32]; var salt = new byte[32];
var password = "password"u8; var password = "hunter2"u8;
var hashInput = new byte[password.Length + salt.Length]; var hashInput = new byte[password.Length + salt.Length];
password.CopyTo(hashInput); password.CopyTo(hashInput);
salt.CopyTo(hashInput, password.Length); salt.CopyTo(hashInput, password.Length);
@ -35,15 +35,14 @@ public static class TestDataSeeder
; ;
INSERT INTO blog.post INSERT INTO blog.post
(id, author_id, possible_reactions, content) (id, author_id, content)
VALUES VALUES
('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, '["🍆", "🧢", "🧑🏾‍🎓", "🥕", "🕗"]', 'However, authors often misinterpret the zoology as a smothered advantage, when in actuality it feels more like a blindfold accordion. They were lost without the chastest puppy that composed their Santa.'), ('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, 'However, authors often misinterpret the zoology as a smothered advantage, when in actuality it feels more like a blindfold accordion. They were lost without the chastest puppy that composed their Santa.'),
('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, '["🍆", "🧢", "🧑🏾‍🎓", "🥕", "🕗"]', 'Extending this logic, a swim can hardly be considered a seasick duckling without also being a tornado. Some posit the whity voyage to be less than dippy.'), ('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, 'Extending this logic, a swim can hardly be considered a seasick duckling without also being a tornado. Some posit the whity voyage to be less than dippy.'),
('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id, '["🍆", "🧢", "🧑🏾‍🎓", "🥕", "🕗"]', 'Few can name a springless sun that isn''t a thudding Vietnam. The burn of a competitor becomes a frosted target.'), ('019691a0-4c3e-726f-b8f6-bcbaabe789ae', @id,'Few can name a springless sun that isn''t a thudding Vietnam. The burn of a competitor becomes a frosted target.'),
('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
@ -55,35 +54,10 @@ public static class TestDataSeeder
('019691b6-2608-7088-8110-f0f6e35fa633', '019691a0-4dd3-7e89-909e-94a6fd19a05e', 'https://www.pinclipart.com/picdir/big/535-5356059_big-transparent-chungus-png-background-big-chungus-clipart.png', 0) ('019691b6-2608-7088-8110-f0f6e35fa633', '019691a0-4dd3-7e89-909e-94a6fd19a05e', 'https://www.pinclipart.com/picdir/big/535-5356059_big-transparent-chungus-png-background-big-chungus-clipart.png', 0)
; ;
INSERT INTO blog.post_reaction
(post_id, author_id, emoji)
VALUES
('019691a0-48ed-7eba-b8d3-608e25e07d4b', @id, '🍆'),
('019691a0-4ace-7bb5-a8f3-e3362920eba0', @id, '🍆'),
('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
(@id, @username, @passwordHash, @salt); (@id, @username, @passwordHash, @salt);
INSERT INTO authn.user_role
(user_id, role)
VALUES
(@id, 1);
INSERT INTO authn.signup_code
(code, recipient_email, recipient_name, expires_at, redeeming_user_id)
VALUES
('fickli', 'me@johnbotr.is', 'john', null, null);
""" """
); );

View file

@ -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

View file

@ -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

View file

@ -1,7 +0,0 @@
namespace Femto.Modules.Auth.Contracts;
public enum Role
{
User = 0,
SuperUser = 1,
}

View file

@ -0,0 +1,17 @@
using Femto.Common.Domain;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Femto.Modules.Auth.Application;
internal class AuthModule(IHost host) : IAuthModule
{
public async Task<TResponse> PostCommand<TResponse>(ICommand<TResponse> command, CancellationToken cancellationToken = default)
{
using var scope = host.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var response = await mediator.Send(command, cancellationToken);
return response;
}
}

View file

@ -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<UserAndSession?> 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<UserInfo?> 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<Session> CreateNewSession(Guid userId)
{
var session = new Session(userId, true);
await sessionStorage.AddSession(session);
return session;
}
public async Task<Session> CreateWeakSession(Guid userId)
{
var session = new Session(userId, false);
await sessionStorage.AddSession(session);
return session;
}
public Task<Session?> GetSession(string sessionId)
{
return sessionStorage.GetSession(sessionId);
}
public async Task DeleteSession(string sessionId)
{
await sessionStorage.DeleteSession(sessionId);
}
public async Task<UserAndSession> 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<ICollection<SignupCodeDto>> 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<GetSignupCodesQueryResultRow>(sql, cancellationToken);
return result
.Select(row => new SignupCodeDto(
row.Code,
row.Email,
row.Name,
row.RedeemedByUserId,
row.RedeemedByUsername,
row.ExpiresOn
))
.ToList();
}
public async Task<NewRememberMeToken> 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; }
}
}

View file

@ -1,6 +1,4 @@
using Femto.Common;
using Femto.Common.Infrastructure; using Femto.Common.Infrastructure;
using Femto.Common.Infrastructure.DbConnection;
using Femto.Common.Infrastructure.Outbox; using Femto.Common.Infrastructure.Outbox;
using Femto.Common.Integration; using Femto.Common.Integration;
using Femto.Modules.Auth.Data; using Femto.Modules.Auth.Data;
@ -16,81 +14,45 @@ namespace Femto.Modules.Auth.Application;
public static class AuthStartup public static class AuthStartup
{ {
public static void InitializeAuthenticationModule( public static void InitializeAuthenticationModule(this IServiceCollection rootContainer,
this IServiceCollection rootContainer, string connectionString, IEventBus eventBus)
string connectionString,
IEventBus eventBus,
ILoggerFactory loggerFactory,
TimeProvider timeProvider
)
{ {
var hostBuilder = Host.CreateDefaultBuilder(); var hostBuilder = Host.CreateDefaultBuilder();
hostBuilder.ConfigureServices(services => ConfigureServices(services, connectionString, eventBus));
hostBuilder.ConfigureServices(services =>
ConfigureServices(services, connectionString, eventBus, loggerFactory, timeProvider)
);
var host = hostBuilder.Build(); var host = hostBuilder.Build();
rootContainer.AddScoped<IAuthModule>(_ => new AuthModule(host));
rootContainer.AddKeyedScoped<ScopeBinding>(
"AuthServiceScope",
(s, o) =>
{
var scope = host.Services.CreateScope();
return new ScopeBinding(scope);
}
);
rootContainer.ExposeScopedService<IAuthService>();
rootContainer.AddHostedService(services => new AuthApplication(host)); rootContainer.AddHostedService(services => new AuthApplication(host));
eventBus.Subscribe( eventBus.Subscribe((evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken));
(evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)
);
} }
private static void ConfigureServices( private static void ConfigureServices(IServiceCollection services, string connectionString, IEventPublisher publisher)
IServiceCollection services,
string connectionString,
IEventPublisher publisher,
ILoggerFactory loggerFactory,
TimeProvider timeProvider
)
{ {
services.AddSingleton(timeProvider);
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
services.AddDbContext<AuthContext>(builder => services.AddDbContext<AuthContext>(builder =>
{ {
builder.UseNpgsql(connectionString); builder.UseNpgsql(connectionString);
builder.UseSnakeCaseNamingConvention(); builder.UseSnakeCaseNamingConvention();
builder.UseLoggerFactory(loggerFactory);
// #if DEBUG
// builder.EnableSensitiveDataLogging();
// #endif
}); });
services.AddSingleton(loggerFactory);
services.AddSingleton(typeof(ILogger<>), typeof(Logger<>));
services.AddQuartzHostedService(options => services.AddQuartzHostedService(options =>
{ {
options.WaitForJobsToComplete = true; options.WaitForJobsToComplete = true;
}); });
// #endif
services.AddOutbox<AuthContext, OutboxMessageHandler>(); services.AddOutbox<AuthContext, OutboxMessageHandler>();
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly)); services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly));
services.AddDbContext<AuthContext>(builder =>
{
builder.UseNpgsql();
builder.UseSnakeCaseNamingConvention();
var loggerFactory = LoggerFactory.Create(b => { });
builder.UseLoggerFactory(loggerFactory);
});
services.ConfigureDomainServices<AuthContext>(); services.ConfigureDomainServices<AuthContext>();
services.AddSingleton(publisher); services.AddSingleton(publisher);
services.AddSingleton<SessionStorage>();
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>));
services.AddScoped<IAuthService, AuthService>();
} }
private static async Task EventSubscriber( private static async Task EventSubscriber(
@ -122,14 +84,3 @@ public static class AuthStartup
} }
} }
} }
internal static class AuthServiceCollectionExtensions
{
public static void ExposeScopedService<T>(this IServiceCollection container)
where T : class
{
container.AddScoped<T>(services =>
services.GetRequiredKeyedService<ScopeBinding>("AuthServiceScope").GetService<T>()
);
}
}

View file

@ -0,0 +1,6 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Commands.Login;
public record LoginCommand(string Username, string Password) : ICommand<LoginResult>;

View file

@ -0,0 +1,30 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Models;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Auth.Application.Commands.Login;
internal class LoginCommandHandler(AuthContext context)
: ICommandHandler<LoginCommand, LoginResult>
{
public async Task<LoginResult> 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), user.Id, user.Username);
}
}

View file

@ -0,0 +1,6 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Commands.Register;
public record RegisterCommand(string Username, string Password) : ICommand<RegisterResult>;

View file

@ -0,0 +1,22 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
using Femto.Modules.Auth.Data;
using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Commands.Register;
internal class RegisterCommandHandler(AuthContext context) : ICommandHandler<RegisterCommand, RegisterResult>
{
public async Task<RegisterResult> Handle(RegisterCommand request, CancellationToken cancellationToken)
{
var user = new UserIdentity(request.Username);
user.SetPassword(request.Password);
var session = user.StartNewSession();
await context.AddAsync(user, cancellationToken);
return new(new Session(session.Id, session.Expires), user.Id, user.Username);
}
}

View file

@ -0,0 +1,10 @@
using Femto.Common.Domain;
using Femto.Modules.Auth.Application.Dto;
namespace Femto.Modules.Auth.Application.Commands.ValidateSession;
/// <summary>
/// Validate an existing session, and then return either the current session, or a new one in case the expiry is further in the future
/// </summary>
/// <param name="SessionId"></param>
public record ValidateSessionCommand(string SessionId) : ICommand<ValidateSessionResult>;

View file

@ -0,0 +1,35 @@
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.Commands.ValidateSession;
internal class ValidateSessionCommandHandler(AuthContext context)
: ICommandHandler<ValidateSessionCommand, ValidateSessionResult>
{
public async Task<ValidateSessionResult> 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),
user.Id,
user.Username
);
}
}

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto; namespace Femto.Modules.Auth.Application.Dto;
public record LoginResult(SessionDto SessionDto, UserInfo User); public record LoginResult(Session Session, Guid UserId, string Username);

View file

@ -1,3 +0,0 @@
namespace Femto.Modules.Auth.Application.Dto;
public record RefreshUserSessionResult(SessionDto SessionDto, UserInfo User);

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto; namespace Femto.Modules.Auth.Application.Dto;
public record RegisterResult(SessionDto SessionDto, UserInfo User); public record RegisterResult(Session Session, Guid UserId, string Username);

View file

@ -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}";
}

View file

@ -2,16 +2,4 @@ using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Dto; namespace Femto.Modules.Auth.Application.Dto;
public record SessionDto( public record Session(string SessionId, DateTimeOffset Expires);
string SessionId,
DateTimeOffset Expires,
bool Weak,
string? RememberMe = null
)
{
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) { }
}

View file

@ -1,10 +0,0 @@
namespace Femto.Modules.Auth.Application.Dto;
public record SignupCodeDto(
string Code,
string Email,
string Name,
Guid? RedeemedByUserId,
string? RedeemedByUsername,
DateTimeOffset? ExpiresOn
);

View file

@ -1,10 +0,0 @@
using Femto.Modules.Auth.Contracts;
using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Dto;
public record UserInfo(Guid Id, string Username, ICollection<Role> Roles)
{
internal UserInfo(UserIdentity user)
: this(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()) { }
};

View file

@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto; namespace Femto.Modules.Auth.Application.Dto;
public record ValidateSessionResult(SessionDto SessionDto); public record ValidateSessionResult(Session Session, Guid UserId, string Username);

View file

@ -0,0 +1,8 @@
using Femto.Common.Domain;
namespace Femto.Modules.Auth.Application;
public interface IAuthModule
{
Task<TResponse> PostCommand<TResponse>(ICommand<TResponse> command, CancellationToken cancellationToken = default);
}

View file

@ -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<UserAndSession?> AuthenticateUserCredentials(
string username,
string password,
CancellationToken cancellationToken = default
);
public Task<UserInfo?> GetUserWithId(
Guid? userId,
CancellationToken cancellationToken = default
);
public Task<Session> CreateNewSession(Guid userId);
public Task<Session> CreateWeakSession(Guid userId);
public Task<Session?> GetSession(string sessionId);
public Task DeleteSession(string sessionId);
public Task<UserAndSession> CreateUserWithCredentials(string username,
string password,
string signupCode,
CancellationToken cancellationToken = default);
public Task AddSignupCode(
string code,
string recipientName,
CancellationToken cancellationToken = default
);
public Task<ICollection<SignupCodeDto>> GetSignupCodes(
CancellationToken cancellationToken = default
);
Task<NewRememberMeToken> 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);

View file

@ -1,18 +1,12 @@
using Femto.Common.Domain;
using Femto.Common.Infrastructure.Outbox; using Femto.Common.Infrastructure.Outbox;
using Femto.Modules.Auth.Models; using Femto.Modules.Auth.Models;
using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.Logging;
namespace Femto.Modules.Auth.Data; namespace Femto.Modules.Auth.Data;
internal class AuthContext(DbContextOptions<AuthContext> options) : DbContext(options), IOutboxContext internal class AuthContext(DbContextOptions<AuthContext> options) : DbContext(options), IOutboxContext
{ {
public virtual DbSet<UserIdentity> Users { get; set; } public virtual DbSet<UserIdentity> Users { get; set; }
public virtual DbSet<SignupCode> SignupCodes { get; set; }
public virtual DbSet<LongTermSession> LongTermSessions { get; set; }
public virtual DbSet<OutboxEntry> Outbox { get; set; } public virtual DbSet<OutboxEntry> Outbox { get; set; }
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
@ -21,43 +15,4 @@ internal class AuthContext(DbContextOptions<AuthContext> options) : DbContext(op
builder.HasDefaultSchema("authn"); builder.HasDefaultSchema("authn");
builder.ApplyConfigurationsFromAssembly(typeof(AuthContext).Assembly); builder.ApplyConfigurationsFromAssembly(typeof(AuthContext).Assembly);
} }
public override int SaveChanges()
{
throw new InvalidOperationException("Use SaveChangesAsync instead");
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
await EmitDomainEvents(cancellationToken);
return await base.SaveChangesAsync(cancellationToken);
}
private async Task EmitDomainEvents(CancellationToken cancellationToken)
{
var logger = this.GetService<ILogger<AuthContext>>();
var publisher = this.GetService<IPublisher>();
var domainEvents = this
.ChangeTracker.Entries<Entity>()
.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);
}
}
} }

View file

@ -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<LongTermSession>
{
public void Configure(EntityTypeBuilder<LongTermSession> builder)
{
builder.ToTable("long_term_session");
}
}

View file

@ -1,14 +0,0 @@
using Femto.Modules.Auth.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Femto.Modules.Auth.Data.Configurations;
internal class SignupCodeConfiguration : IEntityTypeConfiguration<SignupCode>
{
public void Configure(EntityTypeBuilder<SignupCode> builder)
{
builder.ToTable("signup_code");
builder.HasKey(t => t.Code);
}
}

View file

@ -9,21 +9,16 @@ internal class UserIdentityTypeConfiguration : IEntityTypeConfiguration<UserIden
public void Configure(EntityTypeBuilder<UserIdentity> builder) public void Configure(EntityTypeBuilder<UserIdentity> builder)
{ {
builder.ToTable("user_identity"); builder.ToTable("user_identity");
builder.OwnsOne( builder.OwnsOne(u => u.Password, pw =>
u => u.Password,
pw =>
{ {
pw.Property(p => p.Hash).HasColumnName("password_hash").IsRequired(false); pw.Property(p => p.Hash)
.HasColumnName("password_hash")
.IsRequired(false);
pw.Property(p => p.Salt).HasColumnName("password_salt").IsRequired(false); pw.Property(p => p.Salt)
} .HasColumnName("password_salt")
); .IsRequired(false);
builder
.OwnsMany(u => u.Roles, entity =>
{
entity.WithOwner().HasForeignKey(x => x.UserId);
entity.HasKey(x => new { x.UserId, x.Role});
}); });
builder.OwnsMany(u => u.Sessions).WithOwner().HasForeignKey("user_id");
} }
} }

View file

@ -8,7 +8,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Autofac" Version="8.3.0" /> <PackageReference Include="Autofac" Version="8.3.0" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Geralt" Version="3.3.0" /> <PackageReference Include="Geralt" Version="3.3.0" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" /> <PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />

View file

@ -1,23 +0,0 @@
using Femto.Modules.Auth.Data;
using MediatR;
namespace Femto.Modules.Auth.Infrastructure;
internal class SaveChangesPipelineBehaviour<TRequest, TResponse>(AuthContext context)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken
)
{
var response = await next(cancellationToken);
if (context.ChangeTracker.HasChanges())
await context.SaveChangesAsync(cancellationToken);
return response;
}
}

View file

@ -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<Session?> GetSession(string 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)
{
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;
}
}

View file

@ -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<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();
}
}
}

View file

@ -1,5 +0,0 @@
using Femto.Common.Domain;
namespace Femto.Modules.Auth.Models.Events;
internal record UserPasswordChangedDomainEvent(UserIdentity User) : DomainEvent;

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -1,41 +0,0 @@
using Femto.Common.Domain;
namespace Femto.Modules.Auth.Models;
public class SignupCode
{
private static readonly TimeSpan ExpiryTime = TimeSpan.FromDays(14);
public string Code { get; private set; }
/// <summary>
/// The email of the intended recipient
/// </summary>
public string RecipientEmail { get; private set; }
/// <summary>
/// The name of the intended recipient
/// </summary>
public string RecipientName { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset? ExpiresAt { get; private set; }
public Guid? RedeemingUserId { get; private set; }
private SignupCode() { }
public SignupCode(string recipientEmail, string recipientName, string code)
{
this.Code = code;
this.RecipientEmail = recipientEmail;
this.RecipientName = recipientName;
this.CreatedAt = DateTimeOffset.UtcNow;
this.ExpiresAt = this.CreatedAt + ExpiryTime;
}
public void Redeem(Guid userGuid)
{
if (this.RedeemingUserId is not null)
throw new DomainError("invalid signup code");
this.RedeemingUserId = userGuid;
}
}

View file

@ -1,6 +1,8 @@
using System.Text;
using System.Text.Unicode;
using Femto.Common.Domain; using Femto.Common.Domain;
using Femto.Modules.Auth.Contracts;
using Femto.Modules.Auth.Models.Events; using Femto.Modules.Auth.Models.Events;
using Geralt;
namespace Femto.Modules.Auth.Models; namespace Femto.Modules.Auth.Models;
@ -12,7 +14,7 @@ internal class UserIdentity : Entity
public Password? Password { get; private set; } public Password? Password { get; private set; }
public ICollection<UserRole> Roles { get; private set; } = []; public ICollection<UserSession> Sessions { get; private set; } = [];
private UserIdentity() { } private UserIdentity() { }
@ -21,15 +23,17 @@ internal class UserIdentity : Entity
this.Id = Guid.CreateVersion7(); this.Id = Guid.CreateVersion7();
this.Username = username; this.Username = username;
this.Roles = [new UserRole(Role.User)];
this.AddDomainEvent(new UserWasCreatedEvent(this)); this.AddDomainEvent(new UserWasCreatedEvent(this));
} }
public UserIdentity WithPassword(string password)
{
this.SetPassword(password);
return this;
}
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);
} }
@ -42,6 +46,25 @@ internal class UserIdentity : Entity
return this.Password.Check(requestPassword); 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); public class SetPasswordError(string message, Exception inner) : DomainError(message, inner);

View file

@ -1,17 +0,0 @@
using Femto.Modules.Auth.Contracts;
namespace Femto.Modules.Auth.Models;
internal class UserRole
{
public Guid UserId { get; set; }
public Role Role { get; set; }
private UserRole() {}
public UserRole( Role role){
Role = role;
}
}

View file

@ -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
};
}
}

View file

@ -0,0 +1,5 @@
namespace Femto.Modules.Blog.Data;
public class Class1
{
}

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,24 @@
<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>

View file

@ -1,29 +1,37 @@
using Femto.Common.Domain; using Femto.Common.Domain;
using MediatR; using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Femto.Modules.Blog.Application; namespace Femto.Modules.Blog.Application;
internal class BlogModule(IMediator mediator) : IBlogModule internal class BlogModule(IHost host) : IBlogModule
{ {
public async Task Command(ICommand command, CancellationToken cancellationToken = default) public async Task PostCommand(ICommand command, CancellationToken cancellationToken = default)
{ {
using var scope = host.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(command, cancellationToken); await mediator.Send(command, cancellationToken);
} }
public async Task<TResponse> Command<TResponse>( public async Task<TResponse> PostCommand<TResponse>(
ICommand<TResponse> command, ICommand<TResponse> command,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
using var scope = host.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var response = await mediator.Send(command, cancellationToken); var response = await mediator.Send(command, cancellationToken);
return response; return response;
} }
public async Task<TResponse> Query<TResponse>( public async Task<TResponse> PostQuery<TResponse>(
IQuery<TResponse> query, IQuery<TResponse> query,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
using var scope = host.Services.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var response = await mediator.Send(query, cancellationToken); var response = await mediator.Send(query, cancellationToken);
return response; return response;
} }

View file

@ -1,5 +1,4 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using Femto.Common;
using Femto.Common.Infrastructure; using Femto.Common.Infrastructure;
using Femto.Common.Infrastructure.DbConnection; using Femto.Common.Infrastructure.DbConnection;
using Femto.Common.Infrastructure.Outbox; using Femto.Common.Infrastructure.Outbox;
@ -21,28 +20,20 @@ public static class BlogStartup
public static void InitializeBlogModule( public static void InitializeBlogModule(
this IServiceCollection rootContainer, this IServiceCollection rootContainer,
string connectionString, string connectionString,
IEventBus bus, IEventBus bus
ILoggerFactory loggerFactory
) )
{ {
var hostBuilder = Host.CreateDefaultBuilder(); var hostBuilder = Host.CreateDefaultBuilder();
hostBuilder.ConfigureServices(services => hostBuilder.ConfigureServices(services =>
ConfigureServices(services, connectionString, bus, loggerFactory) ConfigureServices(services, connectionString, bus)
); );
var host = hostBuilder.Build(); var host = hostBuilder.Build();
rootContainer.AddHostedService(_ => new BlogApplication(host)); rootContainer.AddHostedService(services => new BlogApplication(host));
rootContainer.AddKeyedScoped<ScopeBinding>( rootContainer.AddScoped<IBlogModule>(_ => new BlogModule(host));
"BlogService",
(_, o) => new ScopeBinding(host.Services.CreateScope())
);
rootContainer.AddScoped(services =>
services.GetRequiredKeyedService<ScopeBinding>("BlogService").GetService<IBlogModule>()
);
bus.Subscribe( bus.Subscribe(
(evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken) (evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)
@ -52,8 +43,7 @@ public static class BlogStartup
private static void ConfigureServices( private static void ConfigureServices(
this IServiceCollection services, this IServiceCollection services,
string connectionString, string connectionString,
IEventPublisher publisher, IEventPublisher publisher
ILoggerFactory loggerFactory
) )
{ {
services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString)); services.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
@ -68,12 +58,10 @@ public static class BlogStartup
} }
); );
builder.UseSnakeCaseNamingConvention(); builder.UseSnakeCaseNamingConvention();
var loggerFactory = LoggerFactory.Create(b => { });
builder.UseLoggerFactory(loggerFactory); builder.UseLoggerFactory(loggerFactory);
}); });
services.AddSingleton(loggerFactory);
services.AddSingleton(typeof(ILogger<>), typeof(Logger<>));
services.AddOutbox<BlogContext, OutboxMessageHandler>(); services.AddOutbox<BlogContext, OutboxMessageHandler>();
services.AddMediatR(c => services.AddMediatR(c =>
@ -83,8 +71,6 @@ public static class BlogStartup
services.ConfigureDomainServices<BlogContext>(); services.ConfigureDomainServices<BlogContext>();
services.AddScoped<IBlogModule, BlogModule>();
services.AddSingleton(publisher); services.AddSingleton(publisher);
} }

View file

@ -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;

View file

@ -1,20 +0,0 @@
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);
}
}

View file

@ -1,6 +0,0 @@
using Femto.Common;
using Femto.Common.Domain;
namespace Femto.Modules.Blog.Application.Commands.AddPostReaction;
public record AddPostReactionCommand(Guid PostId, string Emoji, Guid ReactorId) : ICommand;

View file

@ -1,21 +0,0 @@
using Femto.Common.Domain;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Blog.Application.Commands.AddPostReaction;
internal class AddPostReactionCommandHandler(BlogContext context)
: ICommandHandler<AddPostReactionCommand>
{
public async Task Handle(AddPostReactionCommand request, CancellationToken cancellationToken)
{
var post = await context.Posts.SingleOrDefaultAsync(
p => p.Id == request.PostId,
cancellationToken
);
if (post is null)
return;
post.AddReaction(request.ReactorId, request.Emoji);
}
}

View file

@ -1,6 +0,0 @@
using Femto.Common;
using Femto.Common.Domain;
namespace Femto.Modules.Blog.Application.Commands.ClearPostReaction;
public record ClearPostReactionCommand(Guid PostId, string Emoji, Guid ReactorId): ICommand;

View file

@ -1,22 +0,0 @@
using Femto.Common.Domain;
using Femto.Modules.Blog.Application.Commands.AddPostReaction;
using Microsoft.EntityFrameworkCore;
namespace Femto.Modules.Blog.Application.Commands.ClearPostReaction;
internal class ClearPostReactionCommandHandler(BlogContext context)
: ICommandHandler<ClearPostReactionCommand>
{
public async Task Handle(ClearPostReactionCommand request, CancellationToken cancellationToken)
{
var post = await context.Posts.SingleOrDefaultAsync(
p => p.Id == request.PostId,
cancellationToken
);
if (post is null)
return;
post.RemoveReaction(request.ReactorId, request.Emoji);
}
}

View file

@ -1,22 +1,8 @@
using Femto.Common;
using Femto.Common.Domain; using Femto.Common.Domain;
using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
namespace Femto.Modules.Blog.Application.Commands.CreatePost; namespace Femto.Modules.Blog.Application.Commands.CreatePost;
public record CreatePostCommand( public record CreatePostCommand(Guid AuthorId, string Content, IEnumerable<CreatePostMedia> Media, bool? IsPublic)
Guid AuthorId, : ICommand<Guid>;
string Content,
IEnumerable<CreatePostMedia> Media,
bool? IsPublic,
CurrentUser CurrentUser
) : ICommand<PostDto>;
public record CreatePostMedia( public record CreatePostMedia(Guid MediaId, Uri Url, string? Type, int Order, int? Width, int? Height);
Guid MediaId,
Uri Url,
string? Type,
int Order,
int? Width,
int? Height
);

View file

@ -1,16 +1,12 @@
using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
using Femto.Modules.Blog.Domain.Posts; using Femto.Modules.Blog.Domain.Posts;
using MediatR; using MediatR;
namespace Femto.Modules.Blog.Application.Commands.CreatePost; namespace Femto.Modules.Blog.Application.Commands.CreatePost;
internal class CreatePostCommandHandler(BlogContext context) internal class CreatePostCommandHandler(BlogContext context)
: IRequestHandler<CreatePostCommand, PostDto> : IRequestHandler<CreatePostCommand, Guid>
{ {
public async Task<PostDto> Handle( public async Task<Guid> Handle(CreatePostCommand request, CancellationToken cancellationToken)
CreatePostCommand request,
CancellationToken cancellationToken
)
{ {
var post = new Post( var post = new Post(
request.AuthorId, request.AuthorId,
@ -24,21 +20,13 @@ internal class CreatePostCommandHandler(BlogContext context)
media.Width, media.Width,
media.Height media.Height
)) ))
.ToList(), .ToList()
request.IsPublic is true
); );
post.IsPublic = request.IsPublic is true;
await context.AddAsync(post, cancellationToken); await context.AddAsync(post, cancellationToken);
return new PostDto( return post.Id;
post.Id,
post.Content,
post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)).ToList(),
post.PostedOn,
new PostAuthorDto(post.AuthorId, request.CurrentUser.Username),
[],
post.PossibleReactions,
[]
);
} }
} }

View file

@ -1,5 +0,0 @@
using Femto.Common.Domain;
namespace Femto.Modules.Blog.Application.Commands.DeletePost;
public record DeletePostCommand(Guid PostId, Guid InitiatingUserId) : ICommand;

View file

@ -10,24 +10,5 @@ internal class PostConfiguration : IEntityTypeConfiguration<Post>
{ {
table.ToTable("post"); table.ToTable("post");
table.OwnsMany(post => post.Media).WithOwner(); table.OwnsMany(post => post.Media).WithOwner();
table.OwnsMany(
post => post.Reactions,
reactions =>
{
reactions.WithOwner().HasForeignKey(r => r.PostId);
reactions.HasKey(r => new
{
r.PostId,
r.AuthorId,
r.Emoji,
});
}
);
table.OwnsMany(p => p.Comments).WithOwner();
table.Property<string>("PossibleReactionsJson").HasColumnName("possible_reactions");
table.Ignore(e => e.PossibleReactions);
} }
} }

View file

@ -4,14 +4,14 @@ namespace Femto.Modules.Blog.Application;
public interface IBlogModule public interface IBlogModule
{ {
Task Command(ICommand command, CancellationToken cancellationToken = default); Task PostCommand(ICommand command, CancellationToken cancellationToken = default);
Task<TResponse> Command<TResponse>( Task<TResponse> PostCommand<TResponse>(
ICommand<TResponse> command, ICommand<TResponse> command,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
); );
Task<TResponse> Query<TResponse>( Task<TResponse> PostQuery<TResponse>(
IQuery<TResponse> query, IQuery<TResponse> query,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
); );

View file

@ -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); public record GetPostsQueryResult(IList<PostDto> Posts, Guid? Next);

View file

@ -1,3 +0,0 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);

View file

@ -1,12 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto; namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record PostDto( public record PostDto(Guid PostId, string Text, IList<PostMediaDto> Media, DateTimeOffset CreatedAt, PostAuthorDto Author);
Guid PostId,
string Text,
IList<PostMediaDto> Media,
DateTimeOffset CreatedAt,
PostAuthorDto Author,
IList<PostReactionDto> Reactions,
IEnumerable<string> PossibleReactions,
IList<PostCommentDto> Comments
);

View file

@ -1,3 +0,0 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);

View file

@ -3,27 +3,22 @@ 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>
{ {
/// <summary> public Guid? From { get; init; }
/// 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; }
public GetPostsQuery(Guid postId, Guid? currentUserId) : this(currentUserId) /// <summary>
{ /// Default is to load in reverse chronological order
this.PostId = postId; /// TODO this is not exposed on the client as it probably wouldn't work that well
} /// </summary>
public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward;
}
public enum GetPostsDirection
{
Forward,
Backward,
} }

View file

@ -1,4 +1,3 @@
using System.Text.Json;
using Dapper; using Dapper;
using Femto.Common.Infrastructure.DbConnection; using Femto.Common.Infrastructure.DbConnection;
using Femto.Modules.Blog.Application.Queries.GetPosts.Dto; using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
@ -16,158 +15,101 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
{ {
using var conn = connectionFactory.GetConnection(); using var conn = connectionFactory.GetConnection();
var orderBy = query.Direction is GetPostsDirection.Backward ? "desc" : "asc";
var pageFilter = query.Direction is GetPostsDirection.Backward ? "<=" : ">=";
var username = query.Author; var username = query.Author;
var authorGuid = query.AuthorId; var authorGuid = query.AuthorId;
var cursor = query.After; var cursor = query.From;
var showPrivate = query.CurrentUserId is not null; var showPrivate = query.CurrentUserId is not null;
var loadPostsResult = await conn.QueryAsync<LoadPostRow>( // lang=sql
""" var sql = $$"""
select with page as (
blog.post.id as PostId, select blog.post.*, blog.author.username as Username, blog.author.id as AuthorId
blog.post.content as Content,
blog.post.posted_on as PostedOn,
blog.author.username as Username,
blog.author.id as AuthorId,
blog.post.possible_reactions as PossibleReactions
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 {{pageFilter}} @cursor)
order by blog.post.id desc order by blog.post.id {{orderBy}}
limit @amount limit @amount
""", )
select
page.id as PostId,
page.content as Content,
blog.post_media.url as MediaUrl,
blog.post_media.width as MediaWidth,
blog.post_media.height as MediaHeight,
page.posted_on as PostedOn,
page.Username,
page.AuthorId
from page
left join blog.post_media on blog.post_media.post_id = page.id
order by page.id {{orderBy}}
""";
var result = await conn.QueryAsync<QueryResult>(
sql,
new new
{ {
username, username,
authorGuid, authorGuid,
cursor, cursor,
amount = query.PostId is not null ? 1 : query.Amount, // load an extra one to take for the cursor
amount = query.Amount + 1,
showPrivate, showPrivate,
postId = query.PostId,
} }
); );
var posts = loadPostsResult.ToList(); var rows = result.ToList();
var postIds = posts.Select(p => p.PostId).ToList(); var posts = rows.GroupBy(row => row.PostId)
.Select(group =>
var loadMediaResult = await conn.QueryAsync<LoadMediaRow>(
"""
select
pm.url as MediaUrl,
pm.type as MediaType,
pm.width as MediaWidth,
pm.height as MediaHeight,
pm.post_id as PostId
from blog.post_media pm where pm.post_id = ANY (@postIds)
order by pm.ordering
""",
new { postIds }
);
var media = loadMediaResult.ToList();
var loadReactionsResult = await conn.QueryAsync<LoadReactionRow>(
"""
select
pr.post_id as PostId,
a.username as AuthorName,
pr.emoji as Emoji,
pr.created_at as CreatedOn
from blog.post_reaction pr
join blog.author a on a.id = pr.author_id
where pr.post_id = ANY (@postIds)
""",
new { postIds }
);
var reactions = loadReactionsResult.ToList();
var loadCommentsResult = await conn.QueryAsync<LoadCommentRow>(
"""
select
pc.id as CommentId,
pc.post_id as PostId,
pc.content as Content,
pc.created_at as PostedOn,
a.username as AuthorName
from blog.post_comment pc
join blog.author a on pc.author_id = a.id
where pc.post_id = ANY (@postIds)
""",
new { postIds }
);
var comments = loadCommentsResult.ToList();
return new GetPostsQueryResult(
posts
.Select(p => new PostDto(
p.PostId,
p.Content,
media
.Where(m => m.PostId == p.PostId)
.Select(m => new PostMediaDto(
new Uri(m.MediaUrl),
m.MediaWidth,
m.MediaHeight
))
.ToList(),
p.PostedOn,
new PostAuthorDto(p.AuthorId, p.Username),
reactions
.Where(r => r.PostId == p.PostId)
.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.CreatedAt))
.ToList(),
!string.IsNullOrEmpty(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()
);
}
internal record LoadPostRow
{ {
public Guid PostId { get; init; } var postId = group.Key;
public string Content { get; init; } var post = group.First();
public DateTimeOffset PostedOn { get; init; } var media = group
public string Username { get; init; } .Select(row =>
public Guid AuthorId { get; init; } {
public string? PossibleReactions { get; init; } if (row.MediaUrl is not null)
{
return new PostMediaDto(
new Uri(row.MediaUrl),
row.MediaHeight,
row.MediaHeight
);
}
else
return null;
})
.OfType<PostMediaDto>()
.ToList();
return new PostDto(
postId,
post.Content,
media,
post.PostedOn,
new PostAuthorDto(post.AuthorId, post.Username)
);
})
.ToList();
var next = rows.Count >= query.Amount ? rows.LastOrDefault()?.PostId : null;
return new GetPostsQueryResult(posts, next);
} }
internal record LoadMediaRow internal class QueryResult
{ {
public string MediaUrl { get; init; } public Guid PostId { get; set; }
public string? MediaType { get; init; } public string Content { get; set; }
public int? MediaWidth { get; init; } public string? MediaUrl { get; set; }
public int? MediaHeight { get; init; } public string? MediaType { get; set; }
public Guid PostId { get; init; } public int? MediaWidth { get; set; }
} public int? MediaHeight { get; set; }
public DateTimeOffset PostedOn { get; set; }
internal record LoadReactionRow public Guid AuthorId { get; set; }
{ public string Username { get; set; }
public Guid PostId { get; init; }
public string AuthorName { get; init; }
public string Emoji { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
internal record LoadCommentRow
{
public Guid CommentId { get; init; }
public Guid PostId { get; init; }
public string Content { get; init; }
public DateTimeOffset PostedOn { get; init; }
public string AuthorName { get; init; }
} }
} }

View file

@ -1,7 +1,5 @@
using System.Text.Json;
using Femto.Common.Domain; using Femto.Common.Domain;
using Femto.Modules.Blog.Domain.Posts.Events; using Femto.Modules.Blog.Domain.Posts.Events;
using Femto.Modules.Blog.Emoji;
namespace Femto.Modules.Blog.Domain.Posts; namespace Femto.Modules.Blog.Domain.Posts;
@ -11,62 +9,17 @@ internal class Post : Entity
public Guid AuthorId { get; private set; } public Guid AuthorId { get; private set; }
public string Content { get; private set; } = null!; public string Content { get; private set; } = null!;
public IList<PostMedia> Media { get; private set; } public IList<PostMedia> Media { get; private set; }
public bool IsPublic { get; set; }
public IList<PostReaction> Reactions { get; private set; } = [];
public IList<PostComment> Comments { get; private set; } = [];
public bool IsPublic { get; private set; }
public DateTimeOffset PostedOn { get; private set; }
private string PossibleReactionsJson { get; set; } = null!;
public IEnumerable<string> PossibleReactions
{
get => JsonSerializer.Deserialize<IEnumerable<string>>(this.PossibleReactionsJson)!;
init => PossibleReactionsJson = JsonSerializer.Serialize(value);
}
private Post() { } private Post() { }
public Post(Guid authorId, string content, IList<PostMedia> media, bool isPublic) public Post(Guid authorId, string content, IList<PostMedia> media)
{ {
this.Id = Guid.CreateVersion7(); this.Id = Guid.CreateVersion7();
this.AuthorId = authorId; this.AuthorId = authorId;
this.Content = content; this.Content = content;
this.Media = media; this.Media = media;
this.PossibleReactions = AllEmoji.GetRandomEmoji(5);
this.PostedOn = DateTimeOffset.UtcNow;
this.IsPublic = isPublic;
this.AddDomainEvent(new PostCreated(this)); this.AddDomainEvent(new PostCreated(this));
} }
public void AddReaction(Guid reactorId, string emoji)
{
if (!this.PossibleReactions.Contains(emoji))
return;
if (this.Reactions.Any(r => r.AuthorId == reactorId && r.Emoji == emoji))
return;
this.Reactions.Add(new PostReaction(reactorId, this.Id, emoji));
}
public void RemoveReaction(Guid reactorId, string emoji)
{
this.Reactions = this
.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));
}
} }

Some files were not shown because too many files have changed in this diff Show more