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,

View File

@@ -0,0 +1,60 @@
namespace ChessCubing.Server.Social;
public sealed class ConnectedUserTracker
{
private readonly object _sync = new();
private readonly Dictionary<string, string> _subjectByConnection = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<string>> _connectionsBySubject = new(StringComparer.Ordinal);
public bool TrackConnection(string connectionId, string subject)
{
lock (_sync)
{
_subjectByConnection[connectionId] = subject;
if (!_connectionsBySubject.TryGetValue(subject, out var connections))
{
connections = new HashSet<string>(StringComparer.Ordinal);
_connectionsBySubject[subject] = connections;
}
var wasOffline = connections.Count == 0;
connections.Add(connectionId);
return wasOffline;
}
}
public (string? Subject, bool BecameOffline) RemoveConnection(string connectionId)
{
lock (_sync)
{
if (!_subjectByConnection.Remove(connectionId, out var subject))
{
return (null, false);
}
if (!_connectionsBySubject.TryGetValue(subject, out var connections))
{
return (subject, false);
}
connections.Remove(connectionId);
if (connections.Count > 0)
{
return (subject, false);
}
_connectionsBySubject.Remove(subject);
return (subject, true);
}
}
public bool IsOnline(string subject)
{
lock (_sync)
{
return _connectionsBySubject.TryGetValue(subject, out var connections)
&& connections.Count > 0;
}
}
}

View File

