diff --git a/.github/workflows/blank.yml b/.github/workflows/blank.yml deleted file mode 100644 index fbdea5c..0000000 --- a/.github/workflows/blank.yml +++ /dev/null @@ -1,51 +0,0 @@ -# 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 - - # 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 }} - - # - 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) - - - name: List built images - run: docker images diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..0a2b9b5 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,77 @@ +name: CI + +on: + push: + branches: ["main"] + + workflow_dispatch: + + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Log in to GitHub Container Registry + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Build and push the Docker image to GHCR + - name: Build and push Docker image + uses: docker/build-push-action@v7 + 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 + permissions: + contents: read + + 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: | + GH_ACTOR="${{ github.actor }}" + # Log in to GitHub Container Registry + 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 + + # 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 + 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 diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..ccd4769 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,26 @@ +name: PR Validation + +on: + pull_request: + branches: ["main", "dev"] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - 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 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/.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..165bc08 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:10.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 . +USER $APP_UID +ENTRYPOINT ["dotnet", "GameCollectionAPI.dll"] + 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/Controllers/AuthController.cs b/GameCollectionAPI/Controllers/AuthController.cs new file mode 100644 index 0000000..011f1e5 --- /dev/null +++ b/GameCollectionAPI/Controllers/AuthController.cs @@ -0,0 +1,66 @@ +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; + } + + /// + /// Registers a new user. + /// + /// Authentication DTO containing user details. + /// The created user. + [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 }); + } + } + + /// + /// 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.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) + { + return Unauthorized(new { message = "Invalid credentials" }); + } + } + } +} diff --git a/GameCollectionAPI/Controllers/GamesController.cs b/GameCollectionAPI/Controllers/GamesController.cs new file mode 100644 index 0000000..6cf1eec --- /dev/null +++ b/GameCollectionAPI/Controllers/GamesController.cs @@ -0,0 +1,108 @@ +using GameCollectionAPI.DTOs.Games; +using GameCollectionAPI.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace GameCollectionAPI.Controllers +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class GamesController : ControllerBase + { + private readonly IGameService _service; + + public GamesController(IGameService service) + { + _service = service; + } + + /// + /// Gets all games in the collection. + /// + /// List of games. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Get() => await _service.GetAllGamesAsync(); + + /// + /// Gets a specific game by its ID. + /// + /// Game ID. + /// The requested game. + [HttpGet("{id:int}", Name = "GetGameById")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Get(int id) + { + if (id <= 0) { return BadRequest(); } + + var game = await _service.GetGameByIdAsync(id); + return game == null ? NotFound() : Ok(game); + } + + /// + /// Adds a new game to the collection. + /// + /// Game object to add. + /// The created game. + [HttpPost] + [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 CreatedAtRoute("GetGameById", new { id = game.Id }, game); + } + + /// + /// Updates an existing game. + /// + /// Game ID. + /// 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) + { + if (id <= 0 || updatedGame == null) { return BadRequest(); } + + bool successful = await _service.UpdateGameAsync(id, updatedGame); + return successful ? NoContent() : NotFound(); + } + + /// + /// Deletes a game by its ID. + /// + /// 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) + { + if (id <= 0) { return BadRequest(); } + + bool successful = await _service.DeleteGameAsync(id); + return successful ? NoContent() : NotFound(); + } + } +} diff --git a/GameCollectionAPI/Controllers/UsersController.cs b/GameCollectionAPI/Controllers/UsersController.cs new file mode 100644 index 0000000..2e81928 --- /dev/null +++ b/GameCollectionAPI/Controllers/UsersController.cs @@ -0,0 +1,139 @@ +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; + + 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)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task Get(int id) + { + if (id <= 0 && id != -1) { return BadRequest(); } + + if (!IsOwnerOrAdmin(id)) + { + return Forbid(); + } + + + var user = await _service.GetUserByIdAsync(id); + return user == null ? NotFound() : Ok(user); + } + + + /// + /// Creates a new user. + /// + /// The user creation DTO. + /// The created user. + [HttpPost] + [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) + { + if (createdUser == null) { return BadRequest(); } + + try + { + var user = await _service.CreateUserAsync(createdUser); + return CreatedAtRoute("GetUserById", new { id = user.Id }, user); + } + catch (DuplicateUsernameException ex) + { + return Conflict(new { message = ex.Message }); + } + } + + /// + /// 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)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [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(); + } + + try + { + bool successful = await _service.UpdateUsernameAsync(id, updateUsernameDto); + return successful ? NoContent() : NotFound(); + } + catch (DuplicateUsernameException ex) + { + return Conflict(new { message = ex.Message }); + } + } + + /// + /// Deletes a user. + /// + /// 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.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + 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(); + } + + bool IsOwnerOrAdmin(int id) + { + var currentUserIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + bool isOwner = currentUserIdClaim == id.ToString(); + bool isAdmin = User.IsInRole("Admin"); + + return isOwner || isAdmin; + } + } +} diff --git a/GameCollectionAPI/DTOs/AuthDto.cs b/GameCollectionAPI/DTOs/AuthDto.cs new file mode 100644 index 0000000..e88eee7 --- /dev/null +++ b/GameCollectionAPI/DTOs/AuthDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace GameCollectionAPI.DTOs; + +public class AuthDto +{ + [Required] + [MinLength(3)] + public required string Username { get; set; } + + [Required] + [MinLength(6)] + public required string Password { get; set; } +} diff --git a/GameCollectionAPI/DTOs/Games/GameCreateDto.cs b/GameCollectionAPI/DTOs/Games/GameCreateDto.cs new file mode 100644 index 0000000..fa590da --- /dev/null +++ b/GameCollectionAPI/DTOs/Games/GameCreateDto.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace GameCollectionAPI.DTOs.Games; + +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/Games/GameReadDto.cs b/GameCollectionAPI/DTOs/Games/GameReadDto.cs new file mode 100644 index 0000000..13cf557 --- /dev/null +++ b/GameCollectionAPI/DTOs/Games/GameReadDto.cs @@ -0,0 +1,12 @@ +namespace GameCollectionAPI.DTOs.Games; + +public class GameReadDto +{ + public int Id { 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/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..2b3bb6f --- /dev/null +++ b/GameCollectionAPI/DTOs/Users/UserCreateDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace GameCollectionAPI.DTOs.Users; + +public class UserCreateDto +{ + [Required] + [StringLength(20, MinimumLength = 3)] + public required string Username { get; set; } + + [Required] + [MinLength(6)] + 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 new file mode 100644 index 0000000..d76823a --- /dev/null +++ b/GameCollectionAPI/Data/AppDbContext.cs @@ -0,0 +1,34 @@ +using GameCollectionAPI.Models; +using Microsoft.EntityFrameworkCore; + +namespace GameCollectionAPI.Data; + +public class AppDbContext : DbContext +{ + 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 unique index for Username + modelBuilder.Entity() + .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() }, + new Role { Id = (int)RoleType.User, Name = RoleType.User.ToString() } + ); + } +} diff --git a/GameCollectionAPI/Data/DbInitializer.cs b/GameCollectionAPI/Data/DbInitializer.cs new file mode 100644 index 0000000..56652c2 --- /dev/null +++ b/GameCollectionAPI/Data/DbInitializer.cs @@ -0,0 +1,58 @@ + +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 + var configuration = services.GetRequiredService(); + var adminUsername = configuration["DefaultAdminUsername"] ?? "Admin"; + + if (!await context.Users.AnyAsync(u => u.Username == adminUsername)) + { + 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); + 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/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/GameCollectionAPI.csproj b/GameCollectionAPI/GameCollectionAPI.csproj new file mode 100644 index 0000000..3ef1a10 --- /dev/null +++ b/GameCollectionAPI/GameCollectionAPI.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + a5fe0efc-96d9-41b0-93c1-556182146f8e + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/GameCollectionAPI/GameCollectionAPI.http b/GameCollectionAPI/GameCollectionAPI.http new file mode 100644 index 0000000..5ce5b9a --- /dev/null +++ b/GameCollectionAPI/GameCollectionAPI.http @@ -0,0 +1,4 @@ +@GameCollectionAPI_HostAddress = http://localhost:5116 + + +### 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/20250831143809_AddUsersAndRoles.Designer.cs b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs new file mode 100644 index 0000000..71e15ad --- /dev/null +++ b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.Designer.cs @@ -0,0 +1,124 @@ +// +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"); + + }); + + modelBuilder.Entity("GameCollectionAPI.Models.User", b => + { + b.HasOne("GameCollectionAPI.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .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..eda4011 --- /dev/null +++ b/GameCollectionAPI/Migrations/20250831143809_AddUsersAndRoles.cs @@ -0,0 +1,76 @@ +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.Restrict); + }); + + migrationBuilder.InsertData( + table: "Roles", + columns: new[] { "Id", "Name" }, + values: new object[,] + { + { 1, "Admin" }, + { 2, "User" } + }); + + 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/20260701090346_AddUniqueUsername.Designer.cs b/GameCollectionAPI/Migrations/20260701090346_AddUniqueUsername.Designer.cs new file mode 100644 index 0000000..eff29cb --- /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.Restrict) + .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 new file mode 100644 index 0000000..69aa0ac --- /dev/null +++ b/GameCollectionAPI/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,123 @@ +// +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", "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.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GameCollectionAPI/Models/Game.cs b/GameCollectionAPI/Models/Game.cs new file mode 100644 index 0000000..8971af0 --- /dev/null +++ b/GameCollectionAPI/Models/Game.cs @@ -0,0 +1,51 @@ +using GameCollectionAPI.DTOs.Games; + +namespace GameCollectionAPI.Models; + +public class Game +{ + public int Id { 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 required 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 + }; + } +} 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 new file mode 100644 index 0000000..71527a3 --- /dev/null +++ b/GameCollectionAPI/Models/User.cs @@ -0,0 +1,40 @@ +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 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, + RoleId = (int)RoleType.User + }; + + 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 new file mode 100644 index 0000000..fa0f19d --- /dev/null +++ b/GameCollectionAPI/Program.cs @@ -0,0 +1,75 @@ +using System.Text; +using GameCollectionAPI.Data; +using GameCollectionAPI.Repositories; +using GameCollectionAPI.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +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(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// 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; + 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(jwtKey)) + }; +}); +builder.Services.AddAuthorization(); + + +var app = builder.Build(); + +await app.InitializeDatabaseAsync(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} +else +{ + app.UseHttpsRedirection(); +} + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); 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/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); +} 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..61ba942 --- /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.Include(u => u.Role).FirstOrDefaultAsync(user => user.Id == id); + } + + public Task GetByUsernameAsync(string username) + { + return _context.Users.Include(u => u.Role).FirstOrDefaultAsync(user => user.Username == username); + } + + public Task UpdateAsync() + { + return _context.SaveChangesAsync(); + } +} diff --git a/GameCollectionAPI/Services/AuthService.cs b/GameCollectionAPI/Services/AuthService.cs new file mode 100644 index 0000000..234ed32 --- /dev/null +++ b/GameCollectionAPI/Services/AuthService.cs @@ -0,0 +1,115 @@ +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; + } + + /// + /// 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); + if (user == null) + { + throw new InvalidDataException("User with this username does not exist."); + } + + var passwordHasher = new PasswordHasher(); + if (user.PasswordHash == null || passwordHasher.VerifyHashedPassword(user, user.PasswordHash, authDto.Password) == PasswordVerificationResult.Failed) + { + throw new InvalidDataException("Invalid password."); + } + + var token = GenerateJwtToken(user); + 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) + { + 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(); + } + + /// + /// Generates a JWT token for the specified user. + /// + /// The user entity. + /// A JWT token as a string. + private string GenerateJwtToken(User user) + { + 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"]; + 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); + + // Expiration + var expires = DateTime.UtcNow.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); + } +} diff --git a/GameCollectionAPI/Services/GameService.cs b/GameCollectionAPI/Services/GameService.cs new file mode 100644 index 0000000..926c75d --- /dev/null +++ b/GameCollectionAPI/Services/GameService.cs @@ -0,0 +1,92 @@ +using GameCollectionAPI.DTOs.Games; +using GameCollectionAPI.Models; +using GameCollectionAPI.Repositories; + +namespace GameCollectionAPI.Services; + +public class GameService : IGameService +{ + private readonly IGameRepository _repo; + + public GameService(IGameRepository repo) + { + _repo = repo; + } + + /// + /// Creates a new game in the collection. + /// + /// The game data to create. + /// A task containing the created game's read DTO. + public async Task CreateGameAsync(GameCreateDto createdGame) + { + var game = Game.FromCreateDto(createdGame); + await _repo.AddAsync(game); + return game.ToReadDto(); + } + + /// + /// Deletes a game by ID. + /// + /// 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); + + if (game == null) + { + return false; + } + + await _repo.DeleteAsync(game); + return true; + } + + /// + /// Retrieves all games from the collection. + /// + /// A task containing a list of all games read DTOs. + public async Task> GetAllGamesAsync() + { + var games = await _repo.GetAllAsync(); + return [.. games.Select(g => g.ToReadDto())]; + } + + /// + /// Retrieves a specific game by its ID. + /// + /// 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); + + if (game == null) + { + return null; + } + + return game.ToReadDto(); + } + + /// + /// Updates an existing game with new data. + /// + /// The game ID. + /// The updated game data. + /// 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); + + 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..29ed0a1 --- /dev/null +++ b/GameCollectionAPI/Services/IGameService.cs @@ -0,0 +1,12 @@ +using GameCollectionAPI.DTOs.Games; + +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); +} 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..b58ea7b --- /dev/null +++ b/GameCollectionAPI/Services/UserService.cs @@ -0,0 +1,94 @@ +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; + } + + /// + /// 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) + { + throw new DuplicateUsernameException(); + } + + var user = User.FromCreateDto(createdUser); + await _repo.AddAsync(user); + 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); + + if (user == null) + { + return false; + } + + await _repo.DeleteAsync(user); + 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) + { + var user = await _repo.GetByIdAsync(id); + + if (user == null) + { + 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; + } +} 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..4affdc2 --- /dev/null +++ b/GameCollectionAPI/appsettings.json @@ -0,0 +1,17 @@ +{ + "https_port": 443, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "GameList" : "Data Source=GameList.db" + }, + "Jwt": { + "Issuer": "www.marblegamestudio.com", + "Audience": "GameCollection" + } +}