using LudosData.Api.Auth; using LudosData.Api.Contracts; using LudosData.Api.Data; using LudosData.Api.Domain; using LudosData.Api.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace LudosData.Api.Controllers; /// /// The user's game library. /// /// Every query starts from Where(g => g.OwnerId == currentUserId), taken from /// the JWT subject. The old API took the owner id from a client-supplied query /// parameter (filter[]=userId,eq,N), which meant any valid token could read /// any other user's library by editing the number. /// [ApiController] [Route("api/games")] [Authorize] public class GamesController( LudosDbContext db, IImageStorage images, ILogger logger) : ControllerBase { [HttpGet] public async Task>> List([FromQuery] GameQuery query, CancellationToken ct) { var ownerId = User.GetUserId(); var q = db.Games.AsNoTracking().Where(g => g.OwnerId == ownerId); if (!string.IsNullOrWhiteSpace(query.Search)) { var term = query.Search.Trim(); q = q.Where(g => EF.Functions.Like(g.Title, $"%{term}%") || (g.Developer != null && EF.Functions.Like(g.Developer, $"%{term}%")) || (g.Publisher != null && EF.Functions.Like(g.Publisher, $"%{term}%"))); } if (!string.IsNullOrWhiteSpace(query.System)) q = q.Where(g => g.System == query.System); if (!string.IsNullOrWhiteSpace(query.Genre)) q = q.Where(g => g.Genre == query.Genre); if (query.Own is { } own) q = q.Where(g => g.Own == own); if (query.Dumped is { } dumped) q = q.Where(g => g.Dumped == dumped); if (query.Played is { } played) q = q.Where(g => g.Played == played); if (query.Finished is { } finished) q = q.Where(g => g.Finished == finished); var total = await q.CountAsync(ct); q = ApplySort(q, query.Sort, query.Dir); var items = await q .Skip((query.Page - 1) * query.PageSize) .Take(query.PageSize) .ToListAsync(ct); return Ok(new PagedResult( items.Select(g => ToResponse(g, ownerId)).ToList(), query.Page, query.PageSize, total)); } [HttpGet("{id:int}")] public async Task> Get(int id, CancellationToken ct) { var ownerId = User.GetUserId(); var game = await db.Games.AsNoTracking() .FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct); // A game belonging to someone else is reported as 404, not 403 — the // response should not confirm that the id exists. return game is null ? NotFound() : Ok(ToResponse(game, ownerId)); } [HttpGet("facets")] public async Task> Facets(CancellationToken ct) { var ownerId = User.GetUserId(); var mine = db.Games.AsNoTracking().Where(g => g.OwnerId == ownerId); var systems = await mine .Where(g => g.System != null && g.System != "") .Select(g => g.System!) .Distinct().OrderBy(s => s).ToListAsync(ct); var genres = await mine .Where(g => g.Genre != null && g.Genre != "") .Select(g => g.Genre!) .Distinct().OrderBy(s => s).ToListAsync(ct); return Ok(new FacetsResponse(systems, genres)); } [HttpPost] public async Task> Create(GameRequest request, CancellationToken ct) { var ownerId = User.GetUserId(); var game = new Game { OwnerId = ownerId }; Apply(request, game); db.Games.Add(game); await db.SaveChangesAsync(ct); logger.LogInformation("User {OwnerId} created game {GameId}", ownerId, game.Id); return CreatedAtAction(nameof(Get), new { id = game.Id }, ToResponse(game, ownerId)); } [HttpPut("{id:int}")] public async Task> Update(int id, GameRequest request, CancellationToken ct) { var ownerId = User.GetUserId(); var game = await db.Games.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct); if (game is null) return NotFound(); Apply(request, game); await db.SaveChangesAsync(ct); return Ok(ToResponse(game, ownerId)); } [HttpDelete("{id:int}")] public async Task Delete(int id, CancellationToken ct) { var ownerId = User.GetUserId(); var game = await db.Games.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct); if (game is null) return NotFound(); db.Games.Remove(game); await db.SaveChangesAsync(ct); logger.LogInformation("User {OwnerId} deleted game {GameId}", ownerId, id); return NoContent(); } private static IQueryable ApplySort(IQueryable q, string sort, string dir) { var descending = string.Equals(dir, "desc", StringComparison.OrdinalIgnoreCase); // Allow-list rather than reflecting over the string, so the sort parameter // cannot reach the query shape in any way the API does not define. return (sort?.ToLowerInvariant()) switch { "system" => descending ? q.OrderByDescending(g => g.System) : q.OrderBy(g => g.System), "genre" => descending ? q.OrderByDescending(g => g.Genre) : q.OrderBy(g => g.Genre), "year" => descending ? q.OrderByDescending(g => g.Year) : q.OrderBy(g => g.Year), "developer" => descending ? q.OrderByDescending(g => g.Developer) : q.OrderBy(g => g.Developer), "publisher" => descending ? q.OrderByDescending(g => g.Publisher) : q.OrderBy(g => g.Publisher), "created" => descending ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt), "updated" => descending ? q.OrderByDescending(g => g.UpdatedAt) : q.OrderBy(g => g.UpdatedAt), _ => descending ? q.OrderByDescending(g => g.Title) : q.OrderBy(g => g.Title), }; } private static void Apply(GameRequest request, Game game) { game.Title = request.Title.Trim(); game.System = request.System?.Trim(); game.Genre = request.Genre?.Trim(); game.Year = request.Year?.Trim(); game.Developer = request.Developer?.Trim(); game.Publisher = request.Publisher?.Trim(); game.Art = request.Art?.Trim(); game.Description = request.Description; game.Own = request.Own; game.Dumped = request.Dumped; game.Played = request.Played; game.Finished = request.Finished; } private GameResponse ToResponse(Game g, string ownerId) => new( g.Id, g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher, g.Art, images.BuildUrl(ownerId, g.Art), g.Description, g.Own, g.Dumped, g.Played, g.Finished, g.CreatedAt, g.UpdatedAt); }