Ajoute une page utilisateur et une persistance MySQL

This commit is contained in:
2026-04-14 20:03:26 +02:00
parent d0f9c76b26
commit 5cf46dce31
14 changed files with 1106 additions and 3 deletions

View File

@@ -0,0 +1,40 @@
using System.Security.Claims;
namespace ChessCubing.Server.Users;
public sealed record AuthenticatedSiteUser(
string Subject,
string Username,
string? Email,
string DisplayName);
public static class AuthenticatedSiteUserFactory
{
public static AuthenticatedSiteUser? FromClaimsPrincipal(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)
{
return null;
}
var subject = user.FindFirst("sub")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrWhiteSpace(subject))
{
return null;
}
var username = user.FindFirst("preferred_username")?.Value
?? user.Identity?.Name
?? subject;
var email = user.FindFirst("email")?.Value;
var displayName = user.FindFirst("name")?.Value
?? username;
return new AuthenticatedSiteUser(
subject.Trim(),
username.Trim(),
string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
displayName.Trim());
}
}

View File

@@ -0,0 +1,296 @@
using ChessCubing.Server.Data;
using Microsoft.Extensions.Options;
using MySqlConnector;
namespace ChessCubing.Server.Users;
public sealed class MySqlUserProfileStore(
IOptions<SiteDataOptions> options,
ILogger<MySqlUserProfileStore> logger)
{
private const string CreateTableSql = """
CREATE TABLE IF NOT EXISTS site_users (
id BIGINT NOT NULL AUTO_INCREMENT,
subject VARCHAR(190) NOT NULL,
username VARCHAR(120) NOT NULL,
email VARCHAR(255) NULL,
display_name VARCHAR(120) NOT NULL,
club VARCHAR(120) NULL,
city VARCHAR(120) NULL,
preferred_format VARCHAR(40) NULL,
favorite_cube VARCHAR(120) NULL,
bio TEXT NULL,
created_utc DATETIME(6) NOT NULL,
updated_utc DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_site_users_subject (subject)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
""";
private const string UpsertIdentitySql = """
INSERT INTO site_users (
subject,
username,
email,
display_name,
created_utc,
updated_utc
)
VALUES (
@subject,
@username,
@email,
@displayName,
@createdUtc,
@updatedUtc
)
ON DUPLICATE KEY UPDATE
username = VALUES(username),
email = VALUES(email);
""";
private const string UpsertProfileSql = """
INSERT INTO site_users (
subject,
username,
email,
display_name,
club,
city,
preferred_format,
favorite_cube,
bio,
created_utc,
updated_utc
)
VALUES (
@subject,
@username,
@email,
@displayName,
@club,
@city,
@preferredFormat,
@favoriteCube,
@bio,
@createdUtc,
@updatedUtc
)
ON DUPLICATE KEY UPDATE
username = VALUES(username),
email = VALUES(email),
display_name = VALUES(display_name),
club = VALUES(club),
city = VALUES(city),
preferred_format = VALUES(preferred_format),
favorite_cube = VALUES(favorite_cube),
bio = VALUES(bio),
updated_utc = VALUES(updated_utc);
""";
private const string SelectProfileSql = """
SELECT
subject,
username,
email,
display_name,
club,
city,
preferred_format,
favorite_cube,
bio,
created_utc,
updated_utc
FROM site_users
WHERE subject = @subject
LIMIT 1;
""";
private readonly SiteDataOptions _options = options.Value;
private readonly ILogger<MySqlUserProfileStore> _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 using var command = connection.CreateCommand();
command.CommandText = CreateTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
return;
}
catch (Exception exception) when (attempt < _options.InitializationRetries)
{
_logger.LogWarning(
exception,
"Initialisation MySQL impossible pour le profil utilisateur (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 using var finalCommand = finalConnection.CreateCommand();
finalCommand.CommandText = CreateTableSql;
await finalCommand.ExecuteNonQueryAsync(cancellationToken);
}
public async Task<UserProfileResponse> GetOrCreateAsync(
AuthenticatedSiteUser user,
CancellationToken cancellationToken)
{
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
var nowUtc = DateTime.UtcNow;
await using (var command = connection.CreateCommand())
{
command.CommandText = UpsertIdentitySql;
command.Parameters.AddWithValue("@subject", user.Subject);
command.Parameters.AddWithValue("@username", user.Username);
command.Parameters.AddWithValue("@email", (object?)user.Email ?? DBNull.Value);
command.Parameters.AddWithValue("@displayName", user.DisplayName);
command.Parameters.AddWithValue("@createdUtc", nowUtc);
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
await command.ExecuteNonQueryAsync(cancellationToken);
}
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
}
public async Task<UserProfileResponse> UpdateAsync(
AuthenticatedSiteUser user,
UpdateUserProfileRequest request,
CancellationToken cancellationToken)
{
var input = NormalizeInput(user, request);
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
var nowUtc = DateTime.UtcNow;
await using (var command = connection.CreateCommand())
{
command.CommandText = UpsertProfileSql;
command.Parameters.AddWithValue("@subject", user.Subject);
command.Parameters.AddWithValue("@username", user.Username);
command.Parameters.AddWithValue("@email", (object?)user.Email ?? DBNull.Value);
command.Parameters.AddWithValue("@displayName", input.DisplayName);
command.Parameters.AddWithValue("@club", (object?)input.Club ?? DBNull.Value);
command.Parameters.AddWithValue("@city", (object?)input.City ?? DBNull.Value);
command.Parameters.AddWithValue("@preferredFormat", (object?)input.PreferredFormat ?? DBNull.Value);
command.Parameters.AddWithValue("@favoriteCube", (object?)input.FavoriteCube ?? DBNull.Value);
command.Parameters.AddWithValue("@bio", (object?)input.Bio ?? DBNull.Value);
command.Parameters.AddWithValue("@createdUtc", nowUtc);
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
await command.ExecuteNonQueryAsync(cancellationToken);
}
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
}
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
{
var displayName = NormalizeOptionalValue(request.DisplayName, "nom affiche", 120) ?? user.DisplayName;
var club = NormalizeOptionalValue(request.Club, "club", 120);
var city = NormalizeOptionalValue(request.City, "ville", 120);
var favoriteCube = NormalizeOptionalValue(request.FavoriteCube, "cube favori", 120);
var bio = NormalizeOptionalValue(request.Bio, "bio", 1200);
var preferredFormat = NormalizePreferredFormat(request.PreferredFormat);
return new UserProfileInput(displayName, club, city, preferredFormat, favoriteCube, bio);
}
private static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
{
var trimmed = value?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return null;
}
if (trimmed.Length > maxLength)
{
throw new UserProfileValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
}
return trimmed;
}
private static string? NormalizePreferredFormat(string? value)
{
var normalized = NormalizeOptionalValue(value, "format prefere", 40);
if (normalized is null)
{
return null;
}
return normalized switch
{
"Twice" => "Twice",
"Time" => "Time",
"Les deux" => "Les deux",
_ => throw new UserProfileValidationException("Le format prefere doit etre Twice, Time ou Les deux."),
};
}
private static async Task<UserProfileResponse> ReadProfileAsync(
MySqlConnection connection,
string subject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = SelectProfileSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
throw new InvalidOperationException("Le profil utilisateur n'a pas pu etre charge.");
}
var subjectOrdinal = reader.GetOrdinal("subject");
var usernameOrdinal = reader.GetOrdinal("username");
var emailOrdinal = reader.GetOrdinal("email");
var displayNameOrdinal = reader.GetOrdinal("display_name");
var clubOrdinal = reader.GetOrdinal("club");
var cityOrdinal = reader.GetOrdinal("city");
var preferredFormatOrdinal = reader.GetOrdinal("preferred_format");
var favoriteCubeOrdinal = reader.GetOrdinal("favorite_cube");
var bioOrdinal = reader.GetOrdinal("bio");
var createdUtcOrdinal = reader.GetOrdinal("created_utc");
var updatedUtcOrdinal = reader.GetOrdinal("updated_utc");
return new UserProfileResponse
{
Subject = reader.GetString(subjectOrdinal),
Username = reader.GetString(usernameOrdinal),
Email = reader.IsDBNull(emailOrdinal) ? null : reader.GetString(emailOrdinal),
DisplayName = reader.GetString(displayNameOrdinal),
Club = reader.IsDBNull(clubOrdinal) ? null : reader.GetString(clubOrdinal),
City = reader.IsDBNull(cityOrdinal) ? null : reader.GetString(cityOrdinal),
PreferredFormat = reader.IsDBNull(preferredFormatOrdinal) ? null : reader.GetString(preferredFormatOrdinal),
FavoriteCube = reader.IsDBNull(favoriteCubeOrdinal) ? null : reader.GetString(favoriteCubeOrdinal),
Bio = reader.IsDBNull(bioOrdinal) ? null : reader.GetString(bioOrdinal),
CreatedUtc = DateTime.SpecifyKind(reader.GetDateTime(createdUtcOrdinal), DateTimeKind.Utc),
UpdatedUtc = DateTime.SpecifyKind(reader.GetDateTime(updatedUtcOrdinal), DateTimeKind.Utc),
};
}
private sealed record UserProfileInput(
string DisplayName,
string? Club,
string? City,
string? PreferredFormat,
string? FavoriteCube,
string? Bio);
}

View File

@@ -0,0 +1,43 @@
namespace ChessCubing.Server.Users;
public sealed class UserProfileResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string? Email { get; init; }
public string DisplayName { get; init; } = string.Empty;
public string? Club { get; init; }
public string? City { get; init; }
public string? PreferredFormat { get; init; }
public string? FavoriteCube { get; init; }
public string? Bio { get; init; }
public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
}
public sealed class UpdateUserProfileRequest
{
public string? DisplayName { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public string? PreferredFormat { get; init; }
public string? FavoriteCube { get; init; }
public string? Bio { get; init; }
}
public sealed class UserProfileValidationException(string message) : Exception(message);