From 169535da8c3916c7612b05ed20f3b2d36ec82f6f Mon Sep 17 00:00:00 2001 From: Harsche Date: Sat, 2 Aug 2025 20:49:01 -0300 Subject: [PATCH 01/48] Add WebAPI project named GameCollectionAPI --- GameCollectionAPI.sln | 24 ++++++++++ GameCollectionAPI/GameCollectionAPI.csproj | 14 ++++++ GameCollectionAPI/GameCollectionAPI.http | 6 +++ GameCollectionAPI/Program.cs | 44 +++++++++++++++++++ .../Properties/launchSettings.json | 41 +++++++++++++++++ .../appsettings.Development.json | 8 ++++ GameCollectionAPI/appsettings.json | 9 ++++ 7 files changed, 146 insertions(+) create mode 100644 GameCollectionAPI.sln create mode 100644 GameCollectionAPI/GameCollectionAPI.csproj create mode 100644 GameCollectionAPI/GameCollectionAPI.http create mode 100644 GameCollectionAPI/Program.cs create mode 100644 GameCollectionAPI/Properties/launchSettings.json create mode 100644 GameCollectionAPI/appsettings.Development.json create mode 100644 GameCollectionAPI/appsettings.json diff --git a/GameCollectionAPI.sln b/GameCollectionAPI.sln new file mode 100644 index 0000000..7796ece --- /dev/null +++ b/GameCollectionAPI.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCollectionAPI", "GameCollectionAPI\GameCollectionAPI.csproj", "{1AD172C0-6E37-BB3F-BC22-F837FB4B7FD7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1AD172C0-6E37-BB3F-BC22-F837FB4B7FD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1AD172C0-6E37-BB3F-BC22-F837FB4B7FD7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1AD172C0-6E37-BB3F-BC22-F837FB4B7FD7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1AD172C0-6E37-BB3F-BC22-F837FB4B7FD7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CF7C9A84-D357-417C-852F-432620A32AAD} + EndGlobalSection +EndGlobal diff --git a/GameCollectionAPI/GameCollectionAPI.csproj b/GameCollectionAPI/GameCollectionAPI.csproj new file mode 100644 index 0000000..f8a132c --- /dev/null +++ b/GameCollectionAPI/GameCollectionAPI.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/GameCollectionAPI/GameCollectionAPI.http b/GameCollectionAPI/GameCollectionAPI.http new file mode 100644 index 0000000..82bc5e7 --- /dev/null +++ b/GameCollectionAPI/GameCollectionAPI.http @@ -0,0 +1,6 @@ +@GameCollectionAPI_HostAddress = http://localhost:5116 + +GET {{GameCollectionAPI_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs new file mode 100644 index 0000000..00ff539 --- /dev/null +++ b/GameCollectionAPI/Program.cs @@ -0,0 +1,44 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => +{ + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +}) +.WithName("GetWeatherForecast") +.WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/GameCollectionAPI/Properties/launchSettings.json b/GameCollectionAPI/Properties/launchSettings.json new file mode 100644 index 0000000..bf0eed9 --- /dev/null +++ b/GameCollectionAPI/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:13357", + "sslPort": 44327 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5116", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7224;http://localhost:5116", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/GameCollectionAPI/appsettings.Development.json b/GameCollectionAPI/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/GameCollectionAPI/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/GameCollectionAPI/appsettings.json b/GameCollectionAPI/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/GameCollectionAPI/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From 79a68139ad0d0a2bb10fc1a2ee278d7f94298b49 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sat, 2 Aug 2025 20:52:57 -0300 Subject: [PATCH 02/48] Remove weather forecast endpoint and related model from Program.cs --- GameCollectionAPI/GameCollectionAPI.http | 2 -- GameCollectionAPI/Program.cs | 25 ------------------------ 2 files changed, 27 deletions(-) diff --git a/GameCollectionAPI/GameCollectionAPI.http b/GameCollectionAPI/GameCollectionAPI.http index 82bc5e7..5ce5b9a 100644 --- a/GameCollectionAPI/GameCollectionAPI.http +++ b/GameCollectionAPI/GameCollectionAPI.http @@ -1,6 +1,4 @@ @GameCollectionAPI_HostAddress = http://localhost:5116 -GET {{GameCollectionAPI_HostAddress}}/weatherforecast/ -Accept: application/json ### diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index 00ff539..1ddb525 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -16,29 +16,4 @@ app.UseHttpsRedirection(); -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast") -.WithOpenApi(); - app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} From 90a74033d1fe99cb1bebf38f6ba85eb1e92841e3 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sat, 2 Aug 2025 21:18:18 -0300 Subject: [PATCH 03/48] Add Game model --- GameCollectionAPI/Models/Game.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 GameCollectionAPI/Models/Game.cs diff --git a/GameCollectionAPI/Models/Game.cs b/GameCollectionAPI/Models/Game.cs new file mode 100644 index 0000000..3743c29 --- /dev/null +++ b/GameCollectionAPI/Models/Game.cs @@ -0,0 +1,12 @@ +namespace GameCollectionAPI.Models; + +public class Game +{ + public int Id { get; set; } + public string Name { get; set; } + public string Genre { get; set; } + public string Developer { get; set; } + public string Publisher { get; set; } + public DateTime ReleaseDate { get; set; } + public string Platform { get; set; } +} From 827ac5deaf90851fdbade9d87662bebcd72cbb39 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sat, 2 Aug 2025 21:53:26 -0300 Subject: [PATCH 04/48] Add controllers to app configuration --- GameCollectionAPI/Program.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index 1ddb525..480235a 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -2,6 +2,7 @@ // Add services to the container. // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); @@ -16,4 +17,6 @@ app.UseHttpsRedirection(); +app.MapControllers(); + app.Run(); From 8cbca3827f467a9bdc4a2948f048500252d96e93 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sat, 2 Aug 2025 21:54:06 -0300 Subject: [PATCH 05/48] Add EntityFrameworkCore and initial ApplicationDbContext --- GameCollectionAPI/Data/AppDbContext.cs | 11 +++++++++++ GameCollectionAPI/GameCollectionAPI.csproj | 1 + 2 files changed, 12 insertions(+) create mode 100644 GameCollectionAPI/Data/AppDbContext.cs diff --git a/GameCollectionAPI/Data/AppDbContext.cs b/GameCollectionAPI/Data/AppDbContext.cs new file mode 100644 index 0000000..39269b6 --- /dev/null +++ b/GameCollectionAPI/Data/AppDbContext.cs @@ -0,0 +1,11 @@ +using GameCollectionAPI.Models; +using Microsoft.EntityFrameworkCore; + +namespace GameCollectionAPI.Data; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Games { get; set; } +} diff --git a/GameCollectionAPI/GameCollectionAPI.csproj b/GameCollectionAPI/GameCollectionAPI.csproj index f8a132c..f60ad56 100644 --- a/GameCollectionAPI/GameCollectionAPI.csproj +++ b/GameCollectionAPI/GameCollectionAPI.csproj @@ -8,6 +8,7 @@ + From 2cbc00ba14ddcc54c6cbf80328734c753124ffee Mon Sep 17 00:00:00 2001 From: Harsche Date: Sat, 2 Aug 2025 22:16:27 -0300 Subject: [PATCH 06/48] Add new EntityFramework packages --- GameCollectionAPI/GameCollectionAPI.csproj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/GameCollectionAPI/GameCollectionAPI.csproj b/GameCollectionAPI/GameCollectionAPI.csproj index f60ad56..5b36bea 100644 --- a/GameCollectionAPI/GameCollectionAPI.csproj +++ b/GameCollectionAPI/GameCollectionAPI.csproj @@ -9,6 +9,11 @@ + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + From 47868a63186c694c46c946fd756119cb4dc0b44e Mon Sep 17 00:00:00 2001 From: Harsche Date: Sat, 2 Aug 2025 22:22:05 -0300 Subject: [PATCH 07/48] Configure SQLite --- GameCollectionAPI/Program.cs | 5 +++++ GameCollectionAPI/appsettings.json | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index 480235a..c35f154 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -1,3 +1,5 @@ +using GameCollectionAPI.Data; + var builder = WebApplication.CreateBuilder(args); // Add services to the container. @@ -6,6 +8,9 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +var connectionString = builder.Configuration.GetConnectionString("GameList"); +builder.Services.AddSqlite(connectionString); + var app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/GameCollectionAPI/appsettings.json b/GameCollectionAPI/appsettings.json index 10f68b8..b11831d 100644 --- a/GameCollectionAPI/appsettings.json +++ b/GameCollectionAPI/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ConnectionStrings": { + "GameList" : "Data Source=GameList.db" + } } From 6645d4fecaf50aa481ce3b7b2c3ff22b006c1219 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sat, 2 Aug 2025 22:46:39 -0300 Subject: [PATCH 08/48] Add initial GamesController --- .../Controllers/GamesController.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 GameCollectionAPI/Controllers/GamesController.cs diff --git a/GameCollectionAPI/Controllers/GamesController.cs b/GameCollectionAPI/Controllers/GamesController.cs new file mode 100644 index 0000000..78ea5e9 --- /dev/null +++ b/GameCollectionAPI/Controllers/GamesController.cs @@ -0,0 +1,31 @@ +using GameCollectionAPI.Data; +using GameCollectionAPI.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace GameCollectionAPI.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class GamesController : ControllerBase + { + private readonly AppDbContext _dbContext; + + public GamesController(AppDbContext dbContext) + { + _dbContext = dbContext; + } + + [HttpGet] + public async Task> Get() => await _dbContext.Games.ToListAsync(); + + [HttpPost] + public async Task Post(Game game) + { + _dbContext.Games.Add(game); + await _dbContext.SaveChangesAsync(); + return CreatedAtAction(nameof(Get), new {id = game.Id}, game); + } + } +} From 77f89e94d26f9d487ddfbe394a11daa270e43688 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sat, 2 Aug 2025 23:10:45 -0300 Subject: [PATCH 09/48] Cretae first migration --- ...0250803020835_InitialMigration.Designer.cs | 59 +++++++++++++++++++ .../20250803020835_InitialMigration.cs | 40 +++++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 56 ++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 GameCollectionAPI/Migrations/20250803020835_InitialMigration.Designer.cs create mode 100644 GameCollectionAPI/Migrations/20250803020835_InitialMigration.cs create mode 100644 GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs diff --git a/GameCollectionAPI/Migrations/20250803020835_InitialMigration.Designer.cs b/GameCollectionAPI/Migrations/20250803020835_InitialMigration.Designer.cs new file mode 100644 index 0000000..15f17bc --- /dev/null +++ b/GameCollectionAPI/Migrations/20250803020835_InitialMigration.Designer.cs @@ -0,0 +1,59 @@ +// +using System; +using GameCollectionAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GameCollectionAPI.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20250803020835_InitialMigration")] + partial class InitialMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.18"); + + modelBuilder.Entity("GameCollectionAPI.Models.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Developer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Genre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Publisher") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GameCollectionAPI/Migrations/20250803020835_InitialMigration.cs b/GameCollectionAPI/Migrations/20250803020835_InitialMigration.cs new file mode 100644 index 0000000..e305844 --- /dev/null +++ b/GameCollectionAPI/Migrations/20250803020835_InitialMigration.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameCollectionAPI.Migrations +{ + /// + public partial class InitialMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Games", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false), + Genre = table.Column(type: "TEXT", nullable: false), + Developer = table.Column(type: "TEXT", nullable: false), + Publisher = table.Column(type: "TEXT", nullable: false), + ReleaseDate = table.Column(type: "TEXT", nullable: false), + Platform = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Games", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Games"); + } + } +} diff --git a/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs b/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..7ba40b3 --- /dev/null +++ b/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,56 @@ +// +using System; +using GameCollectionAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GameCollectionAPI.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.18"); + + modelBuilder.Entity("GameCollectionAPI.Models.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Developer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Genre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Publisher") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); +#pragma warning restore 612, 618 + } + } +} From 9d428d4af506e8c5928ffa5c3bf832de4eed7ae9 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sun, 3 Aug 2025 13:14:56 -0300 Subject: [PATCH 10/48] Add Microsoft.EntityFrameworkCore.Design package --- GameCollectionAPI/GameCollectionAPI.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/GameCollectionAPI/GameCollectionAPI.csproj b/GameCollectionAPI/GameCollectionAPI.csproj index 5b36bea..f156447 100644 --- a/GameCollectionAPI/GameCollectionAPI.csproj +++ b/GameCollectionAPI/GameCollectionAPI.csproj @@ -9,6 +9,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive From ddd9d1dac4f8d0a7de0bd54dc4c91c05e3faa21d Mon Sep 17 00:00:00 2001 From: Harsche Date: Sun, 3 Aug 2025 13:18:02 -0300 Subject: [PATCH 11/48] Update games controller --- .../Controllers/GamesController.cs | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/GameCollectionAPI/Controllers/GamesController.cs b/GameCollectionAPI/Controllers/GamesController.cs index 78ea5e9..20e6ef6 100644 --- a/GameCollectionAPI/Controllers/GamesController.cs +++ b/GameCollectionAPI/Controllers/GamesController.cs @@ -1,6 +1,5 @@ using GameCollectionAPI.Data; using GameCollectionAPI.Models; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -17,15 +16,62 @@ public GamesController(AppDbContext dbContext) _dbContext = dbContext; } + /// + /// Retrieves a list of all games from the database. + /// + /// An asynchronous task that returns an enumerable collection of objects. [HttpGet] public async Task> Get() => await _dbContext.Games.ToListAsync(); + + [HttpGet("{id:int}")] + public async Task Get(int id) + { + var game = await _dbContext.Games.FirstOrDefaultAsync(g => g.Id == id); + + if (game == null) { return NotFound(); } + + return Ok(game); + } + [HttpPost] public async Task Post(Game game) { _dbContext.Games.Add(game); await _dbContext.SaveChangesAsync(); - return CreatedAtAction(nameof(Get), new {id = game.Id}, game); - } + return CreatedAtAction(nameof(Get), new { id = game.Id }, game); + } + + [HttpPut("{id:int}")] + public async Task Put(int id, Game updatedGame) + { + var game = await _dbContext.Games.FirstOrDefaultAsync(g => g.Id == id); + + if (game == null) { return NotFound(); } + + game.Name = updatedGame.Name; + game.Genre = updatedGame.Genre; + game.Developer = updatedGame.Developer; + game.Publisher = updatedGame.Publisher; + game.ReleaseDate = updatedGame.ReleaseDate; + game.Platform = updatedGame.Platform; + + await _dbContext.SaveChangesAsync(); + + return NoContent(); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var game = await _dbContext.Games.FirstOrDefaultAsync(g => g.Id == id); + + if (game == null) { return NotFound(); } + + _dbContext.Remove(game); + await _dbContext.SaveChangesAsync(); + + return NoContent(); + } } } From 78bb932116ca7479f252f369d534690ea188f7dc Mon Sep 17 00:00:00 2001 From: Harsche Date: Sun, 3 Aug 2025 13:49:01 -0300 Subject: [PATCH 12/48] Enhance GamesController with improved response type annotations and validation checks --- .../Controllers/GamesController.cs | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/GameCollectionAPI/Controllers/GamesController.cs b/GameCollectionAPI/Controllers/GamesController.cs index 20e6ef6..f891940 100644 --- a/GameCollectionAPI/Controllers/GamesController.cs +++ b/GameCollectionAPI/Controllers/GamesController.cs @@ -16,17 +16,19 @@ public GamesController(AppDbContext dbContext) _dbContext = dbContext; } - /// - /// Retrieves a list of all games from the database. - /// - /// An asynchronous task that returns an enumerable collection of objects. [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task> Get() => await _dbContext.Games.ToListAsync(); [HttpGet("{id:int}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Get(int id) { + if (id <= 0) { return BadRequest(); } + var game = await _dbContext.Games.FirstOrDefaultAsync(g => g.Id == id); if (game == null) { return NotFound(); } @@ -35,16 +37,25 @@ public async Task Get(int id) } [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task Post(Game game) { + if (game == null) { return BadRequest(); } + _dbContext.Games.Add(game); await _dbContext.SaveChangesAsync(); return CreatedAtAction(nameof(Get), new { id = game.Id }, game); } [HttpPut("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Put(int id, Game updatedGame) { + if (id <= 0 || updatedGame == null) { return BadRequest(); } + var game = await _dbContext.Games.FirstOrDefaultAsync(g => g.Id == id); if (game == null) { return NotFound(); } @@ -62,8 +73,13 @@ public async Task Put(int id, Game updatedGame) } [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Delete(int id) { + if (id <= 0) { return BadRequest(); } + var game = await _dbContext.Games.FirstOrDefaultAsync(g => g.Id == id); if (game == null) { return NotFound(); } From c21fb4ee85b1c5acf7b9d701979ced41a2924b30 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sun, 3 Aug 2025 13:53:03 -0300 Subject: [PATCH 13/48] Add XML documentation for GamesController --- .../Controllers/GamesController.cs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/GameCollectionAPI/Controllers/GamesController.cs b/GameCollectionAPI/Controllers/GamesController.cs index f891940..d58346a 100644 --- a/GameCollectionAPI/Controllers/GamesController.cs +++ b/GameCollectionAPI/Controllers/GamesController.cs @@ -16,11 +16,19 @@ public GamesController(AppDbContext dbContext) _dbContext = dbContext; } + /// + /// Gets all games in the collection. + /// + /// List of games. [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> Get() => await _dbContext.Games.ToListAsync(); - + /// + /// Gets a specific game by its ID. + /// + /// Game ID. + /// The requested game. [HttpGet("{id:int}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -36,6 +44,11 @@ public async Task Get(int id) return Ok(game); } + /// + /// Adds a new game to the collection. + /// + /// Game object to add. + /// The created game. [HttpPost] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -48,6 +61,12 @@ public async Task Post(Game game) return CreatedAtAction(nameof(Get), new { id = game.Id }, game); } + /// + /// Updates an existing game. + /// + /// Game ID. + /// Updated game object. + /// No content. [HttpPut("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -72,6 +91,11 @@ public async Task Put(int id, Game updatedGame) return NoContent(); } + /// + /// Deletes a game by its ID. + /// + /// Game ID. + /// No content. [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -79,7 +103,7 @@ public async Task Put(int id, Game updatedGame) public async Task Delete(int id) { if (id <= 0) { return BadRequest(); } - + var game = await _dbContext.Games.FirstOrDefaultAsync(g => g.Id == id); if (game == null) { return NotFound(); } From 151c317f7603dbde6af4161ba12f577e88c0e620 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sun, 3 Aug 2025 14:47:28 -0300 Subject: [PATCH 14/48] Add DTOs for Game and use them in GamesController --- .../Controllers/GamesController.cs | 27 +++++++------ GameCollectionAPI/DTOs/GameCreateDto.cs | 29 ++++++++++++++ GameCollectionAPI/DTOs/GameReadDto.cs | 12 ++++++ GameCollectionAPI/Models/Game.cs | 39 +++++++++++++++++++ 4 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 GameCollectionAPI/DTOs/GameCreateDto.cs create mode 100644 GameCollectionAPI/DTOs/GameReadDto.cs diff --git a/GameCollectionAPI/Controllers/GamesController.cs b/GameCollectionAPI/Controllers/GamesController.cs index d58346a..e0cbf00 100644 --- a/GameCollectionAPI/Controllers/GamesController.cs +++ b/GameCollectionAPI/Controllers/GamesController.cs @@ -1,4 +1,5 @@ using GameCollectionAPI.Data; +using GameCollectionAPI.DTOs; using GameCollectionAPI.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -22,7 +23,9 @@ public GamesController(AppDbContext dbContext) /// List of games. [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> Get() => await _dbContext.Games.ToListAsync(); + public async Task> Get() => await _dbContext.Games + .Select(game => game.ToReadDto()) + .ToListAsync(); /// /// Gets a specific game by its ID. @@ -41,24 +44,26 @@ public async Task Get(int id) if (game == null) { return NotFound(); } - return Ok(game); + return Ok(game.ToReadDto()); } /// /// Adds a new game to the collection. /// - /// Game object to add. + /// Game object to add. /// The created game. [HttpPost] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task Post(Game game) + public async Task Post([FromBody] GameCreateDto createdGame) { - if (game == null) { return BadRequest(); } + if (createdGame == null) { return BadRequest(); } + var game = Game.FromCreateDto(createdGame); _dbContext.Games.Add(game); await _dbContext.SaveChangesAsync(); - return CreatedAtAction(nameof(Get), new { id = game.Id }, game); + + return CreatedAtAction(nameof(Get), new { id = game.Id }, game.ToReadDto()); } /// @@ -71,7 +76,7 @@ public async Task Post(Game game) [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task Put(int id, Game updatedGame) + public async Task Put(int id, [FromBody] GameCreateDto updatedGame) { if (id <= 0 || updatedGame == null) { return BadRequest(); } @@ -79,13 +84,7 @@ public async Task Put(int id, Game updatedGame) if (game == null) { return NotFound(); } - game.Name = updatedGame.Name; - game.Genre = updatedGame.Genre; - game.Developer = updatedGame.Developer; - game.Publisher = updatedGame.Publisher; - game.ReleaseDate = updatedGame.ReleaseDate; - game.Platform = updatedGame.Platform; - + game.UpdateFromDto(updatedGame); await _dbContext.SaveChangesAsync(); return NoContent(); diff --git a/GameCollectionAPI/DTOs/GameCreateDto.cs b/GameCollectionAPI/DTOs/GameCreateDto.cs new file mode 100644 index 0000000..d707df5 --- /dev/null +++ b/GameCollectionAPI/DTOs/GameCreateDto.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace GameCollectionAPI.DTOs; + +public class GameCreateDto +{ + [Required] + [StringLength(64, MinimumLength = 2)] + public required string Name { get; set; } + + [Required] + [StringLength(32, MinimumLength = 2)] + public required string Genre { get; set; } + + [Required] + [StringLength(64, MinimumLength = 3)] + public required string Developer { get; set; } + + [Required] + [StringLength(64, MinimumLength = 3)] + public required string Publisher { get; set; } + + [Required] + public required DateTime ReleaseDate { get; set; } + + [Required] + [StringLength(32, MinimumLength = 2)] + public required string Platform { get; set; } +} diff --git a/GameCollectionAPI/DTOs/GameReadDto.cs b/GameCollectionAPI/DTOs/GameReadDto.cs new file mode 100644 index 0000000..5950c76 --- /dev/null +++ b/GameCollectionAPI/DTOs/GameReadDto.cs @@ -0,0 +1,12 @@ +namespace GameCollectionAPI.DTOs; + +public class GameReadDto +{ + public int Id { get; set; } + public string Name { get; set; } + public string Genre { get; set; } + public string Developer { get; set; } + public string Publisher { get; set; } + public DateTime ReleaseDate { get; set; } + public string Platform { get; set; } +} diff --git a/GameCollectionAPI/Models/Game.cs b/GameCollectionAPI/Models/Game.cs index 3743c29..44b5b87 100644 --- a/GameCollectionAPI/Models/Game.cs +++ b/GameCollectionAPI/Models/Game.cs @@ -1,3 +1,5 @@ +using GameCollectionAPI.DTOs; + namespace GameCollectionAPI.Models; public class Game @@ -9,4 +11,41 @@ public class Game public string Publisher { get; set; } public DateTime ReleaseDate { get; set; } public string Platform { get; set; } + + public void UpdateFromDto(GameCreateDto dto) + { + Name = dto.Name; + Genre = dto.Genre; + Developer = dto.Developer; + Publisher = dto.Publisher; + ReleaseDate = dto.ReleaseDate; + Platform = dto.Platform; + } + + public static Game FromCreateDto(GameCreateDto dto) + { + return new Game + { + Name = dto.Name, + Genre = dto.Genre, + Developer = dto.Developer, + Publisher = dto.Publisher, + ReleaseDate = dto.ReleaseDate, + Platform = dto.Platform + }; + } + + public GameReadDto ToReadDto() + { + return new GameReadDto + { + Id = Id, + Name = Name, + Genre = Genre, + Developer = Developer, + Publisher = Publisher, + ReleaseDate = ReleaseDate, + Platform = Platform + }; + } } From bb8a3c959d3bb7cdd2a317e4ed40489a238349cb Mon Sep 17 00:00:00 2001 From: Harsche Date: Sun, 17 Aug 2025 09:35:47 -0300 Subject: [PATCH 15/48] Implement GameRepository and IGameRepository for data access; update Program.cs for dependency injection --- GameCollectionAPI/GameCollectionAPI.csproj | 4 ++ GameCollectionAPI/Program.cs | 3 ++ .../Repositories/GameRepository.cs | 42 +++++++++++++++++++ .../Repositories/IGameRepository.cs | 12 ++++++ 4 files changed, 61 insertions(+) create mode 100644 GameCollectionAPI/Repositories/GameRepository.cs create mode 100644 GameCollectionAPI/Repositories/IGameRepository.cs diff --git a/GameCollectionAPI/GameCollectionAPI.csproj b/GameCollectionAPI/GameCollectionAPI.csproj index f156447..45fcd28 100644 --- a/GameCollectionAPI/GameCollectionAPI.csproj +++ b/GameCollectionAPI/GameCollectionAPI.csproj @@ -21,4 +21,8 @@ + + + + diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index c35f154..3f3baf9 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -1,4 +1,5 @@ using GameCollectionAPI.Data; +using GameCollectionAPI.Repositories; var builder = WebApplication.CreateBuilder(args); @@ -11,6 +12,8 @@ var connectionString = builder.Configuration.GetConnectionString("GameList"); builder.Services.AddSqlite(connectionString); +builder.Services.AddScoped(); + var app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/GameCollectionAPI/Repositories/GameRepository.cs b/GameCollectionAPI/Repositories/GameRepository.cs new file mode 100644 index 0000000..e37bc4c --- /dev/null +++ b/GameCollectionAPI/Repositories/GameRepository.cs @@ -0,0 +1,42 @@ +using GameCollectionAPI.Data; +using GameCollectionAPI.Models; +using Microsoft.EntityFrameworkCore; + +namespace GameCollectionAPI.Repositories; + +public class GameRepository : IGameRepository +{ + private readonly AppDbContext _dbContext; + + public GameRepository(AppDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task AddAsync(Game game) + { + _dbContext.Games.Add(game); + return _dbContext.SaveChangesAsync(); + } + + public Task DeleteAsync(Game game) + { + _dbContext.Remove(game); + return _dbContext.SaveChangesAsync(); + } + + public Task> GetAllAsync() + { + return _dbContext.Games.ToListAsync(); + } + + public Task GetByIdAsync(int id) + { + return _dbContext.Games.FirstOrDefaultAsync(g => g.Id == id); + } + + public Task UpdateAsync() + { + return _dbContext.SaveChangesAsync(); + } +} diff --git a/GameCollectionAPI/Repositories/IGameRepository.cs b/GameCollectionAPI/Repositories/IGameRepository.cs new file mode 100644 index 0000000..25d3654 --- /dev/null +++ b/GameCollectionAPI/Repositories/IGameRepository.cs @@ -0,0 +1,12 @@ +using GameCollectionAPI.Models; + +namespace GameCollectionAPI.Repositories; + +public interface IGameRepository +{ + Task> GetAllAsync(); + Task GetByIdAsync(int id); + Task AddAsync(Game game); + Task UpdateAsync(); + Task DeleteAsync(Game game); +} From c2b5c303eb1d3562876ce9f51ede57cce2491745 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sat, 23 Aug 2025 17:49:53 -0300 Subject: [PATCH 16/48] Implement GameService class and use it in GamesController --- .../Controllers/GamesController.cs | 47 ++++--------- GameCollectionAPI/GameCollectionAPI.csproj | 4 -- GameCollectionAPI/Program.cs | 2 + GameCollectionAPI/Services/GameService.cs | 67 +++++++++++++++++++ GameCollectionAPI/Services/IGameService.cs | 12 ++++ 5 files changed, 94 insertions(+), 38 deletions(-) create mode 100644 GameCollectionAPI/Services/GameService.cs create mode 100644 GameCollectionAPI/Services/IGameService.cs diff --git a/GameCollectionAPI/Controllers/GamesController.cs b/GameCollectionAPI/Controllers/GamesController.cs index e0cbf00..5d823d9 100644 --- a/GameCollectionAPI/Controllers/GamesController.cs +++ b/GameCollectionAPI/Controllers/GamesController.cs @@ -1,8 +1,6 @@ -using GameCollectionAPI.Data; using GameCollectionAPI.DTOs; -using GameCollectionAPI.Models; +using GameCollectionAPI.Services; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; namespace GameCollectionAPI.Controllers { @@ -10,11 +8,11 @@ namespace GameCollectionAPI.Controllers [ApiController] public class GamesController : ControllerBase { - private readonly AppDbContext _dbContext; + private readonly IGameService _service; - public GamesController(AppDbContext dbContext) + public GamesController(IGameService service) { - _dbContext = dbContext; + _service = service; } /// @@ -23,9 +21,7 @@ public GamesController(AppDbContext dbContext) /// List of games. [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> Get() => await _dbContext.Games - .Select(game => game.ToReadDto()) - .ToListAsync(); + public async Task> Get() => await _service.GetAllGamesAsync(); /// /// Gets a specific game by its ID. @@ -40,11 +36,8 @@ public async Task Get(int id) { if (id <= 0) { return BadRequest(); } - var game = await _dbContext.Games.FirstOrDefaultAsync(g => g.Id == id); - - if (game == null) { return NotFound(); } - - return Ok(game.ToReadDto()); + var game = await _service.GetGameByIdAsync(id); + return game == null ? NotFound() : Ok(game); } /// @@ -59,11 +52,9 @@ public async Task Post([FromBody] GameCreateDto createdGame) { if (createdGame == null) { return BadRequest(); } - var game = Game.FromCreateDto(createdGame); - _dbContext.Games.Add(game); - await _dbContext.SaveChangesAsync(); + var game = await _service.CreateGameAsync(createdGame); - return CreatedAtAction(nameof(Get), new { id = game.Id }, game.ToReadDto()); + return CreatedAtAction(nameof(Get), new { id = game.Id }, game); } /// @@ -80,14 +71,8 @@ public async Task Put(int id, [FromBody] GameCreateDto updatedGam { if (id <= 0 || updatedGame == null) { return BadRequest(); } - var game = await _dbContext.Games.FirstOrDefaultAsync(g => g.Id == id); - - if (game == null) { return NotFound(); } - - game.UpdateFromDto(updatedGame); - await _dbContext.SaveChangesAsync(); - - return NoContent(); + bool successful = await _service.UpdateGameAsync(id, updatedGame); + return successful ? NoContent() : NotFound(); } /// @@ -103,14 +88,8 @@ public async Task Delete(int id) { if (id <= 0) { return BadRequest(); } - var game = await _dbContext.Games.FirstOrDefaultAsync(g => g.Id == id); - - if (game == null) { return NotFound(); } - - _dbContext.Remove(game); - await _dbContext.SaveChangesAsync(); - - return NoContent(); + bool successful = await _service.DeleteGameAsync(id); + return successful ? NoContent() : NotFound(); } } } diff --git a/GameCollectionAPI/GameCollectionAPI.csproj b/GameCollectionAPI/GameCollectionAPI.csproj index 45fcd28..f156447 100644 --- a/GameCollectionAPI/GameCollectionAPI.csproj +++ b/GameCollectionAPI/GameCollectionAPI.csproj @@ -21,8 +21,4 @@ - - - - diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index 3f3baf9..3b92c0a 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -1,5 +1,6 @@ using GameCollectionAPI.Data; using GameCollectionAPI.Repositories; +using GameCollectionAPI.Services; var builder = WebApplication.CreateBuilder(args); @@ -13,6 +14,7 @@ builder.Services.AddSqlite(connectionString); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/GameCollectionAPI/Services/GameService.cs b/GameCollectionAPI/Services/GameService.cs new file mode 100644 index 0000000..398e515 --- /dev/null +++ b/GameCollectionAPI/Services/GameService.cs @@ -0,0 +1,67 @@ +using GameCollectionAPI.DTOs; +using GameCollectionAPI.Models; +using GameCollectionAPI.Repositories; + +namespace GameCollectionAPI.Services; + +public class GameService : IGameService +{ + private readonly IGameRepository _repo; + + public GameService(IGameRepository repo) + { + _repo = repo; + } + + public async Task CreateGameAsync(GameCreateDto createdGame) + { + var game = Game.FromCreateDto(createdGame); + await _repo.AddAsync(game); + return game.ToReadDto(); + } + + public async Task DeleteGameAsync(int id) + { + var game = await _repo.GetByIdAsync(id); + + if (game == null) + { + return false; + } + + await _repo.DeleteAsync(game); + return true; + } + + public async Task> GetAllGamesAsync() + { + var games = await _repo.GetAllAsync(); + return [.. games.Select(g => g.ToReadDto())]; + } + + public async Task GetGameByIdAsync(int id) + { + var game = await _repo.GetByIdAsync(id); + + if (game == null) + { + return null; + } + + return game.ToReadDto(); + } + + public async Task UpdateGameAsync(int id, GameCreateDto updatedGame) + { + var game = await _repo.GetByIdAsync(id); + + if (game == null) + { + return false; + } + + game.UpdateFromDto(updatedGame); + await _repo.UpdateAsync(); + return true; + } +} diff --git a/GameCollectionAPI/Services/IGameService.cs b/GameCollectionAPI/Services/IGameService.cs new file mode 100644 index 0000000..4db3ee9 --- /dev/null +++ b/GameCollectionAPI/Services/IGameService.cs @@ -0,0 +1,12 @@ +using GameCollectionAPI.DTOs; + +namespace GameCollectionAPI.Services; + +public interface IGameService +{ + Task> GetAllGamesAsync(); + Task GetGameByIdAsync(int id); + Task CreateGameAsync(GameCreateDto createdGame); + Task DeleteGameAsync(int id); + Task UpdateGameAsync(int id, GameCreateDto updatedGame); +} From 4ef974a23790ea57af0f27913dd6c7cdd66b3a68 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sat, 23 Aug 2025 18:14:27 -0300 Subject: [PATCH 17/48] Add XML docs to GameService --- GameCollectionAPI/Services/GameService.cs | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/GameCollectionAPI/Services/GameService.cs b/GameCollectionAPI/Services/GameService.cs index 398e515..c9930c8 100644 --- a/GameCollectionAPI/Services/GameService.cs +++ b/GameCollectionAPI/Services/GameService.cs @@ -13,6 +13,11 @@ public GameService(IGameRepository repo) _repo = repo; } + /// + /// Creates a new game in the collection. + /// + /// The game data to create. + /// The created game with assigned ID. public async Task CreateGameAsync(GameCreateDto createdGame) { var game = Game.FromCreateDto(createdGame); @@ -20,6 +25,11 @@ public async Task CreateGameAsync(GameCreateDto createdGame) return game.ToReadDto(); } + /// + /// Deletes a game from the collection by its ID. + /// + /// The ID of the game to delete. + /// True if the game was found and deleted; false if not found. public async Task DeleteGameAsync(int id) { var game = await _repo.GetByIdAsync(id); @@ -33,12 +43,21 @@ public async Task DeleteGameAsync(int id) return true; } + /// + /// Retrieves all games from the collection. + /// + /// A list of all games in the collection. public async Task> GetAllGamesAsync() { var games = await _repo.GetAllAsync(); return [.. games.Select(g => g.ToReadDto())]; } + /// + /// Retrieves a specific game by its ID. + /// + /// The ID of the game to retrieve. + /// The game if found; null if not found. public async Task GetGameByIdAsync(int id) { var game = await _repo.GetByIdAsync(id); @@ -51,6 +70,12 @@ public async Task> GetAllGamesAsync() return game.ToReadDto(); } + /// + /// Updates an existing game with new data. + /// + /// The ID of the game to update. + /// The updated game data. + /// True if the game was found and updated; false if not found. public async Task UpdateGameAsync(int id, GameCreateDto updatedGame) { var game = await _repo.GetByIdAsync(id); From 2a51595f9e38537e7fa33c7c824cf4ba497c3d2b Mon Sep 17 00:00:00 2001 From: Harsche Date: Sat, 23 Aug 2025 18:25:48 -0300 Subject: [PATCH 18/48] Add required keyword to fields that are required --- GameCollectionAPI/DTOs/GameReadDto.cs | 12 ++++++------ GameCollectionAPI/Models/Game.cs | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/GameCollectionAPI/DTOs/GameReadDto.cs b/GameCollectionAPI/DTOs/GameReadDto.cs index 5950c76..dcdbe25 100644 --- a/GameCollectionAPI/DTOs/GameReadDto.cs +++ b/GameCollectionAPI/DTOs/GameReadDto.cs @@ -3,10 +3,10 @@ namespace GameCollectionAPI.DTOs; public class GameReadDto { public int Id { get; set; } - public string Name { get; set; } - public string Genre { get; set; } - public string Developer { get; set; } - public string Publisher { get; set; } - public DateTime ReleaseDate { get; set; } - public string Platform { get; set; } + public required string Name { get; set; } + public required string Genre { get; set; } + public required string Developer { get; set; } + public required string Publisher { get; set; } + public required DateTime ReleaseDate { get; set; } + public required string Platform { get; set; } } diff --git a/GameCollectionAPI/Models/Game.cs b/GameCollectionAPI/Models/Game.cs index 44b5b87..1628b73 100644 --- a/GameCollectionAPI/Models/Game.cs +++ b/GameCollectionAPI/Models/Game.cs @@ -5,12 +5,12 @@ namespace GameCollectionAPI.Models; public class Game { public int Id { get; set; } - public string Name { get; set; } - public string Genre { get; set; } - public string Developer { get; set; } - public string Publisher { get; set; } + public required string Name { get; set; } + public required string Genre { get; set; } + public required string Developer { get; set; } + public required string Publisher { get; set; } public DateTime ReleaseDate { get; set; } - public string Platform { get; set; } + public required string Platform { get; set; } public void UpdateFromDto(GameCreateDto dto) { From a3bef900521131838708ddfdc3ce578dd1a513d1 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sat, 30 Aug 2025 19:53:14 -0300 Subject: [PATCH 19/48] Implement user management features including UserController, UserService, and UserRepository; add DTOs for user creation and updates; handle duplicate usernames with custom exception. --- .../Controllers/GamesController.cs | 2 +- .../Controllers/UsersController.cs | 83 +++++++++++++++++++ .../DTOs/{ => Games}/GameCreateDto.cs | 2 +- .../DTOs/{ => Games}/GameReadDto.cs | 2 +- .../DTOs/Users/UpdateUsernameDto.cs | 10 +++ GameCollectionAPI/DTOs/Users/UserCreateDto.cs | 12 +++ GameCollectionAPI/DTOs/Users/UserReadDto.cs | 8 ++ GameCollectionAPI/Data/AppDbContext.cs | 1 + .../Exceptions/DuplicateUsernameException.cs | 8 ++ GameCollectionAPI/Models/Game.cs | 2 +- GameCollectionAPI/Models/User.cs | 36 ++++++++ GameCollectionAPI/Program.cs | 2 + .../Repositories/IUserRepository.cs | 12 +++ .../Repositories/UserRepository.cs | 42 ++++++++++ GameCollectionAPI/Services/GameService.cs | 2 +- GameCollectionAPI/Services/IGameService.cs | 2 +- GameCollectionAPI/Services/IUserService.cs | 11 +++ GameCollectionAPI/Services/UserService.cs | 66 +++++++++++++++ 18 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 GameCollectionAPI/Controllers/UsersController.cs rename GameCollectionAPI/DTOs/{ => Games}/GameCreateDto.cs (94%) rename GameCollectionAPI/DTOs/{ => Games}/GameReadDto.cs (90%) create mode 100644 GameCollectionAPI/DTOs/Users/UpdateUsernameDto.cs create mode 100644 GameCollectionAPI/DTOs/Users/UserCreateDto.cs create mode 100644 GameCollectionAPI/DTOs/Users/UserReadDto.cs create mode 100644 GameCollectionAPI/Exceptions/DuplicateUsernameException.cs create mode 100644 GameCollectionAPI/Models/User.cs create mode 100644 GameCollectionAPI/Repositories/IUserRepository.cs create mode 100644 GameCollectionAPI/Repositories/UserRepository.cs create mode 100644 GameCollectionAPI/Services/IUserService.cs create mode 100644 GameCollectionAPI/Services/UserService.cs diff --git a/GameCollectionAPI/Controllers/GamesController.cs b/GameCollectionAPI/Controllers/GamesController.cs index 5d823d9..a7eefe0 100644 --- a/GameCollectionAPI/Controllers/GamesController.cs +++ b/GameCollectionAPI/Controllers/GamesController.cs @@ -1,4 +1,4 @@ -using GameCollectionAPI.DTOs; +using GameCollectionAPI.DTOs.Games; using GameCollectionAPI.Services; using Microsoft.AspNetCore.Mvc; diff --git a/GameCollectionAPI/Controllers/UsersController.cs b/GameCollectionAPI/Controllers/UsersController.cs new file mode 100644 index 0000000..e464b43 --- /dev/null +++ b/GameCollectionAPI/Controllers/UsersController.cs @@ -0,0 +1,83 @@ +using GameCollectionAPI.DTOs.Users; +using GameCollectionAPI.Exceptions; +using GameCollectionAPI.Services; +using Microsoft.AspNetCore.Mvc; + +namespace GameCollectionAPI.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class UsersController : ControllerBase + { + private readonly IUserService _service; + + public UsersController(IUserService service) + { + _service = service; + } + + [HttpGet("{id:int}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Get(int id) + { + if (id <= 0) { return BadRequest(); } + + var user = await _service.GetUserByIdAsync(id); + return user == null ? NotFound() : Ok(user); + } + + + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Create([FromBody] UserCreateDto createdUser) + { + if (createdUser == null) { return BadRequest(); } + + try + { + var user = await _service.CreateUserAsync(createdUser); + return CreatedAtAction(nameof(Get), new { id = user.Id }, user); + } + catch (DuplicateUsernameException ex) + { + return Conflict(new { message = ex.Message }); + } + } + + [HttpPut("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task UpdateUsername(int id, [FromBody] UpdateUsernameDto updateUsernameDto) + { + if (id <= 0 || updateUsernameDto == null) { return BadRequest(); } + + try + { + bool successful = await _service.UpdateUsernameAsync(id, updateUsernameDto); + return successful ? NoContent() : NotFound(); + } + catch (DuplicateUsernameException ex) + { + return Conflict(new { message = ex.Message }); + } + } + + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + if (id <= 0) { return BadRequest(); } + + bool successful = await _service.DeleteUserAsync(id); + return successful ? NoContent() : NotFound(); + } + } +} diff --git a/GameCollectionAPI/DTOs/GameCreateDto.cs b/GameCollectionAPI/DTOs/Games/GameCreateDto.cs similarity index 94% rename from GameCollectionAPI/DTOs/GameCreateDto.cs rename to GameCollectionAPI/DTOs/Games/GameCreateDto.cs index d707df5..fa590da 100644 --- a/GameCollectionAPI/DTOs/GameCreateDto.cs +++ b/GameCollectionAPI/DTOs/Games/GameCreateDto.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace GameCollectionAPI.DTOs; +namespace GameCollectionAPI.DTOs.Games; public class GameCreateDto { diff --git a/GameCollectionAPI/DTOs/GameReadDto.cs b/GameCollectionAPI/DTOs/Games/GameReadDto.cs similarity index 90% rename from GameCollectionAPI/DTOs/GameReadDto.cs rename to GameCollectionAPI/DTOs/Games/GameReadDto.cs index dcdbe25..13cf557 100644 --- a/GameCollectionAPI/DTOs/GameReadDto.cs +++ b/GameCollectionAPI/DTOs/Games/GameReadDto.cs @@ -1,4 +1,4 @@ -namespace GameCollectionAPI.DTOs; +namespace GameCollectionAPI.DTOs.Games; public class GameReadDto { diff --git a/GameCollectionAPI/DTOs/Users/UpdateUsernameDto.cs b/GameCollectionAPI/DTOs/Users/UpdateUsernameDto.cs new file mode 100644 index 0000000..51f4472 --- /dev/null +++ b/GameCollectionAPI/DTOs/Users/UpdateUsernameDto.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace GameCollectionAPI.DTOs.Users; + +public class UpdateUsernameDto +{ + [Required] + [StringLength(20, MinimumLength = 1)] + public required string NewUsername { get; set; } +} diff --git a/GameCollectionAPI/DTOs/Users/UserCreateDto.cs b/GameCollectionAPI/DTOs/Users/UserCreateDto.cs new file mode 100644 index 0000000..4012ffa --- /dev/null +++ b/GameCollectionAPI/DTOs/Users/UserCreateDto.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace GameCollectionAPI.DTOs.Users; + +public class UserCreateDto +{ + [Required] + [StringLength(20, MinimumLength = 1)] + public required string Username { get; set; } + + public required string Password { get; set; } +} diff --git a/GameCollectionAPI/DTOs/Users/UserReadDto.cs b/GameCollectionAPI/DTOs/Users/UserReadDto.cs new file mode 100644 index 0000000..3e545d4 --- /dev/null +++ b/GameCollectionAPI/DTOs/Users/UserReadDto.cs @@ -0,0 +1,8 @@ +namespace GameCollectionAPI.DTOs.Users; + +public class UserReadDto +{ + public int Id { get; set; } + public required string Username { get; set; } + public DateTime CreatedDate { get; set; } +} diff --git a/GameCollectionAPI/Data/AppDbContext.cs b/GameCollectionAPI/Data/AppDbContext.cs index 39269b6..ccec03a 100644 --- a/GameCollectionAPI/Data/AppDbContext.cs +++ b/GameCollectionAPI/Data/AppDbContext.cs @@ -7,5 +7,6 @@ public class AppDbContext : DbContext { public AppDbContext(DbContextOptions options) : base(options) { } + public DbSet Users { get; set; } public DbSet Games { get; set; } } diff --git a/GameCollectionAPI/Exceptions/DuplicateUsernameException.cs b/GameCollectionAPI/Exceptions/DuplicateUsernameException.cs new file mode 100644 index 0000000..78893e9 --- /dev/null +++ b/GameCollectionAPI/Exceptions/DuplicateUsernameException.cs @@ -0,0 +1,8 @@ +using System; + +namespace GameCollectionAPI.Exceptions; + +public class DuplicateUsernameException : Exception +{ + public DuplicateUsernameException() : base("Username is already being used.") { } +} diff --git a/GameCollectionAPI/Models/Game.cs b/GameCollectionAPI/Models/Game.cs index 1628b73..8971af0 100644 --- a/GameCollectionAPI/Models/Game.cs +++ b/GameCollectionAPI/Models/Game.cs @@ -1,4 +1,4 @@ -using GameCollectionAPI.DTOs; +using GameCollectionAPI.DTOs.Games; namespace GameCollectionAPI.Models; diff --git a/GameCollectionAPI/Models/User.cs b/GameCollectionAPI/Models/User.cs new file mode 100644 index 0000000..5d9eee3 --- /dev/null +++ b/GameCollectionAPI/Models/User.cs @@ -0,0 +1,36 @@ +using GameCollectionAPI.DTOs.Users; +using Microsoft.AspNetCore.Identity; + +namespace GameCollectionAPI.Models; + +public class User +{ + public int Id { get; set; } + public required string Username { get; set; } + public string? PasswordHash { get; set; } + public DateTime CreatedDate { get; set; } + + public static User FromCreateDto(UserCreateDto createDto) + { + var user = new User + { + Username = createDto.Username, + CreatedDate = DateTime.UtcNow + }; + + var passwordHasher = new PasswordHasher(); + user.PasswordHash = passwordHasher.HashPassword(user, createDto.Password); + + return user; + } + + public UserReadDto ToReadDto() + { + return new UserReadDto + { + Id = Id, + Username = Username, + CreatedDate = CreatedDate + }; + } +} diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index 3b92c0a..f6e5ae8 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -15,6 +15,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/GameCollectionAPI/Repositories/IUserRepository.cs b/GameCollectionAPI/Repositories/IUserRepository.cs new file mode 100644 index 0000000..b585294 --- /dev/null +++ b/GameCollectionAPI/Repositories/IUserRepository.cs @@ -0,0 +1,12 @@ +using GameCollectionAPI.Models; + +namespace GameCollectionAPI.Repositories; + +public interface IUserRepository +{ + Task GetByIdAsync(int id); + Task GetByUsernameAsync(string username); + Task AddAsync(User user); + Task DeleteAsync(User user); + Task UpdateAsync(); +} diff --git a/GameCollectionAPI/Repositories/UserRepository.cs b/GameCollectionAPI/Repositories/UserRepository.cs new file mode 100644 index 0000000..75acf51 --- /dev/null +++ b/GameCollectionAPI/Repositories/UserRepository.cs @@ -0,0 +1,42 @@ +using GameCollectionAPI.Data; +using GameCollectionAPI.Models; +using Microsoft.EntityFrameworkCore; + +namespace GameCollectionAPI.Repositories; + +public class UserRepository : IUserRepository +{ + private AppDbContext _context; + + public UserRepository(AppDbContext context) + { + _context = context; + } + + public Task AddAsync(User user) + { + _context.Users.Add(user); + return _context.SaveChangesAsync(); + } + + public Task DeleteAsync(User user) + { + _context.Users.Remove(user); + return _context.SaveChangesAsync(); + } + + public Task GetByIdAsync(int id) + { + return _context.Users.FirstOrDefaultAsync(user => user.Id == id); + } + + public Task GetByUsernameAsync(string username) + { + return _context.Users.FirstOrDefaultAsync(user => user.Username == username); + } + + public Task UpdateAsync() + { + return _context.SaveChangesAsync(); + } +} diff --git a/GameCollectionAPI/Services/GameService.cs b/GameCollectionAPI/Services/GameService.cs index c9930c8..58f206c 100644 --- a/GameCollectionAPI/Services/GameService.cs +++ b/GameCollectionAPI/Services/GameService.cs @@ -1,4 +1,4 @@ -using GameCollectionAPI.DTOs; +using GameCollectionAPI.DTOs.Games; using GameCollectionAPI.Models; using GameCollectionAPI.Repositories; diff --git a/GameCollectionAPI/Services/IGameService.cs b/GameCollectionAPI/Services/IGameService.cs index 4db3ee9..29ed0a1 100644 --- a/GameCollectionAPI/Services/IGameService.cs +++ b/GameCollectionAPI/Services/IGameService.cs @@ -1,4 +1,4 @@ -using GameCollectionAPI.DTOs; +using GameCollectionAPI.DTOs.Games; namespace GameCollectionAPI.Services; diff --git a/GameCollectionAPI/Services/IUserService.cs b/GameCollectionAPI/Services/IUserService.cs new file mode 100644 index 0000000..824e80f --- /dev/null +++ b/GameCollectionAPI/Services/IUserService.cs @@ -0,0 +1,11 @@ +using GameCollectionAPI.DTOs.Users; + +namespace GameCollectionAPI.Services; + +public interface IUserService +{ + Task CreateUserAsync(UserCreateDto createdUser); + Task GetUserByIdAsync(int id); + Task UpdateUsernameAsync(int id, UpdateUsernameDto updateUsernameDto); + Task DeleteUserAsync(int id); +} diff --git a/GameCollectionAPI/Services/UserService.cs b/GameCollectionAPI/Services/UserService.cs new file mode 100644 index 0000000..32063f4 --- /dev/null +++ b/GameCollectionAPI/Services/UserService.cs @@ -0,0 +1,66 @@ +using GameCollectionAPI.DTOs.Users; +using GameCollectionAPI.Exceptions; +using GameCollectionAPI.Models; +using GameCollectionAPI.Repositories; + +namespace GameCollectionAPI.Services; + +public class UserService : IUserService +{ + private IUserRepository _repo; + + public UserService(IUserRepository repo) + { + _repo = repo; + } + + public async Task CreateUserAsync(UserCreateDto createdUser) + { + if (await _repo.GetByUsernameAsync(createdUser.Username) != null) + { + throw new DuplicateUsernameException(); + } + + var user = User.FromCreateDto(createdUser); + await _repo.AddAsync(user); + return user.ToReadDto(); + } + + public async Task DeleteUserAsync(int id) + { + var user = await _repo.GetByIdAsync(id); + + if (user == null) + { + return false; + } + + await _repo.DeleteAsync(user); + return true; + } + + public async Task GetUserByIdAsync(int id) + { + var user = await _repo.GetByIdAsync(id); + return user?.ToReadDto(); + } + + public async Task UpdateUsernameAsync(int id, UpdateUsernameDto updateUsernameDto) + { + if (await _repo.GetByUsernameAsync(updateUsernameDto.NewUsername) != null) + { + throw new DuplicateUsernameException(); + } + + var user = await _repo.GetByIdAsync(id); + + if (user == null) + { + return false; + } + + user.Username = updateUsernameDto.NewUsername; + await _repo.UpdateAsync(); + return true; + } +} From 495d57f07b99972791f55657d5c5fc03599c62ea Mon Sep 17 00:00:00 2001 From: Harsche Date: Sun, 31 Aug 2025 12:42:47 -0300 Subject: [PATCH 20/48] Add JWT authentication configuration and update appsettings for issuer and audience --- GameCollectionAPI/GameCollectionAPI.csproj | 4 +++- GameCollectionAPI/Program.cs | 24 ++++++++++++++++++++++ GameCollectionAPI/appsettings.json | 5 +++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/GameCollectionAPI/GameCollectionAPI.csproj b/GameCollectionAPI/GameCollectionAPI.csproj index f156447..0c2865e 100644 --- a/GameCollectionAPI/GameCollectionAPI.csproj +++ b/GameCollectionAPI/GameCollectionAPI.csproj @@ -1,12 +1,14 @@ - + net8.0 enable enable + a5fe0efc-96d9-41b0-93c1-556182146f8e + diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index f6e5ae8..d5ceb41 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -1,6 +1,9 @@ +using System.Text; using GameCollectionAPI.Data; using GameCollectionAPI.Repositories; using GameCollectionAPI.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); @@ -17,6 +20,27 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// Authentication +var jwtSettings = builder.Configuration.GetSection("Jwt"); +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings["Issuer"], + ValidAudience = jwtSettings["Audience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings["Key"])) + + }; +}); var app = builder.Build(); diff --git a/GameCollectionAPI/appsettings.json b/GameCollectionAPI/appsettings.json index b11831d..f8a896c 100644 --- a/GameCollectionAPI/appsettings.json +++ b/GameCollectionAPI/appsettings.json @@ -8,5 +8,10 @@ "AllowedHosts": "*", "ConnectionStrings": { "GameList" : "Data Source=GameList.db" + }, + "Jwt": { + "Issuer": "www.marblegamestudio.com", + "Audience": "GameCollection", + "Key": "" } } From c32b7047014af6ff6b411dbaf67624a879cab983 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sun, 31 Aug 2025 12:52:24 -0300 Subject: [PATCH 21/48] Add user and role management with initial data seeding; update User and Role models, and modify UserRepository for role inclusion --- GameCollectionAPI/DTOs/Users/UserCreateDto.cs | 1 + GameCollectionAPI/Data/AppDbContext.cs | 23 +++ ...0250831143809_AddUsersAndRoles.Designer.cs | 133 ++++++++++++++++++ .../20250831143809_AddUsersAndRoles.cs | 81 +++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 74 ++++++++++ GameCollectionAPI/Models/Role.cs | 13 ++ GameCollectionAPI/Models/User.cs | 6 +- .../Repositories/UserRepository.cs | 4 +- 8 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs create mode 100644 GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.cs create mode 100644 GameCollectionAPI/Models/Role.cs diff --git a/GameCollectionAPI/DTOs/Users/UserCreateDto.cs b/GameCollectionAPI/DTOs/Users/UserCreateDto.cs index 4012ffa..9205813 100644 --- a/GameCollectionAPI/DTOs/Users/UserCreateDto.cs +++ b/GameCollectionAPI/DTOs/Users/UserCreateDto.cs @@ -8,5 +8,6 @@ public class UserCreateDto [StringLength(20, MinimumLength = 1)] public required string Username { get; set; } + [Required] public required string Password { get; set; } } diff --git a/GameCollectionAPI/Data/AppDbContext.cs b/GameCollectionAPI/Data/AppDbContext.cs index ccec03a..84d6a91 100644 --- a/GameCollectionAPI/Data/AppDbContext.cs +++ b/GameCollectionAPI/Data/AppDbContext.cs @@ -9,4 +9,27 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Users { get; set; } public DbSet Games { get; set; } + public DbSet Roles { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Add roles + modelBuilder.Entity().HasData( + new Role { Id = (int)RoleType.Admin, Name = RoleType.Admin.ToString() }, + new Role { Id = (int)RoleType.User, Name = RoleType.User.ToString() } + ); + + // Add admin user + var adminUser = User.FromCreateDto( + new DTOs.Users.UserCreateDto + { + Username = "Admin", + Password = "admin", + } + ); + adminUser.Id = -1; + adminUser.CreatedDate = new DateTime(0); + adminUser.RoleId = (int)RoleType.Admin; + modelBuilder.Entity().HasData(adminUser); + } } diff --git a/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs new file mode 100644 index 0000000..809e68c --- /dev/null +++ b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs @@ -0,0 +1,133 @@ +// +using System; +using GameCollectionAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GameCollectionAPI.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20250831143809_AddUsersAndRoles")] + partial class AddUsersAndRoles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.18"); + + modelBuilder.Entity("GameCollectionAPI.Models.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Developer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Genre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Publisher") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("GameCollectionAPI.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + + b.HasData( + new + { + Id = 1, + Name = "Admin" + }, + new + { + Id = 2, + Name = "User" + }); + }); + + modelBuilder.Entity("GameCollectionAPI.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = -1, + CreatedDate = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + PasswordHash = "AQAAAAIAAYagAAAAEK1L5Fzxzxrba0kq9JHNqrHPo5Nv/dlL2zitQkEg5nCnVT1eecOwJzGj9q/C+Ho06Q==", + RoleId = 1, + Username = "Admin" + }); + }); + + modelBuilder.Entity("GameCollectionAPI.Models.User", b => + { + b.HasOne("GameCollectionAPI.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.cs b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.cs new file mode 100644 index 0000000..21abf29 --- /dev/null +++ b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace GameCollectionAPI.Migrations +{ + /// + public partial class AddUsersAndRoles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Roles", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Username = table.Column(type: "TEXT", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: true), + CreatedDate = table.Column(type: "TEXT", nullable: false), + RoleId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + table.ForeignKey( + name: "FK_Users_Roles_RoleId", + column: x => x.RoleId, + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "Roles", + columns: new[] { "Id", "Name" }, + values: new object[,] + { + { 1, "Admin" }, + { 2, "User" } + }); + + migrationBuilder.InsertData( + table: "Users", + columns: new[] { "Id", "CreatedDate", "PasswordHash", "RoleId", "Username" }, + values: new object[] { -1, new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), "AQAAAAIAAYagAAAAEK1L5Fzxzxrba0kq9JHNqrHPo5Nv/dlL2zitQkEg5nCnVT1eecOwJzGj9q/C+Ho06Q==", 1, "Admin" }); + + migrationBuilder.CreateIndex( + name: "IX_Users_RoleId", + table: "Users", + column: "RoleId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropTable( + name: "Roles"); + } + } +} diff --git a/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs b/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs index 7ba40b3..fc2d488 100644 --- a/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs @@ -50,6 +50,80 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Games"); }); + + modelBuilder.Entity("GameCollectionAPI.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + + b.HasData( + new + { + Id = 1, + Name = "Admin" + }, + new + { + Id = 2, + Name = "User" + }); + }); + + modelBuilder.Entity("GameCollectionAPI.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = -1, + CreatedDate = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + PasswordHash = "AQAAAAIAAYagAAAAEK1L5Fzxzxrba0kq9JHNqrHPo5Nv/dlL2zitQkEg5nCnVT1eecOwJzGj9q/C+Ho06Q==", + RoleId = 1, + Username = "Admin" + }); + }); + + modelBuilder.Entity("GameCollectionAPI.Models.User", b => + { + b.HasOne("GameCollectionAPI.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); #pragma warning restore 612, 618 } } diff --git a/GameCollectionAPI/Models/Role.cs b/GameCollectionAPI/Models/Role.cs new file mode 100644 index 0000000..727c399 --- /dev/null +++ b/GameCollectionAPI/Models/Role.cs @@ -0,0 +1,13 @@ +namespace GameCollectionAPI.Models; + +public class Role +{ + public int Id { get; set; } + public required string Name { get; set; } +} + +public enum RoleType +{ + Admin = 1, + User +} \ No newline at end of file diff --git a/GameCollectionAPI/Models/User.cs b/GameCollectionAPI/Models/User.cs index 5d9eee3..71527a3 100644 --- a/GameCollectionAPI/Models/User.cs +++ b/GameCollectionAPI/Models/User.cs @@ -10,12 +10,16 @@ public class User public string? PasswordHash { get; set; } public DateTime CreatedDate { get; set; } + public required int RoleId { get; set; } + public Role? Role { get; set; } + public static User FromCreateDto(UserCreateDto createDto) { var user = new User { Username = createDto.Username, - CreatedDate = DateTime.UtcNow + CreatedDate = DateTime.UtcNow, + RoleId = (int)RoleType.User }; var passwordHasher = new PasswordHasher(); diff --git a/GameCollectionAPI/Repositories/UserRepository.cs b/GameCollectionAPI/Repositories/UserRepository.cs index 75acf51..61ba942 100644 --- a/GameCollectionAPI/Repositories/UserRepository.cs +++ b/GameCollectionAPI/Repositories/UserRepository.cs @@ -27,12 +27,12 @@ public Task DeleteAsync(User user) public Task GetByIdAsync(int id) { - return _context.Users.FirstOrDefaultAsync(user => user.Id == id); + return _context.Users.Include(u => u.Role).FirstOrDefaultAsync(user => user.Id == id); } public Task GetByUsernameAsync(string username) { - return _context.Users.FirstOrDefaultAsync(user => user.Username == username); + return _context.Users.Include(u => u.Role).FirstOrDefaultAsync(user => user.Username == username); } public Task UpdateAsync() From e24bfcb37b0e841869561518c956fc621ac785ab Mon Sep 17 00:00:00 2001 From: Harsche Date: Sun, 31 Aug 2025 13:10:05 -0300 Subject: [PATCH 22/48] Implement authentication features with JWT support - Add AuthController for user registration and login - Create AuthDto for authentication data transfer - Develop AuthService for user authentication logic - Update Program.cs to include IAuthService in DI - Secure GamesController actions with Admin role authorization --- .../Controllers/AuthController.cs | 57 +++++++++++ .../Controllers/GamesController.cs | 2 + .../Controllers/UsersController.cs | 10 +- GameCollectionAPI/DTOs/AuthDto.cs | 13 +++ GameCollectionAPI/Program.cs | 66 ++++++------- GameCollectionAPI/Services/AuthService.cs | 96 +++++++++++++++++++ 6 files changed, 207 insertions(+), 37 deletions(-) create mode 100644 GameCollectionAPI/Controllers/AuthController.cs create mode 100644 GameCollectionAPI/DTOs/AuthDto.cs create mode 100644 GameCollectionAPI/Services/AuthService.cs diff --git a/GameCollectionAPI/Controllers/AuthController.cs b/GameCollectionAPI/Controllers/AuthController.cs new file mode 100644 index 0000000..7474abf --- /dev/null +++ b/GameCollectionAPI/Controllers/AuthController.cs @@ -0,0 +1,57 @@ +using GameCollectionAPI.DTOs; +using GameCollectionAPI.Exceptions; +using GameCollectionAPI.Services; +using Microsoft.AspNetCore.Mvc; + +namespace GameCollectionAPI.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class AuthController : ControllerBase + { + private IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + [HttpPost("Register")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Register([FromBody] AuthDto authDto) + { + if (authDto == null) { return BadRequest(); } + + try + { + var createdUser = await _authService.RegisterUserAsync(authDto); + return CreatedAtRoute("GetUserById", new { id = createdUser.Id }, createdUser); + } + catch (DuplicateUsernameException ex) + { + return Conflict(new { message = ex.Message }); + } + } + + [HttpPost("Login")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Login([FromBody] AuthDto authDto) + { + if (authDto == null) { return BadRequest(); } + + try + { + var token = await _authService.LoginUserAsync(authDto); + return Ok(new { jwt = token }); + } + catch (InvalidDataException ex) + { + return Unauthorized(new { message = ex.Message }); + } + } + } +} diff --git a/GameCollectionAPI/Controllers/GamesController.cs b/GameCollectionAPI/Controllers/GamesController.cs index a7eefe0..dd1ee24 100644 --- a/GameCollectionAPI/Controllers/GamesController.cs +++ b/GameCollectionAPI/Controllers/GamesController.cs @@ -1,5 +1,6 @@ using GameCollectionAPI.DTOs.Games; using GameCollectionAPI.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace GameCollectionAPI.Controllers @@ -46,6 +47,7 @@ public async Task Get(int id) /// Game object to add. /// The created game. [HttpPost] + [Authorize(Roles = "Admin")] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task Post([FromBody] GameCreateDto createdGame) diff --git a/GameCollectionAPI/Controllers/UsersController.cs b/GameCollectionAPI/Controllers/UsersController.cs index e464b43..de8a17b 100644 --- a/GameCollectionAPI/Controllers/UsersController.cs +++ b/GameCollectionAPI/Controllers/UsersController.cs @@ -16,13 +16,13 @@ public UsersController(IUserService service) _service = service; } - [HttpGet("{id:int}")] + [HttpGet("{id:int}", Name = "GetUserById")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Get(int id) { - if (id <= 0) { return BadRequest(); } + if (id == 0) { return BadRequest(); } var user = await _service.GetUserByIdAsync(id); return user == null ? NotFound() : Ok(user); @@ -40,7 +40,7 @@ public async Task Create([FromBody] UserCreateDto createdUser) try { var user = await _service.CreateUserAsync(createdUser); - return CreatedAtAction(nameof(Get), new { id = user.Id }, user); + return CreatedAtAction("GetUserById", new { id = user.Id }, user); } catch (DuplicateUsernameException ex) { @@ -55,7 +55,7 @@ public async Task Create([FromBody] UserCreateDto createdUser) [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task UpdateUsername(int id, [FromBody] UpdateUsernameDto updateUsernameDto) { - if (id <= 0 || updateUsernameDto == null) { return BadRequest(); } + if (id == 0 || updateUsernameDto == null) { return BadRequest(); } try { @@ -74,7 +74,7 @@ public async Task UpdateUsername(int id, [FromBody] UpdateUsernam [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Delete(int id) { - if (id <= 0) { return BadRequest(); } + if (id == 0 || id == -1) { return BadRequest(); } bool successful = await _service.DeleteUserAsync(id); return successful ? NoContent() : NotFound(); diff --git a/GameCollectionAPI/DTOs/AuthDto.cs b/GameCollectionAPI/DTOs/AuthDto.cs new file mode 100644 index 0000000..c5a6d94 --- /dev/null +++ b/GameCollectionAPI/DTOs/AuthDto.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace GameCollectionAPI.DTOs; + +public class AuthDto +{ + [Required] + [MinLength(3)] + public required string Username { get; set; } + + [Required] + public required string Password { get; set; } +} diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index d5ceb41..376e46a 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -1,25 +1,27 @@ using System.Text; -using GameCollectionAPI.Data; -using GameCollectionAPI.Repositories; -using GameCollectionAPI.Services; +using GameCollectionAPI.Data; +using GameCollectionAPI.Repositories; +using GameCollectionAPI.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; - -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddControllers(); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -var connectionString = builder.Configuration.GetConnectionString("GameList"); -builder.Services.AddSqlite(connectionString); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var connectionString = builder.Configuration.GetConnectionString("GameList"); +builder.Services.AddSqlite(connectionString); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + // Authentication var jwtSettings = builder.Configuration.GetSection("Jwt"); builder.Services.AddAuthentication(options => @@ -41,18 +43,18 @@ }; }); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - -app.MapControllers(); - -app.Run(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.MapControllers(); + +app.Run(); diff --git a/GameCollectionAPI/Services/AuthService.cs b/GameCollectionAPI/Services/AuthService.cs new file mode 100644 index 0000000..155fe03 --- /dev/null +++ b/GameCollectionAPI/Services/AuthService.cs @@ -0,0 +1,96 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using GameCollectionAPI.DTOs; +using GameCollectionAPI.DTOs.Users; +using GameCollectionAPI.Exceptions; +using GameCollectionAPI.Models; +using GameCollectionAPI.Repositories; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; + +namespace GameCollectionAPI.Services; + +public interface IAuthService +{ + Task RegisterUserAsync(AuthDto authDto); + Task LoginUserAsync(AuthDto authDto); +} + +public class AuthService : IAuthService +{ + private IUserRepository _userRepository; + private IConfiguration _configuration; + + public AuthService(IUserRepository userRepository, IConfiguration configuration) + { + _userRepository = userRepository; + _configuration = configuration; + } + + public async Task LoginUserAsync(AuthDto authDto) + { + var user = await _userRepository.GetByUsernameAsync(authDto.Username); + if (user == null) + { + throw new InvalidDataException("User with this username does not exists."); + } + + var passwordHasher = new PasswordHasher(); + if (passwordHasher.VerifyHashedPassword(user, user.PasswordHash, authDto.Password) != PasswordVerificationResult.Success) + { + throw new InvalidDataException("Invalid password."); + } + + var token = GenerateJwtToken(user); + return token; + } + + public async Task RegisterUserAsync(AuthDto authDto) + { + if (await _userRepository.GetByUsernameAsync(authDto.Username) != null) + { + throw new DuplicateUsernameException(); + } + + var createdUser = new UserCreateDto + { + Username = authDto.Username, + Password = authDto.Password + }; + + var user = User.FromCreateDto(createdUser); + await _userRepository.AddAsync(user); + return user.ToReadDto(); + } + + private string GenerateJwtToken(User user) + { + // Define as informações do usuário (claims) no token + var claims = new List + { + new (ClaimTypes.NameIdentifier, user.Id.ToString()), + new (ClaimTypes.Name, user.Username), + new (ClaimTypes.Role, user.Role?.Name ?? "User") + }; + + // Signing key + var jwtConfigurations = _configuration.GetSection("Jwt"); + var tokenSecret = jwtConfigurations["Key"]; + var key = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(tokenSecret)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + // Expiration + var expires = DateTime.Now.AddHours(1); + + // Create token + var token = new JwtSecurityToken( + issuer: jwtConfigurations["Issuer"], + audience: jwtConfigurations["Audience"], + claims: claims, + expires: expires, + signingCredentials: creds + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} From 98daf355c45e5afa9d7d268e4c0b93fea0182d8b Mon Sep 17 00:00:00 2001 From: Harsche Date: Sun, 31 Aug 2025 13:23:36 -0300 Subject: [PATCH 23/48] Update XML docs --- .../Controllers/AuthController.cs | 10 +++++++++ .../Controllers/UsersController.cs | 21 +++++++++++++++++++ GameCollectionAPI/Services/AuthService.cs | 15 +++++++++++++ GameCollectionAPI/Services/GameService.cs | 18 ++++++++-------- GameCollectionAPI/Services/UserService.cs | 21 +++++++++++++++++++ 5 files changed, 76 insertions(+), 9 deletions(-) diff --git a/GameCollectionAPI/Controllers/AuthController.cs b/GameCollectionAPI/Controllers/AuthController.cs index 7474abf..c2b7e97 100644 --- a/GameCollectionAPI/Controllers/AuthController.cs +++ b/GameCollectionAPI/Controllers/AuthController.cs @@ -16,6 +16,11 @@ public AuthController(IAuthService authService) _authService = authService; } + /// + /// Registers a new user. + /// + /// Authentication DTO containing user details. + /// The created user. [HttpPost("Register")] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -35,6 +40,11 @@ public async Task Register([FromBody] AuthDto authDto) } } + /// + /// Logs in a user and returns a JWT token. + /// + /// Authentication DTO containing user credentials. + /// A JWT token if credentials are valid; Unauthorized otherwise. [HttpPost("Login")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status204NoContent)] diff --git a/GameCollectionAPI/Controllers/UsersController.cs b/GameCollectionAPI/Controllers/UsersController.cs index de8a17b..ea89f6e 100644 --- a/GameCollectionAPI/Controllers/UsersController.cs +++ b/GameCollectionAPI/Controllers/UsersController.cs @@ -16,6 +16,11 @@ public UsersController(IUserService service) _service = service; } + /// + /// Gets a user by its ID. + /// + /// User's ID. + /// The user if found, otherwise NotFound. [HttpGet("{id:int}", Name = "GetUserById")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -29,6 +34,11 @@ public async Task Get(int id) } + /// + /// Creates a new user. + /// + /// The user creation DTO. + /// The created user. [HttpPost] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -48,6 +58,12 @@ public async Task Create([FromBody] UserCreateDto createdUser) } } + /// + /// Updates the username of an existing user. + /// + /// User's ID. + /// DTO with updated username. + /// No content if successful; NotFound if user does not exist. [HttpPut("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -68,6 +84,11 @@ public async Task UpdateUsername(int id, [FromBody] UpdateUsernam } } + /// + /// Deletes a user. + /// + /// User's ID. + /// No content if deleted; NotFound if user does not exist. [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] diff --git a/GameCollectionAPI/Services/AuthService.cs b/GameCollectionAPI/Services/AuthService.cs index 155fe03..9e2a972 100644 --- a/GameCollectionAPI/Services/AuthService.cs +++ b/GameCollectionAPI/Services/AuthService.cs @@ -27,6 +27,11 @@ public AuthService(IUserRepository userRepository, IConfiguration configuration) _configuration = configuration; } + /// + /// Logs in a user by validating credentials and generating a JWT token. + /// + /// The authentication data. + /// A task containing the JWT token string. public async Task LoginUserAsync(AuthDto authDto) { var user = await _userRepository.GetByUsernameAsync(authDto.Username); @@ -45,6 +50,11 @@ public async Task LoginUserAsync(AuthDto authDto) return token; } + /// + /// Registers a new user and returns the created user's read DTO. + /// + /// The authentication data. + /// A task containing the created user's read DTO. public async Task RegisterUserAsync(AuthDto authDto) { if (await _userRepository.GetByUsernameAsync(authDto.Username) != null) @@ -63,6 +73,11 @@ public async Task RegisterUserAsync(AuthDto authDto) return user.ToReadDto(); } + /// + /// Generates a JWT token for the specified user. + /// + /// The user entity. + /// A JWT token as a string. private string GenerateJwtToken(User user) { // Define as informações do usuário (claims) no token diff --git a/GameCollectionAPI/Services/GameService.cs b/GameCollectionAPI/Services/GameService.cs index 58f206c..926c75d 100644 --- a/GameCollectionAPI/Services/GameService.cs +++ b/GameCollectionAPI/Services/GameService.cs @@ -17,7 +17,7 @@ public GameService(IGameRepository repo) /// Creates a new game in the collection. /// /// The game data to create. - /// The created game with assigned ID. + /// A task containing the created game's read DTO. public async Task CreateGameAsync(GameCreateDto createdGame) { var game = Game.FromCreateDto(createdGame); @@ -26,10 +26,10 @@ public async Task CreateGameAsync(GameCreateDto createdGame) } /// - /// Deletes a game from the collection by its ID. + /// Deletes a game by ID. /// - /// The ID of the game to delete. - /// True if the game was found and deleted; false if not found. + /// The game ID. + /// A task with a boolean indicating whether the deletion was successful. public async Task DeleteGameAsync(int id) { var game = await _repo.GetByIdAsync(id); @@ -46,7 +46,7 @@ public async Task DeleteGameAsync(int id) /// /// Retrieves all games from the collection. /// - /// A list of all games in the collection. + /// A task containing a list of all games read DTOs. public async Task> GetAllGamesAsync() { var games = await _repo.GetAllAsync(); @@ -56,8 +56,8 @@ public async Task> GetAllGamesAsync() /// /// Retrieves a specific game by its ID. /// - /// The ID of the game to retrieve. - /// The game if found; null if not found. + /// The game ID. + /// A task containing the game's read DTO if found; otherwise, null. public async Task GetGameByIdAsync(int id) { var game = await _repo.GetByIdAsync(id); @@ -73,9 +73,9 @@ public async Task> GetAllGamesAsync() /// /// Updates an existing game with new data. /// - /// The ID of the game to update. + /// The game ID. /// The updated game data. - /// True if the game was found and updated; false if not found. + /// A task with a boolean indicating whether the update was successful. public async Task UpdateGameAsync(int id, GameCreateDto updatedGame) { var game = await _repo.GetByIdAsync(id); diff --git a/GameCollectionAPI/Services/UserService.cs b/GameCollectionAPI/Services/UserService.cs index 32063f4..9781471 100644 --- a/GameCollectionAPI/Services/UserService.cs +++ b/GameCollectionAPI/Services/UserService.cs @@ -14,6 +14,11 @@ public UserService(IUserRepository repo) _repo = repo; } + /// + /// Creates a new user. + /// + /// The data for creating a user. + /// A task containing the created user's read DTO. public async Task CreateUserAsync(UserCreateDto createdUser) { if (await _repo.GetByUsernameAsync(createdUser.Username) != null) @@ -26,6 +31,11 @@ public async Task CreateUserAsync(UserCreateDto createdUser) return user.ToReadDto(); } + /// + /// Deletes a user by ID. + /// + /// The user ID. + /// A task with a boolean indicating whether the deletion was successful. public async Task DeleteUserAsync(int id) { var user = await _repo.GetByIdAsync(id); @@ -39,12 +49,23 @@ public async Task DeleteUserAsync(int id) return true; } + /// + /// Retrieves a user by ID. + /// + /// The user ID. + /// A task containing the user read DTO if found; otherwise, null. public async Task GetUserByIdAsync(int id) { var user = await _repo.GetByIdAsync(id); return user?.ToReadDto(); } + /// + /// Updates the username for a specified user. + /// + /// The user ID. + /// The username update data. + /// A task with a boolean indicating whether the update was successful. public async Task UpdateUsernameAsync(int id, UpdateUsernameDto updateUsernameDto) { if (await _repo.GetByUsernameAsync(updateUsernameDto.NewUsername) != null) From db733d35311cde26f8ae32197cbd014501d53bd6 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sun, 31 Aug 2025 14:15:38 -0300 Subject: [PATCH 24/48] Fix various issues; update admin user seed logic - Remove unnecessary response type from Login method - Simplify exception handling for invalid credentials - Add comment for admin user deletion logic - Ensure default admin user is created with UTC date - Fix typo in exception message for non-existing user - Add check for missing JWT secret key in configuration --- .../Controllers/AuthController.cs | 5 ++-- .../Controllers/UsersController.cs | 2 +- GameCollectionAPI/Data/AppDbContext.cs | 25 +++++++++++-------- GameCollectionAPI/Services/AuthService.cs | 8 ++++-- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/GameCollectionAPI/Controllers/AuthController.cs b/GameCollectionAPI/Controllers/AuthController.cs index c2b7e97..011f1e5 100644 --- a/GameCollectionAPI/Controllers/AuthController.cs +++ b/GameCollectionAPI/Controllers/AuthController.cs @@ -47,7 +47,6 @@ public async Task Register([FromBody] AuthDto authDto) /// A JWT token if credentials are valid; Unauthorized otherwise. [HttpPost("Login")] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task Login([FromBody] AuthDto authDto) { @@ -58,9 +57,9 @@ public async Task Login([FromBody] AuthDto authDto) var token = await _authService.LoginUserAsync(authDto); return Ok(new { jwt = token }); } - catch (InvalidDataException ex) + catch (InvalidDataException) { - return Unauthorized(new { message = ex.Message }); + return Unauthorized(new { message = "Invalid credentials" }); } } } diff --git a/GameCollectionAPI/Controllers/UsersController.cs b/GameCollectionAPI/Controllers/UsersController.cs index ea89f6e..354e76f 100644 --- a/GameCollectionAPI/Controllers/UsersController.cs +++ b/GameCollectionAPI/Controllers/UsersController.cs @@ -95,7 +95,7 @@ public async Task UpdateUsername(int id, [FromBody] UpdateUsernam [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Delete(int id) { - if (id == 0 || id == -1) { return BadRequest(); } + if (id == 0 || id == -1) { return BadRequest(); } // Default admin (with ID -1) can't be deleted bool successful = await _service.DeleteUserAsync(id); return successful ? NoContent() : NotFound(); diff --git a/GameCollectionAPI/Data/AppDbContext.cs b/GameCollectionAPI/Data/AppDbContext.cs index 84d6a91..200369b 100644 --- a/GameCollectionAPI/Data/AppDbContext.cs +++ b/GameCollectionAPI/Data/AppDbContext.cs @@ -20,16 +20,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ); // Add admin user - var adminUser = User.FromCreateDto( - new DTOs.Users.UserCreateDto - { - Username = "Admin", - Password = "admin", - } - ); - adminUser.Id = -1; - adminUser.CreatedDate = new DateTime(0); - adminUser.RoleId = (int)RoleType.Admin; - modelBuilder.Entity().HasData(adminUser); + if (!Users.Any(user => user.Id == -1)) // Assure default admin with ID -1 is created + { + var adminUser = User.FromCreateDto( + new DTOs.Users.UserCreateDto + { + Username = "Admin", + Password = "admin", + } + ); + adminUser.Id = -1; + adminUser.CreatedDate = DateTime.UtcNow; + adminUser.RoleId = (int)RoleType.Admin; + modelBuilder.Entity().HasData(adminUser); + } } } diff --git a/GameCollectionAPI/Services/AuthService.cs b/GameCollectionAPI/Services/AuthService.cs index 9e2a972..f3a394d 100644 --- a/GameCollectionAPI/Services/AuthService.cs +++ b/GameCollectionAPI/Services/AuthService.cs @@ -37,7 +37,7 @@ public async Task LoginUserAsync(AuthDto authDto) var user = await _userRepository.GetByUsernameAsync(authDto.Username); if (user == null) { - throw new InvalidDataException("User with this username does not exists."); + throw new InvalidDataException("User with this username does not exist."); } var passwordHasher = new PasswordHasher(); @@ -80,7 +80,6 @@ public async Task RegisterUserAsync(AuthDto authDto) /// A JWT token as a string. private string GenerateJwtToken(User user) { - // Define as informações do usuário (claims) no token var claims = new List { new (ClaimTypes.NameIdentifier, user.Id.ToString()), @@ -91,6 +90,11 @@ private string GenerateJwtToken(User user) // Signing key var jwtConfigurations = _configuration.GetSection("Jwt"); var tokenSecret = jwtConfigurations["Key"]; + if (string.IsNullOrWhiteSpace(tokenSecret)) + { + // TODO - Log warning! + throw new InvalidOperationException("JWT secret key is missing in the app configuration."); + } var key = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(tokenSecret)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); From 8a0d8ce790186c099db588c37d36482300732f67 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sun, 31 Aug 2025 17:16:03 -0300 Subject: [PATCH 25/48] Remove query during model creation. Automatically apply migrations at startup. - Ensure default admin user is always created - Update admin user creation logic in AppDbContext - Add database migration call in Program.cs --- GameCollectionAPI/Data/AppDbContext.cs | 15 ++++++--------- GameCollectionAPI/Program.cs | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/GameCollectionAPI/Data/AppDbContext.cs b/GameCollectionAPI/Data/AppDbContext.cs index 200369b..ab50058 100644 --- a/GameCollectionAPI/Data/AppDbContext.cs +++ b/GameCollectionAPI/Data/AppDbContext.cs @@ -19,20 +19,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) new Role { Id = (int)RoleType.User, Name = RoleType.User.ToString() } ); - // Add admin user - if (!Users.Any(user => user.Id == -1)) // Assure default admin with ID -1 is created - { - var adminUser = User.FromCreateDto( + // Add default admin user with ID -1 + var adminUser = User.FromCreateDto( new DTOs.Users.UserCreateDto { Username = "Admin", Password = "admin", } ); - adminUser.Id = -1; - adminUser.CreatedDate = DateTime.UtcNow; - adminUser.RoleId = (int)RoleType.Admin; - modelBuilder.Entity().HasData(adminUser); - } + adminUser.Id = -1; + adminUser.CreatedDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + adminUser.RoleId = (int)RoleType.Admin; + modelBuilder.Entity().HasData(adminUser); } } diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index 376e46a..803dd76 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -3,6 +3,7 @@ using GameCollectionAPI.Repositories; using GameCollectionAPI.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); @@ -46,14 +47,23 @@ var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + var services = scope.ServiceProvider; + var context = services.GetRequiredService(); + await context.Database.MigrateAsync(); +} + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } - -app.UseHttpsRedirection(); +else +{ + app.UseHttpsRedirection(); +} app.MapControllers(); From d9f4bbb401c6b43e8b32c299f58b718f487e12b2 Mon Sep 17 00:00:00 2001 From: Harsche Date: Sun, 31 Aug 2025 17:16:28 -0300 Subject: [PATCH 26/48] Add Dockerfile and update appsettings.json structure - Introduced Dockerfile for containerization - Added https_port configuration to appsettings.json --- .vscode/settings.json | 10 ++++++++++ Dockerfile | 14 ++++++++++++++ GameCollectionAPI/appsettings.json | 1 + 3 files changed, 25 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 Dockerfile diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a32d6df --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "github.copilot.chat.commitMessageGeneration.instructions": [ + + { + "text": "Use max 50 characters for commit messages and cover the main changes. Descriptions can be longer. Descriptions should provide additional context and details about the changes made and must be clear and concise and formatted as a bullet list." + + + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2e60507 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "GameCollectionAPI.dll"] diff --git a/GameCollectionAPI/appsettings.json b/GameCollectionAPI/appsettings.json index f8a896c..d5cde91 100644 --- a/GameCollectionAPI/appsettings.json +++ b/GameCollectionAPI/appsettings.json @@ -1,4 +1,5 @@ { + "https_port": 443, "Logging": { "LogLevel": { "Default": "Information", From e5d5905f1bb30e6a45f322378051942eed073212 Mon Sep 17 00:00:00 2001 From: Harsche Date: Mon, 1 Sep 2025 12:38:09 -0300 Subject: [PATCH 27/48] Update github actions - Now logging and pushing to github packages --- .github/workflows/blank.yml | 74 ++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 26 deletions(-) diff --git a/.github/workflows/blank.yml b/.github/workflows/blank.yml index fbdea5c..2f2f67e 100644 --- a/.github/workflows/blank.yml +++ b/.github/workflows/blank.yml @@ -1,51 +1,73 @@ -# This is a basic workflow to help you get started with Actions - name: CI -# Controls when the workflow will run on: - # Triggers the workflow on push or pull request events but only for the "main" branch push: branches: ["main"] pull_request: branches: ["main"] - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: -# A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: - # This workflow contains a single job called "build" build: - # The type of runner that the job will run on runs-on: ubuntu-latest + permissions: + contents: read + packages: write - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Checkout repository uses: actions/checkout@v4 - # Runs a single command using the runners shell - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - # Runs a set of commands using the runners shell - - name: Build Docker Image - run: | - docker build . --file Dockerfile --tag gamecollection-api:$(echo $GITHUB_SHA | cut -c1-7) - - # - name: Log in to Docker Hub - # This step is commented out for now. Uncomment and configure - # if you want to push the image to a registry like Docker Hub. - # uses: docker/login-action@v3 - # with: - # username: ${{ secrets.DOCKER_USERNAME }} - # password: ${{ secrets.DOCKER_PASSWORD }} + # Log in to GitHub Container Registry + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - # - name: Push Docker Image - # This step is also commented out. Uncomment to push the image. - # run: docker push gamecollection-api:$(echo $GITHUB_SHA | cut -c1-7) + # Build and push the Docker image to GHCR + - name: Build and push Docker image + uses: docker/build-push-action@v3 + with: + context: . + file: Dockerfile + push: true + tags: ghcr.io/harsche/game-collection-api:latest - name: List built images run: docker images + + deploy: + needs: build + runs-on: ubuntu-latest + environment: oracle-vm + + steps: + - name: Deploy to Oracle VM + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.ORACLE_VM_HOST }} + username: ${{ secrets.ORACLE_VM_USERNAME }} + key: ${{ secrets.ORACLE_VM_SSH_KEY }} + script: | + # Log in to GitHub Container Registry + echo ${{ secrets.GHCR_PAT }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + # Pull the latest image + docker pull ghcr.io/harsche/game-collection-api:latest + + # Stop and remove the old container if it exists + docker stop game-collection-api || true + docker rm game-collection-api || true + + # Run the new container + # IMPORTANT: Change '8080:80' to map your desired host port to the container's port. + docker run -d \ + --name game-collection-api \ + -p 8080:8080 \ + ghcr.io/harsche/game-collection-api:latest \ No newline at end of file From 922282bfd5c54a4ac112b4ac8d3e5aae9d29957b Mon Sep 17 00:00:00 2001 From: Harsche Date: Tue, 30 Jun 2026 22:38:37 -0300 Subject: [PATCH 28/48] feat(controllers): secure UsersController with role-based and ownership authorization - Add `[Authorize]` attribute to `UsersController` to restrict access to authenticated users. - Restrict user creation (`HttpPost`) and deletion (`HttpDelete`) endpoints to users with the "Admin" role. - Implement `IsOwnerOrAdmin` helper method to verify if the current user matches the requested ID or has the "Admin" role. - Apply `IsOwnerOrAdmin` check to `Get` and `UpdateUsername` endpoints, returning `Forbid()` on failure. --- .../Controllers/UsersController.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/GameCollectionAPI/Controllers/UsersController.cs b/GameCollectionAPI/Controllers/UsersController.cs index 354e76f..00bb0ef 100644 --- a/GameCollectionAPI/Controllers/UsersController.cs +++ b/GameCollectionAPI/Controllers/UsersController.cs @@ -1,12 +1,15 @@ +using System.Security.Claims; using GameCollectionAPI.DTOs.Users; using GameCollectionAPI.Exceptions; using GameCollectionAPI.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace GameCollectionAPI.Controllers { [Route("api/[controller]")] [ApiController] + [Authorize] public class UsersController : ControllerBase { private readonly IUserService _service; @@ -27,6 +30,11 @@ public UsersController(IUserService service) [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Get(int id) { + if (!IsOwnerOrAdmin(id)) + { + return Forbid(); + } + if (id == 0) { return BadRequest(); } var user = await _service.GetUserByIdAsync(id); @@ -40,6 +48,7 @@ public async Task Get(int id) /// The user creation DTO. /// The created user. [HttpPost] + [Authorize(Roles = "Admin")] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status409Conflict)] @@ -71,6 +80,11 @@ public async Task Create([FromBody] UserCreateDto createdUser) [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task UpdateUsername(int id, [FromBody] UpdateUsernameDto updateUsernameDto) { + if (!IsOwnerOrAdmin(id)) + { + return Forbid(); + } + if (id == 0 || updateUsernameDto == null) { return BadRequest(); } try @@ -90,6 +104,7 @@ public async Task UpdateUsername(int id, [FromBody] UpdateUsernam /// User's ID. /// No content if deleted; NotFound if user does not exist. [HttpDelete("{id:int}")] + [Authorize(Roles = "Admin")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -100,5 +115,15 @@ public async Task Delete(int id) bool successful = await _service.DeleteUserAsync(id); return successful ? NoContent() : NotFound(); } + + bool IsOwnerOrAdmin(int id) + { + var currentUserIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + bool isOwner = currentUserIdClaim == id.ToString(); + bool isAdmin = User.IsInRole("Admin"); + + return isOwner || isAdmin; + } } } From 9d63455541ad454c405c133b882d547236d8afa1 Mon Sep 17 00:00:00 2001 From: Harsche Date: Tue, 30 Jun 2026 22:52:19 -0300 Subject: [PATCH 29/48] feat(controllers): add authorization security and document API response types - Secure `GamesController` with `[Authorize]` at the class level. - Restrict `Put` and `Delete` actions in `GamesController` to the "Admin" role. - Set a route name for the get game by ID endpoint and update `Post` to return `CreatedAtRoute`. - Add `Status401Unauthorized` and `Status403Forbidden` response types to applicable endpoints in both `GamesController` and `UsersController` for accurate Swagger documentation. --- GameCollectionAPI/Controllers/GamesController.cs | 15 +++++++++++++-- GameCollectionAPI/Controllers/UsersController.cs | 9 +++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/GameCollectionAPI/Controllers/GamesController.cs b/GameCollectionAPI/Controllers/GamesController.cs index dd1ee24..6cf1eec 100644 --- a/GameCollectionAPI/Controllers/GamesController.cs +++ b/GameCollectionAPI/Controllers/GamesController.cs @@ -7,6 +7,7 @@ namespace GameCollectionAPI.Controllers { [Route("api/[controller]")] [ApiController] + [Authorize] public class GamesController : ControllerBase { private readonly IGameService _service; @@ -22,6 +23,7 @@ public GamesController(IGameService service) /// List of games. [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task> Get() => await _service.GetAllGamesAsync(); /// @@ -29,9 +31,10 @@ public GamesController(IGameService service) /// /// Game ID. /// The requested game. - [HttpGet("{id:int}")] + [HttpGet("{id:int}", Name = "GetGameById")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Get(int id) { @@ -50,13 +53,15 @@ public async Task Get(int id) [Authorize(Roles = "Admin")] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task Post([FromBody] GameCreateDto createdGame) { if (createdGame == null) { return BadRequest(); } var game = await _service.CreateGameAsync(createdGame); - return CreatedAtAction(nameof(Get), new { id = game.Id }, game); + return CreatedAtRoute("GetGameById", new { id = game.Id }, game); } /// @@ -66,8 +71,11 @@ public async Task Post([FromBody] GameCreateDto createdGame) /// Updated game object. /// No content. [HttpPut("{id:int}")] + [Authorize(Roles = "Admin")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Put(int id, [FromBody] GameCreateDto updatedGame) { @@ -83,8 +91,11 @@ public async Task Put(int id, [FromBody] GameCreateDto updatedGam /// Game ID. /// No content. [HttpDelete("{id:int}")] + [Authorize(Roles = "Admin")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Delete(int id) { diff --git a/GameCollectionAPI/Controllers/UsersController.cs b/GameCollectionAPI/Controllers/UsersController.cs index 00bb0ef..5df4f24 100644 --- a/GameCollectionAPI/Controllers/UsersController.cs +++ b/GameCollectionAPI/Controllers/UsersController.cs @@ -27,7 +27,10 @@ public UsersController(IUserService service) [HttpGet("{id:int}", Name = "GetUserById")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task Get(int id) { if (!IsOwnerOrAdmin(id)) @@ -51,6 +54,8 @@ public async Task Get(int id) [Authorize(Roles = "Admin")] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task Create([FromBody] UserCreateDto createdUser) { @@ -76,7 +81,9 @@ public async Task Create([FromBody] UserCreateDto createdUser) [HttpPut("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task UpdateUsername(int id, [FromBody] UpdateUsernameDto updateUsernameDto) { @@ -107,6 +114,8 @@ public async Task UpdateUsername(int id, [FromBody] UpdateUsernam [Authorize(Roles = "Admin")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Delete(int id) { From 1475a47b06b368f4e5fe2bf489ed330ecce647a8 Mon Sep 17 00:00:00 2001 From: Harsche Date: Tue, 30 Jun 2026 23:01:06 -0300 Subject: [PATCH 30/48] fix(auth): use UTC time for token expiration and register auth middleware - Change token expiration calculation in `AuthService` to use `DateTime.UtcNow` instead of `DateTime.Now` - Add `app.UseAuthentication()` and `app.UseAuthorization()` to the HTTP request pipeline in `Program.cs` --- GameCollectionAPI/Program.cs | 3 +++ GameCollectionAPI/Services/AuthService.cs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index 803dd76..bc5959e 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -65,6 +65,9 @@ app.UseHttpsRedirection(); } +app.UseAuthentication(); +app.UseAuthorization(); + app.MapControllers(); app.Run(); diff --git a/GameCollectionAPI/Services/AuthService.cs b/GameCollectionAPI/Services/AuthService.cs index f3a394d..41ec82c 100644 --- a/GameCollectionAPI/Services/AuthService.cs +++ b/GameCollectionAPI/Services/AuthService.cs @@ -99,7 +99,7 @@ private string GenerateJwtToken(User user) var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); // Expiration - var expires = DateTime.Now.AddHours(1); + var expires = DateTime.UtcNow.AddHours(1); // Create token var token = new JwtSecurityToken( From 67cc231241038e2e086f24279b81e889bdd5cd9c Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 03:30:25 -0300 Subject: [PATCH 31/48] build: upgrade project to .NET 10.0 - Update `TargetFramework` to `net10.0` in `GameCollectionAPI.csproj` - Update Dockerfile to use `.dotnet/aspnet:10.0` and `.dotnet/sdk:10.0` images - Upgrade NuGet package dependencies to their .NET 10 compatible versions: - `Microsoft.AspNetCore.Authentication.JwtBearer` (8.0.19 -> 10.0.9) - `Microsoft.AspNetCore.OpenApi` (8.0.5 -> 10.0.9) - `Microsoft.EntityFrameworkCore` (8.0.18 -> 10.0.9) - `Microsoft.EntityFrameworkCore.Design` (8.0.18 -> 10.0.9) - `Microsoft.EntityFrameworkCore.Sqlite` (8.0.18 -> 10.0.9) - `Microsoft.EntityFrameworkCore.Tools` (8.0.18 -> 10.0.9) - `Swashbuckle.AspNetCore` (6.4.0 -> 10.2.3) --- Dockerfile | 4 ++-- GameCollectionAPI/GameCollectionAPI.csproj | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2e60507..86d2168 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app EXPOSE 8080 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY . . RUN dotnet restore diff --git a/GameCollectionAPI/GameCollectionAPI.csproj b/GameCollectionAPI/GameCollectionAPI.csproj index 0c2865e..3ef1a10 100644 --- a/GameCollectionAPI/GameCollectionAPI.csproj +++ b/GameCollectionAPI/GameCollectionAPI.csproj @@ -1,26 +1,26 @@  - net8.0 + net10.0 enable enable a5fe0efc-96d9-41b0-93c1-556182146f8e - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + From c2843d70e4bb204e01f0c01f81626ae59d3ee612 Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 03:31:40 -0300 Subject: [PATCH 32/48] fix: address potential null reference exceptions and update gitignore - Update `.gitignore` to ignore SQLite database files (`.db`, `.db-shm`, `.db-wal`) and the `.repomix/` directory - Add a null check for `user.PasswordHash` in `AuthService.cs` before verifying the password hash - Use the null-coalescing operator `?? ""` on the JWT signing key in `Program.cs` to avoid potential null arguments --- .gitignore | 10 +++++++++- GameCollectionAPI/Program.cs | 2 +- GameCollectionAPI/Services/AuthService.cs | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 35063fc..c444641 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,12 @@ CodeCoverage/ # NUnit *.VisualState.xml TestResult.xml -nunit-*.xml \ No newline at end of file +nunit-*.xml + +# Database files +**/**.db +**/**.db-shm +**/**.db-wal + +# Repomix +.repomix/ \ No newline at end of file diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index bc5959e..d2c8a9d 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -40,7 +40,7 @@ ValidateIssuerSigningKey = true, ValidIssuer = jwtSettings["Issuer"], ValidAudience = jwtSettings["Audience"], - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings["Key"])) + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings["Key"] ?? "")) }; }); diff --git a/GameCollectionAPI/Services/AuthService.cs b/GameCollectionAPI/Services/AuthService.cs index 41ec82c..b068dd2 100644 --- a/GameCollectionAPI/Services/AuthService.cs +++ b/GameCollectionAPI/Services/AuthService.cs @@ -41,7 +41,7 @@ public async Task LoginUserAsync(AuthDto authDto) } var passwordHasher = new PasswordHasher(); - if (passwordHasher.VerifyHashedPassword(user, user.PasswordHash, authDto.Password) != PasswordVerificationResult.Success) + if (user.PasswordHash == null || passwordHasher.VerifyHashedPassword(user, user.PasswordHash, authDto.Password) != PasswordVerificationResult.Success) { throw new InvalidDataException("Invalid password."); } From 6287d99dc5a7f269d3c128f9ef68c0f7434fb90d Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 03:32:29 -0300 Subject: [PATCH 33/48] feat(database): configure database initialization and admin user seeding - Remove hardcoded default admin seeding from `AppDbContext.OnModelCreating` and migrations. - Add `DbInitializer` extension method to run migrations and seed the default admin user on application startup. - Retrieve default admin username and password from configuration instead of hardcoding. - Hash the seeded admin's password using `PasswordHasher`. - Refactor database initialization call in `Program.cs` to use the new `InitializeDatabaseAsync` method. --- GameCollectionAPI/Data/AppDbContext.cs | 13 ----- GameCollectionAPI/Data/DbInitializer.cs | 47 +++++++++++++++++++ ...0250831143809_AddUsersAndRoles.Designer.cs | 11 +---- .../20250831143809_AddUsersAndRoles.cs | 7 +-- .../Migrations/AppDbContextModelSnapshot.cs | 11 +---- GameCollectionAPI/Program.cs | 7 +-- 6 files changed, 51 insertions(+), 45 deletions(-) create mode 100644 GameCollectionAPI/Data/DbInitializer.cs diff --git a/GameCollectionAPI/Data/AppDbContext.cs b/GameCollectionAPI/Data/AppDbContext.cs index ab50058..64746c8 100644 --- a/GameCollectionAPI/Data/AppDbContext.cs +++ b/GameCollectionAPI/Data/AppDbContext.cs @@ -18,18 +18,5 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) new Role { Id = (int)RoleType.Admin, Name = RoleType.Admin.ToString() }, new Role { Id = (int)RoleType.User, Name = RoleType.User.ToString() } ); - - // Add default admin user with ID -1 - var adminUser = User.FromCreateDto( - new DTOs.Users.UserCreateDto - { - Username = "Admin", - Password = "admin", - } - ); - adminUser.Id = -1; - adminUser.CreatedDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); - adminUser.RoleId = (int)RoleType.Admin; - modelBuilder.Entity().HasData(adminUser); } } diff --git a/GameCollectionAPI/Data/DbInitializer.cs b/GameCollectionAPI/Data/DbInitializer.cs new file mode 100644 index 0000000..b3845ce --- /dev/null +++ b/GameCollectionAPI/Data/DbInitializer.cs @@ -0,0 +1,47 @@ + +using GameCollectionAPI.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace GameCollectionAPI.Data; + +public static class DbInitializer +{ + + public static async Task InitializeDatabaseAsync(this IHost host) + { + using var scope = host.Services.CreateScope(); + var services = scope.ServiceProvider; + var context = services.GetRequiredService(); + + // Database migration + await context.Database.MigrateAsync(); + + // Seed admin user + if (!await context.Users.AnyAsync(u => u.RoleId == (int)RoleType.Admin)) + { + var configuration = services.GetRequiredService(); + var adminUsername = configuration["DefaultAdminUsername"] ?? "Admin"; + var adminPassword = configuration["DefaultAdminPassword"]; + + if (adminPassword == null) + { + throw new Exception("Default admin password not provided."); + } + + var adminUser = new User + { + Id = -1, + Username = adminUsername, + CreatedDate = DateTime.UtcNow, + RoleId = (int)RoleType.Admin + }; + + var hasher = new PasswordHasher(); + adminUser.PasswordHash = hasher.HashPassword(adminUser, adminPassword); + + context.Users.Add(adminUser); + await context.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs index 809e68c..4b38c87 100644 --- a/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs +++ b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using GameCollectionAPI.Data; using Microsoft.EntityFrameworkCore; @@ -106,15 +106,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Users"); - b.HasData( - new - { - Id = -1, - CreatedDate = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), - PasswordHash = "AQAAAAIAAYagAAAAEK1L5Fzxzxrba0kq9JHNqrHPo5Nv/dlL2zitQkEg5nCnVT1eecOwJzGj9q/C+Ho06Q==", - RoleId = 1, - Username = "Admin" - }); }); modelBuilder.Entity("GameCollectionAPI.Models.User", b => diff --git a/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.cs b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.cs index 21abf29..bc9f387 100644 --- a/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.cs +++ b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -57,11 +57,6 @@ protected override void Up(MigrationBuilder migrationBuilder) { 2, "User" } }); - migrationBuilder.InsertData( - table: "Users", - columns: new[] { "Id", "CreatedDate", "PasswordHash", "RoleId", "Username" }, - values: new object[] { -1, new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), "AQAAAAIAAYagAAAAEK1L5Fzxzxrba0kq9JHNqrHPo5Nv/dlL2zitQkEg5nCnVT1eecOwJzGj9q/C+Ho06Q==", 1, "Admin" }); - migrationBuilder.CreateIndex( name: "IX_Users_RoleId", table: "Users", diff --git a/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs b/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs index fc2d488..6fec7a8 100644 --- a/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using GameCollectionAPI.Data; using Microsoft.EntityFrameworkCore; @@ -103,15 +103,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Users"); - b.HasData( - new - { - Id = -1, - CreatedDate = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), - PasswordHash = "AQAAAAIAAYagAAAAEK1L5Fzxzxrba0kq9JHNqrHPo5Nv/dlL2zitQkEg5nCnVT1eecOwJzGj9q/C+Ho06Q==", - RoleId = 1, - Username = "Admin" - }); }); modelBuilder.Entity("GameCollectionAPI.Models.User", b => diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index d2c8a9d..47ec36a 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -47,12 +47,7 @@ var app = builder.Build(); -using (var scope = app.Services.CreateScope()) -{ - var services = scope.ServiceProvider; - var context = services.GetRequiredService(); - await context.Database.MigrateAsync(); -} +await app.InitializeDatabaseAsync(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) From 3ebd6daf8461886c721893ab1bc179559224a6c2 Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 03:50:03 -0300 Subject: [PATCH 34/48] ci(workflow): rename workflow and configure container environment variables - Rename `.github/workflows/blank.yml` to `.github/workflows/ci-cd.yml` - Pass `ASPNETCORE_ENVIRONMENT`, `Jwt__Key`, and `DefaultAdminPassword` environment variables from GitHub Secrets to the Docker container during deployment - Remove obsolete comment regarding port mapping --- .github/workflows/{blank.yml => ci-cd.yml} | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename .github/workflows/{blank.yml => ci-cd.yml} (90%) diff --git a/.github/workflows/blank.yml b/.github/workflows/ci-cd.yml similarity index 90% rename from .github/workflows/blank.yml rename to .github/workflows/ci-cd.yml index 2f2f67e..b7d85fa 100644 --- a/.github/workflows/blank.yml +++ b/.github/workflows/ci-cd.yml @@ -66,8 +66,10 @@ jobs: docker rm game-collection-api || true # Run the new container - # IMPORTANT: Change '8080:80' to map your desired host port to the container's port. docker run -d \ --name game-collection-api \ -p 8080:8080 \ + -e ASPNETCORE_ENVIRONMENT=Production \ + -e "Jwt__Key=${{ secrets.JWT_SECRET_KEY }}" \ + -e "DefaultAdminPassword=${{ secrets.DEFAULT_ADMIN_PASSWORD }}" \ ghcr.io/harsche/game-collection-api:latest \ No newline at end of file From 2ebdb45ffe74713673800a5c0bf5e977c1552127 Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 05:09:38 -0300 Subject: [PATCH 35/48] ci: separate PR validation from deployment workflow --- .github/workflows/ci-cd.yml | 3 +-- .github/workflows/pr-validation.yml | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/pr-validation.yml diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index b7d85fa..394e972 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -3,11 +3,10 @@ name: CI on: push: branches: ["main"] - pull_request: - branches: ["main"] workflow_dispatch: + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..1c3581c --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,24 @@ +name: PR Validation + +on: + pull_request: + branches: ["main", "dev"] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Build the Docker image to verify compilation and packaging (push is set to false) + - name: Build Docker image + uses: docker/build-push-action@v3 + with: + context: . + file: Dockerfile + push: false From 211de9701acdb5974015b887ac8f67d28732a83e Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 05:46:11 -0300 Subject: [PATCH 36/48] chore(docker): run container as non-root user - Add `USER $APP_UID` instruction to the final stage in the Dockerfile. - Improve container security by running the ASP.NET Core application under a non-root user. --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 86d2168..165bc08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,4 +11,6 @@ RUN dotnet publish -c Release -o /app/publish FROM base AS final WORKDIR /app COPY --from=build /app/publish . +USER $APP_UID ENTRYPOINT ["dotnet", "GameCollectionAPI.dll"] + From c73ec5e1331794b8c0b79f6542615fbc14b6270c Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 05:46:54 -0300 Subject: [PATCH 37/48] feat(auth): validate JWT secret key on application startup - Check if the JWT secret key is null or empty in the configuration and throw an `InvalidOperationException` if so. - Use the validated JWT key for `IssuerSigningKey` configuration in authentication service setup. - Remove the empty `Key` field from `appsettings.json`. --- GameCollectionAPI/Program.cs | 10 ++++++++-- GameCollectionAPI/appsettings.json | 3 +-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index 47ec36a..93caa6b 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -25,6 +25,12 @@ // Authentication var jwtSettings = builder.Configuration.GetSection("Jwt"); +var jwtKey = jwtSettings["Key"]; +if (string.IsNullOrWhiteSpace(jwtKey)) +{ + throw new InvalidOperationException("JWT secret key is missing in the app configuration."); +} + builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -40,11 +46,11 @@ ValidateIssuerSigningKey = true, ValidIssuer = jwtSettings["Issuer"], ValidAudience = jwtSettings["Audience"], - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings["Key"] ?? "")) - + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)) }; }); + var app = builder.Build(); await app.InitializeDatabaseAsync(); diff --git a/GameCollectionAPI/appsettings.json b/GameCollectionAPI/appsettings.json index d5cde91..4affdc2 100644 --- a/GameCollectionAPI/appsettings.json +++ b/GameCollectionAPI/appsettings.json @@ -12,7 +12,6 @@ }, "Jwt": { "Issuer": "www.marblegamestudio.com", - "Audience": "GameCollection", - "Key": "" + "Audience": "GameCollection" } } From b397c065228f730f70e6f57d260ea593b728f371 Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 05:48:33 -0300 Subject: [PATCH 38/48] ci(github-actions): update docker actions and improve deployment script safety - Upgrade `docker/login-action` from `v2` to `v4` - Upgrade `docker/build-push-action` from `v3` to `v7` - Add `contents: read` permission to the `deploy` job - Improve SSH deployment script safety by assigning `github.actor` to a shell variable and properly quoting secrets --- .github/workflows/ci-cd.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 394e972..0a2b9b5 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -23,7 +23,7 @@ jobs: # Log in to GitHub Container Registry - name: Log in to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -31,7 +31,7 @@ jobs: # Build and push the Docker image to GHCR - name: Build and push Docker image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v7 with: context: . file: Dockerfile @@ -45,6 +45,8 @@ jobs: needs: build runs-on: ubuntu-latest environment: oracle-vm + permissions: + contents: read steps: - name: Deploy to Oracle VM @@ -54,8 +56,9 @@ jobs: username: ${{ secrets.ORACLE_VM_USERNAME }} key: ${{ secrets.ORACLE_VM_SSH_KEY }} script: | + GH_ACTOR="${{ github.actor }}" # Log in to GitHub Container Registry - echo ${{ secrets.GHCR_PAT }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin + echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u "$GH_ACTOR" --password-stdin # Pull the latest image docker pull ghcr.io/harsche/game-collection-api:latest From 535176f100e5287d60fd7d90a615787f44e2bdfe Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 05:58:36 -0300 Subject: [PATCH 39/48] fix(controllers): improve user ID validation in UsersController - Move ID validation to run before the authorization check in `Get` and `UpdateUsername` actions. - Update ID validation condition from `id == 0` to `id <= 0 && id != -1`. --- GameCollectionAPI/Controllers/UsersController.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/GameCollectionAPI/Controllers/UsersController.cs b/GameCollectionAPI/Controllers/UsersController.cs index 5df4f24..ca0b6e9 100644 --- a/GameCollectionAPI/Controllers/UsersController.cs +++ b/GameCollectionAPI/Controllers/UsersController.cs @@ -33,12 +33,13 @@ public UsersController(IUserService service) [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task Get(int id) { + if (id <= 0 && id != -1) { return BadRequest(); } + if (!IsOwnerOrAdmin(id)) { return Forbid(); } - if (id == 0) { return BadRequest(); } var user = await _service.GetUserByIdAsync(id); return user == null ? NotFound() : Ok(user); @@ -87,13 +88,13 @@ public async Task Create([FromBody] UserCreateDto createdUser) [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task UpdateUsername(int id, [FromBody] UpdateUsernameDto updateUsernameDto) { + if ((id <= 0 && id != -1) || updateUsernameDto == null) { return BadRequest(); }; + if (!IsOwnerOrAdmin(id)) { return Forbid(); } - if (id == 0 || updateUsernameDto == null) { return BadRequest(); } - try { bool successful = await _service.UpdateUsernameAsync(id, updateUsernameDto); From 32b3d839e6235b5a82bc6e2f47f2157e84cda55b Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 06:00:02 -0300 Subject: [PATCH 40/48] fix(controllers): use CreatedAtRoute instead of CreatedAtAction in UsersController - Change the return statement in the user creation action from `CreatedAtAction` to `CreatedAtRoute` to resolve potential routing issues and correctly generate the `Location` header. --- GameCollectionAPI/Controllers/UsersController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GameCollectionAPI/Controllers/UsersController.cs b/GameCollectionAPI/Controllers/UsersController.cs index ca0b6e9..2e81928 100644 --- a/GameCollectionAPI/Controllers/UsersController.cs +++ b/GameCollectionAPI/Controllers/UsersController.cs @@ -65,7 +65,7 @@ public async Task Create([FromBody] UserCreateDto createdUser) try { var user = await _service.CreateUserAsync(createdUser); - return CreatedAtAction("GetUserById", new { id = user.Id }, user); + return CreatedAtRoute("GetUserById", new { id = user.Id }, user); } catch (DuplicateUsernameException ex) { From 2748a276312a1ed7e5415c69f3b0183ec286cf98 Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 06:12:12 -0300 Subject: [PATCH 41/48] feat(database): add unique index to Username and update seeder - Configure unique index on the `Username` field of the `User` entity in `AppDbContext` - Create and apply database migration `AddUniqueUsername` - Refactor `DbInitializer` to check for existing admin users by username instead of role ID - Enhance `DbInitializer` with error handling (`DbUpdateException`) to gracefully manage potential conflicts during admin user seeding --- GameCollectionAPI/Data/AppDbContext.cs | 5 + GameCollectionAPI/Data/DbInitializer.cs | 21 ++- ...260701090346_AddUniqueUsername.Designer.cs | 126 ++++++++++++++++++ .../20260701090346_AddUniqueUsername.cs | 28 ++++ .../Migrations/AppDbContextModelSnapshot.cs | 8 +- 5 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 GameCollectionAPI/Migrations/20260701090346_AddUniqueUsername.Designer.cs create mode 100644 GameCollectionAPI/Migrations/20260701090346_AddUniqueUsername.cs diff --git a/GameCollectionAPI/Data/AppDbContext.cs b/GameCollectionAPI/Data/AppDbContext.cs index 64746c8..bdd0aa2 100644 --- a/GameCollectionAPI/Data/AppDbContext.cs +++ b/GameCollectionAPI/Data/AppDbContext.cs @@ -13,6 +13,11 @@ public AppDbContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { + // Add unique index for Username + modelBuilder.Entity() + .HasIndex(u => u.Username) + .IsUnique(); + // Add roles modelBuilder.Entity().HasData( new Role { Id = (int)RoleType.Admin, Name = RoleType.Admin.ToString() }, diff --git a/GameCollectionAPI/Data/DbInitializer.cs b/GameCollectionAPI/Data/DbInitializer.cs index b3845ce..56652c2 100644 --- a/GameCollectionAPI/Data/DbInitializer.cs +++ b/GameCollectionAPI/Data/DbInitializer.cs @@ -18,12 +18,12 @@ public static async Task InitializeDatabaseAsync(this IHost host) await context.Database.MigrateAsync(); // Seed admin user - if (!await context.Users.AnyAsync(u => u.RoleId == (int)RoleType.Admin)) + var configuration = services.GetRequiredService(); + var adminUsername = configuration["DefaultAdminUsername"] ?? "Admin"; + + if (!await context.Users.AnyAsync(u => u.Username == adminUsername)) { - var configuration = services.GetRequiredService(); - var adminUsername = configuration["DefaultAdminUsername"] ?? "Admin"; var adminPassword = configuration["DefaultAdminPassword"]; - if (adminPassword == null) { throw new Exception("Default admin password not provided."); @@ -41,7 +41,18 @@ public static async Task InitializeDatabaseAsync(this IHost host) adminUser.PasswordHash = hasher.HashPassword(adminUser, adminPassword); context.Users.Add(adminUser); - await context.SaveChangesAsync(); + try + { + await context.SaveChangesAsync(); + } + catch (DbUpdateException) + { + context.Entry(adminUser).State = EntityState.Detached; + if (!await context.Users.AnyAsync(u => u.Username == adminUsername)) + { + throw; + } + } } } } \ No newline at end of file diff --git a/GameCollectionAPI/Migrations/20260701090346_AddUniqueUsername.Designer.cs b/GameCollectionAPI/Migrations/20260701090346_AddUniqueUsername.Designer.cs new file mode 100644 index 0000000..5a19679 --- /dev/null +++ b/GameCollectionAPI/Migrations/20260701090346_AddUniqueUsername.Designer.cs @@ -0,0 +1,126 @@ +// +using System; +using GameCollectionAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GameCollectionAPI.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260701090346_AddUniqueUsername")] + partial class AddUniqueUsername + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("GameCollectionAPI.Models.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Developer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Genre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Publisher") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("GameCollectionAPI.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + + b.HasData( + new + { + Id = 1, + Name = "Admin" + }, + new + { + Id = 2, + Name = "User" + }); + }); + + modelBuilder.Entity("GameCollectionAPI.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("GameCollectionAPI.Models.User", b => + { + b.HasOne("GameCollectionAPI.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GameCollectionAPI/Migrations/20260701090346_AddUniqueUsername.cs b/GameCollectionAPI/Migrations/20260701090346_AddUniqueUsername.cs new file mode 100644 index 0000000..10c2c6c --- /dev/null +++ b/GameCollectionAPI/Migrations/20260701090346_AddUniqueUsername.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameCollectionAPI.Migrations +{ + /// + public partial class AddUniqueUsername : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Users_Username", + table: "Users"); + } + } +} diff --git a/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs b/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs index 6fec7a8..8019d47 100644 --- a/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using GameCollectionAPI.Data; using Microsoft.EntityFrameworkCore; @@ -15,7 +15,7 @@ partial class AppDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.18"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); modelBuilder.Entity("GameCollectionAPI.Models.Game", b => { @@ -101,8 +101,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("RoleId"); - b.ToTable("Users"); + b.HasIndex("Username") + .IsUnique(); + b.ToTable("Users"); }); modelBuilder.Entity("GameCollectionAPI.Models.User", b => From 1ff770b1567f6c2217d7680f7560c6a3b739337d Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 06:13:53 -0300 Subject: [PATCH 42/48] feat(dto): add minimum password length validation to AuthDto - Add `[MinLength(6)]` attribute to the `Password` property in `AuthDto` to enforce a minimum length constraint of 6 characters. --- GameCollectionAPI/DTOs/AuthDto.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/GameCollectionAPI/DTOs/AuthDto.cs b/GameCollectionAPI/DTOs/AuthDto.cs index c5a6d94..e88eee7 100644 --- a/GameCollectionAPI/DTOs/AuthDto.cs +++ b/GameCollectionAPI/DTOs/AuthDto.cs @@ -9,5 +9,6 @@ public class AuthDto public required string Username { get; set; } [Required] + [MinLength(6)] public required string Password { get; set; } } From 3da5aeefc92dd7ad5e7544800951f022baefc823 Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 06:16:16 -0300 Subject: [PATCH 43/48] refactor(dto): update validation constraints in UserCreateDto - Increase minimum length of `Username` from 1 to 3 - Add minimum length requirement of 6 characters to `Password` --- GameCollectionAPI/DTOs/Users/UserCreateDto.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GameCollectionAPI/DTOs/Users/UserCreateDto.cs b/GameCollectionAPI/DTOs/Users/UserCreateDto.cs index 9205813..2b3bb6f 100644 --- a/GameCollectionAPI/DTOs/Users/UserCreateDto.cs +++ b/GameCollectionAPI/DTOs/Users/UserCreateDto.cs @@ -5,9 +5,10 @@ namespace GameCollectionAPI.DTOs.Users; public class UserCreateDto { [Required] - [StringLength(20, MinimumLength = 1)] + [StringLength(20, MinimumLength = 3)] public required string Username { get; set; } [Required] + [MinLength(6)] public required string Password { get; set; } } From 37fff9e3fff82cc55243e5d48b42e0fb4546aef8 Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 06:36:02 -0300 Subject: [PATCH 44/48] refactor(database): prevent cascade delete on User-Role relationship - Configure the `User` entity to use `DeleteBehavior.Restrict` on its `Role` foreign key in `AppDbContext` - Update the `AddUsersAndRoles` migration to use `ReferentialAction.Restrict` instead of `Cascade` - Regenerate model designer snapshots (`AddUsersAndRoles.Designer.cs`, `AddUniqueUsername.Designer.cs`, `AppDbContextModelSnapshot.cs`) to reflect the updated delete behavior --- GameCollectionAPI/Data/AppDbContext.cs | 7 +++++++ .../Migrations/20250831143809_AddUsersAndRoles.Designer.cs | 2 +- .../Migrations/20250831143809_AddUsersAndRoles.cs | 2 +- .../20260701090346_AddUniqueUsername.Designer.cs | 4 ++-- GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs | 4 ++-- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/GameCollectionAPI/Data/AppDbContext.cs b/GameCollectionAPI/Data/AppDbContext.cs index bdd0aa2..d76823a 100644 --- a/GameCollectionAPI/Data/AppDbContext.cs +++ b/GameCollectionAPI/Data/AppDbContext.cs @@ -18,6 +18,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasIndex(u => u.Username) .IsUnique(); + // Configure User-Role relationship to prevent cascade delete + modelBuilder.Entity() + .HasOne(u => u.Role) + .WithMany() + .HasForeignKey(u => u.RoleId) + .OnDelete(DeleteBehavior.Restrict); + // Add roles modelBuilder.Entity().HasData( new Role { Id = (int)RoleType.Admin, Name = RoleType.Admin.ToString() }, diff --git a/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs index 4b38c87..71e15ad 100644 --- a/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs +++ b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs @@ -113,7 +113,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasOne("GameCollectionAPI.Models.Role", "Role") .WithMany() .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.Restrict) .IsRequired(); b.Navigation("Role"); diff --git a/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.cs b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.cs index bc9f387..eda4011 100644 --- a/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.cs +++ b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.cs @@ -45,7 +45,7 @@ protected override void Up(MigrationBuilder migrationBuilder) column: x => x.RoleId, principalTable: "Roles", principalColumn: "Id", - onDelete: ReferentialAction.Cascade); + onDelete: ReferentialAction.Restrict); }); migrationBuilder.InsertData( diff --git a/GameCollectionAPI/Migrations/20260701090346_AddUniqueUsername.Designer.cs b/GameCollectionAPI/Migrations/20260701090346_AddUniqueUsername.Designer.cs index 5a19679..eff29cb 100644 --- a/GameCollectionAPI/Migrations/20260701090346_AddUniqueUsername.Designer.cs +++ b/GameCollectionAPI/Migrations/20260701090346_AddUniqueUsername.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using GameCollectionAPI.Data; using Microsoft.EntityFrameworkCore; @@ -115,7 +115,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasOne("GameCollectionAPI.Models.Role", "Role") .WithMany() .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.Restrict) .IsRequired(); b.Navigation("Role"); diff --git a/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs b/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs index 8019d47..69aa0ac 100644 --- a/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using GameCollectionAPI.Data; using Microsoft.EntityFrameworkCore; @@ -112,7 +112,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("GameCollectionAPI.Models.Role", "Role") .WithMany() .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.Restrict) .IsRequired(); b.Navigation("Role"); From 48fe30a3c416257f4e63003b77c32d77d0a787a9 Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 06:38:22 -0300 Subject: [PATCH 45/48] feat(auth): register authorization services - Add `builder.Services.AddAuthorization()` to the dependency injection container in `Program.cs` --- GameCollectionAPI/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/GameCollectionAPI/Program.cs b/GameCollectionAPI/Program.cs index 93caa6b..fa0f19d 100644 --- a/GameCollectionAPI/Program.cs +++ b/GameCollectionAPI/Program.cs @@ -49,6 +49,7 @@ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)) }; }); +builder.Services.AddAuthorization(); var app = builder.Build(); From ad037ef8616211bd60612c7407c88b26ef6bd227 Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 06:40:34 -0300 Subject: [PATCH 46/48] fix(auth): allow password verification to succeed when rehash is needed - Update the password verification condition in `AuthService.cs` to check for `PasswordVerificationResult.Failed` instead of `!= PasswordVerificationResult.Success`. - Ensure authentications do not fail when the password verification result is `SuccessRehashNeeded`. --- GameCollectionAPI/Services/AuthService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GameCollectionAPI/Services/AuthService.cs b/GameCollectionAPI/Services/AuthService.cs index b068dd2..234ed32 100644 --- a/GameCollectionAPI/Services/AuthService.cs +++ b/GameCollectionAPI/Services/AuthService.cs @@ -41,7 +41,7 @@ public async Task LoginUserAsync(AuthDto authDto) } var passwordHasher = new PasswordHasher(); - if (user.PasswordHash == null || passwordHasher.VerifyHashedPassword(user, user.PasswordHash, authDto.Password) != PasswordVerificationResult.Success) + if (user.PasswordHash == null || passwordHasher.VerifyHashedPassword(user, user.PasswordHash, authDto.Password) == PasswordVerificationResult.Failed) { throw new InvalidDataException("Invalid password."); } From 1f75c4faf5997e44554aa58a29daf69f1db27d83 Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 06:49:47 -0300 Subject: [PATCH 47/48] fix(service): allow updating username to the current value without duplicate error - Retrieve the user by ID first before performing the duplicate username check. - Only throw `DuplicateUsernameException` if the existing username belongs to a different user. - Add a short-circuit return when the new username is identical to the current one. --- GameCollectionAPI/Services/UserService.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/GameCollectionAPI/Services/UserService.cs b/GameCollectionAPI/Services/UserService.cs index 9781471..b58ea7b 100644 --- a/GameCollectionAPI/Services/UserService.cs +++ b/GameCollectionAPI/Services/UserService.cs @@ -68,11 +68,6 @@ public async Task DeleteUserAsync(int id) /// A task with a boolean indicating whether the update was successful. public async Task UpdateUsernameAsync(int id, UpdateUsernameDto updateUsernameDto) { - if (await _repo.GetByUsernameAsync(updateUsernameDto.NewUsername) != null) - { - throw new DuplicateUsernameException(); - } - var user = await _repo.GetByIdAsync(id); if (user == null) @@ -80,6 +75,18 @@ public async Task UpdateUsernameAsync(int id, UpdateUsernameDto updateUser return false; } + var existingUser = await _repo.GetByUsernameAsync(updateUsernameDto.NewUsername); + + if (existingUser != null && existingUser.Id != user.Id) + { + throw new DuplicateUsernameException(); + } + + if (user.Username == updateUsernameDto.NewUsername) + { + return true; + } + user.Username = updateUsernameDto.NewUsername; await _repo.UpdateAsync(); return true; From be838ead1c9be37c7f4f53905ced62c3bc732be1 Mon Sep 17 00:00:00 2001 From: Harsche Date: Wed, 1 Jul 2026 06:52:04 -0300 Subject: [PATCH 48/48] ci(workflow): disable credential persistence in pr-validation checkout - Add `persist-credentials: false` to the checkout step in `.github/workflows/pr-validation.yml` to improve security. --- .github/workflows/pr-validation.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 1c3581c..ccd4769 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -11,6 +11,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3