Compare commits
No commits in common. "main" and "v0.1.10" have entirely different histories.
103 changed files with 695 additions and 5585 deletions
|
@ -1,6 +1,6 @@
|
||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.1.31</Version>
|
<Version>0.1.10</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
||||||
|
|
|
@ -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; }
|
|
||||||
}
|
}
|
|
@ -3,11 +3,11 @@ 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.Interface.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;
|
||||||
|
using Microsoft.OpenApi.Extensions;
|
||||||
|
|
||||||
namespace Femto.Api.Auth;
|
namespace Femto.Api.Auth;
|
||||||
|
|
||||||
|
@ -15,115 +15,49 @@ 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.Command(new ValidateSessionCommand(sessionId));
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(ClaimTypes.Name, user.Username),
|
new(ClaimTypes.Name, result.User.Username),
|
||||||
new("sub", user.Id.ToString()),
|
new("sub", result.User.Id.ToString()),
|
||||||
new("user_id", user.Id.ToString()),
|
new("user_id", result.User.Id.ToString()),
|
||||||
};
|
};
|
||||||
|
|
||||||
claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role.ToString())));
|
claims.AddRange(
|
||||||
|
result.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);
|
||||||
|
|
||||||
|
this.Context.SetSession(result.Session, result.User, cookieOptions.Value);
|
||||||
currentUserContext.CurrentUser = new CurrentUser(
|
currentUserContext.CurrentUser = new CurrentUser(
|
||||||
user.Id,
|
result.User.Id,
|
||||||
user.Username,
|
result.User.Username,
|
||||||
user.Roles.Contains(Role.SuperUser)
|
result.Session.SessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name));
|
return AuthenticateResult.Success(
|
||||||
}
|
new AuthenticationTicket(principal, this.Scheme.Name)
|
||||||
|
|
||||||
private async Task<UserInfo?> TryAuthenticateWithSession()
|
|
||||||
{
|
|
||||||
var sessionId = this.Context.GetSessionId();
|
|
||||||
|
|
||||||
if (sessionId is null)
|
|
||||||
{
|
|
||||||
Logger.LogDebug("{TraceId} SessionId was null ", this.Context.TraceIdentifier);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var session = await authService.GetSession(sessionId);
|
|
||||||
|
|
||||||
if (session is null)
|
|
||||||
{
|
|
||||||
Logger.LogDebug("{TraceId} Loaded session was null ", this.Context.TraceIdentifier);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.IsExpired)
|
|
||||||
{
|
|
||||||
Logger.LogDebug("{TraceId} Loaded session was expired ", this.Context.TraceIdentifier);
|
|
||||||
await authService.DeleteSession(sessionId);
|
|
||||||
this.Context.DeleteSession();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await authService.GetUserWithId(session.UserId);
|
|
||||||
|
|
||||||
if (user is null)
|
|
||||||
{
|
|
||||||
await authService.DeleteSession(sessionId);
|
|
||||||
this.Context.DeleteSession();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.ExpiresSoon)
|
|
||||||
{
|
|
||||||
session = await authService.CreateWeakSession(session.UserId);
|
|
||||||
this.Context.SetSession(session, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<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)
|
catch (InvalidSessionError)
|
||||||
return null;
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid session");
|
||||||
var session = await authService.CreateWeakSession(user.Id);
|
}
|
||||||
|
|
||||||
this.Context.SetSession(session, user);
|
|
||||||
|
|
||||||
if (newRememberMeToken is not null)
|
|
||||||
this.Context.SetRememberMeToken(newRememberMeToken);
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,174 +1,94 @@
|
||||||
|
using Femto.Api.Auth;
|
||||||
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.Interface.CreateSignupCode;
|
||||||
|
using Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery;
|
||||||
|
using Femto.Modules.Auth.Application.Interface.Login;
|
||||||
|
using Femto.Modules.Auth.Application.Interface.RefreshUserSession;
|
||||||
|
using Femto.Modules.Auth.Application.Interface.Register;
|
||||||
using Femto.Modules.Auth.Contracts;
|
using Femto.Modules.Auth.Contracts;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
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(
|
||||||
: ControllerBase
|
IAuthModule authModule,
|
||||||
|
IOptions<CookieSettings> cookieSettings,
|
||||||
|
ICurrentUserContext currentUserContext
|
||||||
|
) : 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.Command(new LoginCommand(request.Username, request.Password));
|
||||||
request.Username,
|
|
||||||
request.Password,
|
HttpContext.SetSession(result.Session, result.User, cookieSettings.Value);
|
||||||
cancellationToken
|
|
||||||
|
return new LoginResponse(
|
||||||
|
result.User.Id,
|
||||||
|
result.User.Username,
|
||||||
|
result.User.Roles.Any(r => r == Role.SuperUser)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result is null)
|
|
||||||
return this.BadRequest();
|
|
||||||
|
|
||||||
var (user, session) = result;
|
|
||||||
|
|
||||||
HttpContext.SetSession(session, user);
|
|
||||||
|
|
||||||
if (request.RememberMe)
|
|
||||||
{
|
|
||||||
var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
|
|
||||||
HttpContext.SetRememberMeToken(newRememberMeToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new LoginResponse(user.Id, user.Username, user.Roles.Any(r => r == Role.SuperUser));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[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.Command(
|
||||||
request.Username,
|
new RegisterCommand(request.Username, request.Password, request.SignupCode)
|
||||||
request.Password,
|
|
||||||
request.SignupCode,
|
|
||||||
cancellationToken
|
|
||||||
);
|
);
|
||||||
|
|
||||||
HttpContext.SetSession(session, user);
|
HttpContext.SetSession(result.Session, result.User, cookieSettings.Value);
|
||||||
|
|
||||||
if (request.RememberMe)
|
|
||||||
{
|
|
||||||
var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
|
|
||||||
HttpContext.SetRememberMeToken(newRememberMeToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new RegisterResponse(
|
return new RegisterResponse(
|
||||||
user.Id,
|
result.User.Id,
|
||||||
user.Username,
|
result.User.Username,
|
||||||
user.Roles.Any(r => r == Role.SuperUser)
|
result.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();
|
|
||||||
|
|
||||||
if (sessionId is not null)
|
|
||||||
{
|
|
||||||
await authService.DeleteSession(sessionId);
|
|
||||||
HttpContext.DeleteSession();
|
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}")]
|
[HttpGet("user/{userId}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<GetUserInfoResult>> GetUserInfo(
|
public async Task<ActionResult<RefreshUserResult>> RefreshUser(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var currentUser = currentUserContext.CurrentUser;
|
var currentUser = currentUserContext.CurrentUser!;
|
||||||
|
|
||||||
if (currentUser is null || currentUser.Id != userId)
|
try
|
||||||
return this.BadRequest();
|
{
|
||||||
|
var result = await authModule.Command(
|
||||||
|
new RefreshUserSessionCommand(userId, currentUser),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
var user = await authService.GetUserWithId(userId, cancellationToken);
|
return new RefreshUserResult(
|
||||||
|
result.User.Id,
|
||||||
if (user is null)
|
result.User.Username,
|
||||||
return this.BadRequest();
|
result.User.Roles.Any(r => r == Role.SuperUser)
|
||||||
|
|
||||||
return new GetUserInfoResult(
|
|
||||||
user.Id,
|
|
||||||
user.Username,
|
|
||||||
user.Roles.Any(r => r == Role.SuperUser)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
HttpContext.DeleteSession();
|
||||||
|
return this.Forbid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Obsolete("use POST /auth/create-signup-code")]
|
|
||||||
[HttpPost("signup-codes")]
|
[HttpPost("signup-codes")]
|
||||||
[Authorize(Roles = "SuperUser")]
|
[Authorize(Roles = "SuperUser")]
|
||||||
public async Task<ActionResult> CreateSignupCode(
|
public async Task<ActionResult> CreateSignupCode(
|
||||||
|
@ -176,51 +96,21 @@ public class AuthController(ICurrentUserContext currentUserContext, IAuthService
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await authService.AddSignupCode(request.Code, request.Name, cancellationToken);
|
await authModule.Command(
|
||||||
|
new CreateSignupCodeCommand(request.Code, request.Email, request.Name),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
return Ok(new { });
|
return Ok(new { });
|
||||||
}
|
}
|
||||||
|
|
||||||
[Obsolete("use GET /auth/list-signup-codes")]
|
|
||||||
[HttpGet("signup-codes")]
|
[HttpGet("signup-codes")]
|
||||||
[Authorize(Roles = "SuperUser")]
|
[Authorize(Roles = "SuperUser")]
|
||||||
public async Task<ActionResult<ListSignupCodesResult>> ListSignupCodes(
|
public async Task<ActionResult<ListSignupCodesResult>> ListSignupCodes(
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var codes = await authService.GetSignupCodes(cancellationToken);
|
var codes = await authModule.Query(new GetSignupCodesQuery(), 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(
|
return new ListSignupCodesResult(
|
||||||
codes.Select(c => new SignupCodeDto(
|
codes.Select(c => new SignupCodeDto(
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Api.Controllers.Auth;
|
|
||||||
|
|
||||||
public record ChangePasswordRequestBody(Guid UserId, string NewPassword);
|
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Api.Controllers.Auth;
|
|
||||||
|
|
||||||
public record GetUserInfoResult(Guid UserId, string Username, bool IsSuperUser);
|
|
|
@ -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);
|
3
Femto.Api/Controllers/Auth/RefreshUserResult.cs
Normal file
3
Femto.Api/Controllers/Auth/RefreshUserResult.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Auth;
|
||||||
|
|
||||||
|
public record RefreshUserResult(Guid UserId, string Username, bool IsSuperUser);
|
|
@ -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);
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
|
||||||
|
|
||||||
public record AddPostCommentRequest(Guid AuthorId, string Content);
|
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
|
||||||
|
|
||||||
public record AddPostReactionRequest(string Emoji);
|
|
|
@ -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);
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
|
||||||
|
|
||||||
public record DeletePostReactionRequest(string Emoji);
|
|
|
@ -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);
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
|
||||||
|
|
||||||
public record GetPostResponse(PostDto Post);
|
|
|
@ -3,4 +3,4 @@ using JetBrains.Annotations;
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
namespace Femto.Api.Controllers.Posts.Dto;
|
||||||
|
|
||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public record GetPublicPostsSearchParams(Guid? After, int? Amount, Guid? AuthorId, string? Author);
|
public record GetPublicPostsSearchParams(Guid? From, int? Amount, Guid? AuthorId, string? Author);
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
|
||||||
|
|
||||||
public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);
|
|
|
@ -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))
|
|
||||||
);
|
);
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Api.Controllers.Posts.Dto;
|
|
||||||
|
|
||||||
public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);
|
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>());
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,52 +1,49 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
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,
|
||||||
|
UserInfo user,
|
||||||
|
CookieSettings cookieSettings
|
||||||
|
)
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
var secure = cookieSettings.Secure;
|
||||||
};
|
var sameSite = cookieSettings.SameSite ? SameSiteMode.Strict : SameSiteMode.Unspecified;
|
||||||
|
var expires = session.Expires;
|
||||||
|
|
||||||
public static string? GetSessionId(this HttpContext httpContext) =>
|
httpContext.Response.Cookies.Append(
|
||||||
httpContext.Request.Cookies["sid"];
|
"session",
|
||||||
|
session.SessionId,
|
||||||
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 = secure,
|
||||||
SameSite = cookieSettings.Value.SameSite,
|
SameSite = sameSite,
|
||||||
Expires = session.Expires,
|
Expires = expires,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
context.Response.Cookies.Append(
|
httpContext.Response.Cookies.Append(
|
||||||
"user",
|
"user",
|
||||||
JsonSerializer.Serialize(user, JsonOptions),
|
JsonSerializer.Serialize(
|
||||||
|
user,
|
||||||
|
new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
}
|
||||||
|
),
|
||||||
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,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -54,78 +51,7 @@ internal static class HttpContextSessionExtensions
|
||||||
|
|
||||||
public static void DeleteSession(this HttpContext httpContext)
|
public static void DeleteSession(this HttpContext httpContext)
|
||||||
{
|
{
|
||||||
var cookieSettings = httpContext.RequestServices.GetRequiredService<
|
httpContext.Response.Cookies.Delete("session");
|
||||||
IOptions<CookieSettings>
|
httpContext.Response.Cookies.Delete("user");
|
||||||
>();
|
|
||||||
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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": "*"
|
||||||
|
|
|
@ -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, string SessionId);
|
||||||
|
|
|
@ -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<,>)
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
|
@ -6,4 +6,4 @@ CREATE TABLE authn.user_role
|
||||||
user_id uuid REFERENCES authn.user_identity(id),
|
user_id uuid REFERENCES authn.user_identity(id),
|
||||||
role int,
|
role int,
|
||||||
primary key (user_id, role)
|
primary key (user_id, role)
|
||||||
)
|
);
|
|
@ -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)
|
|
||||||
);
|
|
|
@ -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)
|
|
||||||
);
|
|
|
@ -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;
|
|
|
@ -1,4 +0,0 @@
|
||||||
-- Migration: AddTimestampToReaction
|
|
||||||
-- Created at: 10/08/2025 15:21:32
|
|
||||||
alter table blog.post_reaction
|
|
||||||
add column created_at timestamptz;
|
|
|
@ -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()
|
|
||||||
)
|
|
|
@ -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,21 +54,6 @@ 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
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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
|
|
22
Femto.Modules.Auth/Application/AuthModule.cs
Normal file
22
Femto.Modules.Auth/Application/AuthModule.cs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application;
|
||||||
|
|
||||||
|
internal class AuthModule(IMediator mediator) : IAuthModule
|
||||||
|
{
|
||||||
|
public async Task Command(ICommand command, CancellationToken cancellationToken = default) =>
|
||||||
|
await mediator.Send(command, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<TResponse> Command<TResponse>(
|
||||||
|
ICommand<TResponse> command,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
) => await mediator.Send(command, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<TResponse> Query<TResponse>(
|
||||||
|
IQuery<TResponse> query,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
) => await mediator.Send(query, cancellationToken);
|
||||||
|
}
|
|
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +1,3 @@
|
||||||
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;
|
||||||
|
@ -16,69 +15,42 @@ 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.AddKeyedScoped<ScopeBinding>(
|
rootContainer.AddScoped(_ => new ScopeBinding(host.Services.CreateScope()));
|
||||||
"AuthServiceScope",
|
|
||||||
(s, o) =>
|
|
||||||
{
|
|
||||||
var scope = host.Services.CreateScope();
|
|
||||||
return new ScopeBinding(scope);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
rootContainer.ExposeScopedService<IAuthService>();
|
rootContainer.AddScoped<IAuthModule>(services =>
|
||||||
|
services.GetRequiredService<ScopeBinding>().GetService<IAuthModule>());
|
||||||
|
|
||||||
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.AddTransient<IDbConnectionFactory>(_ => new DbConnectionFactory(connectionString));
|
||||||
|
|
||||||
services.AddDbContext<AuthContext>(builder =>
|
services.AddDbContext<AuthContext>(builder =>
|
||||||
{
|
{
|
||||||
builder.UseNpgsql(connectionString);
|
builder.UseNpgsql(connectionString);
|
||||||
builder.UseSnakeCaseNamingConvention();
|
builder.UseSnakeCaseNamingConvention();
|
||||||
|
var loggerFactory = LoggerFactory.Create(b => { });
|
||||||
builder.UseLoggerFactory(loggerFactory);
|
builder.UseLoggerFactory(loggerFactory);
|
||||||
// #if DEBUG
|
// #if DEBUG
|
||||||
// builder.EnableSensitiveDataLogging();
|
// builder.EnableSensitiveDataLogging();
|
||||||
// #endif
|
// #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));
|
||||||
|
@ -86,11 +58,8 @@ public static class AuthStartup
|
||||||
services.ConfigureDomainServices<AuthContext>();
|
services.ConfigureDomainServices<AuthContext>();
|
||||||
|
|
||||||
services.AddSingleton(publisher);
|
services.AddSingleton(publisher);
|
||||||
services.AddSingleton<SessionStorage>();
|
|
||||||
|
|
||||||
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>));
|
services.AddScoped<IAuthModule, AuthModule>();
|
||||||
|
|
||||||
services.AddScoped<IAuthService, AuthService>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task EventSubscriber(
|
private static async Task EventSubscriber(
|
||||||
|
@ -122,14 +91,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>()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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, UserInfo User);
|
|
@ -1,3 +1,3 @@
|
||||||
namespace Femto.Modules.Auth.Application.Dto;
|
namespace Femto.Modules.Auth.Application.Dto;
|
||||||
|
|
||||||
public record RefreshUserSessionResult(SessionDto SessionDto, UserInfo User);
|
public record RefreshUserSessionResult(Session Session, UserInfo User);
|
|
@ -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, UserInfo User);
|
|
@ -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}";
|
|
||||||
}
|
|
|
@ -2,16 +2,9 @@ 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,
|
internal Session(UserSession session) : this(session.Id, session.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) { }
|
|
||||||
}
|
}
|
|
@ -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, UserInfo User);
|
10
Femto.Modules.Auth/Application/IAuthModule.cs
Normal file
10
Femto.Modules.Auth/Application/IAuthModule.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application;
|
||||||
|
|
||||||
|
public interface IAuthModule
|
||||||
|
{
|
||||||
|
Task Command(ICommand command, CancellationToken cancellationToken = default);
|
||||||
|
Task<TResponse> Command<TResponse>(ICommand<TResponse> command, CancellationToken cancellationToken = default);
|
||||||
|
Task<TResponse> Query<TResponse>(IQuery<TResponse> query, CancellationToken cancellationToken = default);
|
||||||
|
}
|
|
@ -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);
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application.Interface.CreateSignupCode;
|
||||||
|
|
||||||
|
public record CreateSignupCodeCommand(string Code, string RecipientEmail, string RecipientName): ICommand;
|
|
@ -0,0 +1,15 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Modules.Auth.Data;
|
||||||
|
using Femto.Modules.Auth.Models;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application.Interface.CreateSignupCode;
|
||||||
|
|
||||||
|
internal class CreateSignupCodeCommandHandler(AuthContext context) : ICommandHandler<CreateSignupCodeCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(CreateSignupCodeCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var code = new SignupCode(command.RecipientEmail, command.RecipientName, command.Code);
|
||||||
|
|
||||||
|
await context.SignupCodes.AddAsync(code, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery;
|
||||||
|
|
||||||
|
public record GetSignupCodesQuery: IQuery<ICollection<SignupCodeDto>>;
|
|
@ -0,0 +1,55 @@
|
||||||
|
using Dapper;
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Common.Infrastructure.DbConnection;
|
||||||
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery;
|
||||||
|
|
||||||
|
public class GetSignupCodesQueryHandler(IDbConnectionFactory connectionFactory)
|
||||||
|
: IQueryHandler<GetSignupCodesQuery, ICollection<SignupCodeDto>>
|
||||||
|
{
|
||||||
|
public async Task<ICollection<SignupCodeDto>> Handle(
|
||||||
|
GetSignupCodesQuery request,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
using var conn = connectionFactory.GetConnection();
|
||||||
|
|
||||||
|
// lang=sql
|
||||||
|
const string sql = """
|
||||||
|
SELECT
|
||||||
|
sc.code as Code,
|
||||||
|
sc.recipient_email as Email,
|
||||||
|
sc.recipient_name as Name,
|
||||||
|
sc.redeeming_user_id as RedeemedByUserId,
|
||||||
|
u.username as RedeemedByUsername,
|
||||||
|
sc.expires_at as ExpiresOn
|
||||||
|
FROM authn.signup_code sc
|
||||||
|
LEFT JOIN authn.user_identity u ON u.id = sc.redeeming_user_id
|
||||||
|
ORDER BY sc.created_at DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = await conn.QueryAsync<QueryResultRow>(sql);
|
||||||
|
|
||||||
|
return result
|
||||||
|
.Select(row => new SignupCodeDto(
|
||||||
|
row.Code,
|
||||||
|
row.Email,
|
||||||
|
row.Name,
|
||||||
|
row.RedeemedByUserId,
|
||||||
|
row.RedeemedByUsername,
|
||||||
|
row.ExpiresOn
|
||||||
|
))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class QueryResultRow
|
||||||
|
{
|
||||||
|
public string Code { get; set; }
|
||||||
|
public string Email { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public Guid? RedeemedByUserId { get; set; }
|
||||||
|
public string? RedeemedByUsername { get; set; }
|
||||||
|
public DateTimeOffset? ExpiresOn { get; set; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application.Interface.Login;
|
||||||
|
|
||||||
|
public record LoginCommand(string Username, string Password) : ICommand<LoginResult>;
|
|
@ -0,0 +1,28 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
|
using Femto.Modules.Auth.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application.Interface.Login;
|
||||||
|
|
||||||
|
internal class LoginCommandHandler(AuthContext context)
|
||||||
|
: ICommandHandler<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), new UserInfo(user));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
using Femto.Common;
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession;
|
||||||
|
|
||||||
|
public record RefreshUserSessionCommand(Guid ForUser, CurrentUser CurrentUser) : ICommand<RefreshUserSessionResult>;
|
|
@ -0,0 +1,32 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Common.Infrastructure.DbConnection;
|
||||||
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
|
using Femto.Modules.Auth.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession;
|
||||||
|
|
||||||
|
internal class RefreshUserSessionCommandHandler(AuthContext context)
|
||||||
|
: ICommandHandler<RefreshUserSessionCommand, RefreshUserSessionResult>
|
||||||
|
{
|
||||||
|
public async Task<RefreshUserSessionResult> Handle(
|
||||||
|
RefreshUserSessionCommand request,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (request.CurrentUser.Id != request.ForUser)
|
||||||
|
throw new DomainError("invalid request");
|
||||||
|
|
||||||
|
var user = await context.Users.SingleOrDefaultAsync(
|
||||||
|
u => u.Id == request.ForUser,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
throw new DomainError("invalid request");
|
||||||
|
|
||||||
|
var session = user.PossiblyRefreshSession(request.CurrentUser.SessionId);
|
||||||
|
|
||||||
|
return new(new Session(session), new UserInfo(user));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application.Interface.Register;
|
||||||
|
|
||||||
|
public record RegisterCommand(string Username, string Password, string SignupCode) : ICommand<RegisterResult>;
|
|
@ -0,0 +1,35 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
|
using Femto.Modules.Auth.Data;
|
||||||
|
using Femto.Modules.Auth.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application.Interface.Register;
|
||||||
|
|
||||||
|
internal class RegisterCommandHandler(AuthContext context) : ICommandHandler<RegisterCommand, RegisterResult>
|
||||||
|
{
|
||||||
|
public async Task<RegisterResult> Handle(RegisterCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var code = await context.SignupCodes
|
||||||
|
.Where(c => c.Code == request.SignupCode)
|
||||||
|
.Where(c => c.ExpiresAt == null || c.ExpiresAt > now)
|
||||||
|
.Where(c => c.RedeemingUserId == null)
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (code is null)
|
||||||
|
throw new DomainError("invalid signup code");
|
||||||
|
|
||||||
|
var user = new UserIdentity(request.Username);
|
||||||
|
|
||||||
|
user.SetPassword(request.Password);
|
||||||
|
|
||||||
|
var session = user.StartNewSession();
|
||||||
|
|
||||||
|
await context.AddAsync(user, cancellationToken);
|
||||||
|
|
||||||
|
code.Redeem(user.Id);
|
||||||
|
|
||||||
|
return new(new Session(session.Id, session.Expires), new UserInfo(user));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application.Interface.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>;
|
|
@ -0,0 +1,34 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
|
using Femto.Modules.Auth.Data;
|
||||||
|
using Femto.Modules.Auth.Errors;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Application.Interface.ValidateSession;
|
||||||
|
|
||||||
|
internal class ValidateSessionCommandHandler(AuthContext context)
|
||||||
|
: ICommandHandler<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),
|
||||||
|
new UserInfo(user)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Femto.Common;
|
namespace Femto.Modules.Auth.Application;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -11,16 +10,7 @@ namespace Femto.Common;
|
||||||
/// <param name="scope"></param>
|
/// <param name="scope"></param>
|
||||||
public class ScopeBinding(IServiceScope scope) : IDisposable
|
public class ScopeBinding(IServiceScope scope) : IDisposable
|
||||||
{
|
{
|
||||||
private IServiceScope Scope { get; } = scope;
|
public T GetService<T>() where T : notnull => scope.ServiceProvider.GetRequiredService<T>();
|
||||||
|
|
||||||
public T GetService<T>()
|
public void Dispose() => scope.Dispose();
|
||||||
where T : notnull
|
|
||||||
{
|
|
||||||
return this.Scope.ServiceProvider.GetRequiredService<T>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public virtual void Dispose()
|
|
||||||
{
|
|
||||||
this.Scope.Dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,10 +1,6 @@
|
||||||
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;
|
||||||
|
|
||||||
|
@ -12,7 +8,6 @@ internal class AuthContext(DbContextOptions<AuthContext> options) : DbContext(op
|
||||||
{
|
{
|
||||||
public virtual DbSet<UserIdentity> Users { get; set; }
|
public virtual DbSet<UserIdentity> Users { get; set; }
|
||||||
public virtual DbSet<SignupCode> SignupCodes { 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 +16,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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -19,6 +19,8 @@ internal class UserIdentityTypeConfiguration : IEntityTypeConfiguration<UserIden
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
builder.OwnsMany(u => u.Sessions).WithOwner().HasForeignKey("user_id");
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.OwnsMany(u => u.Roles, entity =>
|
.OwnsMany(u => u.Roles, entity =>
|
||||||
{
|
{
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
using Femto.Common.Domain;
|
|
||||||
|
|
||||||
namespace Femto.Modules.Auth.Models.Events;
|
|
||||||
|
|
||||||
internal record UserPasswordChangedDomainEvent(UserIdentity User) : DomainEvent;
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -1,6 +1,9 @@
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Unicode;
|
||||||
using Femto.Common.Domain;
|
using Femto.Common.Domain;
|
||||||
using Femto.Modules.Auth.Contracts;
|
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,6 +15,8 @@ internal class UserIdentity : Entity
|
||||||
|
|
||||||
public Password? Password { get; private set; }
|
public Password? Password { get; private set; }
|
||||||
|
|
||||||
|
public ICollection<UserSession> Sessions { get; private set; } = [];
|
||||||
|
|
||||||
public ICollection<UserRole> Roles { get; private set; } = [];
|
public ICollection<UserRole> Roles { get; private set; } = [];
|
||||||
|
|
||||||
private UserIdentity() { }
|
private UserIdentity() { }
|
||||||
|
@ -26,10 +31,14 @@ internal class UserIdentity : Entity
|
||||||
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 +51,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);
|
||||||
|
|
21
Femto.Modules.Auth/Models/UserSession.cs
Normal file
21
Femto.Modules.Auth/Models/UserSession.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
5
Femto.Modules.Blog.Data/Class1.cs
Normal file
5
Femto.Modules.Blog.Data/Class1.cs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
namespace Femto.Modules.Blog.Data;
|
||||||
|
|
||||||
|
public class Class1
|
||||||
|
{
|
||||||
|
}
|
9
Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj
Normal file
9
Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
24
Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj
Normal file
24
Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj
Normal 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>
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
);
|
|
||||||
|
|
|
@ -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,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
using Femto.Common.Domain;
|
|
||||||
|
|
||||||
namespace Femto.Modules.Blog.Application.Commands.DeletePost;
|
|
||||||
|
|
||||||
public record DeletePostCommand(Guid PostId, Guid InitiatingUserId) : ICommand;
|
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
@ -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);
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
|
||||||
|
|
||||||
public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);
|
|
|
@ -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
|
|
||||||
);
|
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
|
|
||||||
|
|
||||||
public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);
|
|
|
@ -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
|
||||||
|
/// 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
|
||||||
{
|
{
|
||||||
this.PostId = postId;
|
Forward,
|
||||||
}
|
Backward,
|
||||||
}
|
}
|
|
@ -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; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
namespace Femto.Modules.Blog.Domain.Posts;
|
|
||||||
|
|
||||||
internal class PostComment
|
|
||||||
{
|
|
||||||
public Guid Id { get; private set; }
|
|
||||||
public Guid AuthorId { get; private set; }
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
|
||||||
public string Content { get; private set; }
|
|
||||||
|
|
||||||
private PostComment() {}
|
|
||||||
|
|
||||||
public PostComment(Guid authorId, string content)
|
|
||||||
{
|
|
||||||
this.Id = Guid.CreateVersion7();
|
|
||||||
this.AuthorId = authorId;
|
|
||||||
this.Content = content;
|
|
||||||
this.CreatedAt = TimeProvider.System.GetUtcNow();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
namespace Femto.Modules.Blog.Domain.Posts;
|
|
||||||
|
|
||||||
public class PostReaction
|
|
||||||
{
|
|
||||||
public Guid AuthorId { get; private set; }
|
|
||||||
public Guid PostId { get; private set; }
|
|
||||||
public string Emoji { get; private set; } = null!;
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
|
||||||
public PostReaction(Guid authorId, Guid postId, string emoji)
|
|
||||||
{
|
|
||||||
this.AuthorId = authorId;
|
|
||||||
this.PostId = postId;
|
|
||||||
this.Emoji = emoji;
|
|
||||||
this.CreatedAt = TimeProvider.System.GetUtcNow();
|
|
||||||
}
|
|
||||||
|
|
||||||
private PostReaction() { }
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
namespace Femto.Modules.Blog.Emoji;
|
|
||||||
|
|
||||||
internal static partial class AllEmoji
|
|
||||||
{
|
|
||||||
public static IList<string> GetRandomEmoji(int count) => new Random().TakeRandomly(Emojis).Distinct().Take(count).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class RandomExtensions
|
|
||||||
{
|
|
||||||
public static IEnumerable<T> TakeRandomly<T>(this Random rand, ICollection<T> collection)
|
|
||||||
{
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var idx = rand.Next(collection.Count);
|
|
||||||
yield return collection.ElementAt(idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load diff
8
Femto.Modules.Files/Domain/Files/File.cs
Normal file
8
Femto.Modules.Files/Domain/Files/File.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
namespace Femto.Modules.Files.Domain.Files;
|
||||||
|
|
||||||
|
public class File
|
||||||
|
{
|
||||||
|
Guid Id { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
}
|
9
Femto.Modules.Files/Femto.Modules.Files.csproj
Normal file
9
Femto.Modules.Files/Femto.Modules.Files.csproj
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue