Tech-Debt-Cleanup: Doku, Security-Hardening, Server-Validierung, Client-Stabilitaet
Some checks failed
Build / build (pull_request) Has been cancelled

Doku/Repo:
- README.md mit Setup, Run-Anleitung, Konfiguration und Sprachpolicy neu geschrieben
- appsettings.json: committed JWT-Key und irreführende LocalDB-Connection-String entfernt
- appsettings.Development.json: expliziter Dev-Only JWT-Key hinterlegt
- build.yml: Build-CI für master/PRs hinzugefügt
- slnx/aspire.config.json: Pfad-Casing korrigiert (Linux-kompatibel)
- .gitignore: Kommentar zu den Legacy-SQLite-Einträgen

Server:
- Jwt:Key ist in Production Pflicht (Fail-fast statt Fallback-Secret)
- Passwort-Mindestlänge 8 Zeichen bei Registrierung
- StartGame: nur aus Lobby, mindestens 2 Spieler
- SetPlayerReady: gameId/Status geprüft
- SubmitSlip: Game-/Round-Status, Round-Membership, IsUsed validiert
- CreateChallenge: Round-Bindung, kein Self-Challenge, nur gespielte Karten, keine Doppel-Challenges
- ResolveChallenge: Pending-Guard, Scoring (Score/SuccessfulSlips/FailedSlips), Approved-Transfer gefixt
- GetPlayerHand: nur Karten der aktiven Runde
- RoundTimeRemaining: StartedAt wird bei Round-Aktivierung gesetzt
- Lobby-Code: crypto-random mit Kollisions-Retry
- EF: explizite NpgsqlDataSource-Auflösung aus DI
- Hub: Reconnect-Gruppenbeitritt, ConnectionId-Cleanup beim Disconnect, ChallengingPlayerName gesetzt, SlipChallenged/ChallengeResolved konsistent

Client:
- Offline-Start löscht Session nicht mehr (NetworkError vs. Invalid unterschieden)
- ResolveChallenge geht durch die Offline-Queue (kein Crash mehr bei Disconnect)
- Queue: Poison-Entries werden nach 3 Retries verworfen statt die Queue ewig zu blockieren
- SignalR: Verbindung wird sauber disposed/rebuildet, Token immer zur Laufzeit gelesen, Connect-Lock gegen parallele Starts
- ViewModels: Thread-Marshaling in allen Receive-Handlern, kein async void mehr
- LobbyViewModel: Logout deaktiviert den Singleton (kein stale-Navigation mehr), Reaktivierung via Activate()
- GameBoard: lokaler 1s-Timer für RoundTimeRemaining
- Overlay: x:DataType für kompilierte Bindings, Frame→Border
- csproj: Preview-Logging-Package auf 10.0.10, Template-Cruft entfernt
This commit is contained in:
Tim Krampitz
2026-08-22 19:37:54 +02:00
parent b1435b2c1e
commit af6528ee68
26 changed files with 588 additions and 158 deletions

View File

@@ -142,6 +142,7 @@ public class GameHub : Hub
}
catch (InvalidOperationException ex)
{
// Geprüfte Fachfehler mit festen, harmonsierten Texten dürfen an den Client.
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex)
@@ -242,6 +243,11 @@ public class GameHub : Hub
{
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (InvalidOperationException ex)
{
// Geprüfte Fachfehler (z. B. "Round is not active") dürfen an den Client.
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error submitting slip");
@@ -259,10 +265,16 @@ public class GameHub : Hub
var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId);
var game = await _gameService.GetGameAsync(gameId);
using var context = _contextFactory.CreateDbContext();
var challengingPlayer = await context.Players
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == challengingPlayerId);
var challengeDto = new SlipChallengeDto
{
ChallengeId = challenge.Id,
ChallengingPlayerId = challengingPlayerId,
ChallengingPlayerName = challengingPlayer?.User.Username ?? string.Empty,
TargetPlayerId = challenge.TargetPlayerId,
TargetCardId = targetCardId,
Status = challenge.Status.ToString(),
@@ -272,7 +284,6 @@ public class GameHub : Hub
await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto);
// Sende direkte Notification an den beschuldigten Spieler
using var context = _contextFactory.CreateDbContext();
var targetPlayer = await context.Players.FindAsync(challenge.TargetPlayerId);
if (targetPlayer?.ConnectionId != null)
{
@@ -283,6 +294,11 @@ public class GameHub : Hub
{
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (InvalidOperationException ex)
{
// Geprüfte Fachfehler (z. B. "Card does not belong to the active round") dürfen an den Client.
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error challenging slip");
@@ -372,9 +388,26 @@ public class GameHub : Hub
{
player.ConnectionId = Context.ConnectionId;
await context.SaveChangesAsync();
// (Re-)Join der Lobby-Gruppe, damit Group-Broadcasts nach einem Reconnect wieder ankommen.
var lobbyCode = await context.Games
.Where(g => g.Id == player.GameId)
.Select(g => g.LobbyCode)
.FirstAsync();
await Groups.AddToGroupAsync(Context.ConnectionId, lobbyCode);
// Der Client bekommt nach einem Reconnect keinen vollständigen Zustand
// mehr aus der Queue gespielt — er muss RequestGameState aufrufen.
}
}
catch { /* Ignorieren falls nicht authentifiziert */ }
catch (UnauthorizedAccessException)
{
// Nicht authentifiziert — ConnectionId wird nicht gespeichert.
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to register connection for authenticated user");
}
await base.OnConnectedAsync();
}
@@ -382,6 +415,23 @@ public class GameHub : Hub
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Client {ConnectionId} disconnected", Context.ConnectionId);
// ConnectionId zurücksetzen, damit keine Nachrichten an eine tote Verbindung gehen.
try
{
using var context = _contextFactory.CreateDbContext();
var players = await context.Players
.Where(p => p.ConnectionId == Context.ConnectionId)
.ToListAsync();
foreach (var player in players)
player.ConnectionId = null;
await context.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to clear connection id on disconnect");
}
await base.OnDisconnectedAsync(exception);
}
}
}