Ajoute les amis et les invitations temps reel

This commit is contained in:
2026-04-15 23:08:48 +02:00
parent 9aae4cadc0
commit 8ea6ef8424
18 changed files with 3136 additions and 25 deletions

View File

@@ -3,10 +3,12 @@ using System.Net.Mail;
using ChessCubing.Server.Admin;
using ChessCubing.Server.Auth;
using ChessCubing.Server.Data;
using ChessCubing.Server.Social;
using ChessCubing.Server.Users;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
var builder = WebApplication.CreateBuilder(args);
@@ -67,15 +69,21 @@ builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));
});
builder.Services.AddSignalR();
builder.Services.AddHttpClient<KeycloakAuthService>();
builder.Services.AddSingleton<MySqlUserProfileStore>();
builder.Services.AddSingleton<MySqlSocialStore>();
builder.Services.AddSingleton<ConnectedUserTracker>();
builder.Services.AddSingleton<PlayInviteCoordinator>();
var app = builder.Build();
await using (var scope = app.Services.CreateAsyncScope())
{
var profileStore = scope.ServiceProvider.GetRequiredService<MySqlUserProfileStore>();
var socialStore = scope.ServiceProvider.GetRequiredService<MySqlSocialStore>();
await profileStore.InitializeAsync(CancellationToken.None);
await socialStore.InitializeAsync(CancellationToken.None);
}
app.UseAuthentication();
@@ -124,6 +132,169 @@ app.MapPut("/api/users/me", async Task<IResult> (
}
}).RequireAuthorization();
var socialGroup = app.MapGroup("/api/social")
.RequireAuthorization();
socialGroup.MapGet("/overview", async Task<IResult> (
ClaimsPrincipal user,
MySqlSocialStore socialStore,
ConnectedUserTracker tracker,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
var overview = await socialStore.GetOverviewAsync(siteUser.Subject, tracker.IsOnline, cancellationToken);
return TypedResults.Ok(overview);
});
socialGroup.MapGet("/search", async Task<IResult> (
string? query,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
ConnectedUserTracker tracker,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var results = await socialStore.SearchUsersAsync(siteUser.Subject, query, tracker.IsOnline, cancellationToken);
return TypedResults.Ok(results);
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapPost("/invitations", async Task<IResult> (
SendFriendInvitationRequest request,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var targetSubject = request.TargetSubject?.Trim() ?? string.Empty;
await socialStore.SendInvitationAsync(siteUser.Subject, targetSubject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, targetSubject);
return TypedResults.NoContent();
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapPost("/invitations/{invitationId:long}/accept", async Task<IResult> (
long invitationId,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var senderSubject = await socialStore.AcceptInvitationAsync(invitationId, siteUser.Subject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, senderSubject);
return TypedResults.NoContent();
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapPost("/invitations/{invitationId:long}/decline", async Task<IResult> (
long invitationId,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var senderSubject = await socialStore.DeclineInvitationAsync(invitationId, siteUser.Subject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, senderSubject);
return TypedResults.NoContent();
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapDelete("/invitations/{invitationId:long}", async Task<IResult> (
long invitationId,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var recipientSubject = await socialStore.CancelInvitationAsync(invitationId, siteUser.Subject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, recipientSubject);
return TypedResults.NoContent();
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapDelete("/friends/{friendSubject}", async Task<IResult> (
string friendSubject,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
var normalizedFriendSubject = friendSubject.Trim();
await socialStore.RemoveFriendAsync(siteUser.Subject, normalizedFriendSubject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, normalizedFriendSubject);
return TypedResults.NoContent();
});
var adminGroup = app.MapGroup("/api/admin")
.RequireAuthorization("AdminOnly");
@@ -230,11 +401,13 @@ adminGroup.MapDelete("/users/{subject}", async Task<IResult> (
string subject,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
MySqlSocialStore socialStore,
CancellationToken cancellationToken) =>
{
try
{
await keycloak.DeleteAdminUserAsync(subject, cancellationToken);
await socialStore.DeleteUserAsync(subject, cancellationToken);
await profileStore.DeleteAsync(subject, cancellationToken);
return TypedResults.NoContent();
}
@@ -307,6 +480,7 @@ app.MapPost("/api/auth/login", async Task<IResult> (
LoginRequest request,
HttpContext httpContext,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
@@ -318,6 +492,7 @@ app.MapPost("/api/auth/login", async Task<IResult> (
{
var userInfo = await keycloak.LoginAsync(request.Username.Trim(), request.Password, cancellationToken);
await SignInAsync(httpContext, userInfo);
await EnsureSiteUserAsync(profileStore, userInfo, cancellationToken);
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
}
catch (KeycloakAuthException exception)
@@ -330,6 +505,7 @@ app.MapPost("/api/auth/register", async Task<IResult> (
RegisterRequest request,
HttpContext httpContext,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Username) ||
@@ -355,6 +531,7 @@ app.MapPost("/api/auth/register", async Task<IResult> (
}, cancellationToken);
await SignInAsync(httpContext, userInfo);
await EnsureSiteUserAsync(profileStore, userInfo, cancellationToken);
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
}
catch (KeycloakAuthException exception)
@@ -375,6 +552,9 @@ app.MapGet("/api/auth/logout/browser", async Task<IResult> (HttpContext httpCont
return TypedResults.Redirect("/index.html");
});
app.MapHub<SocialHub>("/hubs/social")
.RequireAuthorization();
app.Run();
static AdminUserSummaryResponse MapAdminSummary(AdminIdentityUser identity, UserProfileResponse? profile)
@@ -577,6 +757,32 @@ static async Task SignInAsync(HttpContext httpContext, KeycloakUserInfo userInfo
httpContext.User = principal;
}
static async Task EnsureSiteUserAsync(
MySqlUserProfileStore profileStore,
KeycloakUserInfo userInfo,
CancellationToken cancellationToken)
{
var siteUser = AuthenticatedSiteUserFactory.FromKeycloakUserInfo(userInfo);
if (siteUser is null)
{
return;
}
await profileStore.GetOrCreateAsync(siteUser, cancellationToken);
}
static Task NotifySocialChangedAsync(IHubContext<SocialHub> hubContext, params string[] subjects)
{
var distinctSubjects = subjects
.Where(subject => !string.IsNullOrWhiteSpace(subject))
.Distinct(StringComparer.Ordinal)
.ToArray();
return distinctSubjects.Length == 0
? Task.CompletedTask
: hubContext.Clients.Users(distinctSubjects).SendAsync("SocialChanged");
}
sealed record NormalizedAdminUserUpdate(
string? Email,
string? FirstName,