Merge branch 'feature/techdebt-cleanup' of https://git.krampitz.win/tim/SlipItIn into feature/techdebt-cleanup
Some checks failed
Build / build (pull_request) Failing after 3m29s
Some checks failed
Build / build (pull_request) Failing after 3m29s
Signed-off-by: Tim Krampitz <Tim.Krampitz@live.com>
This commit is contained in:
@@ -17,11 +17,13 @@ public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IDbContextFactory<SlipItInDbContext> _contextFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IHostEnvironment _environment;
|
||||
|
||||
public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration)
|
||||
public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration, IHostEnvironment environment)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
_configuration = configuration;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
@@ -30,6 +32,9 @@ public class AuthController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return BadRequest("Username, Email und Passwort sind erforderlich.");
|
||||
|
||||
if (request.Password.Length < 8)
|
||||
return BadRequest("Das Passwort muss mindestens 8 Zeichen lang sein.");
|
||||
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var normalizedEmail = request.Email.Trim().ToLowerInvariant();
|
||||
@@ -86,7 +91,7 @@ public class AuthController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
UserId = int.Parse(userIdClaim),
|
||||
UserId = int.TryParse(userIdClaim, out var parsedUserId) ? parsedUserId : 0,
|
||||
Username = username,
|
||||
Email = email
|
||||
});
|
||||
@@ -94,7 +99,13 @@ public class AuthController : ControllerBase
|
||||
|
||||
private AuthResponseDto CreateAuthResponse(User user)
|
||||
{
|
||||
var jwtKey = _configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
|
||||
var jwtKey = _configuration["Jwt:Key"];
|
||||
if (string.IsNullOrWhiteSpace(jwtKey))
|
||||
{
|
||||
if (!_environment.IsDevelopment())
|
||||
throw new InvalidOperationException("Jwt:Key ist nicht konfiguriert.");
|
||||
jwtKey = "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef";
|
||||
}
|
||||
var jwtIssuer = _configuration["Jwt:Issuer"] ?? "SlipItInServer";
|
||||
var jwtAudience = _configuration["Jwt:Audience"] ?? "SlipItInClient";
|
||||
var expirationMinutes = int.TryParse(_configuration["Jwt:ExpirationMinutes"], out var parsedMinutes) ? parsedMinutes : 1440;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,27 @@ builder.AddServiceDefaults();
|
||||
|
||||
builder.AddNpgsqlDataSource(connectionName: "postgresdb");
|
||||
|
||||
builder.Services.AddDbContextFactory<SlipItInDbContext>(options => options.UseNpgsql());
|
||||
builder.Services.AddDbContextFactory<SlipItInDbContext>((sp, options) =>
|
||||
options.UseNpgsql(sp.GetRequiredService<Npgsql.NpgsqlDataSource>()));
|
||||
|
||||
// Services registrieren
|
||||
builder.Services.AddTransient<IGameService, GameService>();
|
||||
|
||||
// JWT Authentication
|
||||
var jwtKey = builder.Configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
|
||||
var jwtKey = builder.Configuration["Jwt:Key"];
|
||||
if (string.IsNullOrWhiteSpace(jwtKey) || jwtKey.Length < 32)
|
||||
{
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
jwtKey = "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef";
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Jwt:Key ist nicht konfiguriert oder kürzer als 32 Zeichen. " +
|
||||
"In Production muss ein eigener Schlüssel via Konfiguration/Umgebungsvariable gesetzt werden.");
|
||||
}
|
||||
}
|
||||
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "SlipItInServer";
|
||||
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "SlipItInClient";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using SlipItIn.Server.Data;
|
||||
using SlipItIn.Shared.DTOs;
|
||||
using SlipItIn.Shared.Models;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace SlipItIn.Server.Services;
|
||||
|
||||
@@ -21,7 +22,7 @@ public class GameService : IGameService
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var host = await context.Users.FirstOrDefaultAsync(u => u.Id == hostUserId) ?? throw new InvalidOperationException("Host not found");
|
||||
var lobbyCode = GenerateLobbyCode();
|
||||
var lobbyCode = await GenerateUniqueLobbyCodeAsync(context);
|
||||
var game = new Game
|
||||
{
|
||||
LobbyCode = lobbyCode,
|
||||
@@ -70,7 +71,16 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
|
||||
var game = await context.Games
|
||||
.Include(g => g.Players)
|
||||
.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
|
||||
|
||||
if (game.Status != GameStatus.Lobby)
|
||||
throw new InvalidOperationException("Game has already started or ended");
|
||||
|
||||
if (game.Players.Count < 2)
|
||||
throw new InvalidOperationException("At least 2 players are required to start the game");
|
||||
|
||||
game.Status = GameStatus.InProgress;
|
||||
game.StartedAt = DateTime.UtcNow;
|
||||
|
||||
@@ -85,12 +95,16 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId) ?? throw new InvalidOperationException("Player not found");
|
||||
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
|
||||
if (game.Status != GameStatus.Lobby)
|
||||
throw new InvalidOperationException("Game is not in lobby status");
|
||||
|
||||
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId && p.GameId == gameId)
|
||||
?? throw new InvalidOperationException("Player not found in this game");
|
||||
player.IsReady = true;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return await context.Games.FirstOrDefaultAsync(g => g.Id == gameId)
|
||||
?? throw new InvalidOperationException("Game not found");
|
||||
return game;
|
||||
}
|
||||
|
||||
public async Task<List<PlayerCard>> DealCardsAsync(int gameId)
|
||||
@@ -108,7 +122,7 @@ public class GameService : IGameService
|
||||
|
||||
foreach (var player in players)
|
||||
{
|
||||
var selectedPhrases = allPhrases.OrderBy(_ => Random.Shared.Next()).Take(5);
|
||||
var selectedPhrases = allPhrases.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)).Take(5);
|
||||
|
||||
foreach (var phrase in selectedPhrases)
|
||||
{
|
||||
@@ -125,6 +139,7 @@ public class GameService : IGameService
|
||||
}
|
||||
|
||||
currentRound.Status = RoundStatus.Active;
|
||||
currentRound.StartedAt = DateTime.UtcNow;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return dealtCards;
|
||||
@@ -134,10 +149,18 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
|
||||
if (game.Status != GameStatus.InProgress)
|
||||
throw new InvalidOperationException("Game is not in progress");
|
||||
|
||||
var card = await context.PlayerCards
|
||||
.Include(pc => pc.Player)
|
||||
.Include(pc => pc.GameRound)
|
||||
.FirstOrDefaultAsync(pc => pc.Id == cardId) ?? throw new InvalidOperationException("Card not found");
|
||||
|
||||
if (card.PlayerId != playerId) throw new UnauthorizedAccessException("Card does not belong to player");
|
||||
if (card.GameRound.GameId != gameId) throw new InvalidOperationException("Card does not belong to this game");
|
||||
if (card.GameRound.Status != RoundStatus.Active) throw new InvalidOperationException("Round is not active");
|
||||
if (card.IsUsed) throw new InvalidOperationException("Card has already been used");
|
||||
|
||||
card.IsUsed = true;
|
||||
await context.SaveChangesAsync();
|
||||
@@ -149,13 +172,24 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
var targetCard = await context.PlayerCards.FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found");
|
||||
|
||||
var currentRound = await context.GameRounds
|
||||
.Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active)
|
||||
.FirstOrDefaultAsync() ?? throw new InvalidOperationException("No active round found");
|
||||
|
||||
if (currentRound == null) throw new InvalidOperationException("No active round found");
|
||||
var targetCard = await context.PlayerCards
|
||||
.FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found");
|
||||
|
||||
if (targetCard.GameRoundId != currentRound.Id)
|
||||
throw new InvalidOperationException("Card does not belong to the active round");
|
||||
if (targetCard.PlayerId == challengingPlayerId)
|
||||
throw new InvalidOperationException("Cannot challenge your own card");
|
||||
if (!targetCard.IsUsed)
|
||||
throw new InvalidOperationException("Card has not been played yet");
|
||||
|
||||
var pendingExists = await context.SlipChallenges
|
||||
.AnyAsync(c => c.TargetCardId == targetCardId && c.Status == ChallengeStatus.Pending);
|
||||
if (pendingExists)
|
||||
throw new InvalidOperationException("A challenge for this card is already pending");
|
||||
|
||||
var challenge = new SlipChallenge
|
||||
{
|
||||
@@ -179,19 +213,30 @@ public class GameService : IGameService
|
||||
var challenge = await context.SlipChallenges
|
||||
.Include(sc => sc.GameRound)
|
||||
.Include(sc => sc.TargetCard)
|
||||
.Include(sc => sc.ChallengingPlayer)
|
||||
.Include(sc => sc.TargetPlayer)
|
||||
.FirstOrDefaultAsync(sc => sc.Id == challengeId) ?? throw new InvalidOperationException("Challenge not found");
|
||||
|
||||
// approved = Beschuldigung stimmt (Phrase war falsch)
|
||||
// rejected = Beschuldigung stimmt nicht (Phrase war richtig)
|
||||
if (challenge.Status != ChallengeStatus.Pending)
|
||||
throw new InvalidOperationException("Challenge has already been resolved");
|
||||
|
||||
if (!approved)
|
||||
// approved = Beschuldigung stimmt (Phrase war ein unberechtigtes SlipIn des Beschuldigten)
|
||||
// rejected = Beschuldigung stimmt nicht (Phrase wurde regelkonform gespielt)
|
||||
|
||||
if (approved)
|
||||
{
|
||||
// FALSCHE BESCHULDIGUNG: Beschuldiger erhält die Phrase des Beschuldigten
|
||||
var targetCard = challenge.TargetCard;
|
||||
targetCard.PlayerId = challenge.ChallengingPlayerId;
|
||||
context.PlayerCards.Update(targetCard);
|
||||
// Berechtigte Beschuldigung: Beschuldigter muss die Phrase an den Beschuldiger abgeben
|
||||
challenge.TargetCard.PlayerId = challenge.ChallengingPlayerId;
|
||||
challenge.ChallengingPlayer.Score += 1;
|
||||
challenge.TargetPlayer.FailedSlips += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Falsche Beschuldigung: Phrase bleibt beim Beschuldigten
|
||||
challenge.TargetPlayer.SuccessfulSlips += 1;
|
||||
challenge.TargetPlayer.Score += 1;
|
||||
challenge.ChallengingPlayer.FailedSlips += 1;
|
||||
}
|
||||
// Wenn approved: Phrase bleibt bei Beschuldigtem (= Phrase war tatsächlich ein SlipIn)
|
||||
|
||||
challenge.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected;
|
||||
challenge.ResolvedAt = DateTime.UtcNow;
|
||||
@@ -218,7 +263,7 @@ public class GameService : IGameService
|
||||
Username = p.User.Username,
|
||||
Score = p.Score,
|
||||
IsReady = p.IsReady,
|
||||
CardCount = p.Cards.Count // Nur Anzahl, nicht die Karten selbst!
|
||||
CardCount = p.Cards.Count(c => !c.IsUsed) // Nur Anzahl, nicht die Karten selbst!
|
||||
}).ToList();
|
||||
|
||||
var currentRound = game.Rounds
|
||||
@@ -249,9 +294,17 @@ public class GameService : IGameService
|
||||
{
|
||||
using var context = _contextFactory.CreateDbContext();
|
||||
|
||||
// Nur Karten der aktuell aktiven Runde zurückgeben — sonst wächst die Hand
|
||||
// über Runden hinweg unbegrenzt und enthält veraltete Karten.
|
||||
var activeRoundIds = await context.GameRounds
|
||||
.Where(gr => gr.Status == RoundStatus.Active &&
|
||||
gr.Game.Players.Any(p => p.Id == playerId))
|
||||
.Select(gr => gr.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var cards = await context.PlayerCards
|
||||
.Include(pc => pc.Phrase)
|
||||
.Where(pc => pc.PlayerId == playerId)
|
||||
.Where(pc => pc.PlayerId == playerId && activeRoundIds.Contains(pc.GameRoundId))
|
||||
.Select(pc => new PlayerCardDto
|
||||
{
|
||||
CardId = pc.Id,
|
||||
@@ -275,11 +328,25 @@ public class GameService : IGameService
|
||||
?? throw new InvalidOperationException("Game not found");
|
||||
}
|
||||
|
||||
private string GenerateLobbyCode()
|
||||
private static string GenerateLobbyCode()
|
||||
{
|
||||
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
var code = new string([.. Enumerable.Range(0, 6).Select(_ => chars[Random.Shared.Next(chars.Length)])]);
|
||||
var buffer = new char[6];
|
||||
for (var i = 0; i < buffer.Length; i++)
|
||||
buffer[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
return code;
|
||||
private static async Task<string> GenerateUniqueLobbyCodeAsync(SlipItInDbContext context, int maxAttempts = 10)
|
||||
{
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
var code = GenerateLobbyCode();
|
||||
var exists = await context.Games.AnyAsync(g => g.LobbyCode == code);
|
||||
if (!exists)
|
||||
return code;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not generate a unique lobby code");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,7 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"postgresdb": "Server=(localdb)\\postgres;Database=postgresdb"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
|
||||
"Issuer": "SlipItInServer",
|
||||
"Audience": "SlipItInClient",
|
||||
"ExpirationMinutes": 1440
|
||||
|
||||
Reference in New Issue
Block a user