@@ -0,0 +1,787 @@
using ChessCubing.Server.Data;
using Microsoft.Extensions.Options;
using MySqlConnector;
namespace ChessCubing.Server.Social;
public sealed class MySqlSocialStore(
IOptions<SiteDataOptions> options,
ILogger<MySqlSocialStore> logger)
{
private const string CreateFriendshipsTableSql = """
CREATE TABLE IF NOT EXISTS social_friendships (
id BIGINT NOT NULL AUTO_INCREMENT,
subject_low VARCHAR(190) NOT NULL,
subject_high VARCHAR(190) NOT NULL,
created_utc DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_social_friendships_pair (subject_low, subject_high),
KEY ix_social_friendships_low (subject_low),
KEY ix_social_friendships_high (subject_high)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
""";
private const string CreateInvitationsTableSql = """
CREATE TABLE IF NOT EXISTS social_friend_invitations (
id BIGINT NOT NULL AUTO_INCREMENT,
sender_subject VARCHAR(190) NOT NULL,
recipient_subject VARCHAR(190) NOT NULL,
created_utc DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_social_friend_invitations_pair (sender_subject, recipient_subject),
KEY ix_social_friend_invitations_sender (sender_subject),
KEY ix_social_friend_invitations_recipient (recipient_subject)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
""";
private const string SelectOverviewFriendsSql = """
SELECT
u.subject,
u.username,
u.display_name,
u.email,
u.club,
u.city
FROM social_friendships f
INNER JOIN site_users u
ON u.subject = CASE
WHEN f.subject_low = @subject THEN f.subject_high
ELSE f.subject_low
END
WHERE f.subject_low = @subject OR f.subject_high = @subject
ORDER BY u.display_name ASC, u.username ASC;
""";
private const string SelectReceivedInvitationsSql = """
SELECT
i.id,
u.subject,
u.username,
u.display_name,
u.email,
i.created_utc
FROM social_friend_invitations i
INNER JOIN site_users u
ON u.subject = i.sender_subject
WHERE i.recipient_subject = @subject
ORDER BY i.created_utc DESC, u.display_name ASC, u.username ASC;
""";
private const string SelectSentInvitationsSql = """
SELECT
i.id,
u.subject,
u.username,
u.display_name,
u.email,
i.created_utc
FROM social_friend_invitations i
INNER JOIN site_users u
ON u.subject = i.recipient_subject
WHERE i.sender_subject = @subject
ORDER BY i.created_utc DESC, u.display_name ASC, u.username ASC;
""";
private const string SearchUsersTemplateSql = """
SELECT
u.subject,
u.username,
u.display_name,
u.email,
u.club,
u.city,
EXISTS(
SELECT 1
FROM social_friendships f
WHERE (f.subject_low = @subject AND f.subject_high = u.subject)
OR (f.subject_high = @subject AND f.subject_low = u.subject)
) AS is_friend,
EXISTS(
SELECT 1
FROM social_friend_invitations i
WHERE i.sender_subject = @subject
AND i.recipient_subject = u.subject
) AS has_sent_invitation,
EXISTS(
SELECT 1
FROM social_friend_invitations i
WHERE i.sender_subject = u.subject
AND i.recipient_subject = @subject
) AS has_received_invitation
FROM site_users u
WHERE u.subject <> @subject
AND (
u.username LIKE @pattern
OR u.display_name LIKE @pattern
OR COALESCE(u.email, '') LIKE @pattern
OR COALESCE(u.club, '') LIKE @pattern
OR COALESCE(u.city, '') LIKE @pattern
)
ORDER BY
is_friend DESC,
has_received_invitation DESC,
has_sent_invitation DESC,
u.display_name ASC,
u.username ASC
LIMIT @limit;
""";
private const string SelectRelevantPresenceSubjectsSql = """
SELECT participant_subject
FROM (
SELECT CASE
WHEN f.subject_low = @subject THEN f.subject_high
ELSE f.subject_low
END AS participant_subject
FROM social_friendships f
WHERE f.subject_low = @subject OR f.subject_high = @subject
UNION
SELECT i.sender_subject AS participant_subject
FROM social_friend_invitations i
WHERE i.recipient_subject = @subject
UNION
SELECT i.recipient_subject AS participant_subject
FROM social_friend_invitations i
WHERE i.sender_subject = @subject
) participants
WHERE participant_subject <> @subject;
""";
private const string SelectFriendSubjectsSql = """
SELECT CASE
WHEN f.subject_low = @subject THEN f.subject_high
ELSE f.subject_low
END AS friend_subject
FROM social_friendships f
WHERE f.subject_low = @subject OR f.subject_high = @subject;
""";
private const string SelectKnownUserSubjectSql = """
SELECT subject
FROM site_users
WHERE subject = @subject
LIMIT 1;
""";
private const string SelectInvitationBetweenSql = """
SELECT
id,
sender_subject,
recipient_subject
FROM social_friend_invitations
WHERE (sender_subject = @subjectA AND recipient_subject = @subjectB)
OR (sender_subject = @subjectB AND recipient_subject = @subjectA)
LIMIT 1;
""";
private const string SelectInvitationForRecipientSql = """
SELECT
id,
sender_subject,
recipient_subject
FROM social_friend_invitations
WHERE id = @invitationId
AND recipient_subject = @recipientSubject
LIMIT 1;
""";
private const string SelectFriendshipExistsSql = """
SELECT 1
FROM social_friendships
WHERE subject_low = @subjectLow
AND subject_high = @subjectHigh
LIMIT 1;
""";
private const string InsertInvitationSql = """
INSERT INTO social_friend_invitations (
sender_subject,
recipient_subject,
created_utc
)
VALUES (
@senderSubject,
@recipientSubject,
@createdUtc
);
""";
private const string InsertFriendshipSql = """
INSERT IGNORE INTO social_friendships (
subject_low,
subject_high,
created_utc
)
VALUES (
@subjectLow,
@subjectHigh,
@createdUtc
);
""";
private const string DeleteInvitationByRecipientSql = """
DELETE FROM social_friend_invitations
WHERE id = @invitationId
AND recipient_subject = @recipientSubject;
""";
private const string DeleteInvitationBySenderSql = """
DELETE FROM social_friend_invitations
WHERE id = @invitationId
AND sender_subject = @senderSubject;
""";
private const string DeleteInvitationsBetweenSql = """
DELETE FROM social_friend_invitations
WHERE (sender_subject = @subjectA AND recipient_subject = @subjectB)
OR (sender_subject = @subjectB AND recipient_subject = @subjectA);
""";
private const string DeleteFriendshipSql = """
DELETE FROM social_friendships
WHERE subject_low = @subjectLow
AND subject_high = @subjectHigh;
""";
private const string DeleteUserFriendshipsSql = """
DELETE FROM social_friendships
WHERE subject_low = @subject
OR subject_high = @subject;
""";
private const string DeleteUserInvitationsSql = """
DELETE FROM social_friend_invitations
WHERE sender_subject = @subject
OR recipient_subject = @subject;
""";
private readonly SiteDataOptions _options = options.Value;
private readonly ILogger<MySqlSocialStore> _logger = logger;
public async Task InitializeAsync(CancellationToken cancellationToken)
{
for (var attempt = 1; attempt <= _options.InitializationRetries; attempt++)
{
try
{
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await CreateSchemaAsync(connection, cancellationToken);
return;
}
catch (Exception exception) when (attempt < _options.InitializationRetries)
{
_logger.LogWarning(
exception,
"Initialisation MySQL impossible pour le module social (tentative {Attempt}/{MaxAttempts}).",
attempt,
_options.InitializationRetries);
await Task.Delay(TimeSpan.FromSeconds(_options.InitializationDelaySeconds), cancellationToken);
}
}
await using var finalConnection = new MySqlConnection(_options.BuildConnectionString());
await finalConnection.OpenAsync(cancellationToken);
await CreateSchemaAsync(finalConnection, cancellationToken);
}
public async Task<SocialOverviewResponse> GetOverviewAsync(
string subject,
Func<string, bool> isOnline,
CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
var friends = new List<SocialFriendResponse>();
await using (var command = connection.CreateCommand())
{
command.CommandText = SelectOverviewFriendsSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var friendSubject = ReadString(reader, "subject");
friends.Add(new SocialFriendResponse
{
Subject = friendSubject,
Username = ReadString(reader, "username"),
DisplayName = ReadString(reader, "display_name"),
Email = ReadNullableString(reader, "email"),
Club = ReadNullableString(reader, "club"),
City = ReadNullableString(reader, "city"),
IsOnline = isOnline(friendSubject),
});
}
}
var receivedInvitations = new List<SocialInvitationResponse>();
await using (var command = connection.CreateCommand())
{
command.CommandText = SelectReceivedInvitationsSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var senderSubject = ReadString(reader, "subject");
receivedInvitations.Add(new SocialInvitationResponse
{
InvitationId = ReadInt64(reader, "id"),
Subject = senderSubject,
Username = ReadString(reader, "username"),
DisplayName = ReadString(reader, "display_name"),
Email = ReadNullableString(reader, "email"),
CreatedUtc = ReadDateTime(reader, "created_utc"),
IsOnline = isOnline(senderSubject),
});
}
}
var sentInvitations = new List<SocialInvitationResponse>();
await using (var command = connection.CreateCommand())
{
command.CommandText = SelectSentInvitationsSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var recipientSubject = ReadString(reader, "subject");
sentInvitations.Add(new SocialInvitationResponse
{
InvitationId = ReadInt64(reader, "id"),
Subject = recipientSubject,
Username = ReadString(reader, "username"),
DisplayName = ReadString(reader, "display_name"),
Email = ReadNullableString(reader, "email"),
CreatedUtc = ReadDateTime(reader, "created_utc"),
IsOnline = isOnline(recipientSubject),
});
}
}
return new SocialOverviewResponse
{
Friends = friends.ToArray(),
ReceivedInvitations = receivedInvitations.ToArray(),
SentInvitations = sentInvitations.ToArray(),
};
}
public async Task<IReadOnlyList<SocialSearchUserResponse>> SearchUsersAsync(
string subject,
string? query,
Func<string, bool> isOnline,
CancellationToken cancellationToken)
{
var normalizedQuery = NormalizeQuery(query);
if (normalizedQuery is null)
{
return [];
}
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = SearchUsersTemplateSql;
command.Parameters.AddWithValue("@subject", subject);
command.Parameters.AddWithValue("@pattern", $"%{normalizedQuery}%");
command.Parameters.AddWithValue("@limit", 12);
var results = new List<SocialSearchUserResponse>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var foundSubject = ReadString(reader, "subject");
results.Add(new SocialSearchUserResponse
{
Subject = foundSubject,
Username = ReadString(reader, "username"),
DisplayName = ReadString(reader, "display_name"),
Email = ReadNullableString(reader, "email"),
Club = ReadNullableString(reader, "club"),
City = ReadNullableString(reader, "city"),
IsOnline = isOnline(foundSubject),
IsFriend = ReadBoolean(reader, "is_friend"),
HasSentInvitation = ReadBoolean(reader, "has_sent_invitation"),
HasReceivedInvitation = ReadBoolean(reader, "has_received_invitation"),
});
}
return results;
}
public async Task SendInvitationAsync(
string senderSubject,
string recipientSubject,
CancellationToken cancellationToken)
{
if (string.Equals(senderSubject, recipientSubject, StringComparison.Ordinal))
{
throw new SocialValidationException("Impossible de t'inviter toi-meme en ami.");
}
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
if (!await UserExistsAsync(connection, transaction, recipientSubject, cancellationToken))
{
throw new SocialValidationException("Le joueur cible est introuvable.");
}
var pair = NormalizePair(senderSubject, recipientSubject);
if (await FriendshipExistsAsync(connection, transaction, pair.SubjectLow, pair.SubjectHigh, cancellationToken))
{
throw new SocialValidationException("Ce joueur fait deja partie de tes amis.");
}
var invitation = await ReadInvitationBetweenAsync(connection, transaction, senderSubject, recipientSubject, cancellationToken);
if (invitation is not null)
{
throw new SocialValidationException(
string.Equals(invitation.SenderSubject, senderSubject, StringComparison.Ordinal)
? "Une invitation est deja en attente pour ce joueur."
: "Ce joueur t'a deja envoye une invitation. Accepte-la depuis la page utilisateur.");
}
await using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = InsertInvitationSql;
command.Parameters.AddWithValue("@senderSubject", senderSubject);
command.Parameters.AddWithValue("@recipientSubject", recipientSubject);
command.Parameters.AddWithValue("@createdUtc", DateTime.UtcNow);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
public async Task<string> AcceptInvitationAsync(long invitationId, string recipientSubject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var invitation = await ReadInvitationForRecipientAsync(connection, transaction, invitationId, recipientSubject, cancellationToken)
?? throw new SocialValidationException("Invitation introuvable ou deja traitee.");
var pair = NormalizePair(invitation.SenderSubject, invitation.RecipientSubject);
await using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = InsertFriendshipSql;
command.Parameters.AddWithValue("@subjectLow", pair.SubjectLow);
command.Parameters.AddWithValue("@subjectHigh", pair.SubjectHigh);
command.Parameters.AddWithValue("@createdUtc", DateTime.UtcNow);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = DeleteInvitationsBetweenSql;
command.Parameters.AddWithValue("@subjectA", invitation.SenderSubject);
command.Parameters.AddWithValue("@subjectB", invitation.RecipientSubject);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
return invitation.SenderSubject;
}
public async Task<string> DeclineInvitationAsync(long invitationId, string recipientSubject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
var invitation = await ReadInvitationForRecipientAsync(connection, transaction: null, invitationId, recipientSubject, cancellationToken)
?? throw new SocialValidationException("Invitation introuvable ou deja traitee.");
await using var command = connection.CreateCommand();
command.CommandText = DeleteInvitationByRecipientSql;
command.Parameters.AddWithValue("@invitationId", invitationId);
command.Parameters.AddWithValue("@recipientSubject", recipientSubject);
var deleted = await command.ExecuteNonQueryAsync(cancellationToken);
if (deleted <= 0)
{
throw new SocialValidationException("Invitation introuvable ou deja traitee.");
}
return invitation.SenderSubject;
}
public async Task<string> CancelInvitationAsync(long invitationId, string senderSubject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
var invitation = await ReadInvitationForSenderAsync(connection, transaction: null, invitationId, senderSubject, cancellationToken)
?? throw new SocialValidationException("Invitation introuvable ou deja retiree.");
await using var command = connection.CreateCommand();
command.CommandText = DeleteInvitationBySenderSql;
command.Parameters.AddWithValue("@invitationId", invitationId);
command.Parameters.AddWithValue("@senderSubject", senderSubject);
var deleted = await command.ExecuteNonQueryAsync(cancellationToken);
if (deleted <= 0)
{
throw new SocialValidationException("Invitation introuvable ou deja retiree.");
}
return invitation.RecipientSubject;
}
public async Task RemoveFriendAsync(string subject, string friendSubject, CancellationToken cancellationToken)
{
var pair = NormalizePair(subject, friendSubject);
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = DeleteFriendshipSql;
command.Parameters.AddWithValue("@subjectLow", pair.SubjectLow);
command.Parameters.AddWithValue("@subjectHigh", pair.SubjectHigh);
await command.ExecuteNonQueryAsync(cancellationToken);
}
public async Task<bool> AreFriendsAsync(string subject, string otherSubject, CancellationToken cancellationToken)
{
var pair = NormalizePair(subject, otherSubject);
await using var connection = await OpenConnectionAsync(cancellationToken);
return await FriendshipExistsAsync(connection, transaction: null, pair.SubjectLow, pair.SubjectHigh, cancellationToken);
}
public async Task<IReadOnlyList<string>> GetRelevantPresenceSubjectsAsync(string subject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = SelectRelevantPresenceSubjectsSql;
command.Parameters.AddWithValue("@subject", subject);
var subjects = new List<string>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
subjects.Add(ReadString(reader, "participant_subject"));
}
return subjects;
}
public async Task<IReadOnlyList<string>> GetFriendSubjectsAsync(string subject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = SelectFriendSubjectsSql;
command.Parameters.AddWithValue("@subject", subject);
var subjects = new List<string>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
subjects.Add(ReadString(reader, "friend_subject"));
}
return subjects;
}
public async Task DeleteUserAsync(string subject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using (var command = connection.CreateCommand())
{
command.CommandText = DeleteUserFriendshipsSql;
command.Parameters.AddWithValue("@subject", subject);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await using (var command = connection.CreateCommand())
{
command.CommandText = DeleteUserInvitationsSql;
command.Parameters.AddWithValue("@subject", subject);
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
private async Task CreateSchemaAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
await using (var command = connection.CreateCommand())
{
command.CommandText = CreateFriendshipsTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
await using (var command = connection.CreateCommand())
{
command.CommandText = CreateInvitationsTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
return connection;
}
private static (string SubjectLow, string SubjectHigh) NormalizePair(string subjectA, string subjectB)
=> string.CompareOrdinal(subjectA, subjectB) <= 0
? (subjectA, subjectB)
: (subjectB, subjectA);
private static string? NormalizeQuery(string? query)
{
var trimmed = query?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return null;
}
if (trimmed.Length < 2)
{
throw new SocialValidationException("La recherche d'amis demande au moins 2 caracteres.");
}
return trimmed.Length > 80
? trimmed[..80]
: trimmed;
}
private static async Task<bool> UserExistsAsync(
MySqlConnection connection,
MySqlTransaction transaction,
string subject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectKnownUserSubjectSql;
command.Parameters.AddWithValue("@subject", subject);
return await command.ExecuteScalarAsync(cancellationToken) is not null;
}
private static async Task<bool> FriendshipExistsAsync(
MySqlConnection connection,
MySqlTransaction? transaction,
string subjectLow,
string subjectHigh,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectFriendshipExistsSql;
command.Parameters.AddWithValue("@subjectLow", subjectLow);
command.Parameters.AddWithValue("@subjectHigh", subjectHigh);
return await command.ExecuteScalarAsync(cancellationToken) is not null;
}
private static async Task<InvitationRow?> ReadInvitationBetweenAsync(
MySqlConnection connection,
MySqlTransaction transaction,
string subjectA,
string subjectB,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectInvitationBetweenSql;
command.Parameters.AddWithValue("@subjectA", subjectA);
command.Parameters.AddWithValue("@subjectB", subjectB);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new InvitationRow(
ReadInt64(reader, "id"),
ReadString(reader, "sender_subject"),
ReadString(reader, "recipient_subject"));
}
private static async Task<InvitationRow?> ReadInvitationForRecipientAsync(
MySqlConnection connection,
MySqlTransaction? transaction,
long invitationId,
string recipientSubject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectInvitationForRecipientSql;
command.Parameters.AddWithValue("@invitationId", invitationId);
command.Parameters.AddWithValue("@recipientSubject", recipientSubject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new InvitationRow(
ReadInt64(reader, "id"),
ReadString(reader, "sender_subject"),
ReadString(reader, "recipient_subject"));
}
private static async Task<InvitationRow?> ReadInvitationForSenderAsync(
MySqlConnection connection,
MySqlTransaction? transaction,
long invitationId,
string senderSubject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
SELECT
id,
sender_subject,
recipient_subject
FROM social_friend_invitations
WHERE id = @invitationId
AND sender_subject = @senderSubject
LIMIT 1;
""";
command.Parameters.AddWithValue("@invitationId", invitationId);
command.Parameters.AddWithValue("@senderSubject", senderSubject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new InvitationRow(
ReadInt64(reader, "id"),
ReadString(reader, "sender_subject"),
ReadString(reader, "recipient_subject"));
}
private static bool ReadBoolean(MySqlDataReader reader, string columnName)
=> Convert.ToInt32(reader[columnName]) > 0;
private static string ReadString(MySqlDataReader reader, string columnName)
=> reader.GetString(reader.GetOrdinal(columnName));
private static long ReadInt64(MySqlDataReader reader, string columnName)
=> reader.GetInt64(reader.GetOrdinal(columnName));
private static DateTime ReadDateTime(MySqlDataReader reader, string columnName)
=> reader.GetDateTime(reader.GetOrdinal(columnName));
private static string? ReadNullableString(MySqlDataReader reader, string columnName)
{
var ordinal = reader.GetOrdinal(columnName);
return reader.IsDBNull(ordinal)
? null
: reader.GetString(ordinal);
}
private sealed record InvitationRow(long Id, string SenderSubject, string RecipientSubject);
}

View File

@@ -0,0 +1,211 @@
namespace ChessCubing.Server.Social;
public sealed class PlayInviteCoordinator
{
private static readonly TimeSpan InviteLifetime = TimeSpan.FromMinutes(2);
private readonly object _sync = new();
private readonly Dictionary<string, PlayInviteState> _invitesById = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _inviteByParticipant = new(StringComparer.Ordinal);
public PlayInviteMessage CreateInvite(PlayInviteParticipant sender, PlayInviteParticipant recipient, string recipientColor)
{
var normalizedColor = NormalizeRecipientColor(recipientColor);
lock (_sync)
{
CleanupExpiredUnsafe();
if (_inviteByParticipant.ContainsKey(sender.Subject))
{
throw new SocialValidationException("Une invitation de partie est deja en cours pour ton compte.");
}
if (_inviteByParticipant.ContainsKey(recipient.Subject))
{
throw new SocialValidationException("Cet ami traite deja une autre invitation de partie.");
}
var nowUtc = DateTime.UtcNow;
var state = new PlayInviteState(
Guid.NewGuid().ToString("N"),
sender,
recipient,
normalizedColor,
nowUtc,
nowUtc.Add(InviteLifetime));
_invitesById[state.InviteId] = state;
_inviteByParticipant[sender.Subject] = state.InviteId;
_inviteByParticipant[recipient.Subject] = state.InviteId;
return MapInvite(state);
}
}
public PlayInviteCloseResult CancelInvite(string inviteId, string senderSubject)
{
lock (_sync)
{
var state = GetActiveInviteUnsafe(inviteId);
if (!string.Equals(state.Sender.Subject, senderSubject, StringComparison.Ordinal))
{
throw new SocialValidationException("Seul l'expediteur peut annuler cette invitation.");
}
RemoveInviteUnsafe(state);
return new PlayInviteCloseResult(
state.Sender.Subject,
state.Recipient.Subject,
new PlayInviteClosedMessage
{
InviteId = state.InviteId,
Reason = "cancelled",
Message = $"{state.Sender.DisplayName} a annule l'invitation de partie.",
});
}
}
public PlayInviteCloseResult DeclineInvite(string inviteId, string recipientSubject)
{
lock (_sync)
{
var state = GetActiveInviteUnsafe(inviteId);
if (!string.Equals(state.Recipient.Subject, recipientSubject, StringComparison.Ordinal))
{
throw new SocialValidationException("Seul le destinataire peut refuser cette invitation.");
}
RemoveInviteUnsafe(state);
return new PlayInviteCloseResult(
state.Sender.Subject,
state.Recipient.Subject,
new PlayInviteClosedMessage
{
InviteId = state.InviteId,
Reason = "declined",
Message = $"{state.Recipient.DisplayName} a refuse la partie.",
});
}
}
public PlayInviteAcceptResult AcceptInvite(string inviteId, string recipientSubject)
{
lock (_sync)
{
var state = GetActiveInviteUnsafe(inviteId);
if (!string.Equals(state.Recipient.Subject, recipientSubject, StringComparison.Ordinal))
{
throw new SocialValidationException("Seul le destinataire peut accepter cette invitation.");
}
RemoveInviteUnsafe(state);
var white = string.Equals(state.RecipientColor, "white", StringComparison.Ordinal)
? state.Recipient
: state.Sender;
var black = string.Equals(state.RecipientColor, "white", StringComparison.Ordinal)
? state.Sender
: state.Recipient;
var session = new PlaySessionResponse
{
SessionId = Guid.NewGuid().ToString("N"),
WhiteSubject = white.Subject,
WhiteName = white.DisplayName,
BlackSubject = black.Subject,
BlackName = black.DisplayName,
InitiatorSubject = state.Sender.Subject,
RecipientSubject = state.Recipient.Subject,
ConfirmedUtc = DateTime.UtcNow,
};
return new PlayInviteAcceptResult(state.Sender.Subject, state.Recipient.Subject, session);
}
}
private static string NormalizeRecipientColor(string recipientColor)
{
var normalized = recipientColor.Trim().ToLowerInvariant();
return normalized switch
{
"white" => "white",
"black" => "black",
_ => throw new SocialValidationException("La couleur demandee pour l'ami doit etre blanc ou noir."),
};
}
private static PlayInviteMessage MapInvite(PlayInviteState state)
=> new()
{
InviteId = state.InviteId,
SenderSubject = state.Sender.Subject,
SenderUsername = state.Sender.Username,
SenderDisplayName = state.Sender.DisplayName,
RecipientSubject = state.Recipient.Subject,
RecipientUsername = state.Recipient.Username,
RecipientDisplayName = state.Recipient.DisplayName,
RecipientColor = state.RecipientColor,
CreatedUtc = state.CreatedUtc,
ExpiresUtc = state.ExpiresUtc,
};
private PlayInviteState GetActiveInviteUnsafe(string inviteId)
{
CleanupExpiredUnsafe();
if (!_invitesById.TryGetValue(inviteId, out var state))
{
throw new SocialValidationException("Cette invitation de partie n'est plus disponible.");
}
return state;
}
private void CleanupExpiredUnsafe()
{
var nowUtc = DateTime.UtcNow;
var expiredInviteIds = _invitesById.Values
.Where(state => state.ExpiresUtc <= nowUtc)
.Select(state => state.InviteId)
.ToArray();
foreach (var expiredInviteId in expiredInviteIds)
{
if (_invitesById.TryGetValue(expiredInviteId, out var state))
{
RemoveInviteUnsafe(state);
}
}
}
private void RemoveInviteUnsafe(PlayInviteState state)
{
_invitesById.Remove(state.InviteId);
_inviteByParticipant.Remove(state.Sender.Subject);
_inviteByParticipant.Remove(state.Recipient.Subject);
}
private sealed record PlayInviteState(
string InviteId,
PlayInviteParticipant Sender,
PlayInviteParticipant Recipient,
string RecipientColor,
DateTime CreatedUtc,
DateTime ExpiresUtc);
}
public readonly record struct PlayInviteParticipant(
string Subject,
string Username,
string DisplayName);
public sealed record PlayInviteCloseResult(
string SenderSubject,
string RecipientSubject,
PlayInviteClosedMessage ClosedMessage);
public sealed record PlayInviteAcceptResult(
string SenderSubject,
string RecipientSubject,
PlaySessionResponse Session);

View File

@@ -0,0 +1,137 @@
namespace ChessCubing.Server.Social;
public sealed class SocialOverviewResponse
{
public SocialFriendResponse[] Friends { get; init; } = [];
public SocialInvitationResponse[] ReceivedInvitations { get; init; } = [];
public SocialInvitationResponse[] SentInvitations { get; init; } = [];
}
public sealed class SocialFriendResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public bool IsOnline { get; init; }
}
public sealed class SocialInvitationResponse
{
public long InvitationId { get; init; }
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public bool IsOnline { get; init; }
public DateTime CreatedUtc { get; init; }
}
public sealed class SocialSearchUserResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public bool IsOnline { get; init; }
public bool IsFriend { get; init; }
public bool HasSentInvitation { get; init; }
public bool HasReceivedInvitation { get; init; }
}
public sealed class SendFriendInvitationRequest
{
public string TargetSubject { get; init; } = string.Empty;
}
public sealed class PresenceSnapshotMessage
{
public string[] OnlineSubjects { get; init; } = [];
}
public sealed class PresenceChangedMessage
{
public string Subject { get; init; } = string.Empty;
public bool IsOnline { get; init; }
}
public sealed class PlayInviteMessage
{
public string InviteId { get; init; } = string.Empty;
public string SenderSubject { get; init; } = string.Empty;
public string SenderUsername { get; init; } = string.Empty;
public string SenderDisplayName { get; init; } = string.Empty;
public string RecipientSubject { get; init; } = string.Empty;
public string RecipientUsername { get; init; } = string.Empty;
public string RecipientDisplayName { get; init; } = string.Empty;
public string RecipientColor { get; init; } = string.Empty;
public DateTime CreatedUtc { get; init; }
public DateTime ExpiresUtc { get; init; }
}
public sealed class PlayInviteClosedMessage
{
public string InviteId { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
}
public sealed class PlaySessionResponse
{
public string SessionId { get; init; } = string.Empty;
public string WhiteSubject { get; init; } = string.Empty;
public string WhiteName { get; init; } = string.Empty;
public string BlackSubject { get; init; } = string.Empty;
public string BlackName { get; init; } = string.Empty;
public string InitiatorSubject { get; init; } = string.Empty;
public string RecipientSubject { get; init; } = string.Empty;
public DateTime ConfirmedUtc { get; init; }
}
public sealed class SocialValidationException(string message) : Exception(message);

View File

@@ -0,0 +1,168 @@
using ChessCubing.Server.Users;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace ChessCubing.Server.Social;
[Authorize]
public sealed class SocialHub(
ConnectedUserTracker tracker,
MySqlSocialStore socialStore,
MySqlUserProfileStore profileStore,
PlayInviteCoordinator playInviteCoordinator) : Hub
{
private readonly ConnectedUserTracker _tracker = tracker;
private readonly MySqlSocialStore _socialStore = socialStore;
private readonly MySqlUserProfileStore _profileStore = profileStore;
private readonly PlayInviteCoordinator _playInviteCoordinator = playInviteCoordinator;
public override async Task OnConnectedAsync()
{
var siteUser = RequireCurrentUser();
var becameOnline = _tracker.TrackConnection(Context.ConnectionId, siteUser.Subject);
await SendPresenceSnapshotAsync(siteUser.Subject, Context.ConnectionAborted);
if (becameOnline)
{
var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(siteUser.Subject, Context.ConnectionAborted);
await NotifyPresenceChangedAsync(relevantSubjects, siteUser.Subject, isOnline: true);
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var (subject, becameOffline) = _tracker.RemoveConnection(Context.ConnectionId);
if (becameOffline && !string.IsNullOrWhiteSpace(subject))
{
var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(subject, CancellationToken.None);
await NotifyPresenceChangedAsync(relevantSubjects, subject, isOnline: false);
}
await base.OnDisconnectedAsync(exception);
}
public async Task RequestPresenceSnapshot()
{
var siteUser = RequireCurrentUser();
await SendPresenceSnapshotAsync(siteUser.Subject, Context.ConnectionAborted);
}
public async Task<PlayInviteMessage> SendPlayInvite(string recipientSubject, string recipientColor)
{
var sender = RequireCurrentUser();
var normalizedRecipientSubject = recipientSubject.Trim();
if (!await _socialStore.AreFriendsAsync(sender.Subject, normalizedRecipientSubject, Context.ConnectionAborted))
{
throw new HubException("Seuls tes amis peuvent recevoir une invitation de partie.");
}
if (!_tracker.IsOnline(normalizedRecipientSubject))
{
throw new HubException("Cet ami n'est plus connecte pour le moment.");
}
var senderProfile = await _profileStore.GetOrCreateAsync(sender, Context.ConnectionAborted);
var recipientProfile = await _profileStore.FindBySubjectAsync(normalizedRecipientSubject, Context.ConnectionAborted);
if (recipientProfile is null)
{
throw new HubException("Le profil de cet ami est introuvable.");
}
try
{
var message = _playInviteCoordinator.CreateInvite(
new PlayInviteParticipant(sender.Subject, sender.Username, senderProfile.DisplayName),
new PlayInviteParticipant(recipientProfile.Subject, recipientProfile.Username, recipientProfile.DisplayName),
recipientColor);
await Clients.Users([sender.Subject, recipientProfile.Subject])
.SendAsync("PlayInviteUpdated", message, Context.ConnectionAborted);
return message;
}
catch (SocialValidationException exception)
{
throw new HubException(exception.Message);
}
}
public async Task RespondToPlayInvite(string inviteId, bool accept)
{
var recipient = RequireCurrentUser();
try
{
if (accept)
{
var accepted = _playInviteCoordinator.AcceptInvite(inviteId, recipient.Subject);
await Clients.Users([accepted.SenderSubject, accepted.RecipientSubject])
.SendAsync("PlayInviteAccepted", accepted.Session, Context.ConnectionAborted);
return;
}
var declined = _playInviteCoordinator.DeclineInvite(inviteId, recipient.Subject);
await Clients.Users([declined.SenderSubject, declined.RecipientSubject])
.SendAsync("PlayInviteClosed", declined.ClosedMessage, Context.ConnectionAborted);
}
catch (SocialValidationException exception)
{
throw new HubException(exception.Message);
}
}
public async Task CancelPlayInvite(string inviteId)
{
var sender = RequireCurrentUser();
try
{
var cancelled = _playInviteCoordinator.CancelInvite(inviteId, sender.Subject);
await Clients.Users([cancelled.SenderSubject, cancelled.RecipientSubject])
.SendAsync("PlayInviteClosed", cancelled.ClosedMessage, Context.ConnectionAborted);
}
catch (SocialValidationException exception)
{
throw new HubException(exception.Message);
}
}
private AuthenticatedSiteUser RequireCurrentUser()
=> AuthenticatedSiteUserFactory.FromClaimsPrincipal(Context.User ?? new System.Security.Claims.ClaimsPrincipal())
?? throw new HubException("La session utilisateur est incomplete.");
private async Task SendPresenceSnapshotAsync(string subject, CancellationToken cancellationToken)
{
var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(subject, cancellationToken);
var onlineSubjects = relevantSubjects
.Where(_tracker.IsOnline)
.Distinct(StringComparer.Ordinal)
.ToArray();
await Clients.Caller.SendAsync(
"PresenceSnapshot",
new PresenceSnapshotMessage { OnlineSubjects = onlineSubjects },
cancellationToken);
}
private Task NotifyPresenceChangedAsync(IReadOnlyList<string> subjects, string changedSubject, bool isOnline)
{
var distinctSubjects = subjects
.Where(subject => !string.IsNullOrWhiteSpace(subject))
.Distinct(StringComparer.Ordinal)
.ToArray();
return distinctSubjects.Length == 0
? Task.CompletedTask
: Clients.Users(distinctSubjects).SendAsync(
"PresenceChanged",
new PresenceChangedMessage
{
Subject = changedSubject,
IsOnline = isOnline,
});
}
}

View File

@@ -1,3 +1,4 @@
using ChessCubing.Server.Auth;
using System.Security.Claims;
namespace ChessCubing.Server.Users;
@@ -10,6 +11,28 @@ public sealed record AuthenticatedSiteUser(
public static class AuthenticatedSiteUserFactory
{
public static AuthenticatedSiteUser? FromKeycloakUserInfo(KeycloakUserInfo userInfo)
{
if (string.IsNullOrWhiteSpace(userInfo.Subject))
{
return null;
}
var username = string.IsNullOrWhiteSpace(userInfo.PreferredUsername)
? userInfo.Subject
: userInfo.PreferredUsername;
var displayName = string.IsNullOrWhiteSpace(userInfo.Name)
? username
: userInfo.Name;
return new AuthenticatedSiteUser(
userInfo.Subject.Trim(),
username.Trim(),
string.IsNullOrWhiteSpace(userInfo.Email) ? null : userInfo.Email.Trim(),
displayName.Trim());
}
public static AuthenticatedSiteUser? FromClaimsPrincipal(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)