feat(backend): Seperate database activity from controllers

This commit is contained in:
2025-03-31 20:16:44 +00:00
parent d50847ce5b
commit 8a919c7cda
22 changed files with 376 additions and 220 deletions

View File

@@ -1,66 +1,22 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using backend.Models;
using Backend.Interfaces;
namespace backend.Controllers;
public class UserLogin {
public required string Username { get; set; }
public required string Password { get; set; }
}
namespace Backend.Controllers;
[ApiController]
[Route("api/v1/[controller]")]
public class AuthController(GameContext context) : ControllerBase {
private readonly GameContext _context = context;
public class AuthController(IAuthService authService) : ControllerBase {
private readonly IAuthService _authService = authService;
[HttpPost("login")]
public IActionResult Login([FromBody] UserLogin loginData) {
var salt = Environment.GetEnvironmentVariable("MD5_SALT") ?? "";
var passwordHash = CreateMD5(loginData.Password + salt);
var user = _context
.Users
.Where(u => u.Username == loginData.Username && u.PasswordHash == passwordHash)
.FirstOrDefault();
public IActionResult Login([FromBody] UserLogin credentials) {
var user = _authService.ValidateUser(credentials);
if (user != null) {
var token = GenerateJwtToken(user.Id.ToString());
var token = _authService.GenerateJwtToken(user.Id.ToString());
return Ok(new { token });
}
return Unauthorized();
}
private static string GenerateJwtToken(string userId) {
var secret = Environment.GetEnvironmentVariable("JWT_SECRET") ?? "";
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[] {
new Claim(JwtRegisteredClaimNames.Sub, userId),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var token = new JwtSecurityToken(
signingCredentials: creds,
issuer: Environment.GetEnvironmentVariable("JWT_ISSUER") ?? "",
audience: Environment.GetEnvironmentVariable("JWT_AUDIENCE") ?? "",
claims: claims,
expires: DateTime.Now.AddHours(6)
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public static string CreateMD5(string input) {
byte[] inputBytes = Encoding.ASCII.GetBytes(input);
byte[] hashBytes = System.Security.Cryptography.MD5.HashData(inputBytes);
return Convert.ToHexString(hashBytes);
}
}
}

View File

@@ -1,119 +1,84 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using backend.Models;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
using DotNetEnv;
using Backend.Models;
using Backend.Interfaces;
namespace backend.Controllers;
[Route("/api/v1/[controller]")]
[ApiController]
public class GamesController(GameContext context) : ControllerBase {
private readonly GameContext _context = context;
[Route("api/v1/[controller]")]
public class GamesController(IGameService gameService) : ControllerBase {
private readonly IGameService _gameService = gameService;
private long? ParseUserId() {
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (long.TryParse(userIdClaim, out var userId)) {
return userId;
}
return null;
return long.TryParse(userIdClaim, out var userId) ? userId : null;
}
// GET: api/Games
// GET: api/v1/Games
[HttpGet]
[Authorize]
public async Task<ActionResult<IEnumerable<Game>>> GetGames() {
var user = ParseUserId();
if (user == null) {
var userId = ParseUserId();
if (userId == null) {
return BadRequest();
}
return await _context.Games
.Where(g => g.UserId == user)
.ToListAsync();
var games = await _gameService.GetPlayableGamesAsync(userId.Value);
return Ok(games);
}
// GET: api/Games/5
// GET: api/v1/Games/5
[HttpGet("{id}")]
[Authorize]
public async Task<ActionResult<Game>> GetGame(long id) {
var user = ParseUserId();
if (user == null) {
return BadRequest();
var userId = ParseUserId();
if (userId == null) {
return BadRequest("Invalid user ID");
}
var game = await _context.Games
.Where(g => g.Id == id && g.UserId == user)
.FirstOrDefaultAsync();
if (game == null || game.UserId != user) {
return NotFound();
}
return game;
}
// POST: api/Games
// To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
[HttpPost]
[Authorize]
public async Task<ActionResult<Game>> PostGame() {
var user = ParseUserId();
if (user == null) {
return BadRequest();
}
var newGame = new Game {
UserId = user.Value,
Deck = [..
from shape in Enum.GetValues<CardShape>().Cast<CardShape>()
from color in Enum.GetValues<CardColor>().Cast<CardColor>()
from count in Enum.GetValues<CardCount>().Cast<CardCount>()
from shade in Enum.GetValues<CardShade>().Cast<CardShade>()
select new Card {
Shape = shape,
Color = color,
Count = count,
Shade = shade
}.ToUshort()
]
};
newGame.ShuffleDeck();
newGame.DealHand();
_context.Games.Add(newGame);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetGame), new { id = newGame.Id }, newGame);
}
// DELETE: api/Games/5
[HttpDelete("{id}")]
[Authorize]
public async Task<IActionResult> DeleteGame(long id) {
var user = ParseUserId();
if (user == null) {
return BadRequest();
}
var game = await _context.Games
.Where(g => g.Id == id && g.UserId == user)
.FirstOrDefaultAsync();
var game = await _gameService.GetGameAsync(id, userId.Value);
if (game == null) {
return NotFound();
}
_context.Games.Remove(game);
await _context.SaveChangesAsync();
return NoContent();
return game;
}
// POST: api/v1/Games
[HttpPost]
[Authorize]
public async Task<ActionResult<Game>> PostGame() {
var userId = ParseUserId();
if (userId == null) {
return BadRequest();
}
var game = await _gameService.CreateGameAsync(userId.Value);
return CreatedAtAction(nameof(GetGame), new { id = game.Id }, game);
}
// DELETE: api/v1/Games/5
[HttpDelete("{id}")]
[Authorize]
public async Task<IActionResult> DeleteGame(long id) {
var userId = ParseUserId();
if (userId == null) {
return BadRequest();
}
var result = await _gameService.DeleteGameAsync(id, userId.Value);
if (!result) {
return NotFound();
}
return NoContent();
}
// POST: api/v1/Games/CheckSet/5
[HttpPost]
[Route("[action]/{id}")]
[Authorize]
@@ -121,84 +86,55 @@ public class GamesController(GameContext context) : ControllerBase {
long id,
[FromBody] ushort[] cardIndices
) {
var user = ParseUserId();
if (user == null) {
var userId = ParseUserId();
if (userId == null) {
return BadRequest();
}
var game = await _context.Games
.Where(g => g.Id == id && g.UserId == user)
.FirstOrDefaultAsync();
if (game == null) {
return NotFound();
}
var res = game.IsSet(cardIndices);
if (res == null) {
var result = await _gameService.CheckSetAsync(id, userId.Value, cardIndices);
if (result == null) {
return BadRequest();
}
await _context.SaveChangesAsync();
return res;
return result;
}
// GET: api/v1/Games/SetsInHand/5
[HttpGet]
[Route("[action]/{id}")]
[Authorize]
public async Task<ActionResult<List<int[]>>> SetsInHand(long id) {
// Only show if user is admin (id < 1)
var user = ParseUserId();
if (user == null || user > 0) {
var userId = ParseUserId();
if (userId == null) {
return BadRequest();
}
var game = await _context.Games
.Where(g => g.Id == id && g.UserId == user)
.FirstOrDefaultAsync();
if (game == null) {
var result = await _gameService.GetSetsInHandAsync(id, userId.Value);
if (result == null) {
return NotFound();
}
var res = game.GetIndicesOfSet();
if (res == null) {
return BadRequest();
}
return res;
return result;
}
// GET: api/v1/Games/Hint/5
[HttpGet]
[Route("[action]/{id}")]
[Authorize]
public async Task<ActionResult<List<ushort>>> Hint(long id) {
var user = ParseUserId();
if (user == null) {
var userId = ParseUserId();
if (userId == null) {
return BadRequest();
}
var result = await _gameService.GetHintAsync(id, userId.Value);
if (result == null) {
return BadRequest();
}
var game = await _context.Games
.Where(g => g.Id == id && g.UserId == user)
.FirstOrDefaultAsync();
if (game == null) {
return NotFound();
}
var sets = game.GetIndicesOfSet();
if (sets == null || sets.Count == 0) {
return BadRequest("No sets found in hand.");
}
game.Hints++;
await _context.SaveChangesAsync();
return sets[0].Take(2).Select(i => (ushort)i).ToList();
return result;
}
}

View File

@@ -0,0 +1,13 @@
using Backend.Models;
namespace Backend.Interfaces;
public class UserLogin {
public required string Username { get; set; }
public required string Password { get; set; }
}
public interface IAuthService {
User? ValidateUser(UserLogin credentials);
string GenerateJwtToken(string userId);
}

View File

@@ -0,0 +1,11 @@
using Backend.Models;
namespace Backend.Interfaces;
public interface IGameRepository {
Task<IEnumerable<Game>> GetGamesByUserIdAsync(long userId);
Task<Game?> GetGameByIdAsync(long id, long userId);
Task<Game> CreateGameAsync(Game game);
Task<Game> UpdateGameAsync(Game game);
Task DeleteGameAsync(Game game);
}

View File

@@ -0,0 +1,12 @@
using Backend.Models;
namespace Backend.Interfaces;
public interface IGameService {
Task<IEnumerable<Game>> GetPlayableGamesAsync(long userId);
Task<Game?> GetGameAsync(long gameId, long userId);
Task<Game> CreateGameAsync(long userId);
Task<bool> DeleteGameAsync(long gameId, long userId);
Task<SetCheckResult?> CheckSetAsync(long gameId, long userId, ushort[] cardIndices);
Task<List<int[]>> GetSetsInHandAsync(long gameId, long userId);
Task<List<ushort>> GetHintAsync(long gameId, long userId);
}

View File

@@ -0,0 +1,9 @@
using Backend.Models;
namespace Backend.Interfaces;
public interface IUserRepository {
User? GetUserByUsername(string username);
User? ValidateUser(string username, string passwordHash);
Task<User> CreateUserAsync(User user);
}

View File

@@ -5,11 +5,11 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using backend.Models;
using Backend.Models;
#nullable disable
namespace backend.Migrations
namespace Backend.Migrations
{
[DbContext(typeof(GameContext))]
[Migration("20250325145201_InitialDatabase")]
@@ -25,7 +25,7 @@ namespace backend.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("backend.Models.Game", b =>
modelBuilder.Entity("Backend.Models.Game", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
@@ -67,7 +67,7 @@ namespace backend.Migrations
b.ToTable("Games");
});
modelBuilder.Entity("backend.Models.User", b =>
modelBuilder.Entity("Backend.Models.User", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
@@ -100,16 +100,16 @@ namespace backend.Migrations
b.ToTable("Users");
});
modelBuilder.Entity("backend.Models.Game", b =>
modelBuilder.Entity("Backend.Models.Game", b =>
{
b.HasOne("backend.Models.User", null)
b.HasOne("Backend.Models.User", null)
.WithMany("Games")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("backend.Models.User", b =>
modelBuilder.Entity("Backend.Models.User", b =>
{
b.Navigation("Games");
});

View File

@@ -4,7 +4,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace backend.Migrations
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class InitialDatabase : Migration

View File

@@ -4,11 +4,11 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using backend.Models;
using Backend.Models;
#nullable disable
namespace backend.Migrations
namespace Backend.Migrations
{
[DbContext(typeof(GameContext))]
partial class GameContextModelSnapshot : ModelSnapshot
@@ -22,7 +22,7 @@ namespace backend.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("backend.Models.Game", b =>
modelBuilder.Entity("Backend.Models.Game", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
@@ -64,7 +64,7 @@ namespace backend.Migrations
b.ToTable("Games", (string)null);
});
modelBuilder.Entity("backend.Models.User", b =>
modelBuilder.Entity("Backend.Models.User", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
@@ -97,16 +97,16 @@ namespace backend.Migrations
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("backend.Models.Game", b =>
modelBuilder.Entity("Backend.Models.Game", b =>
{
b.HasOne("backend.Models.User", null)
b.HasOne("Backend.Models.User", null)
.WithMany("Games")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("backend.Models.User", b =>
modelBuilder.Entity("Backend.Models.User", b =>
{
b.Navigation("Games");
});

View File

@@ -1,4 +1,4 @@
namespace backend.Models;
namespace Backend.Models;
// 3, 7 and 8 are chosen due to them in no way,
// shape or form adding up or substracting into eachother

View File

@@ -1,4 +1,4 @@
namespace backend.Models;
namespace Backend.Models;
public struct SetCheckResult {
public bool? IsSet { get; set; }

View File

@@ -1,6 +1,6 @@
using Microsoft.EntityFrameworkCore;
namespace backend.Models;
namespace Backend.Models;
public class GameContext : DbContext
{

View File

@@ -1,6 +1,6 @@
using Microsoft.EntityFrameworkCore;
namespace backend.Models;
namespace Backend.Models;
[Index(nameof(Username), IsUnique = true)]
public class User {

View File

@@ -1,9 +1,12 @@
using Microsoft.EntityFrameworkCore;
using backend.Models;
using Backend.Models;
using DotNetEnv;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using Backend.Interfaces;
using Backend.Services;
using Backend.Repositories;
// Load .env file
Env.Load();
@@ -42,6 +45,11 @@ var postgres_connection_string =
builder.Services.AddDbContext<GameContext>(opt => opt.UseNpgsql(postgres_connection_string));
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IGameRepository, GameRepository>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IGameService, GameService>();
var app = builder.Build();
// Disable CORS

View File

@@ -0,0 +1,46 @@
using Backend.Models;
using Backend.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace Backend.Repositories;
public class GameRepository(GameContext context) : IGameRepository {
private readonly GameContext _context = context;
public async Task<IEnumerable<Game>> GetGamesByUserIdAsync(long userId) {
return await _context.Games
.Where(g => g.UserId == userId)
.ToListAsync();
}
public async Task<Game?> GetGameByIdAsync(long id, long userId) {
return await _context.Games
.Where(g => g.Id == id && g.UserId == userId)
.FirstOrDefaultAsync();
}
public async Task<Game> CreateGameAsync(Game game) {
_context.Games.Add(game);
await _context.SaveChangesAsync();
return game;
}
public async Task<Game> UpdateGameAsync(Game game) {
_context.Entry(game).State = EntityState.Modified;
await _context.SaveChangesAsync();
return game;
}
public async Task DeleteGameAsync(long id) {
var game = await _context.Games.FindAsync(id);
if (game != null) {
_context.Games.Remove(game);
await _context.SaveChangesAsync();
}
}
public async Task DeleteGameAsync(Game game) {
_context.Games.Remove(game);
await _context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,23 @@
using Backend.Interfaces;
using Backend.Models;
namespace Backend.Repositories;
public class UserRepository(GameContext context) : IUserRepository {
private readonly GameContext _context = context;
public User? GetUserByUsername(string username) {
return _context.Users.FirstOrDefault(u => u.Username == username);
}
public User? ValidateUser(string username, string passwordHash) {
return _context.Users
.FirstOrDefault(u => u.Username == username && u.PasswordHash == passwordHash);
}
public async Task<User> CreateUserAsync(User user) {
_context.Users.Add(user);
await _context.SaveChangesAsync();
return user;
}
}

View File

@@ -0,0 +1,46 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Backend.Interfaces;
using Backend.Models;
using Microsoft.IdentityModel.Tokens;
namespace Backend.Services;
public class AuthService(IUserRepository userRepository) : IAuthService {
private readonly IUserRepository _userRepository = userRepository;
public User? ValidateUser(UserLogin credentials) {
var salt = Environment.GetEnvironmentVariable("MD5_SALT") ?? "";
var passwordHash = CreateMD5(credentials.Password + salt);
return _userRepository.ValidateUser(credentials.Username, passwordHash);
}
public string GenerateJwtToken(string userId) {
var secret = Environment.GetEnvironmentVariable("JWT_SECRET") ?? "";
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[] {
new Claim(JwtRegisteredClaimNames.Sub, userId),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var token = new JwtSecurityToken(
signingCredentials: creds,
issuer: Environment.GetEnvironmentVariable("JWT_ISSUER") ?? "",
audience: Environment.GetEnvironmentVariable("JWT_AUDIENCE") ?? "",
claims: claims,
expires: DateTime.Now.AddHours(6)
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private static string CreateMD5(string input) {
byte[] inputBytes = Encoding.ASCII.GetBytes(input);
byte[] hashBytes = System.Security.Cryptography.MD5.HashData(inputBytes);
return Convert.ToHexString(hashBytes);
}
}

View File

@@ -0,0 +1,95 @@
using Backend.Models;
using Backend.Interfaces;
namespace Backend.Services;
public class GameService(IGameRepository gameRepository) : IGameService {
private readonly IGameRepository _gameRepository = gameRepository;
public async Task<IEnumerable<Game>> GetPlayableGamesAsync(long userId) {
return await _gameRepository.GetGamesByUserIdAsync(userId);
}
public async Task<Game?> GetGameAsync(long gameId, long userId) {
return await _gameRepository.GetGameByIdAsync(gameId, userId);
}
public async Task<Game> CreateGameAsync(long userId) {
var newGame = new Game {
UserId = userId,
StartedAt = DateTime.UtcNow,
Deck = [..
from shape in Enum.GetValues<CardShape>().Cast<CardShape>()
from color in Enum.GetValues<CardColor>().Cast<CardColor>()
from count in Enum.GetValues<CardCount>().Cast<CardCount>()
from shade in Enum.GetValues<CardShade>().Cast<CardShade>()
select new Card {
Shape = shape,
Color = color,
Count = count,
Shade = shade
}.ToUshort()
]
};
newGame.ShuffleDeck();
newGame.DealHand();
return await _gameRepository.CreateGameAsync(newGame);
}
public async Task<bool> DeleteGameAsync(long gameId, long userId) {
var game = await _gameRepository.GetGameByIdAsync(gameId, userId);
if (game == null) {
return false;
}
await _gameRepository.DeleteGameAsync(game);
return true;
}
public async Task<SetCheckResult?> CheckSetAsync(long gameId, long userId, ushort[] cardIndices) {
var game = await _gameRepository.GetGameByIdAsync(gameId, userId);
if (game == null) {
return null;
}
var result = game.IsSet(cardIndices);
if (result != null) {
await _gameRepository.UpdateGameAsync(game);
}
return result;
}
public async Task<List<int[]>> GetSetsInHandAsync(long gameId, long userId) {
var game = await _gameRepository.GetGameByIdAsync(gameId, userId);
if (game == null) {
return [];
}
return game.GetIndicesOfSet();
}
public async Task<List<ushort>> GetHintAsync(long gameId, long userId) {
var game = await _gameRepository.GetGameByIdAsync(gameId, userId);
if (game == null) {
return [];
}
var sets = game.GetIndicesOfSet();
if (sets == null || sets.Count == 0) {
return [];
}
game.Hints++;
await _gameRepository.UpdateGameAsync(game);
return [.. sets[0].Take(2).Select(i => (ushort)i)];
}
}