diff --git a/Directory.Build.props b/Directory.Build.props
index 0e31fcf..0751e1d 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,6 +1,6 @@
- 0.1.24
+ 0.1.31
diff --git a/Femto.Api/Auth/SessionAuthenticationHandler.cs b/Femto.Api/Auth/SessionAuthenticationHandler.cs
index e71481d..9c44b04 100644
--- a/Femto.Api/Auth/SessionAuthenticationHandler.cs
+++ b/Femto.Api/Auth/SessionAuthenticationHandler.cs
@@ -3,11 +3,11 @@ using System.Text.Encodings.Web;
using Femto.Api.Sessions;
using Femto.Common;
using Femto.Modules.Auth.Application;
-using Femto.Modules.Auth.Application.Interface.ValidateSession;
-using Femto.Modules.Auth.Errors;
+using Femto.Modules.Auth.Application.Dto;
+using Femto.Modules.Auth.Contracts;
+using Femto.Modules.Auth.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
-using Microsoft.OpenApi.Extensions;
namespace Femto.Api.Auth;
@@ -15,48 +15,115 @@ internal class SessionAuthenticationHandler(
IOptionsMonitor options,
ILoggerFactory logger,
UrlEncoder encoder,
- IAuthModule authModule,
+ IAuthService authService,
CurrentUserContext currentUserContext
) : AuthenticationHandler(options, logger, encoder)
{
protected override async Task HandleAuthenticateAsync()
{
- var sessionId = this.Request.Cookies["session"];
- if (string.IsNullOrWhiteSpace(sessionId))
+ Logger.LogDebug("{TraceId} Authenticating session", this.Context.TraceIdentifier);
+
+ var user = await this.TryAuthenticateWithSession();
+
+ if (user is null)
+ user = await this.TryAuthenticateWithRememberMeToken();
+
+ if (user is null)
return AuthenticateResult.NoResult();
- try
+ var claims = new List
{
- var result = await authModule.Command(new ValidateSessionCommand(sessionId));
+ new(ClaimTypes.Name, user.Username),
+ new("sub", user.Id.ToString()),
+ new("user_id", user.Id.ToString()),
+ };
- var claims = new List
- {
- new(ClaimTypes.Name, result.User.Username),
- new("sub", result.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 principal = new ClaimsPrincipal(identity);
+ currentUserContext.CurrentUser = new CurrentUser(
+ user.Id,
+ user.Username,
+ user.Roles.Contains(Role.SuperUser)
+ );
- var identity = new ClaimsIdentity(claims, this.Scheme.Name);
- var principal = new ClaimsPrincipal(identity);
+ return AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name));
+ }
- this.Context.SetSession(result.Session, result.User, Logger);
- currentUserContext.CurrentUser = new CurrentUser(
- result.User.Id,
- result.User.Username,
- result.Session.SessionId
- );
+ private async Task TryAuthenticateWithSession()
+ {
+ var sessionId = this.Context.GetSessionId();
- return AuthenticateResult.Success(
- new AuthenticationTicket(principal, this.Scheme.Name)
- );
- }
- catch (InvalidSessionError)
+ if (sessionId is null)
{
- return AuthenticateResult.Fail("Invalid session");
+ 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 TryAuthenticateWithRememberMeToken()
+ {
+ /*
+ * load remember me from token
+ * if it is null, return null
+ * if it exists, validate it
+ * if it is valid, create a new weak session, return the user
+ * if it is almost expired, refresh it
+ */
+
+ var rememberMeToken = this.Context.GetRememberMeToken();
+
+ if (rememberMeToken is null)
+ return null;
+
+ var (user, newRememberMeToken) = await authService.GetUserWithRememberMeToken(
+ rememberMeToken
+ );
+
+ if (user is null)
+ return null;
+
+ var session = await authService.CreateWeakSession(user.Id);
+
+ this.Context.SetSession(session, user);
+
+ if (newRememberMeToken is not null)
+ this.Context.SetRememberMeToken(newRememberMeToken);
+
+ return user;
}
}
diff --git a/Femto.Api/Controllers/Auth/AuthController.cs b/Femto.Api/Controllers/Auth/AuthController.cs
index b567d1d..c9108da 100644
--- a/Femto.Api/Controllers/Auth/AuthController.cs
+++ b/Femto.Api/Controllers/Auth/AuthController.cs
@@ -1,95 +1,174 @@
-using Femto.Api.Auth;
using Femto.Api.Sessions;
using Femto.Common;
using Femto.Modules.Auth.Application;
-using Femto.Modules.Auth.Application.Dto;
-using Femto.Modules.Auth.Application.Interface.CreateSignupCode;
-using Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery;
-using Femto.Modules.Auth.Application.Interface.Login;
-using Femto.Modules.Auth.Application.Interface.RefreshUserSession;
-using Femto.Modules.Auth.Application.Interface.Register;
using Femto.Modules.Auth.Contracts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.Options;
namespace Femto.Api.Controllers.Auth;
[ApiController]
[Route("auth")]
-public class AuthController(
- IAuthModule authModule,
- IOptions cookieSettings,
- ICurrentUserContext currentUserContext,
- ILogger logger
-) : ControllerBase
+public class AuthController(ICurrentUserContext currentUserContext, IAuthService authService)
+ : ControllerBase
{
[HttpPost("login")]
- public async Task> Login([FromBody] LoginRequest request)
+ public async Task> Login(
+ [FromBody] LoginRequest request,
+ CancellationToken cancellationToken
+ )
{
- var result = await authModule.Command(new LoginCommand(request.Username, request.Password));
-
- HttpContext.SetSession(result.Session, result.User, logger);
-
- return new LoginResponse(
- result.User.Id,
- result.User.Username,
- result.User.Roles.Any(r => r == Role.SuperUser)
+ var result = await authService.AuthenticateUserCredentials(
+ request.Username,
+ request.Password,
+ cancellationToken
);
+
+ if (result is null)
+ return this.BadRequest();
+
+ var (user, session) = result;
+
+ HttpContext.SetSession(session, user);
+
+ if (request.RememberMe)
+ {
+ var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
+ HttpContext.SetRememberMeToken(newRememberMeToken);
+ }
+
+ return new LoginResponse(user.Id, user.Username, user.Roles.Any(r => r == Role.SuperUser));
}
[HttpPost("register")]
- public async Task> Register([FromBody] RegisterRequest request)
+ public async Task> Register(
+ [FromBody] RegisterRequest request,
+ CancellationToken cancellationToken
+ )
{
- var result = await authModule.Command(
- new RegisterCommand(request.Username, request.Password, request.SignupCode)
+ var (user, session) = await authService.CreateUserWithCredentials(
+ request.Username,
+ request.Password,
+ request.SignupCode,
+ cancellationToken
);
- HttpContext.SetSession(result.Session, result.User, logger);
+ HttpContext.SetSession(session, user);
+
+ if (request.RememberMe)
+ {
+ var newRememberMeToken = await authService.CreateRememberMeToken(user.Id);
+ HttpContext.SetRememberMeToken(newRememberMeToken);
+ }
return new RegisterResponse(
- result.User.Id,
- result.User.Username,
- result.User.Roles.Any(r => r == Role.SuperUser)
+ user.Id,
+ user.Username,
+ user.Roles.Any(r => r == Role.SuperUser)
);
}
+ [HttpPost("change-password")]
+ public async Task ChangePassword(
+ [FromBody] ChangePasswordRequestBody req,
+ CancellationToken cancellationToken
+ )
+ {
+ if (currentUserContext.CurrentUser is not {} user)
+ return this.BadRequest();
+
+ // superuser do what superuser want
+ if (!user.IsSuperUser)
+ {
+ if (user.Id != req.UserId)
+ return this.BadRequest();
+
+ var session = await authService.GetSession(this.HttpContext.GetSessionId()!);
+
+ // require strong authentication to change password
+ // the user can re-enter their password
+ if (session is null || !session.IsStronglyAuthenticated)
+ return this.BadRequest();
+ }
+
+ await authService.ChangePassword(req.UserId, req.NewPassword, cancellationToken);
+
+ // TODO would be better do handle this from inside the auth service. maybe just have it happen in a post-save event handler?
+ await authService.InvalidateUserSessions(req.UserId, cancellationToken);
+
+ return this.Ok(new {});
+ }
+
+ [HttpPost("delete-current-session")]
+ public async Task DeleteSessionV2()
+ {
+ var sessionId = HttpContext.GetSessionId();
+
+ if (sessionId is not null)
+ {
+ await authService.DeleteSession(sessionId);
+ HttpContext.DeleteSession();
+ }
+
+ var rememberMeToken = HttpContext.GetRememberMeToken();
+
+ if (rememberMeToken is not null)
+ {
+ await authService.DeleteRememberMeToken(rememberMeToken);
+ HttpContext.DeleteRememberMeToken();
+ }
+
+ return Ok(new { });
+ }
+
+ [Obsolete("use POST /auth/delete-current-session")]
[HttpDelete("session")]
public async Task DeleteSession()
{
- HttpContext.DeleteSession();
+ var sessionId = HttpContext.GetSessionId();
+
+ if (sessionId is not null)
+ {
+ await authService.DeleteSession(sessionId);
+ HttpContext.DeleteSession();
+ }
+
+ var rememberMeToken = HttpContext.GetRememberMeToken();
+
+ if (rememberMeToken is not null)
+ {
+ await authService.DeleteRememberMeToken(rememberMeToken);
+ HttpContext.DeleteRememberMeToken();
+ }
+
return Ok(new { });
}
[HttpGet("user/{userId}")]
[Authorize]
- public async Task> RefreshUser(
+ public async Task> GetUserInfo(
Guid userId,
CancellationToken cancellationToken
)
{
- var currentUser = currentUserContext.CurrentUser!;
+ var currentUser = currentUserContext.CurrentUser;
- try
- {
- var result = await authModule.Command(
- new RefreshUserSessionCommand(userId, currentUser),
- cancellationToken
- );
+ if (currentUser is null || currentUser.Id != userId)
+ return this.BadRequest();
- return new RefreshUserResult(
- result.User.Id,
- result.User.Username,
- result.User.Roles.Any(r => r == Role.SuperUser)
- );
- }
- catch (Exception)
- {
- HttpContext.DeleteSession();
- return this.Forbid();
- }
+ var user = await authService.GetUserWithId(userId, cancellationToken);
+
+ if (user is null)
+ return this.BadRequest();
+
+ return new GetUserInfoResult(
+ user.Id,
+ user.Username,
+ user.Roles.Any(r => r == Role.SuperUser)
+ );
}
+ [Obsolete("use POST /auth/create-signup-code")]
[HttpPost("signup-codes")]
[Authorize(Roles = "SuperUser")]
public async Task CreateSignupCode(
@@ -97,21 +176,51 @@ public class AuthController(
CancellationToken cancellationToken
)
{
- await authModule.Command(
- new CreateSignupCodeCommand(request.Code, request.Email, request.Name),
- cancellationToken
- );
+ await authService.AddSignupCode(request.Code, request.Name, cancellationToken);
return Ok(new { });
}
+ [Obsolete("use GET /auth/list-signup-codes")]
[HttpGet("signup-codes")]
[Authorize(Roles = "SuperUser")]
public async Task> ListSignupCodes(
CancellationToken cancellationToken
)
{
- var codes = await authModule.Query(new GetSignupCodesQuery(), cancellationToken);
+ var codes = await authService.GetSignupCodes(cancellationToken);
+
+ return new ListSignupCodesResult(
+ codes.Select(c => new SignupCodeDto(
+ c.Code,
+ c.Email,
+ c.Name,
+ c.RedeemedByUserId,
+ c.RedeemedByUsername,
+ c.ExpiresOn
+ ))
+ );
+ }
+
+ [HttpPost("create-signup-code")]
+ [Authorize(Roles = "SuperUser")]
+ public async Task CreateSignupCodeV2(
+ [FromBody] CreateSignupCodeRequest request,
+ CancellationToken cancellationToken
+ )
+ {
+ await authService.AddSignupCode(request.Code, request.Name, cancellationToken);
+
+ return Ok(new { });
+ }
+
+ [HttpGet("list-signup-codes")]
+ [Authorize(Roles = "SuperUser")]
+ public async Task> ListSignupCodesV2(
+ CancellationToken cancellationToken
+ )
+ {
+ var codes = await authService.GetSignupCodes(cancellationToken);
return new ListSignupCodesResult(
codes.Select(c => new SignupCodeDto(
diff --git a/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs b/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs
new file mode 100644
index 0000000..77f1dcd
--- /dev/null
+++ b/Femto.Api/Controllers/Auth/ChangePasswordRequestBody.cs
@@ -0,0 +1,3 @@
+namespace Femto.Api.Controllers.Auth;
+
+public record ChangePasswordRequestBody(Guid UserId, string NewPassword);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Auth/GetUserInfoResult.cs b/Femto.Api/Controllers/Auth/GetUserInfoResult.cs
new file mode 100644
index 0000000..0212f32
--- /dev/null
+++ b/Femto.Api/Controllers/Auth/GetUserInfoResult.cs
@@ -0,0 +1,3 @@
+namespace Femto.Api.Controllers.Auth;
+
+public record GetUserInfoResult(Guid UserId, string Username, bool IsSuperUser);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Auth/LoginRequest.cs b/Femto.Api/Controllers/Auth/LoginRequest.cs
index 8366d14..6c09e64 100644
--- a/Femto.Api/Controllers/Auth/LoginRequest.cs
+++ b/Femto.Api/Controllers/Auth/LoginRequest.cs
@@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Auth;
-public record LoginRequest(string Username, string Password);
\ No newline at end of file
+public record LoginRequest(string Username, string Password, bool RememberMe);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Auth/RefreshUserResult.cs b/Femto.Api/Controllers/Auth/RefreshUserResult.cs
deleted file mode 100644
index 8dbdee8..0000000
--- a/Femto.Api/Controllers/Auth/RefreshUserResult.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace Femto.Api.Controllers.Auth;
-
-public record RefreshUserResult(Guid UserId, string Username, bool IsSuperUser);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Auth/RegisterRequest.cs b/Femto.Api/Controllers/Auth/RegisterRequest.cs
index f386198..ee21297 100644
--- a/Femto.Api/Controllers/Auth/RegisterRequest.cs
+++ b/Femto.Api/Controllers/Auth/RegisterRequest.cs
@@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Auth;
-public record RegisterRequest(string Username, string Password, string SignupCode, string? Email);
\ No newline at end of file
+public record RegisterRequest(string Username, string Password, string SignupCode, bool RememberMe);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs b/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs
new file mode 100644
index 0000000..7546af0
--- /dev/null
+++ b/Femto.Api/Controllers/Posts/Dto/AddPostCommentRequest.cs
@@ -0,0 +1,3 @@
+namespace Femto.Api.Controllers.Posts.Dto;
+
+public record AddPostCommentRequest(Guid AuthorId, string Content);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/AddPostReactionRequest.cs b/Femto.Api/Controllers/Posts/Dto/AddPostReactionRequest.cs
new file mode 100644
index 0000000..36330cf
--- /dev/null
+++ b/Femto.Api/Controllers/Posts/Dto/AddPostReactionRequest.cs
@@ -0,0 +1,3 @@
+namespace Femto.Api.Controllers.Posts.Dto;
+
+public record AddPostReactionRequest(string Emoji);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs b/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs
index a03dd93..1cec414 100644
--- a/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs
+++ b/Femto.Api/Controllers/Posts/Dto/CreatePostResponse.cs
@@ -1,3 +1,3 @@
namespace Femto.Api.Controllers.Posts.Dto;
-public record CreatePostResponse(Guid PostId);
\ No newline at end of file
+public record CreatePostResponse(PostDto Post);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/DeletePostReactionRequest.cs b/Femto.Api/Controllers/Posts/Dto/DeletePostReactionRequest.cs
new file mode 100644
index 0000000..cb39e0e
--- /dev/null
+++ b/Femto.Api/Controllers/Posts/Dto/DeletePostReactionRequest.cs
@@ -0,0 +1,3 @@
+namespace Femto.Api.Controllers.Posts.Dto;
+
+public record DeletePostReactionRequest(string Emoji);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs b/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs
new file mode 100644
index 0000000..b44740c
--- /dev/null
+++ b/Femto.Api/Controllers/Posts/Dto/GetPostResponse.cs
@@ -0,0 +1,3 @@
+namespace Femto.Api.Controllers.Posts.Dto;
+
+public record GetPostResponse(PostDto Post);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs b/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs
index e5155f6..3705456 100644
--- a/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs
+++ b/Femto.Api/Controllers/Posts/Dto/GetPublicPostsSearchParams.cs
@@ -3,4 +3,4 @@ using JetBrains.Annotations;
namespace Femto.Api.Controllers.Posts.Dto;
[PublicAPI]
-public record GetPublicPostsSearchParams(Guid? From, int? Amount, Guid? AuthorId, string? Author);
\ No newline at end of file
+public record GetPublicPostsSearchParams(Guid? After, int? Amount, Guid? AuthorId, string? Author);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/GetAllPublicPostsResponse.cs b/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs
similarity index 51%
rename from Femto.Api/Controllers/Posts/Dto/GetAllPublicPostsResponse.cs
rename to Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs
index ededad1..54b9df7 100644
--- a/Femto.Api/Controllers/Posts/Dto/GetAllPublicPostsResponse.cs
+++ b/Femto.Api/Controllers/Posts/Dto/LoadPostsResponse.cs
@@ -3,4 +3,4 @@ using JetBrains.Annotations;
namespace Femto.Api.Controllers.Posts.Dto;
[PublicAPI]
-public record GetAllPublicPostsResponse(IEnumerable Posts, Guid? Next);
\ No newline at end of file
+public record LoadPostsResponse(IEnumerable Posts);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs b/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs
new file mode 100644
index 0000000..04e180a
--- /dev/null
+++ b/Femto.Api/Controllers/Posts/Dto/PostCommentDto.cs
@@ -0,0 +1,3 @@
+namespace Femto.Api.Controllers.Posts.Dto;
+
+public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/PostDto.cs b/Femto.Api/Controllers/Posts/Dto/PostDto.cs
index c48014a..c9af7c6 100644
--- a/Femto.Api/Controllers/Posts/Dto/PostDto.cs
+++ b/Femto.Api/Controllers/Posts/Dto/PostDto.cs
@@ -8,5 +8,21 @@ public record PostDto(
Guid PostId,
string Content,
IEnumerable Media,
- DateTimeOffset CreatedAt
-);
\ No newline at end of file
+ IEnumerable Reactions,
+ DateTimeOffset CreatedAt,
+ IEnumerable PossibleReactions,
+ IEnumerable Comments
+)
+{
+ public static PostDto FromModel(Modules.Blog.Application.Queries.GetPosts.Dto.PostDto post) =>
+ new(
+ new PostAuthorDto(post.Author.AuthorId, post.Author.Username),
+ post.PostId,
+ post.Text,
+ post.Media.Select(m => new PostMediaDto(m.Url, m.Width, m.Height)),
+ post.Reactions.Select(r => new PostReactionDto(r.Emoji, r.AuthorName, r.ReactedOn)),
+ post.CreatedAt,
+ post.PossibleReactions,
+ post.Comments.Select(c => new PostCommentDto(c.Author, c.Content, c.PostedOn))
+ );
+}
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs
new file mode 100644
index 0000000..f9934c6
--- /dev/null
+++ b/Femto.Api/Controllers/Posts/Dto/PostReactionDto.cs
@@ -0,0 +1,3 @@
+namespace Femto.Api.Controllers.Posts.Dto;
+
+public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);
\ No newline at end of file
diff --git a/Femto.Api/Controllers/Posts/PostsController.cs b/Femto.Api/Controllers/Posts/PostsController.cs
index 2b1e830..ed882f7 100644
--- a/Femto.Api/Controllers/Posts/PostsController.cs
+++ b/Femto.Api/Controllers/Posts/PostsController.cs
@@ -1,6 +1,9 @@
using Femto.Api.Controllers.Posts.Dto;
using Femto.Common;
using Femto.Modules.Blog.Application;
+using Femto.Modules.Blog.Application.Commands.AddPostComment;
+using Femto.Modules.Blog.Application.Commands.AddPostReaction;
+using Femto.Modules.Blog.Application.Commands.ClearPostReaction;
using Femto.Modules.Blog.Application.Commands.CreatePost;
using Femto.Modules.Blog.Application.Commands.DeletePost;
using Femto.Modules.Blog.Application.Queries.GetPosts;
@@ -11,10 +14,11 @@ namespace Femto.Api.Controllers.Posts;
[ApiController]
[Route("posts")]
-public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext) : ControllerBase
+public class PostsController(IBlogModule blogModule, ICurrentUserContext currentUserContext, IAuthorizationService auth)
+ : ControllerBase
{
[HttpGet]
- public async Task> LoadPosts(
+ public async Task> LoadPosts(
[FromQuery] GetPublicPostsSearchParams searchParams,
CancellationToken cancellationToken
)
@@ -22,7 +26,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
var res = await blogModule.Query(
new GetPostsQuery(currentUserContext.CurrentUser?.Id)
{
- From = searchParams.From,
+ After = searchParams.After,
Amount = searchParams.Amount ?? 20,
AuthorId = searchParams.AuthorId,
Author = searchParams.Author,
@@ -30,16 +34,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
cancellationToken
);
- 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
- );
+ return new LoadPostsResponse(res.Posts.Select(PostDto.FromModel));
}
[HttpPost]
@@ -49,7 +44,7 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
CancellationToken cancellationToken
)
{
- var guid = await blogModule.Command(
+ var post = await blogModule.Command(
new CreatePostCommand(
req.AuthorId,
req.Content,
@@ -64,18 +59,96 @@ public class PostsController(IBlogModule blogModule, ICurrentUserContext current
media.Height
)
),
- req.IsPublic
+ req.IsPublic,
+ currentUserContext.CurrentUser!
),
cancellationToken
);
- return new CreatePostResponse(guid);
+ return new CreatePostResponse(PostDto.FromModel(post));
+ }
+
+ [HttpGet("{postId}")]
+ public async Task> GetPost(
+ Guid postId,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await blogModule.Query(
+ new GetPostsQuery(postId, currentUserContext.CurrentUser?.Id),
+ cancellationToken
+ );
+
+ var post = result.Posts.SingleOrDefault();
+
+ if (post is null)
+ return NotFound();
+
+ return new GetPostResponse(PostDto.FromModel(post));
}
[HttpDelete("{postId}")]
[Authorize]
public async Task DeletePost(Guid postId, CancellationToken cancellationToken)
{
- await blogModule.Command(new DeletePostCommand(postId, currentUserContext.CurrentUser!.Id), cancellationToken);
+ await blogModule.Command(
+ new DeletePostCommand(postId, currentUserContext.CurrentUser!.Id),
+ cancellationToken
+ );
+ }
+
+ [HttpPost("{postId}/reactions")]
+ [Authorize]
+ public async Task AddPostReaction(
+ Guid postId,
+ [FromBody] AddPostReactionRequest request,
+ CancellationToken cancellationToken
+ )
+ {
+ var currentUser = currentUserContext.CurrentUser!;
+
+ await blogModule.Command(
+ new AddPostReactionCommand(postId, request.Emoji, currentUser.Id),
+ cancellationToken
+ );
+
+ return this.Ok();
+ }
+
+ [HttpDelete("{postId}/reactions")]
+ [Authorize]
+ public async Task DeletePostReaction(
+ Guid postId,
+ [FromBody] DeletePostReactionRequest request,
+ CancellationToken cancellationToken
+ )
+ {
+ var currentUser = currentUserContext.CurrentUser!;
+
+ await blogModule.Command(
+ new ClearPostReactionCommand(postId, request.Emoji, currentUser.Id),
+ cancellationToken
+ );
+
+ return this.Ok();
+ }
+
+ [HttpPost("{postId}/comments")]
+ [Authorize]
+ public async Task AddPostComment(
+ Guid postId,
+ [FromBody] AddPostCommentRequest request,
+ CancellationToken cancellationToken
+ )
+ {
+ if (currentUserContext.CurrentUser?.Id != request.AuthorId)
+ return this.BadRequest();
+
+ await blogModule.Command(
+ new AddPostCommentCommand(postId, request.AuthorId, request.Content),
+ cancellationToken
+ );
+
+ return this.Ok();
}
}
diff --git a/Femto.Api/Program.cs b/Femto.Api/Program.cs
index 08a40b8..ebb81fd 100644
--- a/Femto.Api/Program.cs
+++ b/Femto.Api/Program.cs
@@ -46,7 +46,7 @@ builder.Services.AddHostedService(_ => eventBus);
builder.Services.InitializeBlogModule(connectionString, eventBus, loggerFactory);
builder.Services.InitializeMediaModule(connectionString, blobStorageRoot);
-builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory);
+builder.Services.InitializeAuthenticationModule(connectionString, eventBus, loggerFactory, TimeProvider.System);
builder.Services.AddScoped();
builder.Services.AddScoped(s => s.GetRequiredService());
diff --git a/Femto.Api/Properties/launchSettings.json b/Femto.Api/Properties/launchSettings.json
index 9a9026a..b024278 100644
--- a/Femto.Api/Properties/launchSettings.json
+++ b/Femto.Api/Properties/launchSettings.json
@@ -14,7 +14,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
- "applicationUrl": "https://stinkpad:7269;http://0.0.0.0:5181",
+ "applicationUrl": "https://localhost:7269",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
diff --git a/Femto.Api/Sessions/HttpContextSessionExtensions.cs b/Femto.Api/Sessions/HttpContextSessionExtensions.cs
index f5e5d25..2b8ee96 100644
--- a/Femto.Api/Sessions/HttpContextSessionExtensions.cs
+++ b/Femto.Api/Sessions/HttpContextSessionExtensions.cs
@@ -1,60 +1,52 @@
using System.Text.Json;
-using System.Text.Json.Serialization;
using Femto.Api.Auth;
using Femto.Modules.Auth.Application.Dto;
+using Femto.Modules.Auth.Models;
using Microsoft.Extensions.Options;
namespace Femto.Api.Sessions;
+internal record SessionInfo(string? SessionId, Guid? UserId);
+
internal static class HttpContextSessionExtensions
{
- public static void SetSession(this HttpContext httpContext, Session session, UserInfo user, ILogger logger)
+ private static readonly JsonSerializerOptions JsonOptions = new()
{
- var cookieSettings = httpContext.RequestServices.GetService>();
-
- var secure = cookieSettings?.Value.Secure ?? true;
- var sameSite = cookieSettings?.Value.SameSite ?? SameSiteMode.Strict;
- var domain = cookieSettings?.Value.Domain;
- var expires = session.Expires;
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ };
- logger.LogInformation(
- "cookie settings: Secure={Secure}, SameSite={SameSite}, domain={Domain}, Expires={Expires}",
- secure,
- sameSite,
- domain,
- expires
- );
+ public static string? GetSessionId(this HttpContext httpContext) =>
+ httpContext.Request.Cookies["sid"];
- httpContext.Response.Cookies.Append(
- "session",
- session.SessionId,
+ public static void SetSession(this HttpContext context, Session session, UserInfo user)
+ {
+ var cookieSettings = context.RequestServices.GetRequiredService>();
+
+ context.Response.Cookies.Append(
+ "sid",
+ session.Id,
new CookieOptions
{
+ Path = "/",
IsEssential = true,
- Domain = domain,
+ Domain = cookieSettings.Value.Domain,
HttpOnly = true,
- Secure = secure,
- SameSite = sameSite,
- Expires = expires,
+ Secure = cookieSettings.Value.Secure,
+ SameSite = cookieSettings.Value.SameSite,
+ Expires = session.Expires,
}
);
- httpContext.Response.Cookies.Append(
+ context.Response.Cookies.Append(
"user",
- JsonSerializer.Serialize(
- user,
- new JsonSerializerOptions
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- Converters = { new JsonStringEnumConverter() },
- }
- ),
+ JsonSerializer.Serialize(user, JsonOptions),
new CookieOptions
{
- Domain = domain,
+ Path = "/",
+ Domain = cookieSettings.Value.Domain,
IsEssential = true,
- Secure = secure,
- SameSite = sameSite,
+ Secure = cookieSettings.Value.Secure,
+ SameSite = cookieSettings.Value.SameSite,
Expires = session.Expires,
}
);
@@ -62,28 +54,78 @@ internal static class HttpContextSessionExtensions
public static void DeleteSession(this HttpContext httpContext)
{
- var cookieSettings = httpContext.RequestServices.GetService>();
-
- var secure = cookieSettings?.Value.Secure ?? true;
- var sameSite = secure ? SameSiteMode.None : SameSiteMode.Unspecified;
- var domain = cookieSettings?.Value.Domain;
-
- httpContext.Response.Cookies.Delete("session", new CookieOptions
- {
- HttpOnly = true,
- Domain = domain,
- IsEssential = true,
- Secure = secure,
- SameSite = sameSite,
- Expires = DateTimeOffset.UtcNow.AddDays(-1),
- });
- httpContext.Response.Cookies.Delete("user", new CookieOptions
- {
- Domain = domain,
- IsEssential = true,
- Secure = secure,
- SameSite = sameSite,
- Expires = DateTimeOffset.UtcNow.AddDays(-1),
- });
+ var cookieSettings = httpContext.RequestServices.GetRequiredService<
+ IOptions
+ >();
+
+ httpContext.Response.Cookies.Delete(
+ "sid",
+ new CookieOptions
+ {
+ Path = "/",
+ HttpOnly = true,
+ Domain = cookieSettings.Value.Domain,
+ IsEssential = true,
+ Secure = cookieSettings.Value.Secure,
+ SameSite = cookieSettings.Value.SameSite,
+ Expires = DateTimeOffset.UtcNow.AddDays(-1),
+ }
+ );
+
+ httpContext.Response.Cookies.Delete(
+ "user",
+ new CookieOptions
+ {
+ Path = "/",
+ Domain = cookieSettings.Value.Domain,
+ IsEssential = true,
+ Secure = cookieSettings.Value.Secure,
+ SameSite = cookieSettings.Value.SameSite,
+ Expires = DateTimeOffset.UtcNow.AddDays(-1),
+ }
+ );
+ }
+
+
+ public static RememberMeToken? GetRememberMeToken(this HttpContext httpContext) =>
+ httpContext.Request.Cookies["rid"] is { } code ? RememberMeToken.FromCode(code) : null;
+
+ public static void SetRememberMeToken(this HttpContext context, NewRememberMeToken token)
+ {
+ var cookieSettings = context.RequestServices.GetRequiredService>();
+
+ context.Response.Cookies.Append(
+ "rid",
+ token.Code,
+ new CookieOptions
+ {
+ Path = "/",
+ IsEssential = true,
+ Domain = cookieSettings.Value.Domain,
+ HttpOnly = true,
+ Secure = cookieSettings.Value.Secure,
+ SameSite = cookieSettings.Value.SameSite,
+ Expires = token.Expires,
+ }
+ );
+ }
+
+ public static void DeleteRememberMeToken(this HttpContext context)
+ {
+ var cookieSettings = context.RequestServices.GetRequiredService>();
+
+ context.Response.Cookies.Delete(
+ "rid",
+ new CookieOptions
+ {
+ Path = "/",
+ HttpOnly = true,
+ Domain = cookieSettings.Value.Domain,
+ IsEssential = true,
+ Secure = cookieSettings.Value.Secure,
+ SameSite = cookieSettings.Value.SameSite,
+ Expires = DateTimeOffset.UtcNow.AddDays(-1),
+ }
+ );
}
}
diff --git a/Femto.Common/ICurrentUserContext.cs b/Femto.Common/ICurrentUserContext.cs
index a7233e0..629b2d2 100644
--- a/Femto.Common/ICurrentUserContext.cs
+++ b/Femto.Common/ICurrentUserContext.cs
@@ -5,4 +5,4 @@ public interface ICurrentUserContext
CurrentUser? CurrentUser { get; }
}
-public record CurrentUser(Guid Id, string Username, string SessionId);
+public record CurrentUser(Guid Id, string Username, bool IsSuperUser);
\ No newline at end of file
diff --git a/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs b/Femto.Common/Infrastructure/DDDPipelineBehaviour.cs
similarity index 69%
rename from Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs
rename to Femto.Common/Infrastructure/DDDPipelineBehaviour.cs
index d9aaf03..e5f338f 100644
--- a/Femto.Common/Infrastructure/SaveChangesPipelineBehaviour.cs
+++ b/Femto.Common/Infrastructure/DDDPipelineBehaviour.cs
@@ -5,10 +5,10 @@ using Microsoft.Extensions.Logging;
namespace Femto.Common.Infrastructure;
-public class SaveChangesPipelineBehaviour(
+public class DDDPipelineBehaviour(
DbContext context,
IPublisher publisher,
- ILogger> logger
+ ILogger> logger
) : IPipelineBehavior
where TRequest : notnull
{
@@ -18,7 +18,12 @@ public class SaveChangesPipelineBehaviour(
CancellationToken cancellationToken
)
{
+ logger.LogDebug("handling request {Type}", typeof(TRequest).Name);
var response = await next(cancellationToken);
+
+ var hasChanges = context.ChangeTracker.HasChanges();
+ logger.LogDebug("request handled. Changes? {HasChanges}", hasChanges);
+
if (context.ChangeTracker.HasChanges())
{
await context.EmitDomainEvents(logger, publisher, cancellationToken);
diff --git a/Femto.Common/Infrastructure/DomainServiceExtensions.cs b/Femto.Common/Infrastructure/DomainServiceExtensions.cs
index e83469e..9812c93 100644
--- a/Femto.Common/Infrastructure/DomainServiceExtensions.cs
+++ b/Femto.Common/Infrastructure/DomainServiceExtensions.cs
@@ -12,7 +12,7 @@ public static class DomainServiceExtensions
services.AddScoped(s => s.GetRequiredService());
services.AddTransient(
typeof(IPipelineBehavior<,>),
- typeof(SaveChangesPipelineBehaviour<,>)
+ typeof(DDDPipelineBehaviour<,>)
);
}
diff --git a/Femto.Common/ScopeBinding.cs b/Femto.Common/ScopeBinding.cs
index c78408e..da58589 100644
--- a/Femto.Common/ScopeBinding.cs
+++ b/Femto.Common/ScopeBinding.cs
@@ -3,19 +3,24 @@ using Microsoft.Extensions.Logging;
namespace Femto.Common;
+
///
/// We use this to bind a scope to the request scope in the composition root
/// Any scoped services provided by this subcontainer should be accessed via a ScopeBinding injected in the host
///
///
-public class ScopeBinding(IServiceScope scope) : IDisposable
- where T : notnull
+public class ScopeBinding(IServiceScope scope) : IDisposable
{
- public T GetService() {
- return scope.ServiceProvider.GetRequiredService();
+ private IServiceScope Scope { get; } = scope;
+
+ public T GetService()
+ where T : notnull
+ {
+ return this.Scope.ServiceProvider.GetRequiredService();
}
- public void Dispose() {
- scope.Dispose();
+ public virtual void Dispose()
+ {
+ this.Scope.Dispose();
}
}
diff --git a/Femto.Database/Migrations/20250518193113_AddUserRole.sql b/Femto.Database/Migrations/20250518193113_AddUserRole.sql
index 8199a10..9febe66 100644
--- a/Femto.Database/Migrations/20250518193113_AddUserRole.sql
+++ b/Femto.Database/Migrations/20250518193113_AddUserRole.sql
@@ -6,4 +6,4 @@ CREATE TABLE authn.user_role
user_id uuid REFERENCES authn.user_identity(id),
role int,
primary key (user_id, role)
-);
\ No newline at end of file
+)
\ No newline at end of file
diff --git a/Femto.Database/Migrations/20250526220032_AddReactions.sql b/Femto.Database/Migrations/20250526220032_AddReactions.sql
new file mode 100644
index 0000000..a99bbae
--- /dev/null
+++ b/Femto.Database/Migrations/20250526220032_AddReactions.sql
@@ -0,0 +1,13 @@
+-- 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)
+);
\ No newline at end of file
diff --git a/Femto.Database/Migrations/20250529101346_SessionsRework.sql b/Femto.Database/Migrations/20250529101346_SessionsRework.sql
new file mode 100644
index 0000000..11cb84e
--- /dev/null
+++ b/Femto.Database/Migrations/20250529101346_SessionsRework.sql
@@ -0,0 +1,13 @@
+-- Migration: addLongTermSessions
+-- Created at: 29/05/2025 10:13:46
+
+DROP TABLE authn.user_session;
+
+CREATE TABLE authn.long_term_session
+(
+ id serial PRIMARY KEY,
+ selector varchar(16) NOT NULL,
+ hashed_verifier bytea NOT NULL,
+ expires timestamptz not null,
+ user_id uuid REFERENCES authn.user_identity (id)
+);
\ No newline at end of file
diff --git a/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql b/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql
new file mode 100644
index 0000000..15d0323
--- /dev/null
+++ b/Femto.Database/Migrations/20250719104200_AddInvalidateToLongTermSession.sql
@@ -0,0 +1,5 @@
+-- Migration: AddInvalidateToLongTermSession
+-- Created at: 19/07/2025 10:42:00
+
+ALTER TABLE authn.long_term_session
+ADD COLUMN is_invalidated BOOLEAN NOT NULL DEFAULT FALSE;
\ No newline at end of file
diff --git a/Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql b/Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql
new file mode 100644
index 0000000..4557156
--- /dev/null
+++ b/Femto.Database/Migrations/20250810152132_AddTimestampToReaction.sql
@@ -0,0 +1,4 @@
+-- Migration: AddTimestampToReaction
+-- Created at: 10/08/2025 15:21:32
+alter table blog.post_reaction
+add column created_at timestamptz;
\ No newline at end of file
diff --git a/Femto.Database/Migrations/20250810172242_AddCommentToPost.sql b/Femto.Database/Migrations/20250810172242_AddCommentToPost.sql
new file mode 100644
index 0000000..44e0086
--- /dev/null
+++ b/Femto.Database/Migrations/20250810172242_AddCommentToPost.sql
@@ -0,0 +1,11 @@
+-- Migration: AddCommentToPost
+-- Created at: 10/08/2025 17:22:42
+
+CREATE TABLE blog.post_comment
+(
+ id uuid PRIMARY KEY,
+ post_id uuid REFERENCES blog.post(id),
+ author_id uuid REFERENCES blog.author(id),
+ content TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+)
\ No newline at end of file
diff --git a/Femto.Database/Seed/TestDataSeeder.cs b/Femto.Database/Seed/TestDataSeeder.cs
index 47d22aa..2c8efcc 100644
--- a/Femto.Database/Seed/TestDataSeeder.cs
+++ b/Femto.Database/Seed/TestDataSeeder.cs
@@ -35,14 +35,15 @@ public static class TestDataSeeder
;
INSERT INTO blog.post
- (id, author_id, content)
+ (id, author_id, possible_reactions, content)
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-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-4dd3-7e89-909e-94a6fd19a05e', @id,'Some unwitched marbles are thought of simply as currencies. A boundary sees a nepal as a chordal railway.')
+ ('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-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.')
;
+
INSERT INTO blog.post_media
(id, post_id, url, ordering)
VALUES
@@ -54,6 +55,21 @@ 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)
;
+ 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
(id, username, password_hash, password_salt)
VALUES
diff --git a/Femto.Docs/Design/Auth/RememberMe.md b/Femto.Docs/Design/Auth/RememberMe.md
new file mode 100644
index 0000000..0ff9ec2
--- /dev/null
+++ b/Femto.Docs/Design/Auth/RememberMe.md
@@ -0,0 +1,27 @@
+# Remember me
+
+We want to implement long lived sessions
+
+we will do this with a remember me cookie
+
+this should be implemented as so:
+
+
+logging or registering and including a "rememberMe" flag with the request will generate a new remember me token, which can be stored as a cookie .
+
+the remember me token should live until:
+* the user changes password anywhere
+* the user logs out on that device
+* the user logs in with an expired session, in which case the remember me token will be used to refresh the session, and then it will be swapped out for a new one
+
+that means we need to implement three spots:
+- [ ] login
+- [ ] register
+- [ ] validate session
+
+we will implement it as described [here](https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence)
+
+we will only check the remember me token in "validate session".
+
+"refresh session" is only called with valid sessions so we do not need to check it here, as the session should already have been validated
+
diff --git a/Femto.Docs/Design/Auth/strong_vs_weak_session.md b/Femto.Docs/Design/Auth/strong_vs_weak_session.md
new file mode 100644
index 0000000..5a45a7d
--- /dev/null
+++ b/Femto.Docs/Design/Auth/strong_vs_weak_session.md
@@ -0,0 +1,16 @@
+# Strong vs weak sessions
+
+a **strong** session is one that should have the power to do account level admin tasks like change password
+
+
+a **weak** session has strictly fewer privileges than a strong session
+
+## where to get a strong session
+
+a strong session is created when a user provides a username and a password. a session remains strong until it is refreshed, at which point it becomes weak.
+
+## where to get a weak session
+
+A weak session is any session that has not been directly created by user credentials, i.e.:
+* short-term session refresh
+* long-term session refresh
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/AuthModule.cs b/Femto.Modules.Auth/Application/AuthModule.cs
deleted file mode 100644
index d289d9e..0000000
--- a/Femto.Modules.Auth/Application/AuthModule.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Femto.Common.Domain;
-using MediatR;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-
-namespace Femto.Modules.Auth.Application;
-
-internal class AuthModule(IMediator mediator) : IAuthModule
-{
- public async Task Command(ICommand command, CancellationToken cancellationToken = default) =>
- await mediator.Send(command, cancellationToken);
-
- public async Task Command(
- ICommand command,
- CancellationToken cancellationToken = default
- ) => await mediator.Send(command, cancellationToken);
-
- public async Task Query(
- IQuery query,
- CancellationToken cancellationToken = default
- ) => await mediator.Send(query, cancellationToken);
-}
diff --git a/Femto.Modules.Auth/Application/AuthService.cs b/Femto.Modules.Auth/Application/AuthService.cs
new file mode 100644
index 0000000..eb6215f
--- /dev/null
+++ b/Femto.Modules.Auth/Application/AuthService.cs
@@ -0,0 +1,258 @@
+using Dapper;
+using Femto.Common.Domain;
+using Femto.Common.Infrastructure.DbConnection;
+using Femto.Modules.Auth.Application.Dto;
+using Femto.Modules.Auth.Data;
+using Femto.Modules.Auth.Infrastructure;
+using Femto.Modules.Auth.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace Femto.Modules.Auth.Application;
+
+internal class AuthService(
+ AuthContext context,
+ SessionStorage sessionStorage,
+ IDbConnectionFactory connectionFactory
+) : IAuthService
+{
+ public async Task AuthenticateUserCredentials(
+ string username,
+ string password,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var user = await context
+ .Users.Where(u => u.Username == username)
+ .SingleOrDefaultAsync(cancellationToken);
+
+ if (user is null)
+ return null;
+
+ if (!user.HasPassword(password))
+ return null;
+
+ var session = new Session(user.Id, true);
+
+ await sessionStorage.AddSession(session);
+
+ return new(
+ new UserInfo(user.Id, user.Username, user.Roles.Select(r => r.Role).ToList()),
+ session
+ );
+ }
+
+ public Task GetUserWithId(Guid? userId, CancellationToken cancellationToken)
+ {
+ return context
+ .Users.Where(u => u.Id == userId)
+ .Select(u => new UserInfo(u.Id, u.Username, u.Roles.Select(r => r.Role).ToList()))
+ .SingleOrDefaultAsync(cancellationToken);
+ }
+
+ public async Task CreateNewSession(Guid userId)
+ {
+ var session = new Session(userId, true);
+
+ await sessionStorage.AddSession(session);
+
+ return session;
+ }
+
+ public async Task CreateWeakSession(Guid userId)
+ {
+ var session = new Session(userId, false);
+
+ await sessionStorage.AddSession(session);
+
+ return session;
+ }
+
+ public Task GetSession(string sessionId)
+ {
+ return sessionStorage.GetSession(sessionId);
+ }
+
+ public async Task DeleteSession(string sessionId)
+ {
+ await sessionStorage.DeleteSession(sessionId);
+ }
+
+ public async Task CreateUserWithCredentials(
+ string username,
+ string password,
+ string signupCode,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var now = DateTimeOffset.UtcNow;
+
+ var code = await context
+ .SignupCodes.Where(c => c.Code == signupCode)
+ .Where(c => c.ExpiresAt == null || c.ExpiresAt > now)
+ .Where(c => c.RedeemingUserId == null)
+ .SingleOrDefaultAsync(cancellationToken);
+
+ if (code is null)
+ throw new DomainError("invalid signup code");
+
+ var usernameTaken = await context.Users.AnyAsync(
+ u => u.Username == username,
+ cancellationToken
+ );
+
+ if (usernameTaken)
+ throw new DomainError("username taken");
+
+ var user = new UserIdentity(username);
+
+ await context.AddAsync(user, cancellationToken);
+
+ user.SetPassword(password);
+
+ code.Redeem(user.Id);
+
+ var session = new Session(user.Id, true);
+
+ await sessionStorage.AddSession(session);
+
+ await context.SaveChangesAsync(cancellationToken);
+
+ return new(new UserInfo(user), session);
+ }
+
+ public async Task AddSignupCode(
+ string code,
+ string recipientName,
+ CancellationToken cancellationToken
+ )
+ {
+ await context.SignupCodes.AddAsync(
+ new SignupCode("", recipientName, code),
+ cancellationToken
+ );
+
+ await context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task> GetSignupCodes(
+ CancellationToken cancellationToken = default
+ )
+ {
+ using var conn = connectionFactory.GetConnection();
+
+ // lang=sql
+ const string sql = """
+ SELECT
+ sc.code as Code,
+ sc.recipient_email as Email,
+ sc.recipient_name as Name,
+ sc.redeeming_user_id as RedeemedByUserId,
+ u.username as RedeemedByUsername,
+ sc.expires_at as ExpiresOn
+ FROM authn.signup_code sc
+ LEFT JOIN authn.user_identity u ON u.id = sc.redeeming_user_id
+ ORDER BY sc.created_at DESC
+ """;
+
+ var result = await conn.QueryAsync(sql, cancellationToken);
+
+ return result
+ .Select(row => new SignupCodeDto(
+ row.Code,
+ row.Email,
+ row.Name,
+ row.RedeemedByUserId,
+ row.RedeemedByUsername,
+ row.ExpiresOn
+ ))
+ .ToList();
+ }
+
+ public async Task CreateRememberMeToken(Guid userId)
+ {
+ var (rememberMeToken, verifier) = LongTermSession.Create(userId);
+
+ await context.AddAsync(rememberMeToken);
+ await context.SaveChangesAsync();
+
+ return new(rememberMeToken.Selector, verifier, rememberMeToken.Expires);
+ }
+
+ public async Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken(
+ RememberMeToken rememberMeToken
+ )
+ {
+ var token = await context.LongTermSessions.SingleOrDefaultAsync(t =>
+ t.Selector == rememberMeToken.Selector
+ );
+
+ if (token is null)
+ return (null, null);
+
+ if (!token.CheckVerifier(rememberMeToken.Verifier))
+ return (null, null);
+
+ var user = await context.Users.SingleOrDefaultAsync(u => u.Id == token.UserId);
+
+ if (user is null)
+ return (null, null);
+
+ if (token.ExpiresSoon)
+ {
+ var (newToken, verifier) = LongTermSession.Create(user.Id);
+ await context.AddAsync(newToken);
+ await context.SaveChangesAsync();
+
+ return (new(user), new(newToken.Selector, verifier, newToken.Expires));
+ }
+
+ return (new(user), null);
+ }
+
+ public async Task DeleteRememberMeToken(RememberMeToken rememberMeToken)
+ {
+ var session = await context.LongTermSessions.SingleOrDefaultAsync(s =>
+ s.Selector == rememberMeToken.Selector
+ );
+
+ if (session is null)
+ return;
+
+ if (!session.CheckVerifier(rememberMeToken.Verifier))
+ return;
+
+ context.Remove(session);
+ await context.SaveChangesAsync();
+ }
+
+ public async Task ChangePassword(Guid userId, string password, CancellationToken cancellationToken)
+ {
+ // change the password
+ // invalidate long term sessions
+ // invalidate sessions
+
+ var user = await context.Users.SingleOrDefaultAsync(u => u.Id == userId,cancellationToken);
+
+ if (user is null)
+ throw new DomainError("invalid user");
+
+ user.SetPassword(password);
+
+ await context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task InvalidateUserSessions(Guid userId, CancellationToken cancellationToken)
+ {
+ await sessionStorage.InvalidateUserSessions(userId);
+ }
+
+ private class GetSignupCodesQueryResultRow
+ {
+ public string Code { get; set; }
+ public string Email { get; set; }
+ public string Name { get; set; }
+ public Guid? RedeemedByUserId { get; set; }
+ public string? RedeemedByUsername { get; set; }
+ public DateTimeOffset? ExpiresOn { get; set; }
+ }
+}
diff --git a/Femto.Modules.Auth/Application/AuthStartup.cs b/Femto.Modules.Auth/Application/AuthStartup.cs
index b9e6132..362e600 100644
--- a/Femto.Modules.Auth/Application/AuthStartup.cs
+++ b/Femto.Modules.Auth/Application/AuthStartup.cs
@@ -20,20 +20,29 @@ public static class AuthStartup
this IServiceCollection rootContainer,
string connectionString,
IEventBus eventBus,
- ILoggerFactory loggerFactory
+ ILoggerFactory loggerFactory,
+ TimeProvider timeProvider
)
{
var hostBuilder = Host.CreateDefaultBuilder();
+
hostBuilder.ConfigureServices(services =>
- ConfigureServices(services, connectionString, eventBus, loggerFactory)
+ ConfigureServices(services, connectionString, eventBus, loggerFactory, timeProvider)
);
+
var host = hostBuilder.Build();
- rootContainer.AddScoped(_ => new ScopeBinding(host.Services.CreateScope()));
- rootContainer.AddScoped(services =>
- services.GetRequiredService>().GetService()
+ rootContainer.AddKeyedScoped(
+ "AuthServiceScope",
+ (s, o) =>
+ {
+ var scope = host.Services.CreateScope();
+ return new ScopeBinding(scope);
+ }
);
+ rootContainer.ExposeScopedService();
+
rootContainer.AddHostedService(services => new AuthApplication(host));
eventBus.Subscribe(
(evt, cancellationToken) => EventSubscriber(evt, host.Services, cancellationToken)
@@ -44,9 +53,12 @@ public static class AuthStartup
IServiceCollection services,
string connectionString,
IEventPublisher publisher,
- ILoggerFactory loggerFactory
+ ILoggerFactory loggerFactory,
+ TimeProvider timeProvider
)
{
+ services.AddSingleton(timeProvider);
+
services.AddTransient(_ => new DbConnectionFactory(connectionString));
services.AddDbContext(builder =>
@@ -66,7 +78,7 @@ public static class AuthStartup
{
options.WaitForJobsToComplete = true;
});
-
+ // #endif
services.AddOutbox();
services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(AuthStartup).Assembly));
@@ -74,8 +86,11 @@ public static class AuthStartup
services.ConfigureDomainServices();
services.AddSingleton(publisher);
+ services.AddSingleton();
- services.AddScoped();
+ services.AddScoped(typeof(IPipelineBehavior<,>), typeof(SaveChangesPipelineBehaviour<,>));
+
+ services.AddScoped();
}
private static async Task EventSubscriber(
@@ -107,3 +122,14 @@ public static class AuthStartup
}
}
}
+
+internal static class AuthServiceCollectionExtensions
+{
+ public static void ExposeScopedService(this IServiceCollection container)
+ where T : class
+ {
+ container.AddScoped(services =>
+ services.GetRequiredKeyedService("AuthServiceScope").GetService()
+ );
+ }
+}
diff --git a/Femto.Modules.Auth/Application/Dto/LoginResult.cs b/Femto.Modules.Auth/Application/Dto/LoginResult.cs
index 1405a28..c9048ad 100644
--- a/Femto.Modules.Auth/Application/Dto/LoginResult.cs
+++ b/Femto.Modules.Auth/Application/Dto/LoginResult.cs
@@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto;
-public record LoginResult(Session Session, UserInfo User);
\ No newline at end of file
+public record LoginResult(SessionDto SessionDto, UserInfo User);
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs b/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs
index ac1bbc3..19f1d17 100644
--- a/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs
+++ b/Femto.Modules.Auth/Application/Dto/RefreshUserSessionResult.cs
@@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto;
-public record RefreshUserSessionResult(Session Session, UserInfo User);
\ No newline at end of file
+public record RefreshUserSessionResult(SessionDto SessionDto, UserInfo User);
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Dto/RegisterResult.cs b/Femto.Modules.Auth/Application/Dto/RegisterResult.cs
index 13e1d12..e0a1243 100644
--- a/Femto.Modules.Auth/Application/Dto/RegisterResult.cs
+++ b/Femto.Modules.Auth/Application/Dto/RegisterResult.cs
@@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto;
-public record RegisterResult(Session Session, UserInfo User);
\ No newline at end of file
+public record RegisterResult(SessionDto SessionDto, UserInfo User);
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Dto/RememberMeToken.cs b/Femto.Modules.Auth/Application/Dto/RememberMeToken.cs
new file mode 100644
index 0000000..5750b8d
--- /dev/null
+++ b/Femto.Modules.Auth/Application/Dto/RememberMeToken.cs
@@ -0,0 +1,18 @@
+using Femto.Modules.Auth.Models;
+
+namespace Femto.Modules.Auth.Application.Dto;
+
+public record RememberMeToken(string Selector, string Verifier)
+{
+ public static RememberMeToken FromCode(string code)
+ {
+ var parts = code.Split('.');
+ return new RememberMeToken(parts[0], parts[1]);
+ }
+
+};
+
+public record NewRememberMeToken(string Selector, string Verifier, DateTimeOffset Expires)
+{
+ public string Code => $"{Selector}.{Verifier}";
+}
diff --git a/Femto.Modules.Auth/Application/Dto/Session.cs b/Femto.Modules.Auth/Application/Dto/Session.cs
index 9e87ca8..7f422eb 100644
--- a/Femto.Modules.Auth/Application/Dto/Session.cs
+++ b/Femto.Modules.Auth/Application/Dto/Session.cs
@@ -2,9 +2,16 @@ using Femto.Modules.Auth.Models;
namespace Femto.Modules.Auth.Application.Dto;
-public record Session(string SessionId, DateTimeOffset Expires)
+public record SessionDto(
+ string SessionId,
+ DateTimeOffset Expires,
+ bool Weak,
+ string? RememberMe = null
+)
{
- internal Session(UserSession session) : this(session.Id, session.Expires)
- {
- }
-}
\ No newline at end of file
+ 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) { }
+}
diff --git a/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs b/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs
index 7fb022f..e29c84a 100644
--- a/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs
+++ b/Femto.Modules.Auth/Application/Dto/ValidateSessionResult.cs
@@ -1,3 +1,3 @@
namespace Femto.Modules.Auth.Application.Dto;
-public record ValidateSessionResult(Session Session, UserInfo User);
\ No newline at end of file
+public record ValidateSessionResult(SessionDto SessionDto);
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/IAuthModule.cs b/Femto.Modules.Auth/Application/IAuthModule.cs
deleted file mode 100644
index 4559161..0000000
--- a/Femto.Modules.Auth/Application/IAuthModule.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using Femto.Common.Domain;
-
-namespace Femto.Modules.Auth.Application;
-
-public interface IAuthModule
-{
- Task Command(ICommand command, CancellationToken cancellationToken = default);
- Task Command(ICommand command, CancellationToken cancellationToken = default);
- Task Query(IQuery query, CancellationToken cancellationToken = default);
-}
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/IAuthService.cs b/Femto.Modules.Auth/Application/IAuthService.cs
new file mode 100644
index 0000000..f656d95
--- /dev/null
+++ b/Femto.Modules.Auth/Application/IAuthService.cs
@@ -0,0 +1,45 @@
+using Femto.Modules.Auth.Application.Dto;
+using Femto.Modules.Auth.Models;
+
+namespace Femto.Modules.Auth.Application;
+
+public interface IAuthService
+{
+ public Task AuthenticateUserCredentials(
+ string username,
+ string password,
+ CancellationToken cancellationToken = default
+ );
+ public Task GetUserWithId(
+ Guid? userId,
+ CancellationToken cancellationToken = default
+ );
+ public Task CreateNewSession(Guid userId);
+ public Task CreateWeakSession(Guid userId);
+ public Task GetSession(string sessionId);
+ public Task DeleteSession(string sessionId);
+
+ public Task CreateUserWithCredentials(string username,
+ string password,
+ string signupCode,
+ CancellationToken cancellationToken = default);
+
+ public Task AddSignupCode(
+ string code,
+ string recipientName,
+ CancellationToken cancellationToken = default
+ );
+
+ public Task> GetSignupCodes(
+ CancellationToken cancellationToken = default
+ );
+
+ Task CreateRememberMeToken(Guid userId);
+ Task<(UserInfo?, NewRememberMeToken?)> GetUserWithRememberMeToken(RememberMeToken rememberMeToken);
+ Task DeleteRememberMeToken(RememberMeToken rememberMeToken);
+
+ Task ChangePassword(Guid userId, string password, CancellationToken cancellationToken = default);
+ Task InvalidateUserSessions(Guid userId, CancellationToken cancellationToken = default);
+}
+
+public record UserAndSession(UserInfo User, Session Session);
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommand.cs b/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommand.cs
deleted file mode 100644
index be24aa9..0000000
--- a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommand.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-using Femto.Common.Domain;
-
-namespace Femto.Modules.Auth.Application.Interface.CreateSignupCode;
-
-public record CreateSignupCodeCommand(string Code, string RecipientEmail, string RecipientName): ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommandHandler.cs
deleted file mode 100644
index cfbb44a..0000000
--- a/Femto.Modules.Auth/Application/Interface/CreateSignupCode/CreateSignupCodeCommandHandler.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Data;
-using Femto.Modules.Auth.Models;
-
-namespace Femto.Modules.Auth.Application.Interface.CreateSignupCode;
-
-internal class CreateSignupCodeCommandHandler(AuthContext context) : ICommandHandler
-{
- public async Task Handle(CreateSignupCodeCommand command, CancellationToken cancellationToken)
- {
- var code = new SignupCode(command.RecipientEmail, command.RecipientName, command.Code);
-
- await context.SignupCodes.AddAsync(code, cancellationToken);
- }
-}
diff --git a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQuery.cs b/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQuery.cs
deleted file mode 100644
index 422a09d..0000000
--- a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQuery.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Application.Dto;
-
-namespace Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery;
-
-public record GetSignupCodesQuery: IQuery>;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQueryHandler.cs b/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQueryHandler.cs
deleted file mode 100644
index 201fdce..0000000
--- a/Femto.Modules.Auth/Application/Interface/GetSignupCodesQuery/GetSignupCodesQueryHandler.cs
+++ /dev/null
@@ -1,55 +0,0 @@
-using Dapper;
-using Femto.Common.Domain;
-using Femto.Common.Infrastructure.DbConnection;
-using Femto.Modules.Auth.Application.Dto;
-
-namespace Femto.Modules.Auth.Application.Interface.GetSignupCodesQuery;
-
-public class GetSignupCodesQueryHandler(IDbConnectionFactory connectionFactory)
- : IQueryHandler>
-{
- public async Task> Handle(
- GetSignupCodesQuery request,
- CancellationToken cancellationToken
- )
- {
- using var conn = connectionFactory.GetConnection();
-
- // lang=sql
- const string sql = """
- SELECT
- sc.code as Code,
- sc.recipient_email as Email,
- sc.recipient_name as Name,
- sc.redeeming_user_id as RedeemedByUserId,
- u.username as RedeemedByUsername,
- sc.expires_at as ExpiresOn
- FROM authn.signup_code sc
- LEFT JOIN authn.user_identity u ON u.id = sc.redeeming_user_id
- ORDER BY sc.created_at DESC
- """;
-
- var result = await conn.QueryAsync(sql);
-
- return result
- .Select(row => new SignupCodeDto(
- row.Code,
- row.Email,
- row.Name,
- row.RedeemedByUserId,
- row.RedeemedByUsername,
- row.ExpiresOn
- ))
- .ToList();
- }
-
- private class QueryResultRow
- {
- public string Code { get; set; }
- public string Email { get; set; }
- public string Name { get; set; }
- public Guid? RedeemedByUserId { get; set; }
- public string? RedeemedByUsername { get; set; }
- public DateTimeOffset? ExpiresOn { get; set; }
- }
-}
diff --git a/Femto.Modules.Auth/Application/Interface/Login/LoginCommand.cs b/Femto.Modules.Auth/Application/Interface/Login/LoginCommand.cs
deleted file mode 100644
index 8252e2e..0000000
--- a/Femto.Modules.Auth/Application/Interface/Login/LoginCommand.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Application.Dto;
-
-namespace Femto.Modules.Auth.Application.Interface.Login;
-
-public record LoginCommand(string Username, string Password) : ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/Login/LoginCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/Login/LoginCommandHandler.cs
deleted file mode 100644
index 45b1ae4..0000000
--- a/Femto.Modules.Auth/Application/Interface/Login/LoginCommandHandler.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Application.Dto;
-using Femto.Modules.Auth.Data;
-using Microsoft.EntityFrameworkCore;
-
-namespace Femto.Modules.Auth.Application.Interface.Login;
-
-internal class LoginCommandHandler(AuthContext context)
- : ICommandHandler
-{
- public async Task Handle(LoginCommand request, CancellationToken cancellationToken)
- {
- var user = await context.Users.SingleOrDefaultAsync(
- u => u.Username == request.Username,
- cancellationToken
- );
-
- if (user is null)
- throw new DomainError("invalid credentials");
-
- if (!user.HasPassword(request.Password))
- throw new DomainError("invalid credentials");
-
- var session = user.StartNewSession();
-
- return new(new Session(session.Id, session.Expires), new UserInfo(user));
- }
-}
diff --git a/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs b/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs
deleted file mode 100644
index f04fa82..0000000
--- a/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommand.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-using Femto.Common;
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Application.Dto;
-
-namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession;
-
-public record RefreshUserSessionCommand(Guid ForUser, CurrentUser CurrentUser) : ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs
deleted file mode 100644
index f0c6dc1..0000000
--- a/Femto.Modules.Auth/Application/Interface/RefreshUserSession/RefreshUserSessionCommandHandler.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Common.Infrastructure.DbConnection;
-using Femto.Modules.Auth.Application.Dto;
-using Femto.Modules.Auth.Data;
-using Microsoft.EntityFrameworkCore;
-
-namespace Femto.Modules.Auth.Application.Interface.RefreshUserSession;
-
-internal class RefreshUserSessionCommandHandler(AuthContext context)
- : ICommandHandler
-{
- public async Task Handle(
- RefreshUserSessionCommand request,
- CancellationToken cancellationToken
- )
- {
- if (request.CurrentUser.Id != request.ForUser)
- throw new DomainError("invalid request");
-
- var user = await context.Users.SingleOrDefaultAsync(
- u => u.Id == request.ForUser,
- cancellationToken
- );
-
- if (user is null)
- throw new DomainError("invalid request");
-
- var session = user.PossiblyRefreshSession(request.CurrentUser.SessionId);
-
- return new(new Session(session), new UserInfo(user));
- }
-}
diff --git a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs
deleted file mode 100644
index dd3c186..0000000
--- a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommand.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Application.Dto;
-
-namespace Femto.Modules.Auth.Application.Interface.Register;
-
-public record RegisterCommand(string Username, string Password, string SignupCode) : ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs
deleted file mode 100644
index 9e29be6..0000000
--- a/Femto.Modules.Auth/Application/Interface/Register/RegisterCommandHandler.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Application.Dto;
-using Femto.Modules.Auth.Data;
-using Femto.Modules.Auth.Models;
-using Microsoft.EntityFrameworkCore;
-
-namespace Femto.Modules.Auth.Application.Interface.Register;
-
-internal class RegisterCommandHandler(AuthContext context) : ICommandHandler
-{
- public async Task Handle(RegisterCommand request, CancellationToken cancellationToken)
- {
- var now = DateTimeOffset.UtcNow;
- var code = await context.SignupCodes
- .Where(c => c.Code == request.SignupCode)
- .Where(c => c.ExpiresAt == null || c.ExpiresAt > now)
- .Where(c => c.RedeemingUserId == null)
- .SingleOrDefaultAsync(cancellationToken);
-
- if (code is null)
- throw new DomainError("invalid signup code");
-
- var user = new UserIdentity(request.Username);
-
- user.SetPassword(request.Password);
-
- var session = user.StartNewSession();
-
- await context.AddAsync(user, cancellationToken);
-
- code.Redeem(user.Id);
-
- return new(new Session(session.Id, session.Expires), new UserInfo(user));
- }
-}
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs
deleted file mode 100644
index 40d5417..0000000
--- a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommand.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Application.Dto;
-
-namespace Femto.Modules.Auth.Application.Interface.ValidateSession;
-
-///
-/// Validate an existing session, and then return either the current session, or a new one in case the expiry is further in the future
-///
-///
-public record ValidateSessionCommand(string SessionId) : ICommand;
diff --git a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs b/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs
deleted file mode 100644
index f79552c..0000000
--- a/Femto.Modules.Auth/Application/Interface/ValidateSession/ValidateSessionCommandHandler.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using Femto.Common.Domain;
-using Femto.Modules.Auth.Application.Dto;
-using Femto.Modules.Auth.Data;
-using Femto.Modules.Auth.Errors;
-using Microsoft.EntityFrameworkCore;
-
-namespace Femto.Modules.Auth.Application.Interface.ValidateSession;
-
-internal class ValidateSessionCommandHandler(AuthContext context)
- : ICommandHandler
-{
- public async Task Handle(
- ValidateSessionCommand request,
- CancellationToken cancellationToken
- )
- {
- var now = DateTimeOffset.UtcNow;
-
- var user = await context.Users.SingleOrDefaultAsync(
- u => u.Sessions.Any(s => s.Id == request.SessionId && s.Expires > now),
- cancellationToken
- );
-
- if (user is null)
- throw new InvalidSessionError();
-
- var session = user.PossiblyRefreshSession(request.SessionId);
-
- return new ValidateSessionResult(
- new Session(session.Id, session.Expires),
- new UserInfo(user)
- );
- }
-}
diff --git a/Femto.Modules.Auth/Data/AuthContext.cs b/Femto.Modules.Auth/Data/AuthContext.cs
index e850eb8..ac395ba 100644
--- a/Femto.Modules.Auth/Data/AuthContext.cs
+++ b/Femto.Modules.Auth/Data/AuthContext.cs
@@ -1,6 +1,10 @@
+using Femto.Common.Domain;
using Femto.Common.Infrastructure.Outbox;
using Femto.Modules.Auth.Models;
+using MediatR;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.Extensions.Logging;
namespace Femto.Modules.Auth.Data;
@@ -8,6 +12,7 @@ internal class AuthContext(DbContextOptions options) : DbContext(op
{
public virtual DbSet Users { get; set; }
public virtual DbSet SignupCodes { get; set; }
+ public virtual DbSet LongTermSessions { get; set; }
public virtual DbSet Outbox { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
@@ -16,4 +21,43 @@ internal class AuthContext(DbContextOptions options) : DbContext(op
builder.HasDefaultSchema("authn");
builder.ApplyConfigurationsFromAssembly(typeof(AuthContext).Assembly);
}
+
+ public override int SaveChanges()
+ {
+ throw new InvalidOperationException("Use SaveChangesAsync instead");
+ }
+
+ public override async Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ await EmitDomainEvents(cancellationToken);
+
+ return await base.SaveChangesAsync(cancellationToken);
+ }
+
+ private async Task EmitDomainEvents(CancellationToken cancellationToken)
+ {
+ var logger = this.GetService>();
+ var publisher = this.GetService();
+ var domainEvents = this
+ .ChangeTracker.Entries()
+ .SelectMany(e =>
+ {
+ var events = e.Entity.DomainEvents;
+ e.Entity.ClearDomainEvents();
+ return events;
+ })
+ .ToList();
+
+ logger.LogTrace("loaded {Count} domain events", domainEvents.Count);
+
+ foreach (var domainEvent in domainEvents)
+ {
+ logger.LogTrace(
+ "publishing {Type} domain event {Id}",
+ domainEvent.GetType().Name,
+ domainEvent.EventId
+ );
+ await publisher.Publish(domainEvent, cancellationToken);
+ }
+ }
}
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Data/Configurations/LongTermSessionConfiguration.cs b/Femto.Modules.Auth/Data/Configurations/LongTermSessionConfiguration.cs
new file mode 100644
index 0000000..00f2a13
--- /dev/null
+++ b/Femto.Modules.Auth/Data/Configurations/LongTermSessionConfiguration.cs
@@ -0,0 +1,13 @@
+using Femto.Modules.Auth.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Femto.Modules.Auth.Data.Configurations;
+
+public class LongTermSessionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("long_term_session");
+ }
+}
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs b/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs
index 2e5086b..1921451 100644
--- a/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs
+++ b/Femto.Modules.Auth/Data/Configurations/UserIdentityTypeConfiguration.cs
@@ -19,8 +19,6 @@ internal class UserIdentityTypeConfiguration : IEntityTypeConfiguration u.Sessions).WithOwner().HasForeignKey("user_id");
-
builder
.OwnsMany(u => u.Roles, entity =>
{
diff --git a/Femto.Modules.Auth/Infrastructure/SaveChangesPipelineBehaviour.cs b/Femto.Modules.Auth/Infrastructure/SaveChangesPipelineBehaviour.cs
new file mode 100644
index 0000000..cc4f983
--- /dev/null
+++ b/Femto.Modules.Auth/Infrastructure/SaveChangesPipelineBehaviour.cs
@@ -0,0 +1,23 @@
+using Femto.Modules.Auth.Data;
+using MediatR;
+
+namespace Femto.Modules.Auth.Infrastructure;
+
+internal class SaveChangesPipelineBehaviour(AuthContext context)
+ : IPipelineBehavior
+ where TRequest : notnull
+{
+ public async Task Handle(
+ TRequest request,
+ RequestHandlerDelegate next,
+ CancellationToken cancellationToken
+ )
+ {
+ var response = await next(cancellationToken);
+
+ if (context.ChangeTracker.HasChanges())
+ await context.SaveChangesAsync(cancellationToken);
+
+ return response;
+ }
+}
diff --git a/Femto.Modules.Auth/Infrastructure/SessionStorage.cs b/Femto.Modules.Auth/Infrastructure/SessionStorage.cs
new file mode 100644
index 0000000..e331396
--- /dev/null
+++ b/Femto.Modules.Auth/Infrastructure/SessionStorage.cs
@@ -0,0 +1,56 @@
+using System.Collections;
+using System.Collections.Concurrent;
+using Femto.Modules.Auth.Models;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace Femto.Modules.Auth.Infrastructure;
+
+internal class SessionStorage(TimeProvider timeProvider)
+{
+ private readonly IMemoryCache _storage = new MemoryCache(new MemoryCacheOptions());
+
+ public async Task GetSession(string id)
+ {
+ var session = this._storage.Get($"session:{id}");
+
+ if (session is null)
+ return null;
+
+ var invalidUntil = this._storage.Get(
+ $"user:invalid_until:{session.UserId}"
+ );
+
+ if (invalidUntil is not null && invalidUntil > session.Expires)
+ return null;
+
+ return session;
+ }
+
+ public Task AddSession(Session session)
+ {
+ using var sessionEntry = this._storage.CreateEntry($"session:{session.Id}");
+ sessionEntry.Value = session;
+ sessionEntry.SetAbsoluteExpiration(session.Expires);
+
+ return Task.CompletedTask;
+ }
+
+ public Task DeleteSession(string id)
+ {
+ this._storage.Remove($"session:{id}");
+
+ return Task.CompletedTask;
+ }
+
+ public Task InvalidateUserSessions(Guid userId)
+ {
+ var invalidUntil = timeProvider.GetUtcNow() + Session.ValidityPeriod;
+
+ // invalidate sessions who are currently valid
+ // any sessions created after this will have a validity period that extends past invalid_until
+ // this cache entry doesn't need to live longer than that point in time
+ this._storage.Set($"user:invalid_until:{userId}", invalidUntil, invalidUntil);
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs b/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs
new file mode 100644
index 0000000..e09c66e
--- /dev/null
+++ b/Femto.Modules.Auth/Models/DomainEventHandlers/UserPasswordChangedHandler.cs
@@ -0,0 +1,22 @@
+using Femto.Modules.Auth.Data;
+using Femto.Modules.Auth.Models.Events;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Femto.Modules.Auth.Models.DomainEventHandlers;
+
+internal class UserPasswordChangedHandler(AuthContext context)
+ : INotificationHandler
+{
+ public async Task Handle(UserWasCreatedEvent notification, CancellationToken cancellationToken)
+ {
+ var longTermSessions = await context
+ .LongTermSessions.Where(s => s.UserId == notification.User.Id)
+ .ToListAsync(cancellationToken);
+
+ foreach (var session in longTermSessions)
+ {
+ session.Invalidate();
+ }
+ }
+}
diff --git a/Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs b/Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs
new file mode 100644
index 0000000..70c9a73
--- /dev/null
+++ b/Femto.Modules.Auth/Models/Events/UserPasswordChangedDomainEvent.cs
@@ -0,0 +1,5 @@
+using Femto.Common.Domain;
+
+namespace Femto.Modules.Auth.Models.Events;
+
+internal record UserPasswordChangedDomainEvent(UserIdentity User) : DomainEvent;
\ No newline at end of file
diff --git a/Femto.Modules.Auth/Models/LongTermSession.cs b/Femto.Modules.Auth/Models/LongTermSession.cs
new file mode 100644
index 0000000..eba3d9d
--- /dev/null
+++ b/Femto.Modules.Auth/Models/LongTermSession.cs
@@ -0,0 +1,73 @@
+using System.ComponentModel.DataAnnotations.Schema;
+using System.Text;
+using static System.Security.Cryptography.RandomNumberGenerator;
+
+namespace Femto.Modules.Auth.Models;
+
+public class LongTermSession
+{
+ private static TimeSpan TokenTimeout { get; } = TimeSpan.FromDays(90);
+ private static TimeSpan RefreshBuffer { get; } = TimeSpan.FromDays(5);
+
+ public int Id { get; private set; }
+
+ public string Selector { get; private set; }
+
+ public byte[] HashedVerifier { get; private set; }
+
+ public DateTimeOffset Expires { get; private set; }
+
+ public Guid UserId { get; private set; }
+
+ public bool IsInvalidated { get; private set; }
+
+ [NotMapped]
+ public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer;
+
+ private LongTermSession() { }
+
+ public static (LongTermSession, string) Create(Guid userId)
+ {
+ var selector = GetString(
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
+ 12
+ );
+
+ var verifier = GetString("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 32);
+
+ using var sha256 = System.Security.Cryptography.SHA256.Create();
+
+ var longTermSession = new LongTermSession
+ {
+ Selector = selector,
+ HashedVerifier = ComputeHash(verifier),
+ UserId = userId,
+ Expires = DateTimeOffset.UtcNow + TokenTimeout,
+ };
+
+ return (longTermSession, verifier);
+ }
+
+ public bool CheckVerifier(string verifier)
+ {
+ if (this.IsInvalidated)
+ return false;
+
+ if (this.Expires < DateTimeOffset.UtcNow)
+ return false;
+
+ return ComputeHash(verifier).SequenceEqual(this.HashedVerifier);
+ }
+
+ private static byte[] ComputeHash(string verifier)
+ {
+ using var sha256 = System.Security.Cryptography.SHA256.Create();
+ var hashedVerifier = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier));
+ return hashedVerifier;
+ }
+
+ public void Invalidate()
+ {
+ this.IsInvalidated = true;
+ }
+}
diff --git a/Femto.Modules.Auth/Models/Session.cs b/Femto.Modules.Auth/Models/Session.cs
new file mode 100644
index 0000000..e641a61
--- /dev/null
+++ b/Femto.Modules.Auth/Models/Session.cs
@@ -0,0 +1,16 @@
+using static System.Security.Cryptography.RandomNumberGenerator;
+
+namespace Femto.Modules.Auth.Models;
+
+public class Session(Guid userId, bool isStrong)
+{
+ public static readonly TimeSpan ValidityPeriod = TimeSpan.FromMinutes(15);
+ private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMinutes(5);
+ public string Id { get; } = Convert.ToBase64String(GetBytes(32));
+ public Guid UserId { get; } = userId;
+ public DateTimeOffset Expires { get; } = DateTimeOffset.UtcNow + ValidityPeriod;
+
+ public bool ExpiresSoon => this.Expires < DateTimeOffset.UtcNow + RefreshBuffer;
+ public bool IsStronglyAuthenticated { get; } = isStrong;
+ public bool IsExpired => this.Expires < DateTimeOffset.UtcNow;
+}
diff --git a/Femto.Modules.Auth/Models/UserIdentity.cs b/Femto.Modules.Auth/Models/UserIdentity.cs
index 756be41..bd0288f 100644
--- a/Femto.Modules.Auth/Models/UserIdentity.cs
+++ b/Femto.Modules.Auth/Models/UserIdentity.cs
@@ -1,9 +1,6 @@
-using System.Text;
-using System.Text.Unicode;
using Femto.Common.Domain;
using Femto.Modules.Auth.Contracts;
using Femto.Modules.Auth.Models.Events;
-using Geralt;
namespace Femto.Modules.Auth.Models;
@@ -15,8 +12,6 @@ internal class UserIdentity : Entity
public Password? Password { get; private set; }
- public ICollection Sessions { get; private set; } = [];
-
public ICollection Roles { get; private set; } = [];
private UserIdentity() { }
@@ -31,14 +26,10 @@ internal class UserIdentity : Entity
this.AddDomainEvent(new UserWasCreatedEvent(this));
}
- public UserIdentity WithPassword(string password)
- {
- this.SetPassword(password);
- return this;
- }
-
public void SetPassword(string password)
{
+ if (this.Password is not null)
+ this.AddDomainEvent(new UserPasswordChangedDomainEvent(this));
this.Password = new Password(password);
}
@@ -51,25 +42,6 @@ internal class UserIdentity : Entity
return this.Password.Check(requestPassword);
}
-
- public UserSession PossiblyRefreshSession(string sessionId)
- {
- var session = this.Sessions.Single(s => s.Id == sessionId);
-
- if (session.ExpiresSoon)
- return this.StartNewSession();
-
- return session;
- }
-
- public UserSession StartNewSession()
- {
- var session = UserSession.Create();
-
- this.Sessions.Add(session);
-
- return session;
- }
}
public class SetPasswordError(string message, Exception inner) : DomainError(message, inner);
diff --git a/Femto.Modules.Auth/Models/UserSession.cs b/Femto.Modules.Auth/Models/UserSession.cs
deleted file mode 100644
index 7deb251..0000000
--- a/Femto.Modules.Auth/Models/UserSession.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-namespace Femto.Modules.Auth.Models;
-
-internal class UserSession
-{
- private static TimeSpan SessionTimeout { get; } = TimeSpan.FromMinutes(30);
- private static TimeSpan ExpiryBuffer { get; } = TimeSpan.FromMinutes(5);
- public string Id { get; private set; }
- public DateTimeOffset Expires { get; private set; }
- public bool ExpiresSoon => Expires < DateTimeOffset.UtcNow + ExpiryBuffer;
-
- private UserSession() {}
-
- public static UserSession Create()
- {
- return new()
- {
- Id = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)),
- Expires = DateTimeOffset.UtcNow + SessionTimeout
- };
- }
-}
\ No newline at end of file
diff --git a/Femto.Modules.Blog.Data/Class1.cs b/Femto.Modules.Blog.Data/Class1.cs
deleted file mode 100644
index 3be8b2a..0000000
--- a/Femto.Modules.Blog.Data/Class1.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-๏ปฟnamespace Femto.Modules.Blog.Data;
-
-public class Class1
-{
-}
\ No newline at end of file
diff --git a/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj b/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj
deleted file mode 100644
index 17b910f..0000000
--- a/Femto.Modules.Blog.Data/Femto.Modules.Blog.Data.csproj
+++ /dev/null
@@ -1,9 +0,0 @@
-๏ปฟ
-
-
- net9.0
- enable
- enable
-
-
-
diff --git a/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj b/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj
deleted file mode 100644
index 6ae6742..0000000
--- a/Femto.Modules.Blog.Domain/Femto.Modules.Blog.Domain.csproj
+++ /dev/null
@@ -1,24 +0,0 @@
-๏ปฟ
-
-
- net9.0
- enable
- enable
-
-
-
-
-
-
-
-
-
-
-
-
-
- ..\..\..\..\.nuget\packages\microsoft.entityframeworkcore\9.0.4\lib\net8.0\Microsoft.EntityFrameworkCore.dll
-
-
-
-
diff --git a/Femto.Modules.Blog/Application/BlogStartup.cs b/Femto.Modules.Blog/Application/BlogStartup.cs
index b134f4c..afd18b5 100644
--- a/Femto.Modules.Blog/Application/BlogStartup.cs
+++ b/Femto.Modules.Blog/Application/BlogStartup.cs
@@ -35,9 +35,13 @@ public static class BlogStartup
rootContainer.AddHostedService(_ => new BlogApplication(host));
- rootContainer.AddScoped(_ => new ScopeBinding(host.Services.CreateScope()));
+ rootContainer.AddKeyedScoped(
+ "BlogService",
+ (_, o) => new ScopeBinding(host.Services.CreateScope())
+ );
+
rootContainer.AddScoped(services =>
- services.GetRequiredService>().GetService()
+ services.GetRequiredKeyedService("BlogService").GetService()
);
bus.Subscribe(
diff --git a/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs
new file mode 100644
index 0000000..445c59e
--- /dev/null
+++ b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommand.cs
@@ -0,0 +1,5 @@
+using Femto.Common.Domain;
+
+namespace Femto.Modules.Blog.Application.Commands.AddPostComment;
+
+public record AddPostCommentCommand(Guid PostId, Guid AuthorId, string Content) : ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs
new file mode 100644
index 0000000..6e52877
--- /dev/null
+++ b/Femto.Modules.Blog/Application/Commands/AddPostComment/AddPostCommentCommandHandler.cs
@@ -0,0 +1,20 @@
+using Femto.Common.Domain;
+using Microsoft.EntityFrameworkCore;
+
+namespace Femto.Modules.Blog.Application.Commands.AddPostComment;
+
+internal class AddPostCommentCommandHandler(BlogContext context) : ICommandHandler
+{
+ public async Task Handle(AddPostCommentCommand request, CancellationToken cancellationToken)
+ {
+ var post = await context.Posts.SingleOrDefaultAsync(
+ p => p.Id == request.PostId,
+ cancellationToken
+ );
+
+ if (post is null)
+ return;
+
+ post.AddComment(request.AuthorId, request.Content);
+ }
+}
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommand.cs b/Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommand.cs
new file mode 100644
index 0000000..9096687
--- /dev/null
+++ b/Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommand.cs
@@ -0,0 +1,6 @@
+using Femto.Common;
+using Femto.Common.Domain;
+
+namespace Femto.Modules.Blog.Application.Commands.AddPostReaction;
+
+public record AddPostReactionCommand(Guid PostId, string Emoji, Guid ReactorId) : ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommandHandler.cs
new file mode 100644
index 0000000..e2c3f8a
--- /dev/null
+++ b/Femto.Modules.Blog/Application/Commands/AddPostReaction/AddPostReactionCommandHandler.cs
@@ -0,0 +1,21 @@
+using Femto.Common.Domain;
+using Microsoft.EntityFrameworkCore;
+
+namespace Femto.Modules.Blog.Application.Commands.AddPostReaction;
+
+internal class AddPostReactionCommandHandler(BlogContext context)
+ : ICommandHandler
+{
+ 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);
+ }
+}
diff --git a/Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommand.cs b/Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommand.cs
new file mode 100644
index 0000000..618305f
--- /dev/null
+++ b/Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommand.cs
@@ -0,0 +1,6 @@
+using Femto.Common;
+using Femto.Common.Domain;
+
+namespace Femto.Modules.Blog.Application.Commands.ClearPostReaction;
+
+public record ClearPostReactionCommand(Guid PostId, string Emoji, Guid ReactorId): ICommand;
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommandHandler.cs
new file mode 100644
index 0000000..e4d344e
--- /dev/null
+++ b/Femto.Modules.Blog/Application/Commands/ClearPostReaction/ClearPostReactionCommandHandler.cs
@@ -0,0 +1,22 @@
+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
+{
+ 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);
+ }
+}
diff --git a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommand.cs b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommand.cs
index d10c496..30cc602 100644
--- a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommand.cs
+++ b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommand.cs
@@ -1,8 +1,22 @@
+using Femto.Common;
using Femto.Common.Domain;
+using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
namespace Femto.Modules.Blog.Application.Commands.CreatePost;
-public record CreatePostCommand(Guid AuthorId, string Content, IEnumerable Media, bool? IsPublic)
- : ICommand;
+public record CreatePostCommand(
+ Guid AuthorId,
+ string Content,
+ IEnumerable Media,
+ bool? IsPublic,
+ CurrentUser CurrentUser
+) : ICommand;
-public record CreatePostMedia(Guid MediaId, Uri Url, string? Type, int Order, int? Width, int? Height);
+public record CreatePostMedia(
+ Guid MediaId,
+ Uri Url,
+ string? Type,
+ int Order,
+ int? Width,
+ int? Height
+);
diff --git a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs
index cda4b2d..25bab45 100644
--- a/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs
+++ b/Femto.Modules.Blog/Application/Commands/CreatePost/CreatePostCommandHandler.cs
@@ -1,12 +1,16 @@
+using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
using Femto.Modules.Blog.Domain.Posts;
using MediatR;
namespace Femto.Modules.Blog.Application.Commands.CreatePost;
internal class CreatePostCommandHandler(BlogContext context)
- : IRequestHandler
+ : IRequestHandler
{
- public async Task Handle(CreatePostCommand request, CancellationToken cancellationToken)
+ public async Task Handle(
+ CreatePostCommand request,
+ CancellationToken cancellationToken
+ )
{
var post = new Post(
request.AuthorId,
@@ -20,13 +24,21 @@ internal class CreatePostCommandHandler(BlogContext context)
media.Width,
media.Height
))
- .ToList()
+ .ToList(),
+ request.IsPublic is true
);
-
- post.IsPublic = request.IsPublic is true;
await context.AddAsync(post, cancellationToken);
- return post.Id;
+ return new PostDto(
+ 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,
+ []
+ );
}
}
diff --git a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs
index 8cb2a64..630cbe2 100644
--- a/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs
+++ b/Femto.Modules.Blog/Application/Configurations/PostConfiguration.cs
@@ -10,5 +10,24 @@ internal class PostConfiguration : IEntityTypeConfiguration
{
table.ToTable("post");
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("PossibleReactionsJson").HasColumnName("possible_reactions");
+
+ table.Ignore(e => e.PossibleReactions);
}
}
diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs
index be8157a..8b75d6e 100644
--- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs
+++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/GetPostsQueryResult.cs
@@ -1,3 +1,3 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
-public record GetPostsQueryResult(IList Posts, Guid? Next);
\ No newline at end of file
+public record GetPostsQueryResult(IList Posts);
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs
new file mode 100644
index 0000000..55ea5e8
--- /dev/null
+++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostCommentDto.cs
@@ -0,0 +1,3 @@
+namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
+
+public record PostCommentDto(string Author, string Content, DateTimeOffset PostedOn);
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs
index 584d1f5..63efede 100644
--- a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs
+++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostDto.cs
@@ -1,3 +1,12 @@
namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
-public record PostDto(Guid PostId, string Text, IList Media, DateTimeOffset CreatedAt, PostAuthorDto Author);
\ No newline at end of file
+public record PostDto(
+ Guid PostId,
+ string Text,
+ IList Media,
+ DateTimeOffset CreatedAt,
+ PostAuthorDto Author,
+ IList Reactions,
+ IEnumerable PossibleReactions,
+ IList Comments
+);
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs
new file mode 100644
index 0000000..60349b9
--- /dev/null
+++ b/Femto.Modules.Blog/Application/Queries/GetPosts/Dto/PostReactionDto.cs
@@ -0,0 +1,3 @@
+namespace Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
+
+public record PostReactionDto(string Emoji, string AuthorName, DateTimeOffset ReactedOn);
diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs
index f8af9d2..1bb1d4c 100644
--- a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs
+++ b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQuery.cs
@@ -3,22 +3,27 @@ using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
namespace Femto.Modules.Blog.Application.Queries.GetPosts;
+///
+/// Get posts in reverse chronological order
+///
+///
public record GetPostsQuery(Guid? CurrentUserId) : IQuery
{
- public Guid? From { get; init; }
+ ///
+ /// Id of the specific post to load. If specified, After and Amount are ignored
+ ///
+ public Guid? PostId { get; }
+
+ ///
+ /// If specified, loads posts from after the given Id. Used for paging
+ ///
+ public Guid? After { get; init; }
public int Amount { get; init; } = 20;
public Guid? AuthorId { get; init; }
public string? Author { get; init; }
- ///
- /// Default is to load in reverse chronological order
- /// TODO this is not exposed on the client as it probably wouldn't work that well
- ///
- public GetPostsDirection Direction { get; init; } = GetPostsDirection.Backward;
-}
-
-public enum GetPostsDirection
-{
- Forward,
- Backward,
-}
+ public GetPostsQuery(Guid postId, Guid? currentUserId) : this(currentUserId)
+ {
+ this.PostId = postId;
+ }
+}
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs
index 7ace8a6..c57627f 100644
--- a/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs
+++ b/Femto.Modules.Blog/Application/Queries/GetPosts/GetPostsQueryHandler.cs
@@ -1,3 +1,4 @@
+using System.Text.Json;
using Dapper;
using Femto.Common.Infrastructure.DbConnection;
using Femto.Modules.Blog.Application.Queries.GetPosts.Dto;
@@ -15,101 +16,158 @@ public class GetPostsQueryHandler(IDbConnectionFactory connectionFactory)
{
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 authorGuid = query.AuthorId;
- var cursor = query.From;
+ var cursor = query.After;
var showPrivate = query.CurrentUserId is not null;
- // lang=sql
- var sql = $$"""
- with page as (
- select blog.post.*, blog.author.username as Username, blog.author.id as AuthorId
- from blog.post
- inner join blog.author on blog.author.id = blog.post.author_id
- where (@username is null or blog.author.username = @username)
- and (@showPrivate or blog.post.is_public = true)
- and (@authorGuid is null or blog.author.id = @authorGuid)
- and (@cursor is null or blog.post.id {{pageFilter}} @cursor)
- order by blog.post.id {{orderBy}}
- 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(
- sql,
+ var loadPostsResult = await conn.QueryAsync(
+ """
+ select
+ blog.post.id as PostId,
+ 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
+ inner join blog.author on blog.author.id = blog.post.author_id
+ where (@username is null or blog.author.username = @username)
+ and (@postId is null or blog.post.id = @postId)
+ and (@showPrivate or blog.post.is_public = true)
+ and (@authorGuid is null or blog.author.id = @authorGuid)
+ and (@cursor is null or blog.post.id < @cursor)
+ order by blog.post.id desc
+ limit @amount
+ """,
new
{
username,
authorGuid,
cursor,
- // load an extra one to take for the cursor
- amount = query.Amount + 1,
+ amount = query.PostId is not null ? 1 : query.Amount,
showPrivate,
+ postId = query.PostId,
}
);
- var rows = result.ToList();
+ var posts = loadPostsResult.ToList();
- var posts = rows.GroupBy(row => row.PostId)
- .Select(group =>
- {
- var postId = group.Key;
- var post = group.First();
- var media = group
- .Select(row =>
- {
- if (row.MediaUrl is not null)
- {
- return new PostMediaDto(
- new Uri(row.MediaUrl),
- row.MediaHeight,
- row.MediaHeight
- );
- }
- else
- return null;
- })
- .OfType()
- .ToList();
- return new PostDto(
- postId,
- post.Content,
- media,
- post.PostedOn,
- new PostAuthorDto(post.AuthorId, post.Username)
- );
- })
- .ToList();
+ var postIds = posts.Select(p => p.PostId).ToList();
- var next = rows.Count >= query.Amount ? rows.LastOrDefault()?.PostId : null;
+ var loadMediaResult = await conn.QueryAsync(
+ """
+ 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 }
+ );
- return new GetPostsQueryResult(posts, next);
+ var media = loadMediaResult.ToList();
+
+ var loadReactionsResult = await conn.QueryAsync(
+ """
+ select
+ pr.post_id as PostId,
+ a.username as AuthorName,
+ pr.emoji as Emoji,
+ pr.created_at as CreatedOn
+ 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(
+ """
+ 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>(p.PossibleReactions)!
+ : [],
+ comments
+ .Where(c => c.PostId == p.PostId)
+ .Select(c => new PostCommentDto(c.AuthorName, c.Content, c.PostedOn))
+ .ToList()
+ ))
+ .ToList()
+ );
}
- internal class QueryResult
+ internal record LoadPostRow
{
- public Guid PostId { get; set; }
- public string Content { get; set; }
- public string? MediaUrl { get; set; }
- public string? MediaType { get; set; }
- public int? MediaWidth { get; set; }
- public int? MediaHeight { get; set; }
- public DateTimeOffset PostedOn { get; set; }
- public Guid AuthorId { get; set; }
- public string Username { get; set; }
+ public Guid PostId { get; init; }
+ public string Content { get; init; }
+ public DateTimeOffset PostedOn { get; init; }
+ public string Username { get; init; }
+ public Guid AuthorId { get; init; }
+ public string? PossibleReactions { get; init; }
+ }
+
+ internal record LoadMediaRow
+ {
+ public string MediaUrl { get; init; }
+ public string? MediaType { get; init; }
+ public int? MediaWidth { get; init; }
+ public int? MediaHeight { get; init; }
+ public Guid PostId { get; init; }
+ }
+
+ internal record LoadReactionRow
+ {
+ 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; }
}
}
diff --git a/Femto.Modules.Blog/Domain/Posts/Post.cs b/Femto.Modules.Blog/Domain/Posts/Post.cs
index 65f90d6..b5a9b2d 100644
--- a/Femto.Modules.Blog/Domain/Posts/Post.cs
+++ b/Femto.Modules.Blog/Domain/Posts/Post.cs
@@ -1,5 +1,7 @@
+using System.Text.Json;
using Femto.Common.Domain;
using Femto.Modules.Blog.Domain.Posts.Events;
+using Femto.Modules.Blog.Emoji;
namespace Femto.Modules.Blog.Domain.Posts;
@@ -9,17 +11,62 @@ internal class Post : Entity
public Guid AuthorId { get; private set; }
public string Content { get; private set; } = null!;
public IList Media { get; private set; }
- public bool IsPublic { get; set; }
+
+ public IList Reactions { get; private set; } = [];
+
+ public IList Comments { get; private set; } = [];
+ public bool IsPublic { get; private set; }
+
+ public DateTimeOffset PostedOn { get; private set; }
+
+ private string PossibleReactionsJson { get; set; } = null!;
+
+ public IEnumerable PossibleReactions
+ {
+ get => JsonSerializer.Deserialize>(this.PossibleReactionsJson)!;
+ init => PossibleReactionsJson = JsonSerializer.Serialize(value);
+ }
private Post() { }
- public Post(Guid authorId, string content, IList media)
+ public Post(Guid authorId, string content, IList media, bool isPublic)
{
this.Id = Guid.CreateVersion7();
this.AuthorId = authorId;
this.Content = content;
this.Media = media;
+ this.PossibleReactions = AllEmoji.GetRandomEmoji(5);
+ this.PostedOn = DateTimeOffset.UtcNow;
+ this.IsPublic = isPublic;
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));
+ }
}
diff --git a/Femto.Modules.Blog/Domain/Posts/PostComment.cs b/Femto.Modules.Blog/Domain/Posts/PostComment.cs
new file mode 100644
index 0000000..6f658a8
--- /dev/null
+++ b/Femto.Modules.Blog/Domain/Posts/PostComment.cs
@@ -0,0 +1,19 @@
+namespace Femto.Modules.Blog.Domain.Posts;
+
+internal class PostComment
+{
+ public Guid Id { get; private set; }
+ public Guid AuthorId { get; private set; }
+ public DateTimeOffset CreatedAt { get; private set; }
+ public string Content { get; private set; }
+
+ private PostComment() {}
+
+ public PostComment(Guid authorId, string content)
+ {
+ this.Id = Guid.CreateVersion7();
+ this.AuthorId = authorId;
+ this.Content = content;
+ this.CreatedAt = TimeProvider.System.GetUtcNow();
+ }
+}
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Domain/Posts/PostReaction.cs b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs
new file mode 100644
index 0000000..38e33b8
--- /dev/null
+++ b/Femto.Modules.Blog/Domain/Posts/PostReaction.cs
@@ -0,0 +1,18 @@
+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() { }
+}
\ No newline at end of file
diff --git a/Femto.Modules.Blog/Emoji/GetRandomEmoji.cs b/Femto.Modules.Blog/Emoji/GetRandomEmoji.cs
new file mode 100644
index 0000000..8fa7928
--- /dev/null
+++ b/Femto.Modules.Blog/Emoji/GetRandomEmoji.cs
@@ -0,0 +1,18 @@
+namespace Femto.Modules.Blog.Emoji;
+
+internal static partial class AllEmoji
+{
+ public static IList GetRandomEmoji(int count) => new Random().TakeRandomly(Emojis).Distinct().Take(count).ToList();
+}
+
+internal static class RandomExtensions
+{
+ public static IEnumerable TakeRandomly(this Random rand, ICollection collection)
+ {
+ while (true)
+ {
+ var idx = rand.Next(collection.Count);
+ yield return collection.ElementAt(idx);
+ }
+ }
+}
diff --git a/Femto.Modules.Blog/Emoji/ListOfEmoji.cs b/Femto.Modules.Blog/Emoji/ListOfEmoji.cs
new file mode 100644
index 0000000..f3ad8b5
--- /dev/null
+++ b/Femto.Modules.Blog/Emoji/ListOfEmoji.cs
@@ -0,0 +1,3789 @@
+namespace Femto.Modules.Blog.Emoji;
+
+internal static partial class AllEmoji
+{
+ public static readonly string[] Emojis =
+ [
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐
",
+ "๐คฃ",
+ "๐",
+ "๐",
+ "๐",
+ "๐ซ ",
+ "๐",
+ "๐",
+ "๐",
+ "๐ฅฐ",
+ "๐",
+ "๐คฉ",
+ "๐",
+ "๐",
+ "โบ๏ธ",
+ "๐",
+ "๐",
+ "๐ฅฒ",
+ "๐",
+ "๐",
+ "๐",
+ "๐คช",
+ "๐",
+ "๐ค",
+ "๐ค",
+ "๐คญ",
+ "๐ซข",
+ "๐ซฃ",
+ "๐คซ",
+ "๐ค",
+ "๐ซก",
+ "๐ค",
+ "๐คจ",
+ "๐",
+ "๐",
+ "๐ถ",
+ "๐ซฅ",
+ "๐ถโ๐ซ๏ธ",
+ "๐",
+ "๐",
+ "๐",
+ "๐ฌ",
+ "๐ฎโ๐จ",
+ "๐คฅ",
+ "๐ซจ",
+ "๐โโ๏ธ",
+ "๐โโ๏ธ",
+ "๐",
+ "๐",
+ "๐ช",
+ "๐คค",
+ "๐ด",
+ "๐ซฉ",
+ "๐ท",
+ "๐ค",
+ "๐ค",
+ "๐คข",
+ "๐คฎ",
+ "๐คง",
+ "๐ฅต",
+ "๐ฅถ",
+ "๐ฅด",
+ "๐ต",
+ "๐ตโ๐ซ",
+ "๐คฏ",
+ "๐ค ",
+ "๐ฅณ",
+ "๐ฅธ",
+ "๐",
+ "๐ค",
+ "๐ง",
+ "๐",
+ "๐ซค",
+ "๐",
+ "๐",
+ "โน๏ธ",
+ "๐ฎ",
+ "๐ฏ",
+ "๐ฒ",
+ "๐ณ",
+ "๐ฅบ",
+ "๐ฅน",
+ "๐ฆ",
+ "๐ง",
+ "๐จ",
+ "๐ฐ",
+ "๐ฅ",
+ "๐ข",
+ "๐ญ",
+ "๐ฑ",
+ "๐",
+ "๐ฃ",
+ "๐",
+ "๐",
+ "๐ฉ",
+ "๐ซ",
+ "๐ฅฑ",
+ "๐ค",
+ "๐ก",
+ "๐ ",
+ "๐คฌ",
+ "๐",
+ "๐ฟ",
+ "๐",
+ "โ ๏ธ",
+ "๐ฉ",
+ "๐คก",
+ "๐น",
+ "๐บ",
+ "๐ป",
+ "๐ฝ",
+ "๐พ",
+ "๐ค",
+ "๐บ",
+ "๐ธ",
+ "๐น",
+ "๐ป",
+ "๐ผ",
+ "๐ฝ",
+ "๐",
+ "๐ฟ",
+ "๐พ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "โฃ๏ธ",
+ "๐",
+ "โค๏ธโ๐ฅ",
+ "โค๏ธโ๐ฉน",
+ "โค๏ธ",
+ "๐ฉท",
+ "๐งก",
+ "๐",
+ "๐",
+ "๐",
+ "๐ฉต",
+ "๐",
+ "๐ค",
+ "๐ค",
+ "๐ฉถ",
+ "๐ค",
+ "๐",
+ "๐ฏ",
+ "๐ข",
+ "๐ฅ",
+ "๐ซ",
+ "๐ฆ",
+ "๐จ",
+ "๐ณ๏ธ",
+ "๐ฌ",
+ "๐๏ธโ๐จ๏ธ",
+ "๐จ๏ธ",
+ "๐ฏ๏ธ",
+ "๐ญ",
+ "๐ค",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐ค",
+ "๐ค๐ป",
+ "๐ค๐ผ",
+ "๐ค๐ฝ",
+ "๐ค๐พ",
+ "๐ค๐ฟ",
+ "๐๏ธ",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "โ",
+ "โ๐ป",
+ "โ๐ผ",
+ "โ๐ฝ",
+ "โ๐พ",
+ "โ๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐ซฑ",
+ "๐ซฑ๐ป",
+ "๐ซฑ๐ผ",
+ "๐ซฑ๐ฝ",
+ "๐ซฑ๐พ",
+ "๐ซฑ๐ฟ",
+ "๐ซฒ",
+ "๐ซฒ๐ป",
+ "๐ซฒ๐ผ",
+ "๐ซฒ๐ฝ",
+ "๐ซฒ๐พ",
+ "๐ซฒ๐ฟ",
+ "๐ซณ",
+ "๐ซณ๐ป",
+ "๐ซณ๐ผ",
+ "๐ซณ๐ฝ",
+ "๐ซณ๐พ",
+ "๐ซณ๐ฟ",
+ "๐ซด",
+ "๐ซด๐ป",
+ "๐ซด๐ผ",
+ "๐ซด๐ฝ",
+ "๐ซด๐พ",
+ "๐ซด๐ฟ",
+ "๐ซท",
+ "๐ซท๐ป",
+ "๐ซท๐ผ",
+ "๐ซท๐ฝ",
+ "๐ซท๐พ",
+ "๐ซท๐ฟ",
+ "๐ซธ",
+ "๐ซธ๐ป",
+ "๐ซธ๐ผ",
+ "๐ซธ๐ฝ",
+ "๐ซธ๐พ",
+ "๐ซธ๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐ค",
+ "๐ค๐ป",
+ "๐ค๐ผ",
+ "๐ค๐ฝ",
+ "๐ค๐พ",
+ "๐ค๐ฟ",
+ "๐ค",
+ "๐ค๐ป",
+ "๐ค๐ผ",
+ "๐ค๐ฝ",
+ "๐ค๐พ",
+ "๐ค๐ฟ",
+ "โ๏ธ",
+ "โ๐ป",
+ "โ๐ผ",
+ "โ๐ฝ",
+ "โ๐พ",
+ "โ๐ฟ",
+ "๐ค",
+ "๐ค๐ป",
+ "๐ค๐ผ",
+ "๐ค๐ฝ",
+ "๐ค๐พ",
+ "๐ค๐ฟ",
+ "๐ซฐ",
+ "๐ซฐ๐ป",
+ "๐ซฐ๐ผ",
+ "๐ซฐ๐ฝ",
+ "๐ซฐ๐พ",
+ "๐ซฐ๐ฟ",
+ "๐ค",
+ "๐ค๐ป",
+ "๐ค๐ผ",
+ "๐ค๐ฝ",
+ "๐ค๐พ",
+ "๐ค๐ฟ",
+ "๐ค",
+ "๐ค๐ป",
+ "๐ค๐ผ",
+ "๐ค๐ฝ",
+ "๐ค๐พ",
+ "๐ค๐ฟ",
+ "๐ค",
+ "๐ค๐ป",
+ "๐ค๐ผ",
+ "๐ค๐ฝ",
+ "๐ค๐พ",
+ "๐ค๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "โ๏ธ",
+ "โ๐ป",
+ "โ๐ผ",
+ "โ๐ฝ",
+ "โ๐พ",
+ "โ๐ฟ",
+ "๐ซต",
+ "๐ซต๐ป",
+ "๐ซต๐ผ",
+ "๐ซต๐ฝ",
+ "๐ซต๐พ",
+ "๐ซต๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "โ",
+ "โ๐ป",
+ "โ๐ผ",
+ "โ๐ฝ",
+ "โ๐พ",
+ "โ๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐ค",
+ "๐ค๐ป",
+ "๐ค๐ผ",
+ "๐ค๐ฝ",
+ "๐ค๐พ",
+ "๐ค๐ฟ",
+ "๐ค",
+ "๐ค๐ป",
+ "๐ค๐ผ",
+ "๐ค๐ฝ",
+ "๐ค๐พ",
+ "๐ค๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐ซถ",
+ "๐ซถ๐ป",
+ "๐ซถ๐ผ",
+ "๐ซถ๐ฝ",
+ "๐ซถ๐พ",
+ "๐ซถ๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐คฒ",
+ "๐คฒ๐ป",
+ "๐คฒ๐ผ",
+ "๐คฒ๐ฝ",
+ "๐คฒ๐พ",
+ "๐คฒ๐ฟ",
+ "๐ค",
+ "๐ค๐ป",
+ "๐ค๐ผ",
+ "๐ค๐ฝ",
+ "๐ค๐พ",
+ "๐ค๐ฟ",
+ "๐ซฑ๐ปโ๐ซฒ๐ผ",
+ "๐ซฑ๐ปโ๐ซฒ๐ฝ",
+ "๐ซฑ๐ปโ๐ซฒ๐พ",
+ "๐ซฑ๐ปโ๐ซฒ๐ฟ",
+ "๐ซฑ๐ผโ๐ซฒ๐ป",
+ "๐ซฑ๐ผโ๐ซฒ๐ฝ",
+ "๐ซฑ๐ผโ๐ซฒ๐พ",
+ "๐ซฑ๐ผโ๐ซฒ๐ฟ",
+ "๐ซฑ๐ฝโ๐ซฒ๐ป",
+ "๐ซฑ๐ฝโ๐ซฒ๐ผ",
+ "๐ซฑ๐ฝโ๐ซฒ๐พ",
+ "๐ซฑ๐ฝโ๐ซฒ๐ฟ",
+ "๐ซฑ๐พโ๐ซฒ๐ป",
+ "๐ซฑ๐พโ๐ซฒ๐ผ",
+ "๐ซฑ๐พโ๐ซฒ๐ฝ",
+ "๐ซฑ๐พโ๐ซฒ๐ฟ",
+ "๐ซฑ๐ฟโ๐ซฒ๐ป",
+ "๐ซฑ๐ฟโ๐ซฒ๐ผ",
+ "๐ซฑ๐ฟโ๐ซฒ๐ฝ",
+ "๐ซฑ๐ฟโ๐ซฒ๐พ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "โ๏ธ",
+ "โ๐ป",
+ "โ๐ผ",
+ "โ๐ฝ",
+ "โ๐พ",
+ "โ๐ฟ",
+ "๐
",
+ "๐
๐ป",
+ "๐
๐ผ",
+ "๐
๐ฝ",
+ "๐
๐พ",
+ "๐
๐ฟ",
+ "๐คณ",
+ "๐คณ๐ป",
+ "๐คณ๐ผ",
+ "๐คณ๐ฝ",
+ "๐คณ๐พ",
+ "๐คณ๐ฟ",
+ "๐ช",
+ "๐ช๐ป",
+ "๐ช๐ผ",
+ "๐ช๐ฝ",
+ "๐ช๐พ",
+ "๐ช๐ฟ",
+ "๐ฆพ",
+ "๐ฆฟ",
+ "๐ฆต",
+ "๐ฆต๐ป",
+ "๐ฆต๐ผ",
+ "๐ฆต๐ฝ",
+ "๐ฆต๐พ",
+ "๐ฆต๐ฟ",
+ "๐ฆถ",
+ "๐ฆถ๐ป",
+ "๐ฆถ๐ผ",
+ "๐ฆถ๐ฝ",
+ "๐ฆถ๐พ",
+ "๐ฆถ๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐ฆป",
+ "๐ฆป๐ป",
+ "๐ฆป๐ผ",
+ "๐ฆป๐ฝ",
+ "๐ฆป๐พ",
+ "๐ฆป๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐ง ",
+ "๐ซ",
+ "๐ซ",
+ "๐ฆท",
+ "๐ฆด",
+ "๐",
+ "๐๏ธ",
+ "๐
",
+ "๐",
+ "๐ซฆ",
+ "๐ถ",
+ "๐ถ๐ป",
+ "๐ถ๐ผ",
+ "๐ถ๐ฝ",
+ "๐ถ๐พ",
+ "๐ถ๐ฟ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐ฆ",
+ "๐ฆ๐ป",
+ "๐ฆ๐ผ",
+ "๐ฆ๐ฝ",
+ "๐ฆ๐พ",
+ "๐ฆ๐ฟ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐ฑ",
+ "๐ฑ๐ป",
+ "๐ฑ๐ผ",
+ "๐ฑ๐ฝ",
+ "๐ฑ๐พ",
+ "๐ฑ๐ฟ",
+ "๐จ",
+ "๐จ๐ป",
+ "๐จ๐ผ",
+ "๐จ๐ฝ",
+ "๐จ๐พ",
+ "๐จ๐ฟ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐จโ๐ฆฐ",
+ "๐จ๐ปโ๐ฆฐ",
+ "๐จ๐ผโ๐ฆฐ",
+ "๐จ๐ฝโ๐ฆฐ",
+ "๐จ๐พโ๐ฆฐ",
+ "๐จ๐ฟโ๐ฆฐ",
+ "๐จโ๐ฆฑ",
+ "๐จ๐ปโ๐ฆฑ",
+ "๐จ๐ผโ๐ฆฑ",
+ "๐จ๐ฝโ๐ฆฑ",
+ "๐จ๐พโ๐ฆฑ",
+ "๐จ๐ฟโ๐ฆฑ",
+ "๐จโ๐ฆณ",
+ "๐จ๐ปโ๐ฆณ",
+ "๐จ๐ผโ๐ฆณ",
+ "๐จ๐ฝโ๐ฆณ",
+ "๐จ๐พโ๐ฆณ",
+ "๐จ๐ฟโ๐ฆณ",
+ "๐จโ๐ฆฒ",
+ "๐จ๐ปโ๐ฆฒ",
+ "๐จ๐ผโ๐ฆฒ",
+ "๐จ๐ฝโ๐ฆฒ",
+ "๐จ๐พโ๐ฆฒ",
+ "๐จ๐ฟโ๐ฆฒ",
+ "๐ฉ",
+ "๐ฉ๐ป",
+ "๐ฉ๐ผ",
+ "๐ฉ๐ฝ",
+ "๐ฉ๐พ",
+ "๐ฉ๐ฟ",
+ "๐ฉโ๐ฆฐ",
+ "๐ฉ๐ปโ๐ฆฐ",
+ "๐ฉ๐ผโ๐ฆฐ",
+ "๐ฉ๐ฝโ๐ฆฐ",
+ "๐ฉ๐พโ๐ฆฐ",
+ "๐ฉ๐ฟโ๐ฆฐ",
+ "๐งโ๐ฆฐ",
+ "๐ง๐ปโ๐ฆฐ",
+ "๐ง๐ผโ๐ฆฐ",
+ "๐ง๐ฝโ๐ฆฐ",
+ "๐ง๐พโ๐ฆฐ",
+ "๐ง๐ฟโ๐ฆฐ",
+ "๐ฉโ๐ฆฑ",
+ "๐ฉ๐ปโ๐ฆฑ",
+ "๐ฉ๐ผโ๐ฆฑ",
+ "๐ฉ๐ฝโ๐ฆฑ",
+ "๐ฉ๐พโ๐ฆฑ",
+ "๐ฉ๐ฟโ๐ฆฑ",
+ "๐งโ๐ฆฑ",
+ "๐ง๐ปโ๐ฆฑ",
+ "๐ง๐ผโ๐ฆฑ",
+ "๐ง๐ฝโ๐ฆฑ",
+ "๐ง๐พโ๐ฆฑ",
+ "๐ง๐ฟโ๐ฆฑ",
+ "๐ฉโ๐ฆณ",
+ "๐ฉ๐ปโ๐ฆณ",
+ "๐ฉ๐ผโ๐ฆณ",
+ "๐ฉ๐ฝโ๐ฆณ",
+ "๐ฉ๐พโ๐ฆณ",
+ "๐ฉ๐ฟโ๐ฆณ",
+ "๐งโ๐ฆณ",
+ "๐ง๐ปโ๐ฆณ",
+ "๐ง๐ผโ๐ฆณ",
+ "๐ง๐ฝโ๐ฆณ",
+ "๐ง๐พโ๐ฆณ",
+ "๐ง๐ฟโ๐ฆณ",
+ "๐ฉโ๐ฆฒ",
+ "๐ฉ๐ปโ๐ฆฒ",
+ "๐ฉ๐ผโ๐ฆฒ",
+ "๐ฉ๐ฝโ๐ฆฒ",
+ "๐ฉ๐พโ๐ฆฒ",
+ "๐ฉ๐ฟโ๐ฆฒ",
+ "๐งโ๐ฆฒ",
+ "๐ง๐ปโ๐ฆฒ",
+ "๐ง๐ผโ๐ฆฒ",
+ "๐ง๐ฝโ๐ฆฒ",
+ "๐ง๐พโ๐ฆฒ",
+ "๐ง๐ฟโ๐ฆฒ",
+ "๐ฑโโ๏ธ",
+ "๐ฑ๐ปโโ๏ธ",
+ "๐ฑ๐ผโโ๏ธ",
+ "๐ฑ๐ฝโโ๏ธ",
+ "๐ฑ๐พโโ๏ธ",
+ "๐ฑ๐ฟโโ๏ธ",
+ "๐ฑโโ๏ธ",
+ "๐ฑ๐ปโโ๏ธ",
+ "๐ฑ๐ผโโ๏ธ",
+ "๐ฑ๐ฝโโ๏ธ",
+ "๐ฑ๐พโโ๏ธ",
+ "๐ฑ๐ฟโโ๏ธ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐ด",
+ "๐ด๐ป",
+ "๐ด๐ผ",
+ "๐ด๐ฝ",
+ "๐ด๐พ",
+ "๐ด๐ฟ",
+ "๐ต",
+ "๐ต๐ป",
+ "๐ต๐ผ",
+ "๐ต๐ฝ",
+ "๐ต๐พ",
+ "๐ต๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐
",
+ "๐
๐ป",
+ "๐
๐ผ",
+ "๐
๐ฝ",
+ "๐
๐พ",
+ "๐
๐ฟ",
+ "๐
โโ๏ธ",
+ "๐
๐ปโโ๏ธ",
+ "๐
๐ผโโ๏ธ",
+ "๐
๐ฝโโ๏ธ",
+ "๐
๐พโโ๏ธ",
+ "๐
๐ฟโโ๏ธ",
+ "๐
โโ๏ธ",
+ "๐
๐ปโโ๏ธ",
+ "๐
๐ผโโ๏ธ",
+ "๐
๐ฝโโ๏ธ",
+ "๐
๐พโโ๏ธ",
+ "๐
๐ฟโโ๏ธ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐คฆ",
+ "๐คฆ๐ป",
+ "๐คฆ๐ผ",
+ "๐คฆ๐ฝ",
+ "๐คฆ๐พ",
+ "๐คฆ๐ฟ",
+ "๐คฆโโ๏ธ",
+ "๐คฆ๐ปโโ๏ธ",
+ "๐คฆ๐ผโโ๏ธ",
+ "๐คฆ๐ฝโโ๏ธ",
+ "๐คฆ๐พโโ๏ธ",
+ "๐คฆ๐ฟโโ๏ธ",
+ "๐คฆโโ๏ธ",
+ "๐คฆ๐ปโโ๏ธ",
+ "๐คฆ๐ผโโ๏ธ",
+ "๐คฆ๐ฝโโ๏ธ",
+ "๐คฆ๐พโโ๏ธ",
+ "๐คฆ๐ฟโโ๏ธ",
+ "๐คท",
+ "๐คท๐ป",
+ "๐คท๐ผ",
+ "๐คท๐ฝ",
+ "๐คท๐พ",
+ "๐คท๐ฟ",
+ "๐คทโโ๏ธ",
+ "๐คท๐ปโโ๏ธ",
+ "๐คท๐ผโโ๏ธ",
+ "๐คท๐ฝโโ๏ธ",
+ "๐คท๐พโโ๏ธ",
+ "๐คท๐ฟโโ๏ธ",
+ "๐คทโโ๏ธ",
+ "๐คท๐ปโโ๏ธ",
+ "๐คท๐ผโโ๏ธ",
+ "๐คท๐ฝโโ๏ธ",
+ "๐คท๐พโโ๏ธ",
+ "๐คท๐ฟโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐จโโ๏ธ",
+ "๐จ๐ปโโ๏ธ",
+ "๐จ๐ผโโ๏ธ",
+ "๐จ๐ฝโโ๏ธ",
+ "๐จ๐พโโ๏ธ",
+ "๐จ๐ฟโโ๏ธ",
+ "๐ฉโโ๏ธ",
+ "๐ฉ๐ปโโ๏ธ",
+ "๐ฉ๐ผโโ๏ธ",
+ "๐ฉ๐ฝโโ๏ธ",
+ "๐ฉ๐พโโ๏ธ",
+ "๐ฉ๐ฟโโ๏ธ",
+ "๐งโ๐",
+ "๐ง๐ปโ๐",
+ "๐ง๐ผโ๐",
+ "๐ง๐ฝโ๐",
+ "๐ง๐พโ๐",
+ "๐ง๐ฟโ๐",
+ "๐จโ๐",
+ "๐จ๐ปโ๐",
+ "๐จ๐ผโ๐",
+ "๐จ๐ฝโ๐",
+ "๐จ๐พโ๐",
+ "๐จ๐ฟโ๐",
+ "๐ฉโ๐",
+ "๐ฉ๐ปโ๐",
+ "๐ฉ๐ผโ๐",
+ "๐ฉ๐ฝโ๐",
+ "๐ฉ๐พโ๐",
+ "๐ฉ๐ฟโ๐",
+ "๐งโ๐ซ",
+ "๐ง๐ปโ๐ซ",
+ "๐ง๐ผโ๐ซ",
+ "๐ง๐ฝโ๐ซ",
+ "๐ง๐พโ๐ซ",
+ "๐ง๐ฟโ๐ซ",
+ "๐จโ๐ซ",
+ "๐จ๐ปโ๐ซ",
+ "๐จ๐ผโ๐ซ",
+ "๐จ๐ฝโ๐ซ",
+ "๐จ๐พโ๐ซ",
+ "๐จ๐ฟโ๐ซ",
+ "๐ฉโ๐ซ",
+ "๐ฉ๐ปโ๐ซ",
+ "๐ฉ๐ผโ๐ซ",
+ "๐ฉ๐ฝโ๐ซ",
+ "๐ฉ๐พโ๐ซ",
+ "๐ฉ๐ฟโ๐ซ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐จโโ๏ธ",
+ "๐จ๐ปโโ๏ธ",
+ "๐จ๐ผโโ๏ธ",
+ "๐จ๐ฝโโ๏ธ",
+ "๐จ๐พโโ๏ธ",
+ "๐จ๐ฟโโ๏ธ",
+ "๐ฉโโ๏ธ",
+ "๐ฉ๐ปโโ๏ธ",
+ "๐ฉ๐ผโโ๏ธ",
+ "๐ฉ๐ฝโโ๏ธ",
+ "๐ฉ๐พโโ๏ธ",
+ "๐ฉ๐ฟโโ๏ธ",
+ "๐งโ๐พ",
+ "๐ง๐ปโ๐พ",
+ "๐ง๐ผโ๐พ",
+ "๐ง๐ฝโ๐พ",
+ "๐ง๐พโ๐พ",
+ "๐ง๐ฟโ๐พ",
+ "๐จโ๐พ",
+ "๐จ๐ปโ๐พ",
+ "๐จ๐ผโ๐พ",
+ "๐จ๐ฝโ๐พ",
+ "๐จ๐พโ๐พ",
+ "๐จ๐ฟโ๐พ",
+ "๐ฉโ๐พ",
+ "๐ฉ๐ปโ๐พ",
+ "๐ฉ๐ผโ๐พ",
+ "๐ฉ๐ฝโ๐พ",
+ "๐ฉ๐พโ๐พ",
+ "๐ฉ๐ฟโ๐พ",
+ "๐งโ๐ณ",
+ "๐ง๐ปโ๐ณ",
+ "๐ง๐ผโ๐ณ",
+ "๐ง๐ฝโ๐ณ",
+ "๐ง๐พโ๐ณ",
+ "๐ง๐ฟโ๐ณ",
+ "๐จโ๐ณ",
+ "๐จ๐ปโ๐ณ",
+ "๐จ๐ผโ๐ณ",
+ "๐จ๐ฝโ๐ณ",
+ "๐จ๐พโ๐ณ",
+ "๐จ๐ฟโ๐ณ",
+ "๐ฉโ๐ณ",
+ "๐ฉ๐ปโ๐ณ",
+ "๐ฉ๐ผโ๐ณ",
+ "๐ฉ๐ฝโ๐ณ",
+ "๐ฉ๐พโ๐ณ",
+ "๐ฉ๐ฟโ๐ณ",
+ "๐งโ๐ง",
+ "๐ง๐ปโ๐ง",
+ "๐ง๐ผโ๐ง",
+ "๐ง๐ฝโ๐ง",
+ "๐ง๐พโ๐ง",
+ "๐ง๐ฟโ๐ง",
+ "๐จโ๐ง",
+ "๐จ๐ปโ๐ง",
+ "๐จ๐ผโ๐ง",
+ "๐จ๐ฝโ๐ง",
+ "๐จ๐พโ๐ง",
+ "๐จ๐ฟโ๐ง",
+ "๐ฉโ๐ง",
+ "๐ฉ๐ปโ๐ง",
+ "๐ฉ๐ผโ๐ง",
+ "๐ฉ๐ฝโ๐ง",
+ "๐ฉ๐พโ๐ง",
+ "๐ฉ๐ฟโ๐ง",
+ "๐งโ๐ญ",
+ "๐ง๐ปโ๐ญ",
+ "๐ง๐ผโ๐ญ",
+ "๐ง๐ฝโ๐ญ",
+ "๐ง๐พโ๐ญ",
+ "๐ง๐ฟโ๐ญ",
+ "๐จโ๐ญ",
+ "๐จ๐ปโ๐ญ",
+ "๐จ๐ผโ๐ญ",
+ "๐จ๐ฝโ๐ญ",
+ "๐จ๐พโ๐ญ",
+ "๐จ๐ฟโ๐ญ",
+ "๐ฉโ๐ญ",
+ "๐ฉ๐ปโ๐ญ",
+ "๐ฉ๐ผโ๐ญ",
+ "๐ฉ๐ฝโ๐ญ",
+ "๐ฉ๐พโ๐ญ",
+ "๐ฉ๐ฟโ๐ญ",
+ "๐งโ๐ผ",
+ "๐ง๐ปโ๐ผ",
+ "๐ง๐ผโ๐ผ",
+ "๐ง๐ฝโ๐ผ",
+ "๐ง๐พโ๐ผ",
+ "๐ง๐ฟโ๐ผ",
+ "๐จโ๐ผ",
+ "๐จ๐ปโ๐ผ",
+ "๐จ๐ผโ๐ผ",
+ "๐จ๐ฝโ๐ผ",
+ "๐จ๐พโ๐ผ",
+ "๐จ๐ฟโ๐ผ",
+ "๐ฉโ๐ผ",
+ "๐ฉ๐ปโ๐ผ",
+ "๐ฉ๐ผโ๐ผ",
+ "๐ฉ๐ฝโ๐ผ",
+ "๐ฉ๐พโ๐ผ",
+ "๐ฉ๐ฟโ๐ผ",
+ "๐งโ๐ฌ",
+ "๐ง๐ปโ๐ฌ",
+ "๐ง๐ผโ๐ฌ",
+ "๐ง๐ฝโ๐ฌ",
+ "๐ง๐พโ๐ฌ",
+ "๐ง๐ฟโ๐ฌ",
+ "๐จโ๐ฌ",
+ "๐จ๐ปโ๐ฌ",
+ "๐จ๐ผโ๐ฌ",
+ "๐จ๐ฝโ๐ฌ",
+ "๐จ๐พโ๐ฌ",
+ "๐จ๐ฟโ๐ฌ",
+ "๐ฉโ๐ฌ",
+ "๐ฉ๐ปโ๐ฌ",
+ "๐ฉ๐ผโ๐ฌ",
+ "๐ฉ๐ฝโ๐ฌ",
+ "๐ฉ๐พโ๐ฌ",
+ "๐ฉ๐ฟโ๐ฌ",
+ "๐งโ๐ป",
+ "๐ง๐ปโ๐ป",
+ "๐ง๐ผโ๐ป",
+ "๐ง๐ฝโ๐ป",
+ "๐ง๐พโ๐ป",
+ "๐ง๐ฟโ๐ป",
+ "๐จโ๐ป",
+ "๐จ๐ปโ๐ป",
+ "๐จ๐ผโ๐ป",
+ "๐จ๐ฝโ๐ป",
+ "๐จ๐พโ๐ป",
+ "๐จ๐ฟโ๐ป",
+ "๐ฉโ๐ป",
+ "๐ฉ๐ปโ๐ป",
+ "๐ฉ๐ผโ๐ป",
+ "๐ฉ๐ฝโ๐ป",
+ "๐ฉ๐พโ๐ป",
+ "๐ฉ๐ฟโ๐ป",
+ "๐งโ๐ค",
+ "๐ง๐ปโ๐ค",
+ "๐ง๐ผโ๐ค",
+ "๐ง๐ฝโ๐ค",
+ "๐ง๐พโ๐ค",
+ "๐ง๐ฟโ๐ค",
+ "๐จโ๐ค",
+ "๐จ๐ปโ๐ค",
+ "๐จ๐ผโ๐ค",
+ "๐จ๐ฝโ๐ค",
+ "๐จ๐พโ๐ค",
+ "๐จ๐ฟโ๐ค",
+ "๐ฉโ๐ค",
+ "๐ฉ๐ปโ๐ค",
+ "๐ฉ๐ผโ๐ค",
+ "๐ฉ๐ฝโ๐ค",
+ "๐ฉ๐พโ๐ค",
+ "๐ฉ๐ฟโ๐ค",
+ "๐งโ๐จ",
+ "๐ง๐ปโ๐จ",
+ "๐ง๐ผโ๐จ",
+ "๐ง๐ฝโ๐จ",
+ "๐ง๐พโ๐จ",
+ "๐ง๐ฟโ๐จ",
+ "๐จโ๐จ",
+ "๐จ๐ปโ๐จ",
+ "๐จ๐ผโ๐จ",
+ "๐จ๐ฝโ๐จ",
+ "๐จ๐พโ๐จ",
+ "๐จ๐ฟโ๐จ",
+ "๐ฉโ๐จ",
+ "๐ฉ๐ปโ๐จ",
+ "๐ฉ๐ผโ๐จ",
+ "๐ฉ๐ฝโ๐จ",
+ "๐ฉ๐พโ๐จ",
+ "๐ฉ๐ฟโ๐จ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐จโโ๏ธ",
+ "๐จ๐ปโโ๏ธ",
+ "๐จ๐ผโโ๏ธ",
+ "๐จ๐ฝโโ๏ธ",
+ "๐จ๐พโโ๏ธ",
+ "๐จ๐ฟโโ๏ธ",
+ "๐ฉโโ๏ธ",
+ "๐ฉ๐ปโโ๏ธ",
+ "๐ฉ๐ผโโ๏ธ",
+ "๐ฉ๐ฝโโ๏ธ",
+ "๐ฉ๐พโโ๏ธ",
+ "๐ฉ๐ฟโโ๏ธ",
+ "๐งโ๐",
+ "๐ง๐ปโ๐",
+ "๐ง๐ผโ๐",
+ "๐ง๐ฝโ๐",
+ "๐ง๐พโ๐",
+ "๐ง๐ฟโ๐",
+ "๐จโ๐",
+ "๐จ๐ปโ๐",
+ "๐จ๐ผโ๐",
+ "๐จ๐ฝโ๐",
+ "๐จ๐พโ๐",
+ "๐จ๐ฟโ๐",
+ "๐ฉโ๐",
+ "๐ฉ๐ปโ๐",
+ "๐ฉ๐ผโ๐",
+ "๐ฉ๐ฝโ๐",
+ "๐ฉ๐พโ๐",
+ "๐ฉ๐ฟโ๐",
+ "๐งโ๐",
+ "๐ง๐ปโ๐",
+ "๐ง๐ผโ๐",
+ "๐ง๐ฝโ๐",
+ "๐ง๐พโ๐",
+ "๐ง๐ฟโ๐",
+ "๐จโ๐",
+ "๐จ๐ปโ๐",
+ "๐จ๐ผโ๐",
+ "๐จ๐ฝโ๐",
+ "๐จ๐พโ๐",
+ "๐จ๐ฟโ๐",
+ "๐ฉโ๐",
+ "๐ฉ๐ปโ๐",
+ "๐ฉ๐ผโ๐",
+ "๐ฉ๐ฝโ๐",
+ "๐ฉ๐พโ๐",
+ "๐ฉ๐ฟโ๐",
+ "๐ฎ",
+ "๐ฎ๐ป",
+ "๐ฎ๐ผ",
+ "๐ฎ๐ฝ",
+ "๐ฎ๐พ",
+ "๐ฎ๐ฟ",
+ "๐ฎโโ๏ธ",
+ "๐ฎ๐ปโโ๏ธ",
+ "๐ฎ๐ผโโ๏ธ",
+ "๐ฎ๐ฝโโ๏ธ",
+ "๐ฎ๐พโโ๏ธ",
+ "๐ฎ๐ฟโโ๏ธ",
+ "๐ฎโโ๏ธ",
+ "๐ฎ๐ปโโ๏ธ",
+ "๐ฎ๐ผโโ๏ธ",
+ "๐ฎ๐ฝโโ๏ธ",
+ "๐ฎ๐พโโ๏ธ",
+ "๐ฎ๐ฟโโ๏ธ",
+ "๐ต๏ธ",
+ "๐ต๐ป",
+ "๐ต๐ผ",
+ "๐ต๐ฝ",
+ "๐ต๐พ",
+ "๐ต๐ฟ",
+ "๐ต๏ธโโ๏ธ",
+ "๐ต๐ปโโ๏ธ",
+ "๐ต๐ผโโ๏ธ",
+ "๐ต๐ฝโโ๏ธ",
+ "๐ต๐พโโ๏ธ",
+ "๐ต๐ฟโโ๏ธ",
+ "๐ต๏ธโโ๏ธ",
+ "๐ต๐ปโโ๏ธ",
+ "๐ต๐ผโโ๏ธ",
+ "๐ต๐ฝโโ๏ธ",
+ "๐ต๐พโโ๏ธ",
+ "๐ต๐ฟโโ๏ธ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐ฅท",
+ "๐ฅท๐ป",
+ "๐ฅท๐ผ",
+ "๐ฅท๐ฝ",
+ "๐ฅท๐พ",
+ "๐ฅท๐ฟ",
+ "๐ท",
+ "๐ท๐ป",
+ "๐ท๐ผ",
+ "๐ท๐ฝ",
+ "๐ท๐พ",
+ "๐ท๐ฟ",
+ "๐ทโโ๏ธ",
+ "๐ท๐ปโโ๏ธ",
+ "๐ท๐ผโโ๏ธ",
+ "๐ท๐ฝโโ๏ธ",
+ "๐ท๐พโโ๏ธ",
+ "๐ท๐ฟโโ๏ธ",
+ "๐ทโโ๏ธ",
+ "๐ท๐ปโโ๏ธ",
+ "๐ท๐ผโโ๏ธ",
+ "๐ท๐ฝโโ๏ธ",
+ "๐ท๐พโโ๏ธ",
+ "๐ท๐ฟโโ๏ธ",
+ "๐ซ
",
+ "๐ซ
๐ป",
+ "๐ซ
๐ผ",
+ "๐ซ
๐ฝ",
+ "๐ซ
๐พ",
+ "๐ซ
๐ฟ",
+ "๐คด",
+ "๐คด๐ป",
+ "๐คด๐ผ",
+ "๐คด๐ฝ",
+ "๐คด๐พ",
+ "๐คด๐ฟ",
+ "๐ธ",
+ "๐ธ๐ป",
+ "๐ธ๐ผ",
+ "๐ธ๐ฝ",
+ "๐ธ๐พ",
+ "๐ธ๐ฟ",
+ "๐ณ",
+ "๐ณ๐ป",
+ "๐ณ๐ผ",
+ "๐ณ๐ฝ",
+ "๐ณ๐พ",
+ "๐ณ๐ฟ",
+ "๐ณโโ๏ธ",
+ "๐ณ๐ปโโ๏ธ",
+ "๐ณ๐ผโโ๏ธ",
+ "๐ณ๐ฝโโ๏ธ",
+ "๐ณ๐พโโ๏ธ",
+ "๐ณ๐ฟโโ๏ธ",
+ "๐ณโโ๏ธ",
+ "๐ณ๐ปโโ๏ธ",
+ "๐ณ๐ผโโ๏ธ",
+ "๐ณ๐ฝโโ๏ธ",
+ "๐ณ๐พโโ๏ธ",
+ "๐ณ๐ฟโโ๏ธ",
+ "๐ฒ",
+ "๐ฒ๐ป",
+ "๐ฒ๐ผ",
+ "๐ฒ๐ฝ",
+ "๐ฒ๐พ",
+ "๐ฒ๐ฟ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐คต",
+ "๐คต๐ป",
+ "๐คต๐ผ",
+ "๐คต๐ฝ",
+ "๐คต๐พ",
+ "๐คต๐ฟ",
+ "๐คตโโ๏ธ",
+ "๐คต๐ปโโ๏ธ",
+ "๐คต๐ผโโ๏ธ",
+ "๐คต๐ฝโโ๏ธ",
+ "๐คต๐พโโ๏ธ",
+ "๐คต๐ฟโโ๏ธ",
+ "๐คตโโ๏ธ",
+ "๐คต๐ปโโ๏ธ",
+ "๐คต๐ผโโ๏ธ",
+ "๐คต๐ฝโโ๏ธ",
+ "๐คต๐พโโ๏ธ",
+ "๐คต๐ฟโโ๏ธ",
+ "๐ฐ",
+ "๐ฐ๐ป",
+ "๐ฐ๐ผ",
+ "๐ฐ๐ฝ",
+ "๐ฐ๐พ",
+ "๐ฐ๐ฟ",
+ "๐ฐโโ๏ธ",
+ "๐ฐ๐ปโโ๏ธ",
+ "๐ฐ๐ผโโ๏ธ",
+ "๐ฐ๐ฝโโ๏ธ",
+ "๐ฐ๐พโโ๏ธ",
+ "๐ฐ๐ฟโโ๏ธ",
+ "๐ฐโโ๏ธ",
+ "๐ฐ๐ปโโ๏ธ",
+ "๐ฐ๐ผโโ๏ธ",
+ "๐ฐ๐ฝโโ๏ธ",
+ "๐ฐ๐พโโ๏ธ",
+ "๐ฐ๐ฟโโ๏ธ",
+ "๐คฐ",
+ "๐คฐ๐ป",
+ "๐คฐ๐ผ",
+ "๐คฐ๐ฝ",
+ "๐คฐ๐พ",
+ "๐คฐ๐ฟ",
+ "๐ซ",
+ "๐ซ๐ป",
+ "๐ซ๐ผ",
+ "๐ซ๐ฝ",
+ "๐ซ๐พ",
+ "๐ซ๐ฟ",
+ "๐ซ",
+ "๐ซ๐ป",
+ "๐ซ๐ผ",
+ "๐ซ๐ฝ",
+ "๐ซ๐พ",
+ "๐ซ๐ฟ",
+ "๐คฑ",
+ "๐คฑ๐ป",
+ "๐คฑ๐ผ",
+ "๐คฑ๐ฝ",
+ "๐คฑ๐พ",
+ "๐คฑ๐ฟ",
+ "๐ฉโ๐ผ",
+ "๐ฉ๐ปโ๐ผ",
+ "๐ฉ๐ผโ๐ผ",
+ "๐ฉ๐ฝโ๐ผ",
+ "๐ฉ๐พโ๐ผ",
+ "๐ฉ๐ฟโ๐ผ",
+ "๐จโ๐ผ",
+ "๐จ๐ปโ๐ผ",
+ "๐จ๐ผโ๐ผ",
+ "๐จ๐ฝโ๐ผ",
+ "๐จ๐พโ๐ผ",
+ "๐จ๐ฟโ๐ผ",
+ "๐งโ๐ผ",
+ "๐ง๐ปโ๐ผ",
+ "๐ง๐ผโ๐ผ",
+ "๐ง๐ฝโ๐ผ",
+ "๐ง๐พโ๐ผ",
+ "๐ง๐ฟโ๐ผ",
+ "๐ผ",
+ "๐ผ๐ป",
+ "๐ผ๐ผ",
+ "๐ผ๐ฝ",
+ "๐ผ๐พ",
+ "๐ผ๐ฟ",
+ "๐
",
+ "๐
๐ป",
+ "๐
๐ผ",
+ "๐
๐ฝ",
+ "๐
๐พ",
+ "๐
๐ฟ",
+ "๐คถ",
+ "๐คถ๐ป",
+ "๐คถ๐ผ",
+ "๐คถ๐ฝ",
+ "๐คถ๐พ",
+ "๐คถ๐ฟ",
+ "๐งโ๐",
+ "๐ง๐ปโ๐",
+ "๐ง๐ผโ๐",
+ "๐ง๐ฝโ๐",
+ "๐ง๐พโ๐",
+ "๐ง๐ฟโ๐",
+ "๐ฆธ",
+ "๐ฆธ๐ป",
+ "๐ฆธ๐ผ",
+ "๐ฆธ๐ฝ",
+ "๐ฆธ๐พ",
+ "๐ฆธ๐ฟ",
+ "๐ฆธโโ๏ธ",
+ "๐ฆธ๐ปโโ๏ธ",
+ "๐ฆธ๐ผโโ๏ธ",
+ "๐ฆธ๐ฝโโ๏ธ",
+ "๐ฆธ๐พโโ๏ธ",
+ "๐ฆธ๐ฟโโ๏ธ",
+ "๐ฆธโโ๏ธ",
+ "๐ฆธ๐ปโโ๏ธ",
+ "๐ฆธ๐ผโโ๏ธ",
+ "๐ฆธ๐ฝโโ๏ธ",
+ "๐ฆธ๐พโโ๏ธ",
+ "๐ฆธ๐ฟโโ๏ธ",
+ "๐ฆน",
+ "๐ฆน๐ป",
+ "๐ฆน๐ผ",
+ "๐ฆน๐ฝ",
+ "๐ฆน๐พ",
+ "๐ฆน๐ฟ",
+ "๐ฆนโโ๏ธ",
+ "๐ฆน๐ปโโ๏ธ",
+ "๐ฆน๐ผโโ๏ธ",
+ "๐ฆน๐ฝโโ๏ธ",
+ "๐ฆน๐พโโ๏ธ",
+ "๐ฆน๐ฟโโ๏ธ",
+ "๐ฆนโโ๏ธ",
+ "๐ฆน๐ปโโ๏ธ",
+ "๐ฆน๐ผโโ๏ธ",
+ "๐ฆน๐ฝโโ๏ธ",
+ "๐ฆน๐พโโ๏ธ",
+ "๐ฆน๐ฟโโ๏ธ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐ง",
+ "๐งโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง",
+ "๐งโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐ถ",
+ "๐ถ๐ป",
+ "๐ถ๐ผ",
+ "๐ถ๐ฝ",
+ "๐ถ๐พ",
+ "๐ถ๐ฟ",
+ "๐ถโโ๏ธ",
+ "๐ถ๐ปโโ๏ธ",
+ "๐ถ๐ผโโ๏ธ",
+ "๐ถ๐ฝโโ๏ธ",
+ "๐ถ๐พโโ๏ธ",
+ "๐ถ๐ฟโโ๏ธ",
+ "๐ถโโ๏ธ",
+ "๐ถ๐ปโโ๏ธ",
+ "๐ถ๐ผโโ๏ธ",
+ "๐ถ๐ฝโโ๏ธ",
+ "๐ถ๐พโโ๏ธ",
+ "๐ถ๐ฟโโ๏ธ",
+ "๐ถโโก๏ธ",
+ "๐ถ๐ปโโก๏ธ",
+ "๐ถ๐ผโโก๏ธ",
+ "๐ถ๐ฝโโก๏ธ",
+ "๐ถ๐พโโก๏ธ",
+ "๐ถ๐ฟโโก๏ธ",
+ "๐ถโโ๏ธโโก๏ธ",
+ "๐ถ๐ปโโ๏ธโโก๏ธ",
+ "๐ถ๐ผโโ๏ธโโก๏ธ",
+ "๐ถ๐ฝโโ๏ธโโก๏ธ",
+ "๐ถ๐พโโ๏ธโโก๏ธ",
+ "๐ถ๐ฟโโ๏ธโโก๏ธ",
+ "๐ถโโ๏ธโโก๏ธ",
+ "๐ถ๐ปโโ๏ธโโก๏ธ",
+ "๐ถ๐ผโโ๏ธโโก๏ธ",
+ "๐ถ๐ฝโโ๏ธโโก๏ธ",
+ "๐ถ๐พโโ๏ธโโก๏ธ",
+ "๐ถ๐ฟโโ๏ธโโก๏ธ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐งโโก๏ธ",
+ "๐ง๐ปโโก๏ธ",
+ "๐ง๐ผโโก๏ธ",
+ "๐ง๐ฝโโก๏ธ",
+ "๐ง๐พโโก๏ธ",
+ "๐ง๐ฟโโก๏ธ",
+ "๐งโโ๏ธโโก๏ธ",
+ "๐ง๐ปโโ๏ธโโก๏ธ",
+ "๐ง๐ผโโ๏ธโโก๏ธ",
+ "๐ง๐ฝโโ๏ธโโก๏ธ",
+ "๐ง๐พโโ๏ธโโก๏ธ",
+ "๐ง๐ฟโโ๏ธโโก๏ธ",
+ "๐งโโ๏ธโโก๏ธ",
+ "๐ง๐ปโโ๏ธโโก๏ธ",
+ "๐ง๐ผโโ๏ธโโก๏ธ",
+ "๐ง๐ฝโโ๏ธโโก๏ธ",
+ "๐ง๐พโโ๏ธโโก๏ธ",
+ "๐ง๐ฟโโ๏ธโโก๏ธ",
+ "๐งโ๐ฆฏ",
+ "๐ง๐ปโ๐ฆฏ",
+ "๐ง๐ผโ๐ฆฏ",
+ "๐ง๐ฝโ๐ฆฏ",
+ "๐ง๐พโ๐ฆฏ",
+ "๐ง๐ฟโ๐ฆฏ",
+ "๐งโ๐ฆฏโโก๏ธ",
+ "๐ง๐ปโ๐ฆฏโโก๏ธ",
+ "๐ง๐ผโ๐ฆฏโโก๏ธ",
+ "๐ง๐ฝโ๐ฆฏโโก๏ธ",
+ "๐ง๐พโ๐ฆฏโโก๏ธ",
+ "๐ง๐ฟโ๐ฆฏโโก๏ธ",
+ "๐จโ๐ฆฏ",
+ "๐จ๐ปโ๐ฆฏ",
+ "๐จ๐ผโ๐ฆฏ",
+ "๐จ๐ฝโ๐ฆฏ",
+ "๐จ๐พโ๐ฆฏ",
+ "๐จ๐ฟโ๐ฆฏ",
+ "๐จโ๐ฆฏโโก๏ธ",
+ "๐จ๐ปโ๐ฆฏโโก๏ธ",
+ "๐จ๐ผโ๐ฆฏโโก๏ธ",
+ "๐จ๐ฝโ๐ฆฏโโก๏ธ",
+ "๐จ๐พโ๐ฆฏโโก๏ธ",
+ "๐จ๐ฟโ๐ฆฏโโก๏ธ",
+ "๐ฉโ๐ฆฏ",
+ "๐ฉ๐ปโ๐ฆฏ",
+ "๐ฉ๐ผโ๐ฆฏ",
+ "๐ฉ๐ฝโ๐ฆฏ",
+ "๐ฉ๐พโ๐ฆฏ",
+ "๐ฉ๐ฟโ๐ฆฏ",
+ "๐ฉโ๐ฆฏโโก๏ธ",
+ "๐ฉ๐ปโ๐ฆฏโโก๏ธ",
+ "๐ฉ๐ผโ๐ฆฏโโก๏ธ",
+ "๐ฉ๐ฝโ๐ฆฏโโก๏ธ",
+ "๐ฉ๐พโ๐ฆฏโโก๏ธ",
+ "๐ฉ๐ฟโ๐ฆฏโโก๏ธ",
+ "๐งโ๐ฆผ",
+ "๐ง๐ปโ๐ฆผ",
+ "๐ง๐ผโ๐ฆผ",
+ "๐ง๐ฝโ๐ฆผ",
+ "๐ง๐พโ๐ฆผ",
+ "๐ง๐ฟโ๐ฆผ",
+ "๐งโ๐ฆผโโก๏ธ",
+ "๐ง๐ปโ๐ฆผโโก๏ธ",
+ "๐ง๐ผโ๐ฆผโโก๏ธ",
+ "๐ง๐ฝโ๐ฆผโโก๏ธ",
+ "๐ง๐พโ๐ฆผโโก๏ธ",
+ "๐ง๐ฟโ๐ฆผโโก๏ธ",
+ "๐จโ๐ฆผ",
+ "๐จ๐ปโ๐ฆผ",
+ "๐จ๐ผโ๐ฆผ",
+ "๐จ๐ฝโ๐ฆผ",
+ "๐จ๐พโ๐ฆผ",
+ "๐จ๐ฟโ๐ฆผ",
+ "๐จโ๐ฆผโโก๏ธ",
+ "๐จ๐ปโ๐ฆผโโก๏ธ",
+ "๐จ๐ผโ๐ฆผโโก๏ธ",
+ "๐จ๐ฝโ๐ฆผโโก๏ธ",
+ "๐จ๐พโ๐ฆผโโก๏ธ",
+ "๐จ๐ฟโ๐ฆผโโก๏ธ",
+ "๐ฉโ๐ฆผ",
+ "๐ฉ๐ปโ๐ฆผ",
+ "๐ฉ๐ผโ๐ฆผ",
+ "๐ฉ๐ฝโ๐ฆผ",
+ "๐ฉ๐พโ๐ฆผ",
+ "๐ฉ๐ฟโ๐ฆผ",
+ "๐ฉโ๐ฆผโโก๏ธ",
+ "๐ฉ๐ปโ๐ฆผโโก๏ธ",
+ "๐ฉ๐ผโ๐ฆผโโก๏ธ",
+ "๐ฉ๐ฝโ๐ฆผโโก๏ธ",
+ "๐ฉ๐พโ๐ฆผโโก๏ธ",
+ "๐ฉ๐ฟโ๐ฆผโโก๏ธ",
+ "๐งโ๐ฆฝ",
+ "๐ง๐ปโ๐ฆฝ",
+ "๐ง๐ผโ๐ฆฝ",
+ "๐ง๐ฝโ๐ฆฝ",
+ "๐ง๐พโ๐ฆฝ",
+ "๐ง๐ฟโ๐ฆฝ",
+ "๐งโ๐ฆฝโโก๏ธ",
+ "๐ง๐ปโ๐ฆฝโโก๏ธ",
+ "๐ง๐ผโ๐ฆฝโโก๏ธ",
+ "๐ง๐ฝโ๐ฆฝโโก๏ธ",
+ "๐ง๐พโ๐ฆฝโโก๏ธ",
+ "๐ง๐ฟโ๐ฆฝโโก๏ธ",
+ "๐จโ๐ฆฝ",
+ "๐จ๐ปโ๐ฆฝ",
+ "๐จ๐ผโ๐ฆฝ",
+ "๐จ๐ฝโ๐ฆฝ",
+ "๐จ๐พโ๐ฆฝ",
+ "๐จ๐ฟโ๐ฆฝ",
+ "๐จโ๐ฆฝโโก๏ธ",
+ "๐จ๐ปโ๐ฆฝโโก๏ธ",
+ "๐จ๐ผโ๐ฆฝโโก๏ธ",
+ "๐จ๐ฝโ๐ฆฝโโก๏ธ",
+ "๐จ๐พโ๐ฆฝโโก๏ธ",
+ "๐จ๐ฟโ๐ฆฝโโก๏ธ",
+ "๐ฉโ๐ฆฝ",
+ "๐ฉ๐ปโ๐ฆฝ",
+ "๐ฉ๐ผโ๐ฆฝ",
+ "๐ฉ๐ฝโ๐ฆฝ",
+ "๐ฉ๐พโ๐ฆฝ",
+ "๐ฉ๐ฟโ๐ฆฝ",
+ "๐ฉโ๐ฆฝโโก๏ธ",
+ "๐ฉ๐ปโ๐ฆฝโโก๏ธ",
+ "๐ฉ๐ผโ๐ฆฝโโก๏ธ",
+ "๐ฉ๐ฝโ๐ฆฝโโก๏ธ",
+ "๐ฉ๐พโ๐ฆฝโโก๏ธ",
+ "๐ฉ๐ฟโ๐ฆฝโโก๏ธ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐โโก๏ธ",
+ "๐๐ปโโก๏ธ",
+ "๐๐ผโโก๏ธ",
+ "๐๐ฝโโก๏ธ",
+ "๐๐พโโก๏ธ",
+ "๐๐ฟโโก๏ธ",
+ "๐โโ๏ธโโก๏ธ",
+ "๐๐ปโโ๏ธโโก๏ธ",
+ "๐๐ผโโ๏ธโโก๏ธ",
+ "๐๐ฝโโ๏ธโโก๏ธ",
+ "๐๐พโโ๏ธโโก๏ธ",
+ "๐๐ฟโโ๏ธโโก๏ธ",
+ "๐โโ๏ธโโก๏ธ",
+ "๐๐ปโโ๏ธโโก๏ธ",
+ "๐๐ผโโ๏ธโโก๏ธ",
+ "๐๐ฝโโ๏ธโโก๏ธ",
+ "๐๐พโโ๏ธโโก๏ธ",
+ "๐๐ฟโโ๏ธโโก๏ธ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐บ",
+ "๐บ๐ป",
+ "๐บ๐ผ",
+ "๐บ๐ฝ",
+ "๐บ๐พ",
+ "๐บ๐ฟ",
+ "๐ด๏ธ",
+ "๐ด๐ป",
+ "๐ด๐ผ",
+ "๐ด๐ฝ",
+ "๐ด๐พ",
+ "๐ด๐ฟ",
+ "๐ฏ",
+ "๐ฏโโ๏ธ",
+ "๐ฏโโ๏ธ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐คบ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "โท๏ธ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐๏ธ",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐๏ธโโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐๏ธโโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐ฃ",
+ "๐ฃ๐ป",
+ "๐ฃ๐ผ",
+ "๐ฃ๐ฝ",
+ "๐ฃ๐พ",
+ "๐ฃ๐ฟ",
+ "๐ฃโโ๏ธ",
+ "๐ฃ๐ปโโ๏ธ",
+ "๐ฃ๐ผโโ๏ธ",
+ "๐ฃ๐ฝโโ๏ธ",
+ "๐ฃ๐พโโ๏ธ",
+ "๐ฃ๐ฟโโ๏ธ",
+ "๐ฃโโ๏ธ",
+ "๐ฃ๐ปโโ๏ธ",
+ "๐ฃ๐ผโโ๏ธ",
+ "๐ฃ๐ฝโโ๏ธ",
+ "๐ฃ๐พโโ๏ธ",
+ "๐ฃ๐ฟโโ๏ธ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐โโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "โน๏ธ",
+ "โน๐ป",
+ "โน๐ผ",
+ "โน๐ฝ",
+ "โน๐พ",
+ "โน๐ฟ",
+ "โน๏ธโโ๏ธ",
+ "โน๐ปโโ๏ธ",
+ "โน๐ผโโ๏ธ",
+ "โน๐ฝโโ๏ธ",
+ "โน๐พโโ๏ธ",
+ "โน๐ฟโโ๏ธ",
+ "โน๏ธโโ๏ธ",
+ "โน๐ปโโ๏ธ",
+ "โน๐ผโโ๏ธ",
+ "โน๐ฝโโ๏ธ",
+ "โน๐พโโ๏ธ",
+ "โน๐ฟโโ๏ธ",
+ "๐๏ธ",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐๏ธโโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐๏ธโโ๏ธ",
+ "๐๐ปโโ๏ธ",
+ "๐๐ผโโ๏ธ",
+ "๐๐ฝโโ๏ธ",
+ "๐๐พโโ๏ธ",
+ "๐๐ฟโโ๏ธ",
+ "๐ด",
+ "๐ด๐ป",
+ "๐ด๐ผ",
+ "๐ด๐ฝ",
+ "๐ด๐พ",
+ "๐ด๐ฟ",
+ "๐ดโโ๏ธ",
+ "๐ด๐ปโโ๏ธ",
+ "๐ด๐ผโโ๏ธ",
+ "๐ด๐ฝโโ๏ธ",
+ "๐ด๐พโโ๏ธ",
+ "๐ด๐ฟโโ๏ธ",
+ "๐ดโโ๏ธ",
+ "๐ด๐ปโโ๏ธ",
+ "๐ด๐ผโโ๏ธ",
+ "๐ด๐ฝโโ๏ธ",
+ "๐ด๐พโโ๏ธ",
+ "๐ด๐ฟโโ๏ธ",
+ "๐ต",
+ "๐ต๐ป",
+ "๐ต๐ผ",
+ "๐ต๐ฝ",
+ "๐ต๐พ",
+ "๐ต๐ฟ",
+ "๐ตโโ๏ธ",
+ "๐ต๐ปโโ๏ธ",
+ "๐ต๐ผโโ๏ธ",
+ "๐ต๐ฝโโ๏ธ",
+ "๐ต๐พโโ๏ธ",
+ "๐ต๐ฟโโ๏ธ",
+ "๐ตโโ๏ธ",
+ "๐ต๐ปโโ๏ธ",
+ "๐ต๐ผโโ๏ธ",
+ "๐ต๐ฝโโ๏ธ",
+ "๐ต๐พโโ๏ธ",
+ "๐ต๐ฟโโ๏ธ",
+ "๐คธ",
+ "๐คธ๐ป",
+ "๐คธ๐ผ",
+ "๐คธ๐ฝ",
+ "๐คธ๐พ",
+ "๐คธ๐ฟ",
+ "๐คธโโ๏ธ",
+ "๐คธ๐ปโโ๏ธ",
+ "๐คธ๐ผโโ๏ธ",
+ "๐คธ๐ฝโโ๏ธ",
+ "๐คธ๐พโโ๏ธ",
+ "๐คธ๐ฟโโ๏ธ",
+ "๐คธโโ๏ธ",
+ "๐คธ๐ปโโ๏ธ",
+ "๐คธ๐ผโโ๏ธ",
+ "๐คธ๐ฝโโ๏ธ",
+ "๐คธ๐พโโ๏ธ",
+ "๐คธ๐ฟโโ๏ธ",
+ "๐คผ",
+ "๐คผโโ๏ธ",
+ "๐คผโโ๏ธ",
+ "๐คฝ",
+ "๐คฝ๐ป",
+ "๐คฝ๐ผ",
+ "๐คฝ๐ฝ",
+ "๐คฝ๐พ",
+ "๐คฝ๐ฟ",
+ "๐คฝโโ๏ธ",
+ "๐คฝ๐ปโโ๏ธ",
+ "๐คฝ๐ผโโ๏ธ",
+ "๐คฝ๐ฝโโ๏ธ",
+ "๐คฝ๐พโโ๏ธ",
+ "๐คฝ๐ฟโโ๏ธ",
+ "๐คฝโโ๏ธ",
+ "๐คฝ๐ปโโ๏ธ",
+ "๐คฝ๐ผโโ๏ธ",
+ "๐คฝ๐ฝโโ๏ธ",
+ "๐คฝ๐พโโ๏ธ",
+ "๐คฝ๐ฟโโ๏ธ",
+ "๐คพ",
+ "๐คพ๐ป",
+ "๐คพ๐ผ",
+ "๐คพ๐ฝ",
+ "๐คพ๐พ",
+ "๐คพ๐ฟ",
+ "๐คพโโ๏ธ",
+ "๐คพ๐ปโโ๏ธ",
+ "๐คพ๐ผโโ๏ธ",
+ "๐คพ๐ฝโโ๏ธ",
+ "๐คพ๐พโโ๏ธ",
+ "๐คพ๐ฟโโ๏ธ",
+ "๐คพโโ๏ธ",
+ "๐คพ๐ปโโ๏ธ",
+ "๐คพ๐ผโโ๏ธ",
+ "๐คพ๐ฝโโ๏ธ",
+ "๐คพ๐พโโ๏ธ",
+ "๐คพ๐ฟโโ๏ธ",
+ "๐คน",
+ "๐คน๐ป",
+ "๐คน๐ผ",
+ "๐คน๐ฝ",
+ "๐คน๐พ",
+ "๐คน๐ฟ",
+ "๐คนโโ๏ธ",
+ "๐คน๐ปโโ๏ธ",
+ "๐คน๐ผโโ๏ธ",
+ "๐คน๐ฝโโ๏ธ",
+ "๐คน๐พโโ๏ธ",
+ "๐คน๐ฟโโ๏ธ",
+ "๐คนโโ๏ธ",
+ "๐คน๐ปโโ๏ธ",
+ "๐คน๐ผโโ๏ธ",
+ "๐คน๐ฝโโ๏ธ",
+ "๐คน๐พโโ๏ธ",
+ "๐คน๐ฟโโ๏ธ",
+ "๐ง",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐ฝ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐งโโ๏ธ",
+ "๐ง๐ปโโ๏ธ",
+ "๐ง๐ผโโ๏ธ",
+ "๐ง๐ฝโโ๏ธ",
+ "๐ง๐พโโ๏ธ",
+ "๐ง๐ฟโโ๏ธ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐งโ๐คโ๐ง",
+ "๐ง๐ปโ๐คโ๐ง๐ป",
+ "๐ง๐ปโ๐คโ๐ง๐ผ",
+ "๐ง๐ปโ๐คโ๐ง๐ฝ",
+ "๐ง๐ปโ๐คโ๐ง๐พ",
+ "๐ง๐ปโ๐คโ๐ง๐ฟ",
+ "๐ง๐ผโ๐คโ๐ง๐ป",
+ "๐ง๐ผโ๐คโ๐ง๐ผ",
+ "๐ง๐ผโ๐คโ๐ง๐ฝ",
+ "๐ง๐ผโ๐คโ๐ง๐พ",
+ "๐ง๐ผโ๐คโ๐ง๐ฟ",
+ "๐ง๐ฝโ๐คโ๐ง๐ป",
+ "๐ง๐ฝโ๐คโ๐ง๐ผ",
+ "๐ง๐ฝโ๐คโ๐ง๐ฝ",
+ "๐ง๐ฝโ๐คโ๐ง๐พ",
+ "๐ง๐ฝโ๐คโ๐ง๐ฟ",
+ "๐ง๐พโ๐คโ๐ง๐ป",
+ "๐ง๐พโ๐คโ๐ง๐ผ",
+ "๐ง๐พโ๐คโ๐ง๐ฝ",
+ "๐ง๐พโ๐คโ๐ง๐พ",
+ "๐ง๐พโ๐คโ๐ง๐ฟ",
+ "๐ง๐ฟโ๐คโ๐ง๐ป",
+ "๐ง๐ฟโ๐คโ๐ง๐ผ",
+ "๐ง๐ฟโ๐คโ๐ง๐ฝ",
+ "๐ง๐ฟโ๐คโ๐ง๐พ",
+ "๐ง๐ฟโ๐คโ๐ง๐ฟ",
+ "๐ญ",
+ "๐ญ๐ป",
+ "๐ฉ๐ปโ๐คโ๐ฉ๐ผ",
+ "๐ฉ๐ปโ๐คโ๐ฉ๐ฝ",
+ "๐ฉ๐ปโ๐คโ๐ฉ๐พ",
+ "๐ฉ๐ปโ๐คโ๐ฉ๐ฟ",
+ "๐ฉ๐ผโ๐คโ๐ฉ๐ป",
+ "๐ญ๐ผ",
+ "๐ฉ๐ผโ๐คโ๐ฉ๐ฝ",
+ "๐ฉ๐ผโ๐คโ๐ฉ๐พ",
+ "๐ฉ๐ผโ๐คโ๐ฉ๐ฟ",
+ "๐ฉ๐ฝโ๐คโ๐ฉ๐ป",
+ "๐ฉ๐ฝโ๐คโ๐ฉ๐ผ",
+ "๐ญ๐ฝ",
+ "๐ฉ๐ฝโ๐คโ๐ฉ๐พ",
+ "๐ฉ๐ฝโ๐คโ๐ฉ๐ฟ",
+ "๐ฉ๐พโ๐คโ๐ฉ๐ป",
+ "๐ฉ๐พโ๐คโ๐ฉ๐ผ",
+ "๐ฉ๐พโ๐คโ๐ฉ๐ฝ",
+ "๐ญ๐พ",
+ "๐ฉ๐พโ๐คโ๐ฉ๐ฟ",
+ "๐ฉ๐ฟโ๐คโ๐ฉ๐ป",
+ "๐ฉ๐ฟโ๐คโ๐ฉ๐ผ",
+ "๐ฉ๐ฟโ๐คโ๐ฉ๐ฝ",
+ "๐ฉ๐ฟโ๐คโ๐ฉ๐พ",
+ "๐ญ๐ฟ",
+ "๐ซ",
+ "๐ซ๐ป",
+ "๐ฉ๐ปโ๐คโ๐จ๐ผ",
+ "๐ฉ๐ปโ๐คโ๐จ๐ฝ",
+ "๐ฉ๐ปโ๐คโ๐จ๐พ",
+ "๐ฉ๐ปโ๐คโ๐จ๐ฟ",
+ "๐ฉ๐ผโ๐คโ๐จ๐ป",
+ "๐ซ๐ผ",
+ "๐ฉ๐ผโ๐คโ๐จ๐ฝ",
+ "๐ฉ๐ผโ๐คโ๐จ๐พ",
+ "๐ฉ๐ผโ๐คโ๐จ๐ฟ",
+ "๐ฉ๐ฝโ๐คโ๐จ๐ป",
+ "๐ฉ๐ฝโ๐คโ๐จ๐ผ",
+ "๐ซ๐ฝ",
+ "๐ฉ๐ฝโ๐คโ๐จ๐พ",
+ "๐ฉ๐ฝโ๐คโ๐จ๐ฟ",
+ "๐ฉ๐พโ๐คโ๐จ๐ป",
+ "๐ฉ๐พโ๐คโ๐จ๐ผ",
+ "๐ฉ๐พโ๐คโ๐จ๐ฝ",
+ "๐ซ๐พ",
+ "๐ฉ๐พโ๐คโ๐จ๐ฟ",
+ "๐ฉ๐ฟโ๐คโ๐จ๐ป",
+ "๐ฉ๐ฟโ๐คโ๐จ๐ผ",
+ "๐ฉ๐ฟโ๐คโ๐จ๐ฝ",
+ "๐ฉ๐ฟโ๐คโ๐จ๐พ",
+ "๐ซ๐ฟ",
+ "๐ฌ",
+ "๐ฌ๐ป",
+ "๐จ๐ปโ๐คโ๐จ๐ผ",
+ "๐จ๐ปโ๐คโ๐จ๐ฝ",
+ "๐จ๐ปโ๐คโ๐จ๐พ",
+ "๐จ๐ปโ๐คโ๐จ๐ฟ",
+ "๐จ๐ผโ๐คโ๐จ๐ป",
+ "๐ฌ๐ผ",
+ "๐จ๐ผโ๐คโ๐จ๐ฝ",
+ "๐จ๐ผโ๐คโ๐จ๐พ",
+ "๐จ๐ผโ๐คโ๐จ๐ฟ",
+ "๐จ๐ฝโ๐คโ๐จ๐ป",
+ "๐จ๐ฝโ๐คโ๐จ๐ผ",
+ "๐ฌ๐ฝ",
+ "๐จ๐ฝโ๐คโ๐จ๐พ",
+ "๐จ๐ฝโ๐คโ๐จ๐ฟ",
+ "๐จ๐พโ๐คโ๐จ๐ป",
+ "๐จ๐พโ๐คโ๐จ๐ผ",
+ "๐จ๐พโ๐คโ๐จ๐ฝ",
+ "๐ฌ๐พ",
+ "๐จ๐พโ๐คโ๐จ๐ฟ",
+ "๐จ๐ฟโ๐คโ๐จ๐ป",
+ "๐จ๐ฟโ๐คโ๐จ๐ผ",
+ "๐จ๐ฟโ๐คโ๐จ๐ฝ",
+ "๐จ๐ฟโ๐คโ๐จ๐พ",
+ "๐ฌ๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐ง๐ปโโค๏ธโ๐โ๐ง๐ผ",
+ "๐ง๐ปโโค๏ธโ๐โ๐ง๐ฝ",
+ "๐ง๐ปโโค๏ธโ๐โ๐ง๐พ",
+ "๐ง๐ปโโค๏ธโ๐โ๐ง๐ฟ",
+ "๐ง๐ผโโค๏ธโ๐โ๐ง๐ป",
+ "๐ง๐ผโโค๏ธโ๐โ๐ง๐ฝ",
+ "๐ง๐ผโโค๏ธโ๐โ๐ง๐พ",
+ "๐ง๐ผโโค๏ธโ๐โ๐ง๐ฟ",
+ "๐ง๐ฝโโค๏ธโ๐โ๐ง๐ป",
+ "๐ง๐ฝโโค๏ธโ๐โ๐ง๐ผ",
+ "๐ง๐ฝโโค๏ธโ๐โ๐ง๐พ",
+ "๐ง๐ฝโโค๏ธโ๐โ๐ง๐ฟ",
+ "๐ง๐พโโค๏ธโ๐โ๐ง๐ป",
+ "๐ง๐พโโค๏ธโ๐โ๐ง๐ผ",
+ "๐ง๐พโโค๏ธโ๐โ๐ง๐ฝ",
+ "๐ง๐พโโค๏ธโ๐โ๐ง๐ฟ",
+ "๐ง๐ฟโโค๏ธโ๐โ๐ง๐ป",
+ "๐ง๐ฟโโค๏ธโ๐โ๐ง๐ผ",
+ "๐ง๐ฟโโค๏ธโ๐โ๐ง๐ฝ",
+ "๐ง๐ฟโโค๏ธโ๐โ๐ง๐พ",
+ "๐ฉโโค๏ธโ๐โ๐จ",
+ "๐ฉ๐ปโโค๏ธโ๐โ๐จ๐ป",
+ "๐ฉ๐ปโโค๏ธโ๐โ๐จ๐ผ",
+ "๐ฉ๐ปโโค๏ธโ๐โ๐จ๐ฝ",
+ "๐ฉ๐ปโโค๏ธโ๐โ๐จ๐พ",
+ "๐ฉ๐ปโโค๏ธโ๐โ๐จ๐ฟ",
+ "๐ฉ๐ผโโค๏ธโ๐โ๐จ๐ป",
+ "๐ฉ๐ผโโค๏ธโ๐โ๐จ๐ผ",
+ "๐ฉ๐ผโโค๏ธโ๐โ๐จ๐ฝ",
+ "๐ฉ๐ผโโค๏ธโ๐โ๐จ๐พ",
+ "๐ฉ๐ผโโค๏ธโ๐โ๐จ๐ฟ",
+ "๐ฉ๐ฝโโค๏ธโ๐โ๐จ๐ป",
+ "๐ฉ๐ฝโโค๏ธโ๐โ๐จ๐ผ",
+ "๐ฉ๐ฝโโค๏ธโ๐โ๐จ๐ฝ",
+ "๐ฉ๐ฝโโค๏ธโ๐โ๐จ๐พ",
+ "๐ฉ๐ฝโโค๏ธโ๐โ๐จ๐ฟ",
+ "๐ฉ๐พโโค๏ธโ๐โ๐จ๐ป",
+ "๐ฉ๐พโโค๏ธโ๐โ๐จ๐ผ",
+ "๐ฉ๐พโโค๏ธโ๐โ๐จ๐ฝ",
+ "๐ฉ๐พโโค๏ธโ๐โ๐จ๐พ",
+ "๐ฉ๐พโโค๏ธโ๐โ๐จ๐ฟ",
+ "๐ฉ๐ฟโโค๏ธโ๐โ๐จ๐ป",
+ "๐ฉ๐ฟโโค๏ธโ๐โ๐จ๐ผ",
+ "๐ฉ๐ฟโโค๏ธโ๐โ๐จ๐ฝ",
+ "๐ฉ๐ฟโโค๏ธโ๐โ๐จ๐พ",
+ "๐ฉ๐ฟโโค๏ธโ๐โ๐จ๐ฟ",
+ "๐จโโค๏ธโ๐โ๐จ",
+ "๐จ๐ปโโค๏ธโ๐โ๐จ๐ป",
+ "๐จ๐ปโโค๏ธโ๐โ๐จ๐ผ",
+ "๐จ๐ปโโค๏ธโ๐โ๐จ๐ฝ",
+ "๐จ๐ปโโค๏ธโ๐โ๐จ๐พ",
+ "๐จ๐ปโโค๏ธโ๐โ๐จ๐ฟ",
+ "๐จ๐ผโโค๏ธโ๐โ๐จ๐ป",
+ "๐จ๐ผโโค๏ธโ๐โ๐จ๐ผ",
+ "๐จ๐ผโโค๏ธโ๐โ๐จ๐ฝ",
+ "๐จ๐ผโโค๏ธโ๐โ๐จ๐พ",
+ "๐จ๐ผโโค๏ธโ๐โ๐จ๐ฟ",
+ "๐จ๐ฝโโค๏ธโ๐โ๐จ๐ป",
+ "๐จ๐ฝโโค๏ธโ๐โ๐จ๐ผ",
+ "๐จ๐ฝโโค๏ธโ๐โ๐จ๐ฝ",
+ "๐จ๐ฝโโค๏ธโ๐โ๐จ๐พ",
+ "๐จ๐ฝโโค๏ธโ๐โ๐จ๐ฟ",
+ "๐จ๐พโโค๏ธโ๐โ๐จ๐ป",
+ "๐จ๐พโโค๏ธโ๐โ๐จ๐ผ",
+ "๐จ๐พโโค๏ธโ๐โ๐จ๐ฝ",
+ "๐จ๐พโโค๏ธโ๐โ๐จ๐พ",
+ "๐จ๐พโโค๏ธโ๐โ๐จ๐ฟ",
+ "๐จ๐ฟโโค๏ธโ๐โ๐จ๐ป",
+ "๐จ๐ฟโโค๏ธโ๐โ๐จ๐ผ",
+ "๐จ๐ฟโโค๏ธโ๐โ๐จ๐ฝ",
+ "๐จ๐ฟโโค๏ธโ๐โ๐จ๐พ",
+ "๐จ๐ฟโโค๏ธโ๐โ๐จ๐ฟ",
+ "๐ฉโโค๏ธโ๐โ๐ฉ",
+ "๐ฉ๐ปโโค๏ธโ๐โ๐ฉ๐ป",
+ "๐ฉ๐ปโโค๏ธโ๐โ๐ฉ๐ผ",
+ "๐ฉ๐ปโโค๏ธโ๐โ๐ฉ๐ฝ",
+ "๐ฉ๐ปโโค๏ธโ๐โ๐ฉ๐พ",
+ "๐ฉ๐ปโโค๏ธโ๐โ๐ฉ๐ฟ",
+ "๐ฉ๐ผโโค๏ธโ๐โ๐ฉ๐ป",
+ "๐ฉ๐ผโโค๏ธโ๐โ๐ฉ๐ผ",
+ "๐ฉ๐ผโโค๏ธโ๐โ๐ฉ๐ฝ",
+ "๐ฉ๐ผโโค๏ธโ๐โ๐ฉ๐พ",
+ "๐ฉ๐ผโโค๏ธโ๐โ๐ฉ๐ฟ",
+ "๐ฉ๐ฝโโค๏ธโ๐โ๐ฉ๐ป",
+ "๐ฉ๐ฝโโค๏ธโ๐โ๐ฉ๐ผ",
+ "๐ฉ๐ฝโโค๏ธโ๐โ๐ฉ๐ฝ",
+ "๐ฉ๐ฝโโค๏ธโ๐โ๐ฉ๐พ",
+ "๐ฉ๐ฝโโค๏ธโ๐โ๐ฉ๐ฟ",
+ "๐ฉ๐พโโค๏ธโ๐โ๐ฉ๐ป",
+ "๐ฉ๐พโโค๏ธโ๐โ๐ฉ๐ผ",
+ "๐ฉ๐พโโค๏ธโ๐โ๐ฉ๐ฝ",
+ "๐ฉ๐พโโค๏ธโ๐โ๐ฉ๐พ",
+ "๐ฉ๐พโโค๏ธโ๐โ๐ฉ๐ฟ",
+ "๐ฉ๐ฟโโค๏ธโ๐โ๐ฉ๐ป",
+ "๐ฉ๐ฟโโค๏ธโ๐โ๐ฉ๐ผ",
+ "๐ฉ๐ฟโโค๏ธโ๐โ๐ฉ๐ฝ",
+ "๐ฉ๐ฟโโค๏ธโ๐โ๐ฉ๐พ",
+ "๐ฉ๐ฟโโค๏ธโ๐โ๐ฉ๐ฟ",
+ "๐",
+ "๐๐ป",
+ "๐๐ผ",
+ "๐๐ฝ",
+ "๐๐พ",
+ "๐๐ฟ",
+ "๐ง๐ปโโค๏ธโ๐ง๐ผ",
+ "๐ง๐ปโโค๏ธโ๐ง๐ฝ",
+ "๐ง๐ปโโค๏ธโ๐ง๐พ",
+ "๐ง๐ปโโค๏ธโ๐ง๐ฟ",
+ "๐ง๐ผโโค๏ธโ๐ง๐ป",
+ "๐ง๐ผโโค๏ธโ๐ง๐ฝ",
+ "๐ง๐ผโโค๏ธโ๐ง๐พ",
+ "๐ง๐ผโโค๏ธโ๐ง๐ฟ",
+ "๐ง๐ฝโโค๏ธโ๐ง๐ป",
+ "๐ง๐ฝโโค๏ธโ๐ง๐ผ",
+ "๐ง๐ฝโโค๏ธโ๐ง๐พ",
+ "๐ง๐ฝโโค๏ธโ๐ง๐ฟ",
+ "๐ง๐พโโค๏ธโ๐ง๐ป",
+ "๐ง๐พโโค๏ธโ๐ง๐ผ",
+ "๐ง๐พโโค๏ธโ๐ง๐ฝ",
+ "๐ง๐พโโค๏ธโ๐ง๐ฟ",
+ "๐ง๐ฟโโค๏ธโ๐ง๐ป",
+ "๐ง๐ฟโโค๏ธโ๐ง๐ผ",
+ "๐ง๐ฟโโค๏ธโ๐ง๐ฝ",
+ "๐ง๐ฟโโค๏ธโ๐ง๐พ",
+ "๐ฉโโค๏ธโ๐จ",
+ "๐ฉ๐ปโโค๏ธโ๐จ๐ป",
+ "๐ฉ๐ปโโค๏ธโ๐จ๐ผ",
+ "๐ฉ๐ปโโค๏ธโ๐จ๐ฝ",
+ "๐ฉ๐ปโโค๏ธโ๐จ๐พ",
+ "๐ฉ๐ปโโค๏ธโ๐จ๐ฟ",
+ "๐ฉ๐ผโโค๏ธโ๐จ๐ป",
+ "๐ฉ๐ผโโค๏ธโ๐จ๐ผ",
+ "๐ฉ๐ผโโค๏ธโ๐จ๐ฝ",
+ "๐ฉ๐ผโโค๏ธโ๐จ๐พ",
+ "๐ฉ๐ผโโค๏ธโ๐จ๐ฟ",
+ "๐ฉ๐ฝโโค๏ธโ๐จ๐ป",
+ "๐ฉ๐ฝโโค๏ธโ๐จ๐ผ",
+ "๐ฉ๐ฝโโค๏ธโ๐จ๐ฝ",
+ "๐ฉ๐ฝโโค๏ธโ๐จ๐พ",
+ "๐ฉ๐ฝโโค๏ธโ๐จ๐ฟ",
+ "๐ฉ๐พโโค๏ธโ๐จ๐ป",
+ "๐ฉ๐พโโค๏ธโ๐จ๐ผ",
+ "๐ฉ๐พโโค๏ธโ๐จ๐ฝ",
+ "๐ฉ๐พโโค๏ธโ๐จ๐พ",
+ "๐ฉ๐พโโค๏ธโ๐จ๐ฟ",
+ "๐ฉ๐ฟโโค๏ธโ๐จ๐ป",
+ "๐ฉ๐ฟโโค๏ธโ๐จ๐ผ",
+ "๐ฉ๐ฟโโค๏ธโ๐จ๐ฝ",
+ "๐ฉ๐ฟโโค๏ธโ๐จ๐พ",
+ "๐ฉ๐ฟโโค๏ธโ๐จ๐ฟ",
+ "๐จโโค๏ธโ๐จ",
+ "๐จ๐ปโโค๏ธโ๐จ๐ป",
+ "๐จ๐ปโโค๏ธโ๐จ๐ผ",
+ "๐จ๐ปโโค๏ธโ๐จ๐ฝ",
+ "๐จ๐ปโโค๏ธโ๐จ๐พ",
+ "๐จ๐ปโโค๏ธโ๐จ๐ฟ",
+ "๐จ๐ผโโค๏ธโ๐จ๐ป",
+ "๐จ๐ผโโค๏ธโ๐จ๐ผ",
+ "๐จ๐ผโโค๏ธโ๐จ๐ฝ",
+ "๐จ๐ผโโค๏ธโ๐จ๐พ",
+ "๐จ๐ผโโค๏ธโ๐จ๐ฟ",
+ "๐จ๐ฝโโค๏ธโ๐จ๐ป",
+ "๐จ๐ฝโโค๏ธโ๐จ๐ผ",
+ "๐จ๐ฝโโค๏ธโ๐จ๐ฝ",
+ "๐จ๐ฝโโค๏ธโ๐จ๐พ",
+ "๐จ๐ฝโโค๏ธโ๐จ๐ฟ",
+ "๐จ๐พโโค๏ธโ๐จ๐ป",
+ "๐จ๐พโโค๏ธโ๐จ๐ผ",
+ "๐จ๐พโโค๏ธโ๐จ๐ฝ",
+ "๐จ๐พโโค๏ธโ๐จ๐พ",
+ "๐จ๐พโโค๏ธโ๐จ๐ฟ",
+ "๐จ๐ฟโโค๏ธโ๐จ๐ป",
+ "๐จ๐ฟโโค๏ธโ๐จ๐ผ",
+ "๐จ๐ฟโโค๏ธโ๐จ๐ฝ",
+ "๐จ๐ฟโโค๏ธโ๐จ๐พ",
+ "๐จ๐ฟโโค๏ธโ๐จ๐ฟ",
+ "๐ฉโโค๏ธโ๐ฉ",
+ "๐ฉ๐ปโโค๏ธโ๐ฉ๐ป",
+ "๐ฉ๐ปโโค๏ธโ๐ฉ๐ผ",
+ "๐ฉ๐ปโโค๏ธโ๐ฉ๐ฝ",
+ "๐ฉ๐ปโโค๏ธโ๐ฉ๐พ",
+ "๐ฉ๐ปโโค๏ธโ๐ฉ๐ฟ",
+ "๐ฉ๐ผโโค๏ธโ๐ฉ๐ป",
+ "๐ฉ๐ผโโค๏ธโ๐ฉ๐ผ",
+ "๐ฉ๐ผโโค๏ธโ๐ฉ๐ฝ",
+ "๐ฉ๐ผโโค๏ธโ๐ฉ๐พ",
+ "๐ฉ๐ผโโค๏ธโ๐ฉ๐ฟ",
+ "๐ฉ๐ฝโโค๏ธโ๐ฉ๐ป",
+ "๐ฉ๐ฝโโค๏ธโ๐ฉ๐ผ",
+ "๐ฉ๐ฝโโค๏ธโ๐ฉ๐ฝ",
+ "๐ฉ๐ฝโโค๏ธโ๐ฉ๐พ",
+ "๐ฉ๐ฝโโค๏ธโ๐ฉ๐ฟ",
+ "๐ฉ๐พโโค๏ธโ๐ฉ๐ป",
+ "๐ฉ๐พโโค๏ธโ๐ฉ๐ผ",
+ "๐ฉ๐พโโค๏ธโ๐ฉ๐ฝ",
+ "๐ฉ๐พโโค๏ธโ๐ฉ๐พ",
+ "๐ฉ๐พโโค๏ธโ๐ฉ๐ฟ",
+ "๐ฉ๐ฟโโค๏ธโ๐ฉ๐ป",
+ "๐ฉ๐ฟโโค๏ธโ๐ฉ๐ผ",
+ "๐ฉ๐ฟโโค๏ธโ๐ฉ๐ฝ",
+ "๐ฉ๐ฟโโค๏ธโ๐ฉ๐พ",
+ "๐ฉ๐ฟโโค๏ธโ๐ฉ๐ฟ",
+ "๐จโ๐ฉโ๐ฆ",
+ "๐จโ๐ฉโ๐ง",
+ "๐จโ๐ฉโ๐งโ๐ฆ",
+ "๐จโ๐ฉโ๐ฆโ๐ฆ",
+ "๐จโ๐ฉโ๐งโ๐ง",
+ "๐จโ๐จโ๐ฆ",
+ "๐จโ๐จโ๐ง",
+ "๐จโ๐จโ๐งโ๐ฆ",
+ "๐จโ๐จโ๐ฆโ๐ฆ",
+ "๐จโ๐จโ๐งโ๐ง",
+ "๐ฉโ๐ฉโ๐ฆ",
+ "๐ฉโ๐ฉโ๐ง",
+ "๐ฉโ๐ฉโ๐งโ๐ฆ",
+ "๐ฉโ๐ฉโ๐ฆโ๐ฆ",
+ "๐ฉโ๐ฉโ๐งโ๐ง",
+ "๐จโ๐ฆ",
+ "๐จโ๐ฆโ๐ฆ",
+ "๐จโ๐ง",
+ "๐จโ๐งโ๐ฆ",
+ "๐จโ๐งโ๐ง",
+ "๐ฉโ๐ฆ",
+ "๐ฉโ๐ฆโ๐ฆ",
+ "๐ฉโ๐ง",
+ "๐ฉโ๐งโ๐ฆ",
+ "๐ฉโ๐งโ๐ง",
+ "๐ฃ๏ธ",
+ "๐ค",
+ "๐ฅ",
+ "๐ซ",
+ "๐ช",
+ "๐งโ๐งโ๐ง",
+ "๐งโ๐งโ๐งโ๐ง",
+ "๐งโ๐ง",
+ "๐งโ๐งโ๐ง",
+ "๐ฃ",
+ "๐ซ",
+ "๐ต",
+ "๐",
+ "๐ฆ",
+ "๐ฆง",
+ "๐ถ",
+ "๐",
+ "๐ฆฎ",
+ "๐โ๐ฆบ",
+ "๐ฉ",
+ "๐บ",
+ "๐ฆ",
+ "๐ฆ",
+ "๐ฑ",
+ "๐",
+ "๐โโฌ",
+ "๐ฆ",
+ "๐ฏ",
+ "๐
",
+ "๐",
+ "๐ด",
+ "๐ซ",
+ "๐ซ",
+ "๐",
+ "๐ฆ",
+ "๐ฆ",
+ "๐ฆ",
+ "๐ฆฌ",
+ "๐ฎ",
+ "๐",
+ "๐",
+ "๐",
+ "๐ท",
+ "๐",
+ "๐",
+ "๐ฝ",
+ "๐",
+ "๐",
+ "๐",
+ "๐ช",
+ "๐ซ",
+ "๐ฆ",
+ "๐ฆ",
+ "๐",
+ "๐ฆฃ",
+ "๐ฆ",
+ "๐ฆ",
+ "๐ญ",
+ "๐",
+ "๐",
+ "๐น",
+ "๐ฐ",
+ "๐",
+ "๐ฟ๏ธ",
+ "๐ฆซ",
+ "๐ฆ",
+ "๐ฆ",
+ "๐ป",
+ "๐ปโโ๏ธ",
+ "๐จ",
+ "๐ผ",
+ "๐ฆฅ",
+ "๐ฆฆ",
+ "๐ฆจ",
+ "๐ฆ",
+ "๐ฆก",
+ "๐พ",
+ "๐ฆ",
+ "๐",
+ "๐",
+ "๐ฃ",
+ "๐ค",
+ "๐ฅ",
+ "๐ฆ",
+ "๐ง",
+ "๐๏ธ",
+ "๐ฆ
",
+ "๐ฆ",
+ "๐ฆข",
+ "๐ฆ",
+ "๐ฆค",
+ "๐ชถ",
+ "๐ฆฉ",
+ "๐ฆ",
+ "๐ฆ",
+ "๐ชฝ",
+ "๐ฆโโฌ",
+ "๐ชฟ",
+ "๐ฆโ๐ฅ",
+ "๐ธ",
+ "๐",
+ "๐ข",
+ "๐ฆ",
+ "๐",
+ "๐ฒ",
+ "๐",
+ "๐ฆ",
+ "๐ฆ",
+ "๐ณ",
+ "๐",
+ "๐ฌ",
+ "๐ฆญ",
+ "๐",
+ "๐ ",
+ "๐ก",
+ "๐ฆ",
+ "๐",
+ "๐",
+ "๐ชธ",
+ "๐ชผ",
+ "๐ฆ",
+ "๐ฆ",
+ "๐ฆ",
+ "๐ฆ",
+ "๐ฆช",
+ "๐",
+ "๐ฆ",
+ "๐",
+ "๐",
+ "๐",
+ "๐ชฒ",
+ "๐",
+ "๐ฆ",
+ "๐ชณ",
+ "๐ท๏ธ",
+ "๐ธ๏ธ",
+ "๐ฆ",
+ "๐ฆ",
+ "๐ชฐ",
+ "๐ชฑ",
+ "๐ฆ ",
+ "๐",
+ "๐ธ",
+ "๐ฎ",
+ "๐ชท",
+ "๐ต๏ธ",
+ "๐น",
+ "๐ฅ",
+ "๐บ",
+ "๐ป",
+ "๐ผ",
+ "๐ท",
+ "๐ชป",
+ "๐ฑ",
+ "๐ชด",
+ "๐ฒ",
+ "๐ณ",
+ "๐ด",
+ "๐ต",
+ "๐พ",
+ "๐ฟ",
+ "โ๏ธ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐ชน",
+ "๐ชบ",
+ "๐",
+ "๐ชพ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐โ๐ฉ",
+ "๐",
+ "๐",
+ "๐ฅญ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐ซ",
+ "๐ฅ",
+ "๐
",
+ "๐ซ",
+ "๐ฅฅ",
+ "๐ฅ",
+ "๐",
+ "๐ฅ",
+ "๐ฅ",
+ "๐ฝ",
+ "๐ถ๏ธ",
+ "๐ซ",
+ "๐ฅ",
+ "๐ฅฌ",
+ "๐ฅฆ",
+ "๐ง",
+ "๐ง
",
+ "๐ฅ",
+ "๐ซ",
+ "๐ฐ",
+ "๐ซ",
+ "๐ซ",
+ "๐โ๐ซ",
+ "๐ซ",
+ "๐",
+ "๐ฅ",
+ "๐ฅ",
+ "๐ซ",
+ "๐ฅจ",
+ "๐ฅฏ",
+ "๐ฅ",
+ "๐ง",
+ "๐ง",
+ "๐",
+ "๐",
+ "๐ฅฉ",
+ "๐ฅ",
+ "๐",
+ "๐",
+ "๐",
+ "๐ญ",
+ "๐ฅช",
+ "๐ฎ",
+ "๐ฏ",
+ "๐ซ",
+ "๐ฅ",
+ "๐ง",
+ "๐ฅ",
+ "๐ณ",
+ "๐ฅ",
+ "๐ฒ",
+ "๐ซ",
+ "๐ฅฃ",
+ "๐ฅ",
+ "๐ฟ",
+ "๐ง",
+ "๐ง",
+ "๐ฅซ",
+ "๐ฑ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐ ",
+ "๐ข",
+ "๐ฃ",
+ "๐ค",
+ "๐ฅ",
+ "๐ฅฎ",
+ "๐ก",
+ "๐ฅ",
+ "๐ฅ ",
+ "๐ฅก",
+ "๐ฆ",
+ "๐ง",
+ "๐จ",
+ "๐ฉ",
+ "๐ช",
+ "๐",
+ "๐ฐ",
+ "๐ง",
+ "๐ฅง",
+ "๐ซ",
+ "๐ฌ",
+ "๐ญ",
+ "๐ฎ",
+ "๐ฏ",
+ "๐ผ",
+ "๐ฅ",
+ "โ",
+ "๐ซ",
+ "๐ต",
+ "๐ถ",
+ "๐พ",
+ "๐ท",
+ "๐ธ",
+ "๐น",
+ "๐บ",
+ "๐ป",
+ "๐ฅ",
+ "๐ฅ",
+ "๐ซ",
+ "๐ฅค",
+ "๐ง",
+ "๐ง",
+ "๐ง",
+ "๐ง",
+ "๐ฅข",
+ "๐ฝ๏ธ",
+ "๐ด",
+ "๐ฅ",
+ "๐ช",
+ "๐ซ",
+ "๐บ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐บ๏ธ",
+ "๐พ",
+ "๐งญ",
+ "๐๏ธ",
+ "โฐ๏ธ",
+ "๐",
+ "๐ป",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐งฑ",
+ "๐ชจ",
+ "๐ชต",
+ "๐",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐ ",
+ "๐ก",
+ "๐ข",
+ "๐ฃ",
+ "๐ค",
+ "๐ฅ",
+ "๐ฆ",
+ "๐จ",
+ "๐ฉ",
+ "๐ช",
+ "๐ซ",
+ "๐ฌ",
+ "๐ญ",
+ "๐ฏ",
+ "๐ฐ",
+ "๐",
+ "๐ผ",
+ "๐ฝ",
+ "โช",
+ "๐",
+ "๐",
+ "๐",
+ "โฉ๏ธ",
+ "๐",
+ "โฒ",
+ "โบ",
+ "๐",
+ "๐",
+ "๐๏ธ",
+ "๐",
+ "๐
",
+ "๐",
+ "๐",
+ "๐",
+ "โจ๏ธ",
+ "๐ ",
+ "๐",
+ "๐ก",
+ "๐ข",
+ "๐",
+ "๐ช",
+ "๐",
+ "๐",
+ "๐",
+ "๐
",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐ป",
+ "๐",
+ "๐",
+ "๐",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐ต",
+ "๐ฆฝ",
+ "๐ฆผ",
+ "๐บ",
+ "๐ฒ",
+ "๐ด",
+ "๐น",
+ "๐ผ",
+ "๐",
+ "๐ฃ๏ธ",
+ "๐ค๏ธ",
+ "๐ข๏ธ",
+ "โฝ",
+ "๐",
+ "๐จ",
+ "๐ฅ",
+ "๐ฆ",
+ "๐",
+ "๐ง",
+ "โ",
+ "๐",
+ "โต",
+ "๐ถ",
+ "๐ค",
+ "๐ณ๏ธ",
+ "โด๏ธ",
+ "๐ฅ๏ธ",
+ "๐ข",
+ "โ๏ธ",
+ "๐ฉ๏ธ",
+ "๐ซ",
+ "๐ฌ",
+ "๐ช",
+ "๐บ",
+ "๐",
+ "๐",
+ "๐ ",
+ "๐ก",
+ "๐ฐ๏ธ",
+ "๐",
+ "๐ธ",
+ "๐๏ธ",
+ "๐งณ",
+ "โ",
+ "โณ",
+ "โ",
+ "โฐ",
+ "โฑ๏ธ",
+ "โฒ๏ธ",
+ "๐ฐ๏ธ",
+ "๐",
+ "๐ง",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐ ",
+ "๐",
+ "๐ก",
+ "๐",
+ "๐ข",
+ "๐",
+ "๐ฃ",
+ "๐",
+ "๐ค",
+ "๐",
+ "๐ฅ",
+ "๐",
+ "๐ฆ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐ก๏ธ",
+ "โ๏ธ",
+ "๐",
+ "๐",
+ "๐ช",
+ "โญ",
+ "๐",
+ "๐ ",
+ "๐",
+ "โ๏ธ",
+ "โ
",
+ "โ๏ธ",
+ "๐ค๏ธ",
+ "๐ฅ๏ธ",
+ "๐ฆ๏ธ",
+ "๐ง๏ธ",
+ "๐จ๏ธ",
+ "๐ฉ๏ธ",
+ "๐ช๏ธ",
+ "๐ซ๏ธ",
+ "๐ฌ๏ธ",
+ "๐",
+ "๐",
+ "๐",
+ "โ๏ธ",
+ "โ",
+ "โฑ๏ธ",
+ "โก",
+ "โ๏ธ",
+ "โ๏ธ",
+ "โ",
+ "โ๏ธ",
+ "๐ฅ",
+ "๐ง",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐งจ",
+ "โจ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐งง",
+ "๐",
+ "๐",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐ซ",
+ "๐๏ธ",
+ "๐",
+ "๐
",
+ "๐ฅ",
+ "๐ฅ",
+ "๐ฅ",
+ "โฝ",
+ "โพ",
+ "๐ฅ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐พ",
+ "๐ฅ",
+ "๐ณ",
+ "๐",
+ "๐",
+ "๐",
+ "๐ฅ",
+ "๐",
+ "๐ธ",
+ "๐ฅ",
+ "๐ฅ",
+ "๐ฅ
",
+ "โณ",
+ "โธ๏ธ",
+ "๐ฃ",
+ "๐คฟ",
+ "๐ฝ",
+ "๐ฟ",
+ "๐ท",
+ "๐ฅ",
+ "๐ฏ",
+ "๐ช",
+ "๐ช",
+ "๐ซ",
+ "๐ฑ",
+ "๐ฎ",
+ "๐ช",
+ "๐ฎ",
+ "๐น๏ธ",
+ "๐ฐ",
+ "๐ฒ",
+ "๐งฉ",
+ "๐งธ",
+ "๐ช
",
+ "๐ชฉ",
+ "๐ช",
+ "โ ๏ธ",
+ "โฅ๏ธ",
+ "โฆ๏ธ",
+ "โฃ๏ธ",
+ "โ๏ธ",
+ "๐",
+ "๐",
+ "๐ด",
+ "๐ญ",
+ "๐ผ๏ธ",
+ "๐จ",
+ "๐งต",
+ "๐ชก",
+ "๐งถ",
+ "๐ชข",
+ "๐",
+ "๐ถ๏ธ",
+ "๐ฅฝ",
+ "๐ฅผ",
+ "๐ฆบ",
+ "๐",
+ "๐",
+ "๐",
+ "๐งฃ",
+ "๐งค",
+ "๐งฅ",
+ "๐งฆ",
+ "๐",
+ "๐",
+ "๐ฅป",
+ "๐ฉฑ",
+ "๐ฉฒ",
+ "๐ฉณ",
+ "๐",
+ "๐",
+ "๐ชญ",
+ "๐",
+ "๐",
+ "๐",
+ "๐๏ธ",
+ "๐",
+ "๐ฉด",
+ "๐",
+ "๐",
+ "๐ฅพ",
+ "๐ฅฟ",
+ "๐ ",
+ "๐ก",
+ "๐ฉฐ",
+ "๐ข",
+ "๐ชฎ",
+ "๐",
+ "๐",
+ "๐ฉ",
+ "๐",
+ "๐งข",
+ "๐ช",
+ "โ๏ธ",
+ "๐ฟ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐ข",
+ "๐ฃ",
+ "๐ฏ",
+ "๐",
+ "๐",
+ "๐ผ",
+ "๐ต",
+ "๐ถ",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐ค",
+ "๐ง",
+ "๐ป",
+ "๐ท",
+ "๐ช",
+ "๐ธ",
+ "๐น",
+ "๐บ",
+ "๐ป",
+ "๐ช",
+ "๐ฅ",
+ "๐ช",
+ "๐ช",
+ "๐ช",
+ "๐ช",
+ "๐ฑ",
+ "๐ฒ",
+ "โ๏ธ",
+ "๐",
+ "๐",
+ "๐ ",
+ "๐",
+ "๐ชซ",
+ "๐",
+ "๐ป",
+ "๐ฅ๏ธ",
+ "๐จ๏ธ",
+ "โจ๏ธ",
+ "๐ฑ๏ธ",
+ "๐ฒ๏ธ",
+ "๐ฝ",
+ "๐พ",
+ "๐ฟ",
+ "๐",
+ "๐งฎ",
+ "๐ฅ",
+ "๐๏ธ",
+ "๐ฝ๏ธ",
+ "๐ฌ",
+ "๐บ",
+ "๐ท",
+ "๐ธ",
+ "๐น",
+ "๐ผ",
+ "๐",
+ "๐",
+ "๐ฏ๏ธ",
+ "๐ก",
+ "๐ฆ",
+ "๐ฎ",
+ "๐ช",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐ฐ",
+ "๐๏ธ",
+ "๐",
+ "๐",
+ "๐ท๏ธ",
+ "๐ฐ",
+ "๐ช",
+ "๐ด",
+ "๐ต",
+ "๐ถ",
+ "๐ท",
+ "๐ธ",
+ "๐ณ",
+ "๐งพ",
+ "๐น",
+ "โ๏ธ",
+ "๐ง",
+ "๐จ",
+ "๐ฉ",
+ "๐ค",
+ "๐ฅ",
+ "๐ฆ",
+ "๐ซ",
+ "๐ช",
+ "๐ฌ",
+ "๐ญ",
+ "๐ฎ",
+ "๐ณ๏ธ",
+ "โ๏ธ",
+ "โ๏ธ",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐",
+ "๐ผ",
+ "๐",
+ "๐",
+ "๐๏ธ",
+ "๐
",
+ "๐",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐๏ธ",
+ "๐",
+ "๐",
+ "โ๏ธ",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐๏ธ",
+ "๐จ",
+ "๐ช",
+ "โ๏ธ",
+ "โ๏ธ",
+ "๐ ๏ธ",
+ "๐ก๏ธ",
+ "โ๏ธ",
+ "๐ฃ",
+ "๐ช",
+ "๐น",
+ "๐ก๏ธ",
+ "๐ช",
+ "๐ง",
+ "๐ช",
+ "๐ฉ",
+ "โ๏ธ",
+ "๐๏ธ",
+ "โ๏ธ",
+ "๐ฆฏ",
+ "๐",
+ "โ๏ธโ๐ฅ",
+ "โ๏ธ",
+ "๐ช",
+ "๐งฐ",
+ "๐งฒ",
+ "๐ช",
+ "๐ช",
+ "โ๏ธ",
+ "๐งช",
+ "๐งซ",
+ "๐งฌ",
+ "๐ฌ",
+ "๐ญ",
+ "๐ก",
+ "๐",
+ "๐ฉธ",
+ "๐",
+ "๐ฉน",
+ "๐ฉผ",
+ "๐ฉบ",
+ "๐ฉป",
+ "๐ช",
+ "๐",
+ "๐ช",
+ "๐ช",
+ "๐๏ธ",
+ "๐๏ธ",
+ "๐ช",
+ "๐ฝ",
+ "๐ช ",
+ "๐ฟ",
+ "๐",
+ "๐ชค",
+ "๐ช",
+ "๐งด",
+ "๐งท",
+ "๐งน",
+ "๐งบ",
+ "๐งป",
+ "๐ชฃ",
+ "๐งผ",
+ "๐ซง",
+ "๐ชฅ",
+ "๐งฝ",
+ "๐งฏ",
+ "๐",
+ "๐ฌ",
+ "โฐ๏ธ",
+ "๐ชฆ",
+ "โฑ๏ธ",
+ "๐งฟ",
+ "๐ชฌ",
+ "๐ฟ",
+ "๐ชง",
+ "๐ชช",
+ "๐ง",
+ "๐ฎ",
+ "๐ฐ",
+ "โฟ",
+ "๐น",
+ "๐บ",
+ "๐ป",
+ "๐ผ",
+ "๐พ",
+ "๐",
+ "๐",
+ "๐",
+ "๐
",
+ "โ ๏ธ",
+ "๐ธ",
+ "โ",
+ "๐ซ",
+ "๐ณ",
+ "๐ญ",
+ "๐ฏ",
+ "๐ฑ",
+ "๐ท",
+ "๐ต",
+ "๐",
+ "โข๏ธ",
+ "โฃ๏ธ",
+ "โฌ๏ธ",
+ "โ๏ธ",
+ "โก๏ธ",
+ "โ๏ธ",
+ "โฌ๏ธ",
+ "โ๏ธ",
+ "โฌ
๏ธ",
+ "โ๏ธ",
+ "โ๏ธ",
+ "โ๏ธ",
+ "โฉ๏ธ",
+ "โช๏ธ",
+ "โคด๏ธ",
+ "โคต๏ธ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "โ๏ธ",
+ "๐๏ธ",
+ "โก๏ธ",
+ "โธ๏ธ",
+ "โฏ๏ธ",
+ "โ๏ธ",
+ "โฆ๏ธ",
+ "โช๏ธ",
+ "โฎ๏ธ",
+ "๐",
+ "๐ฏ",
+ "๐ชฏ",
+ "โ",
+ "โ",
+ "โ",
+ "โ",
+ "โ",
+ "โ",
+ "โ",
+ "โ",
+ "โ",
+ "โ",
+ "โ",
+ "โ",
+ "โ",
+ "๐",
+ "๐",
+ "๐",
+ "โถ๏ธ",
+ "โฉ",
+ "โญ๏ธ",
+ "โฏ๏ธ",
+ "โ๏ธ",
+ "โช",
+ "โฎ๏ธ",
+ "๐ผ",
+ "โซ",
+ "๐ฝ",
+ "โฌ",
+ "โธ๏ธ",
+ "โน๏ธ",
+ "โบ๏ธ",
+ "โ๏ธ",
+ "๐ฆ",
+ "๐
",
+ "๐",
+ "๐ถ",
+ "๐",
+ "๐ณ",
+ "๐ด",
+ "โ๏ธ",
+ "โ๏ธ",
+ "โง๏ธ",
+ "โ๏ธ",
+ "โ",
+ "โ",
+ "โ",
+ "๐ฐ",
+ "โพ๏ธ",
+ "โผ๏ธ",
+ "โ๏ธ",
+ "โ",
+ "โ",
+ "โ",
+ "โ",
+ "ใฐ๏ธ",
+ "๐ฑ",
+ "๐ฒ",
+ "โ๏ธ",
+ "โป๏ธ",
+ "โ๏ธ",
+ "๐ฑ",
+ "๐",
+ "๐ฐ",
+ "โญ",
+ "โ
",
+ "โ๏ธ",
+ "โ๏ธ",
+ "โ",
+ "โ",
+ "โฐ",
+ "โฟ",
+ "ใฝ๏ธ",
+ "โณ๏ธ",
+ "โด๏ธ",
+ "โ๏ธ",
+ "ยฉ๏ธ",
+ "ยฎ๏ธ",
+ "โข๏ธ",
+ "๐ซ",
+ "#๏ธโฃ",
+ "*๏ธโฃ",
+ "0๏ธโฃ",
+ "1๏ธโฃ",
+ "2๏ธโฃ",
+ "3๏ธโฃ",
+ "4๏ธโฃ",
+ "5๏ธโฃ",
+ "6๏ธโฃ",
+ "7๏ธโฃ",
+ "8๏ธโฃ",
+ "9๏ธโฃ",
+ "๐",
+ "๐ ",
+ "๐ก",
+ "๐ข",
+ "๐ฃ",
+ "๐ค",
+ "๐
ฐ๏ธ",
+ "๐",
+ "๐
ฑ๏ธ",
+ "๐",
+ "๐",
+ "๐",
+ "โน๏ธ",
+ "๐",
+ "โ๏ธ",
+ "๐",
+ "๐",
+ "๐
พ๏ธ",
+ "๐",
+ "๐
ฟ๏ธ",
+ "๐",
+ "๐",
+ "๐",
+ "๐",
+ "๐๏ธ",
+ "๐ท๏ธ",
+ "๐ถ",
+ "๐ฏ",
+ "๐",
+ "๐น",
+ "๐",
+ "๐ฒ",
+ "๐",
+ "๐ธ",
+ "๐ด",
+ "๐ณ",
+ "ใ๏ธ",
+ "ใ๏ธ",
+ "๐บ",
+ "๐ต",
+ "๐ด",
+ "๐ ",
+ "๐ก",
+ "๐ข",
+ "๐ต",
+ "๐ฃ",
+ "๐ค",
+ "โซ",
+ "โช",
+ "๐ฅ",
+ "๐ง",
+ "๐จ",
+ "๐ฉ",
+ "๐ฆ",
+ "๐ช",
+ "๐ซ",
+ "โฌ",
+ "โฌ",
+ "โผ๏ธ",
+ "โป๏ธ",
+ "โพ",
+ "โฝ",
+ "โช๏ธ",
+ "โซ๏ธ",
+ "๐ถ",
+ "๐ท",
+ "๐ธ",
+ "๐น",
+ "๐บ",
+ "๐ป",
+ "๐ ",
+ "๐",
+ "๐ณ",
+ "๐ฒ",
+ "๐",
+ "๐ฉ",
+ "๐",
+ "๐ด",
+ "๐ณ๏ธ",
+ "๐ณ๏ธโ๐",
+ "๐ณ๏ธโโง๏ธ",
+ "๐ดโโ ๏ธ",
+ "๐ฆ๐จ",
+ "๐ฆ๐ฉ",
+ "๐ฆ๐ช",
+ "๐ฆ๐ซ",
+ "๐ฆ๐ฌ",
+ "๐ฆ๐ฎ",
+ "๐ฆ๐ฑ",
+ "๐ฆ๐ฒ",
+ "๐ฆ๐ด",
+ "๐ฆ๐ถ",
+ "๐ฆ๐ท",
+ "๐ฆ๐ธ",
+ "๐ฆ๐น",
+ "๐ฆ๐บ",
+ "๐ฆ๐ผ",
+ "๐ฆ๐ฝ",
+ "๐ฆ๐ฟ",
+ "๐ง๐ฆ",
+ "๐ง๐ง",
+ "๐ง๐ฉ",
+ "๐ง๐ช",
+ "๐ง๐ซ",
+ "๐ง๐ฌ",
+ "๐ง๐ญ",
+ "๐ง๐ฎ",
+ "๐ง๐ฏ",
+ "๐ง๐ฑ",
+ "๐ง๐ฒ",
+ "๐ง๐ณ",
+ "๐ง๐ด",
+ "๐ง๐ถ",
+ "๐ง๐ท",
+ "๐ง๐ธ",
+ "๐ง๐น",
+ "๐ง๐ป",
+ "๐ง๐ผ",
+ "๐ง๐พ",
+ "๐ง๐ฟ",
+ "๐จ๐ฆ",
+ "๐จ๐จ",
+ "๐จ๐ฉ",
+ "๐จ๐ซ",
+ "๐จ๐ฌ",
+ "๐จ๐ญ",
+ "๐จ๐ฎ",
+ "๐จ๐ฐ",
+ "๐จ๐ฑ",
+ "๐จ๐ฒ",
+ "๐จ๐ณ",
+ "๐จ๐ด",
+ "๐จ๐ต",
+ "๐จ๐ถ",
+ "๐จ๐ท",
+ "๐จ๐บ",
+ "๐จ๐ป",
+ "๐จ๐ผ",
+ "๐จ๐ฝ",
+ "๐จ๐พ",
+ "๐จ๐ฟ",
+ "๐ฉ๐ช",
+ "๐ฉ๐ฌ",
+ "๐ฉ๐ฏ",
+ "๐ฉ๐ฐ",
+ "๐ฉ๐ฒ",
+ "๐ฉ๐ด",
+ "๐ฉ๐ฟ",
+ "๐ช๐ฆ",
+ "๐ช๐จ",
+ "๐ช๐ช",
+ "๐ช๐ฌ",
+ "๐ช๐ญ",
+ "๐ช๐ท",
+ "๐ช๐ธ",
+ "๐ช๐น",
+ "๐ช๐บ",
+ "๐ซ๐ฎ",
+ "๐ซ๐ฏ",
+ "๐ซ๐ฐ",
+ "๐ซ๐ฒ",
+ "๐ซ๐ด",
+ "๐ซ๐ท",
+ "๐ฌ๐ฆ",
+ "๐ฌ๐ง",
+ "๐ฌ๐ฉ",
+ "๐ฌ๐ช",
+ "๐ฌ๐ซ",
+ "๐ฌ๐ฌ",
+ "๐ฌ๐ญ",
+ "๐ฌ๐ฎ",
+ "๐ฌ๐ฑ",
+ "๐ฌ๐ฒ",
+ "๐ฌ๐ณ",
+ "๐ฌ๐ต",
+ "๐ฌ๐ถ",
+ "๐ฌ๐ท",
+ "๐ฌ๐ธ",
+ "๐ฌ๐น",
+ "๐ฌ๐บ",
+ "๐ฌ๐ผ",
+ "๐ฌ๐พ",
+ "๐ญ๐ฐ",
+ "๐ญ๐ฒ",
+ "๐ญ๐ณ",
+ "๐ญ๐ท",
+ "๐ญ๐น",
+ "๐ญ๐บ",
+ "๐ฎ๐จ",
+ "๐ฎ๐ฉ",
+ "๐ฎ๐ช",
+ "๐ฎ๐ฑ",
+ "๐ฎ๐ฒ",
+ "๐ฎ๐ณ",
+ "๐ฎ๐ด",
+ "๐ฎ๐ถ",
+ "๐ฎ๐ท",
+ "๐ฎ๐ธ",
+ "๐ฎ๐น",
+ "๐ฏ๐ช",
+ "๐ฏ๐ฒ",
+ "๐ฏ๐ด",
+ "๐ฏ๐ต",
+ "๐ฐ๐ช",
+ "๐ฐ๐ฌ",
+ "๐ฐ๐ญ",
+ "๐ฐ๐ฎ",
+ "๐ฐ๐ฒ",
+ "๐ฐ๐ณ",
+ "๐ฐ๐ต",
+ "๐ฐ๐ท",
+ "๐ฐ๐ผ",
+ "๐ฐ๐พ",
+ "๐ฐ๐ฟ",
+ "๐ฑ๐ฆ",
+ "๐ฑ๐ง",
+ "๐ฑ๐จ",
+ "๐ฑ๐ฎ",
+ "๐ฑ๐ฐ",
+ "๐ฑ๐ท",
+ "๐ฑ๐ธ",
+ "๐ฑ๐น",
+ "๐ฑ๐บ",
+ "๐ฑ๐ป",
+ "๐ฑ๐พ",
+ "๐ฒ๐ฆ",
+ "๐ฒ๐จ",
+ "๐ฒ๐ฉ",
+ "๐ฒ๐ช",
+ "๐ฒ๐ซ",
+ "๐ฒ๐ฌ",
+ "๐ฒ๐ญ",
+ "๐ฒ๐ฐ",
+ "๐ฒ๐ฑ",
+ "๐ฒ๐ฒ",
+ "๐ฒ๐ณ",
+ "๐ฒ๐ด",
+ "๐ฒ๐ต",
+ "๐ฒ๐ถ",
+ "๐ฒ๐ท",
+ "๐ฒ๐ธ",
+ "๐ฒ๐น",
+ "๐ฒ๐บ",
+ "๐ฒ๐ป",
+ "๐ฒ๐ผ",
+ "๐ฒ๐ฝ",
+ "๐ฒ๐พ",
+ "๐ฒ๐ฟ",
+ "๐ณ๐ฆ",
+ "๐ณ๐จ",
+ "๐ณ๐ช",
+ "๐ณ๐ซ",
+ "๐ณ๐ฌ",
+ "๐ณ๐ฎ",
+ "๐ณ๐ฑ",
+ "๐ณ๐ด",
+ "๐ณ๐ต",
+ "๐ณ๐ท",
+ "๐ณ๐บ",
+ "๐ณ๐ฟ",
+ "๐ด๐ฒ",
+ "๐ต๐ฆ",
+ "๐ต๐ช",
+ "๐ต๐ซ",
+ "๐ต๐ฌ",
+ "๐ต๐ญ",
+ "๐ต๐ฐ",
+ "๐ต๐ฑ",
+ "๐ต๐ฒ",
+ "๐ต๐ณ",
+ "๐ต๐ท",
+ "๐ต๐ธ",
+ "๐ต๐น",
+ "๐ต๐ผ",
+ "๐ต๐พ",
+ "๐ถ๐ฆ",
+ "๐ท๐ช",
+ "๐ท๐ด",
+ "๐ท๐ธ",
+ "๐ท๐บ",
+ "๐ท๐ผ",
+ "๐ธ๐ฆ",
+ "๐ธ๐ง",
+ "๐ธ๐จ",
+ "๐ธ๐ฉ",
+ "๐ธ๐ช",
+ "๐ธ๐ฌ",
+ "๐ธ๐ญ",
+ "๐ธ๐ฎ",
+ "๐ธ๐ฏ",
+ "๐ธ๐ฐ",
+ "๐ธ๐ฑ",
+ "๐ธ๐ฒ",
+ "๐ธ๐ณ",
+ "๐ธ๐ด",
+ "๐ธ๐ท",
+ "๐ธ๐ธ",
+ "๐ธ๐น",
+ "๐ธ๐ป",
+ "๐ธ๐ฝ",
+ "๐ธ๐พ",
+ "๐ธ๐ฟ",
+ "๐น๐ฆ",
+ "๐น๐จ",
+ "๐น๐ฉ",
+ "๐น๐ซ",
+ "๐น๐ฌ",
+ "๐น๐ญ",
+ "๐น๐ฏ",
+ "๐น๐ฐ",
+ "๐น๐ฑ",
+ "๐น๐ฒ",
+ "๐น๐ณ",
+ "๐น๐ด",
+ "๐น๐ท",
+ "๐น๐น",
+ "๐น๐ป",
+ "๐น๐ผ",
+ "๐น๐ฟ",
+ "๐บ๐ฆ",
+ "๐บ๐ฌ",
+ "๐บ๐ฒ",
+ "๐บ๐ณ",
+ "๐บ๐ธ",
+ "๐บ๐พ",
+ "๐บ๐ฟ",
+ "๐ป๐ฆ",
+ "๐ป๐จ",
+ "๐ป๐ช",
+ "๐ป๐ฌ",
+ "๐ป๐ฎ",
+ "๐ป๐ณ",
+ "๐ป๐บ",
+ "๐ผ๐ซ",
+ "๐ผ๐ธ",
+ "๐ฝ๐ฐ",
+ "๐พ๐ช",
+ "๐พ๐น",
+ "๐ฟ๐ฆ",
+ "๐ฟ๐ฒ",
+ "๐ฟ๐ผ",
+ "๐ด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ",
+ "๐ด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ",
+ "๐ด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ"
+ ];
+}
\ No newline at end of file
diff --git a/Femto.Modules.Files/Domain/Files/File.cs b/Femto.Modules.Files/Domain/Files/File.cs
deleted file mode 100644
index 9600ceb..0000000
--- a/Femto.Modules.Files/Domain/Files/File.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace Femto.Modules.Files.Domain.Files;
-
-public class File
-{
- Guid Id { get; set; }
-
-
-}
\ No newline at end of file
diff --git a/Femto.Modules.Files/Femto.Modules.Files.csproj b/Femto.Modules.Files/Femto.Modules.Files.csproj
deleted file mode 100644
index 17b910f..0000000
--- a/Femto.Modules.Files/Femto.Modules.Files.csproj
+++ /dev/null
@@ -1,9 +0,0 @@
-๏ปฟ
-
-
- net9.0
- enable
- enable
-
-
-
diff --git a/scripts/generate-emojis.js b/scripts/generate-emojis.js
new file mode 100644
index 0000000..ba3a3b6
--- /dev/null
+++ b/scripts/generate-emojis.js
@@ -0,0 +1,38 @@
+try {
+ console.error('Downloading emoji-test.txt...');
+ const response = await fetch('https://unicode.org/Public/emoji/latest/emoji-test.txt');
+ const text = await response.text();
+ const emojis = extractEmojis(text);
+ console.error(`Extracted ${emojis.length} fully-qualified emojis.`);
+ console.log(`
+ namespace Femto.Modules.Blog.Emoji;
+
+ internal static partial class AllEmoji
+ {
+ public static readonly string[] Emojis = [\n${emojis.map(e => `"${e}"`).join(',\n')}\n];
+ }
+ `)
+} catch (err) {
+ console.error('Error:', err);
+}
+
+
+function extractEmojis(text) {
+ const lines = text.split('\n');
+ const emojis = [];
+
+ for (const line of lines) {
+ if (line.startsWith('#') || line.trim() === '') continue;
+
+ const [codePart, descPart] = line.split(';');
+ if (!descPart || !descPart.includes('fully-qualified')) continue;
+
+ const match = line.match(/#\s+(.+?)\s+E\d+\.\d+/);
+ if (match) {
+ emojis.push(match[1]);
+ }
+ }
+
+ return emojis;
+}
+