Rebuild on Angular 22 + ASP.NET Core 10, containerised

The 2018 stack (Angular 5.2 / CLI 1.7, PHP, MySQL) had not been touched since
July 2018. Rebuilt rather than upgraded in place: the frontend was 17 major
versions behind, and of ~16,700 lines of PHP only ~150 were application logic —
the rest was four near-identical vendored copies of php-crud-api plus
class.upload.php.

Backend — ASP.NET Core 10, EF Core, SQLite
  * ASP.NET Core Identity (PBKDF2) + JWT bearer auth
  * Clean REST API replacing php-crud-api's filter[]/transform query syntax
  * Box art uploads re-encoded to WebP via SkiaSharp
  * Imports the 105 games recovered from the 2018 dump on first run

Frontend — Angular 22, zoneless, signals, Material 22
  * Standalone components, lazy routes, functional guards and interceptor
  * Vitest replaces Karma/Jasmine; fonts and icons bundled, no CDN calls
  * No provideAnimations: @angular/animations is deprecated in v22 and
    Material no longer depends on it (pinned by a test)

Docker
  * Multi-stage builds for both services, non-root at runtime
  * nginx serves the SPA and reverse-proxies the API, so everything is
    same-origin; one volume holds the database, uploads and DP keys

Security issues in the old code, not carried across:
  * Two endpoints exposed unauthenticated CRUD over every table
  * The client chose whose rows to read (filter[]=userId,eq,N); ownership now
    comes from the JWT subject server-side
  * Login was hardcoded to a single username
  * crypt() with one global salt, silently truncating passwords to 8 chars
  * JWT secret was the literal string "testing", tokens never expired
  * Token travelled in the query string rather than a header
  * Uploads were anonymous with the path built from the client filename
  * Access-Control-Allow-Origin: *

The live MySQL password committed in 2018 remains in git history and must be
rotated independently of this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 18:46:33 -04:00
co-authored by Claude Opus 5
parent 10757575c2
commit 2a7d90b2d5
165 changed files with 14908 additions and 31942 deletions
+7
View File
@@ -0,0 +1,7 @@
**/bin/
**/obj/
**/data/
**/uploads/
**/*.user
**/.vs/
**/.vscode/
+50
View File
@@ -0,0 +1,50 @@
# syntax=docker/dockerfile:1
# ---- build ----------------------------------------------------------------
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore against the project file alone so the layer caches across code edits.
COPY src/LudosData.Api/LudosData.Api.csproj src/LudosData.Api/
RUN dotnet restore src/LudosData.Api/LudosData.Api.csproj
COPY src/ src/
RUN dotnet publish src/LudosData.Api/LudosData.Api.csproj \
-c Release \
-o /app/publish \
--no-restore \
/p:UseAppHost=false
# ---- runtime --------------------------------------------------------------
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# The runtime image ships neither curl nor wget, so the container healthcheck
# below has nothing to probe with unless one is added.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# Run as the non-root user the base image already ships with.
ENV ASPNETCORE_HTTP_PORTS=8080 \
DOTNET_RUNNING_IN_CONTAINER=true \
ConnectionStrings__Default="Data Source=/data/ludos.db" \
Uploads__RootPath=/data/uploads
COPY --from=build /app/publish .
# Writable mount point for the SQLite file and uploaded art. Declared as a volume
# so an unmounted run still persists for the life of the container rather than
# failing to open the database.
RUN mkdir -p /data/uploads && chown -R $APP_UID:$APP_UID /data
VOLUME ["/data"]
USER $APP_UID
EXPOSE 8080
# Probes the app's own health endpoint, so "healthy" means it is actually
# serving requests rather than merely that the process exists.
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
CMD curl -fsS http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "LudosData.Api.dll"]
+5
View File
@@ -0,0 +1,5 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/LudosData.Api/LudosData.Api.csproj" />
</Folder>
</Solution>
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.302",
"rollForward": "latestFeature"
}
}
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace LudosData.Api.Auth;
public class JwtOptions
{
public const string SectionName = "Jwt";
/// <summary>
/// HMAC-SHA256 signing key. Supplied via the JWT__KEY environment variable —
/// there is deliberately no default, so a misconfigured deployment fails to
/// start rather than signing tokens with a guessable key.
/// </summary>
[Required(AllowEmptyStrings = false)]
[MinLength(32, ErrorMessage = "Jwt:Key must be at least 32 characters.")]
public string Key { get; set; } = string.Empty;
[Required] public string Issuer { get; set; } = "LudosData";
[Required] public string Audience { get; set; } = "LudosData";
/// <summary>
/// Access token lifetime. Twelve hours suits a single-user library app; there
/// is no refresh token flow, so expiry sends the user back to the login form.
/// </summary>
[Range(1, 24 * 60 * 7)]
public int LifetimeMinutes { get; set; } = 720;
}
@@ -0,0 +1,65 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using LudosData.Api.Domain;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace LudosData.Api.Auth;
public interface ITokenService
{
(string Token, DateTimeOffset ExpiresAt) CreateAccessToken(AppUser user);
}
public class TokenService(IOptions<JwtOptions> options) : ITokenService
{
private readonly JwtOptions _options = options.Value;
public (string Token, DateTimeOffset ExpiresAt) CreateAccessToken(AppUser user)
{
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(_options.LifetimeMinutes);
var claims = new List<Claim>
{
// The subject is the only thing authorization trusts. Ownership checks
// read it server-side; the client cannot influence which rows it sees.
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new(ClaimTypes.NameIdentifier, user.Id),
};
if (!string.IsNullOrEmpty(user.UserName))
{
claims.Add(new Claim(JwtRegisteredClaimNames.UniqueName, user.UserName));
}
if (!string.IsNullOrEmpty(user.Email))
{
claims.Add(new Claim(JwtRegisteredClaimNames.Email, user.Email));
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key));
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: DateTime.UtcNow,
expires: expiresAt.UtcDateTime,
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
}
}
public static class ClaimsPrincipalExtensions
{
/// <summary>
/// The authenticated user's id. Throws rather than returning null: every call
/// site sits behind [Authorize], so a missing subject is a bug, not a branch.
/// </summary>
public static string GetUserId(this ClaimsPrincipal principal) =>
principal.FindFirstValue(ClaimTypes.NameIdentifier)
?? principal.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? throw new InvalidOperationException("Authenticated principal has no subject claim.");
}
@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
namespace LudosData.Api.Contracts;
public record RegisterRequest
{
[Required, MinLength(3), MaxLength(50)]
public string UserName { get; init; } = string.Empty;
[Required, EmailAddress, MaxLength(256)]
public string Email { get; init; } = string.Empty;
[Required, MinLength(12), MaxLength(128)]
public string Password { get; init; } = string.Empty;
[MaxLength(100)] public string? FirstName { get; init; }
[MaxLength(100)] public string? LastName { get; init; }
}
public record LoginRequest
{
[Required] public string UserName { get; init; } = string.Empty;
[Required] public string Password { get; init; } = string.Empty;
}
public record UserResponse(
string Id,
string UserName,
string? Email,
string? FirstName,
string? LastName,
string? Art);
public record AuthResponse(string Token, DateTimeOffset ExpiresAt, UserResponse User);
public record AvailabilityResponse(bool Available);
@@ -0,0 +1,86 @@
using System.ComponentModel.DataAnnotations;
namespace LudosData.Api.Contracts;
/// <summary>A page of results plus the totals the paginator needs.</summary>
public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int Total)
{
public int TotalPages => PageSize > 0 ? (int)Math.Ceiling(Total / (double)PageSize) : 0;
}
/// <summary>
/// A game as returned to the client. <c>Art</c> is the stored filename; <c>ArtUrl</c>
/// is the ready-to-use URL built server-side, so the client never has to
/// string-concatenate upload paths the way the old grid did.
/// </summary>
public record GameResponse(
int Id,
string Title,
string? System,
string? Genre,
string? Year,
string? Developer,
string? Publisher,
string? Art,
string? ArtUrl,
string? Description,
bool Own,
bool Dumped,
bool Played,
bool Finished,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
/// <summary>
/// Create/update payload. Deliberately has no Id and no OwnerId — the route supplies
/// the former and the JWT the latter, so neither can be spoofed by the client.
/// </summary>
public record GameRequest
{
[Required(AllowEmptyStrings = false), MaxLength(200)]
public string Title { get; init; } = string.Empty;
[MaxLength(50)] public string? System { get; init; }
[MaxLength(50)] public string? Genre { get; init; }
[MaxLength(50)] public string? Year { get; init; }
[MaxLength(100)] public string? Developer { get; init; }
[MaxLength(100)] public string? Publisher { get; init; }
[MaxLength(200)] public string? Art { get; init; }
[MaxLength(10_000)] public string? Description { get; init; }
public bool Own { get; init; }
public bool Dumped { get; init; }
public bool Played { get; init; }
public bool Finished { get; init; }
}
/// <summary>Query string for the library list, bound from [FromQuery].</summary>
public record GameQuery
{
/// <summary>Free-text match against title, developer and publisher.</summary>
public string? Search { get; init; }
public string? System { get; init; }
public string? Genre { get; init; }
public bool? Own { get; init; }
public bool? Dumped { get; init; }
public bool? Played { get; init; }
public bool? Finished { get; init; }
[Range(1, int.MaxValue)] public int Page { get; init; } = 1;
/// <summary>Capped at 100 to keep a hostile or buggy client from asking for everything.</summary>
[Range(1, 100)] public int PageSize { get; init; } = 20;
/// <summary>One of: title, system, genre, year, developer, publisher, created, updated.</summary>
public string Sort { get; init; } = "title";
/// <summary>"asc" or "desc".</summary>
public string Dir { get; init; } = "asc";
}
/// <summary>Distinct values present in the user's library, for filter dropdowns.</summary>
public record FacetsResponse(IReadOnlyList<string> Systems, IReadOnlyList<string> Genres);
public record UploadResponse(string FileName, string Url);
@@ -0,0 +1,118 @@
using LudosData.Api.Auth;
using LudosData.Api.Contracts;
using LudosData.Api.Domain;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace LudosData.Api.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController(
UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
ITokenService tokenService,
ILogger<AuthController> logger) : ControllerBase
{
[HttpPost("register")]
[AllowAnonymous]
public async Task<ActionResult<AuthResponse>> Register(RegisterRequest request)
{
var user = new AppUser
{
UserName = request.UserName,
Email = request.Email,
FirstName = request.FirstName,
LastName = request.LastName,
};
var result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(error.Code, error.Description);
}
return ValidationProblem(ModelState);
}
logger.LogInformation("Registered user {UserName}", user.UserName);
return Ok(BuildAuthResponse(user));
}
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<AuthResponse>> Login(LoginRequest request)
{
var user = await userManager.FindByNameAsync(request.UserName);
if (user is null)
{
// Same response as a bad password, so this endpoint cannot be used to
// enumerate which usernames exist.
return Unauthorized(new ProblemDetails { Title = "Invalid username or password." });
}
var result = await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true);
if (result.IsLockedOut)
{
return StatusCode(StatusCodes.Status423Locked,
new ProblemDetails { Title = "Account temporarily locked after too many failed attempts." });
}
if (!result.Succeeded)
{
return Unauthorized(new ProblemDetails { Title = "Invalid username or password." });
}
return Ok(BuildAuthResponse(user));
}
[HttpGet("me")]
[Authorize]
public async Task<ActionResult<UserResponse>> Me()
{
var user = await userManager.FindByIdAsync(User.GetUserId());
return user is null ? Unauthorized() : Ok(ToUserResponse(user));
}
/// <summary>
/// Availability check for the registration form. Replaces the old approach of
/// querying the users table through the generic CRUD endpoint, which exposed
/// every user column to anonymous callers; this returns only a boolean.
/// </summary>
[HttpGet("available")]
[AllowAnonymous]
public async Task<ActionResult<AvailabilityResponse>> Available(
[FromQuery] string? userName,
[FromQuery] string? email)
{
if (!string.IsNullOrWhiteSpace(userName))
{
return Ok(new AvailabilityResponse(await userManager.FindByNameAsync(userName) is null));
}
if (!string.IsNullOrWhiteSpace(email))
{
return Ok(new AvailabilityResponse(await userManager.FindByEmailAsync(email) is null));
}
return BadRequest(new ProblemDetails { Title = "Provide either userName or email." });
}
private AuthResponse BuildAuthResponse(AppUser user)
{
var (token, expiresAt) = tokenService.CreateAccessToken(user);
return new AuthResponse(token, expiresAt, ToUserResponse(user));
}
private static UserResponse ToUserResponse(AppUser user) => new(
user.Id,
user.UserName ?? string.Empty,
user.Email,
user.FirstName,
user.LastName,
user.Art);
}
@@ -0,0 +1,179 @@
using LudosData.Api.Auth;
using LudosData.Api.Contracts;
using LudosData.Api.Data;
using LudosData.Api.Domain;
using LudosData.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LudosData.Api.Controllers;
/// <summary>
/// The user's game library.
///
/// Every query starts from <c>Where(g => g.OwnerId == currentUserId)</c>, taken from
/// the JWT subject. The old API took the owner id from a client-supplied query
/// parameter (<c>filter[]=userId,eq,N</c>), which meant any valid token could read
/// any other user's library by editing the number.
/// </summary>
[ApiController]
[Route("api/games")]
[Authorize]
public class GamesController(
LudosDbContext db,
IImageStorage images,
ILogger<GamesController> logger) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PagedResult<GameResponse>>> List([FromQuery] GameQuery query, CancellationToken ct)
{
var ownerId = User.GetUserId();
var q = db.Games.AsNoTracking().Where(g => g.OwnerId == ownerId);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var term = query.Search.Trim();
q = q.Where(g =>
EF.Functions.Like(g.Title, $"%{term}%") ||
(g.Developer != null && EF.Functions.Like(g.Developer, $"%{term}%")) ||
(g.Publisher != null && EF.Functions.Like(g.Publisher, $"%{term}%")));
}
if (!string.IsNullOrWhiteSpace(query.System)) q = q.Where(g => g.System == query.System);
if (!string.IsNullOrWhiteSpace(query.Genre)) q = q.Where(g => g.Genre == query.Genre);
if (query.Own is { } own) q = q.Where(g => g.Own == own);
if (query.Dumped is { } dumped) q = q.Where(g => g.Dumped == dumped);
if (query.Played is { } played) q = q.Where(g => g.Played == played);
if (query.Finished is { } finished) q = q.Where(g => g.Finished == finished);
var total = await q.CountAsync(ct);
q = ApplySort(q, query.Sort, query.Dir);
var items = await q
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.ToListAsync(ct);
return Ok(new PagedResult<GameResponse>(
items.Select(g => ToResponse(g, ownerId)).ToList(),
query.Page,
query.PageSize,
total));
}
[HttpGet("{id:int}")]
public async Task<ActionResult<GameResponse>> Get(int id, CancellationToken ct)
{
var ownerId = User.GetUserId();
var game = await db.Games.AsNoTracking()
.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
// A game belonging to someone else is reported as 404, not 403 — the
// response should not confirm that the id exists.
return game is null ? NotFound() : Ok(ToResponse(game, ownerId));
}
[HttpGet("facets")]
public async Task<ActionResult<FacetsResponse>> Facets(CancellationToken ct)
{
var ownerId = User.GetUserId();
var mine = db.Games.AsNoTracking().Where(g => g.OwnerId == ownerId);
var systems = await mine
.Where(g => g.System != null && g.System != "")
.Select(g => g.System!)
.Distinct().OrderBy(s => s).ToListAsync(ct);
var genres = await mine
.Where(g => g.Genre != null && g.Genre != "")
.Select(g => g.Genre!)
.Distinct().OrderBy(s => s).ToListAsync(ct);
return Ok(new FacetsResponse(systems, genres));
}
[HttpPost]
public async Task<ActionResult<GameResponse>> Create(GameRequest request, CancellationToken ct)
{
var ownerId = User.GetUserId();
var game = new Game { OwnerId = ownerId };
Apply(request, game);
db.Games.Add(game);
await db.SaveChangesAsync(ct);
logger.LogInformation("User {OwnerId} created game {GameId}", ownerId, game.Id);
return CreatedAtAction(nameof(Get), new { id = game.Id }, ToResponse(game, ownerId));
}
[HttpPut("{id:int}")]
public async Task<ActionResult<GameResponse>> Update(int id, GameRequest request, CancellationToken ct)
{
var ownerId = User.GetUserId();
var game = await db.Games.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
if (game is null) return NotFound();
Apply(request, game);
await db.SaveChangesAsync(ct);
return Ok(ToResponse(game, ownerId));
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var ownerId = User.GetUserId();
var game = await db.Games.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
if (game is null) return NotFound();
db.Games.Remove(game);
await db.SaveChangesAsync(ct);
logger.LogInformation("User {OwnerId} deleted game {GameId}", ownerId, id);
return NoContent();
}
private static IQueryable<Game> ApplySort(IQueryable<Game> q, string sort, string dir)
{
var descending = string.Equals(dir, "desc", StringComparison.OrdinalIgnoreCase);
// Allow-list rather than reflecting over the string, so the sort parameter
// cannot reach the query shape in any way the API does not define.
return (sort?.ToLowerInvariant()) switch
{
"system" => descending ? q.OrderByDescending(g => g.System) : q.OrderBy(g => g.System),
"genre" => descending ? q.OrderByDescending(g => g.Genre) : q.OrderBy(g => g.Genre),
"year" => descending ? q.OrderByDescending(g => g.Year) : q.OrderBy(g => g.Year),
"developer" => descending ? q.OrderByDescending(g => g.Developer) : q.OrderBy(g => g.Developer),
"publisher" => descending ? q.OrderByDescending(g => g.Publisher) : q.OrderBy(g => g.Publisher),
"created" => descending ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt),
"updated" => descending ? q.OrderByDescending(g => g.UpdatedAt) : q.OrderBy(g => g.UpdatedAt),
_ => descending ? q.OrderByDescending(g => g.Title) : q.OrderBy(g => g.Title),
};
}
private static void Apply(GameRequest request, Game game)
{
game.Title = request.Title.Trim();
game.System = request.System?.Trim();
game.Genre = request.Genre?.Trim();
game.Year = request.Year?.Trim();
game.Developer = request.Developer?.Trim();
game.Publisher = request.Publisher?.Trim();
game.Art = request.Art?.Trim();
game.Description = request.Description;
game.Own = request.Own;
game.Dumped = request.Dumped;
game.Played = request.Played;
game.Finished = request.Finished;
}
private GameResponse ToResponse(Game g, string ownerId) => new(
g.Id, g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher,
g.Art, images.BuildUrl(ownerId, g.Art), g.Description,
g.Own, g.Dumped, g.Played, g.Finished, g.CreatedAt, g.UpdatedAt);
}
@@ -0,0 +1,63 @@
using LudosData.Api.Auth;
using LudosData.Api.Contracts;
using LudosData.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace LudosData.Api.Controllers;
/// <summary>
/// Box art uploads.
///
/// The PHP original accepted anonymous uploads, wrote every file into one hardcoded
/// "ckoch" folder, and built the destination path from the client-supplied filename.
/// This requires authentication, files land in the caller's own folder, and the
/// stored name is generated server-side.
/// </summary>
[ApiController]
[Route("api/images")]
[Authorize]
public class ImagesController(
IImageStorage images,
IOptions<ImageStorageOptions> options,
ILogger<ImagesController> logger) : ControllerBase
{
private readonly ImageStorageOptions _options = options.Value;
[HttpPost]
[RequestSizeLimit(6 * 1024 * 1024)]
public async Task<ActionResult<UploadResponse>> Upload(IFormFile file, CancellationToken ct)
{
if (file is null || file.Length == 0)
{
return BadRequest(new ProblemDetails { Title = "No file was uploaded." });
}
if (file.Length > _options.MaxBytes)
{
return BadRequest(new ProblemDetails
{
Title = $"File is larger than the {_options.MaxBytes / (1024 * 1024)} MB limit.",
});
}
var ownerId = User.GetUserId();
try
{
await using var stream = file.OpenReadStream();
var fileName = await images.SaveAsync(stream, ownerId, ct);
return Ok(new UploadResponse(fileName, images.BuildUrl(ownerId, fileName)!));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// The most common cause is bytes that are not a decodable image. The
// detail is logged but not returned, so probing does not reveal the
// internals of the decoder.
logger.LogWarning(ex, "Rejected upload from user {OwnerId}", ownerId);
return BadRequest(new ProblemDetails { Title = "The file could not be read as an image." });
}
}
}
+136
View File
@@ -0,0 +1,136 @@
using System.Text.Json;
using LudosData.Api.Domain;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace LudosData.Api.Data;
public class SeedOptions
{
public const string SectionName = "Seed";
/// <summary>When false, migrations still run but no user or games are created.</summary>
public bool Enabled { get; set; } = true;
public string UserName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// Applies migrations and, on a genuinely empty database, creates the initial user
/// and imports the 105 games recovered from the 2018 MySQL dump.
///
/// The dump predates the multi-user work, so it has no users table and no per-game
/// owner: every imported game is assigned to the seed user.
/// </summary>
public static class DbSeeder
{
public static async Task MigrateAndSeedAsync(IServiceProvider services, CancellationToken ct = default)
{
using var scope = services.CreateScope();
var sp = scope.ServiceProvider;
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("DbSeeder");
var db = sp.GetRequiredService<LudosDbContext>();
await db.Database.MigrateAsync(ct);
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeedOptions>>().Value;
if (!options.Enabled)
{
logger.LogInformation("Seeding disabled; skipping.");
return;
}
if (await db.Users.AnyAsync(ct))
{
logger.LogInformation("Database already has users; skipping seed.");
return;
}
if (string.IsNullOrWhiteSpace(options.UserName) || string.IsNullOrWhiteSpace(options.Password))
{
logger.LogWarning(
"Seeding is enabled but Seed:UserName / Seed:Password are not set, so no initial user was " +
"created and the {Count} games from the 2018 dump were not imported. Set SEED__USERNAME, " +
"SEED__EMAIL and SEED__PASSWORD and restart, or register a user and import manually.",
await CountSeedGamesAsync(ct));
return;
}
var userManager = sp.GetRequiredService<UserManager<AppUser>>();
var user = new AppUser
{
UserName = options.UserName,
Email = string.IsNullOrWhiteSpace(options.Email) ? $"{options.UserName}@localhost" : options.Email,
EmailConfirmed = true,
};
var created = await userManager.CreateAsync(user, options.Password);
if (!created.Succeeded)
{
var errors = string.Join("; ", created.Errors.Select(e => e.Description));
logger.LogError("Could not create the seed user: {Errors}", errors);
return;
}
var games = await LoadSeedGamesAsync(ct);
foreach (var game in games)
{
game.OwnerId = user.Id;
db.Games.Add(game);
}
await db.SaveChangesAsync(ct);
logger.LogInformation(
"Seeded user {UserName} with {Count} games from the 2018 dump.", user.UserName, games.Count);
}
private static async Task<List<Game>> LoadSeedGamesAsync(CancellationToken ct)
{
var path = Path.Combine(AppContext.BaseDirectory, "Data", "Seed", "games.json");
if (!File.Exists(path))
{
return [];
}
await using var stream = File.OpenRead(path);
var records = await JsonSerializer.DeserializeAsync<List<SeedGame>>(
stream, new JsonSerializerOptions(JsonSerializerDefaults.Web), ct) ?? [];
return records.Select(r => new Game
{
Title = r.Title,
System = r.System,
Genre = r.Genre,
Year = r.Year,
Developer = r.Developer,
Publisher = r.Publisher,
Art = r.Art,
Description = r.Description,
Own = r.Own,
Dumped = r.Dumped,
Played = r.Played,
Finished = r.Finished,
}).ToList();
}
private static async Task<int> CountSeedGamesAsync(CancellationToken ct) =>
(await LoadSeedGamesAsync(ct)).Count;
private sealed record SeedGame(
string Title,
string? System,
string? Genre,
string? Year,
string? Developer,
string? Publisher,
string? Art,
string? Description,
bool Own,
bool Dumped,
bool Played,
bool Finished);
}
@@ -0,0 +1,60 @@
using LudosData.Api.Domain;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace LudosData.Api.Data;
public class LudosDbContext(DbContextOptions<LudosDbContext> options)
: IdentityDbContext<AppUser>(options)
{
public DbSet<Game> Games => Set<Game>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<Game>(game =>
{
game.HasOne(g => g.Owner)
.WithMany(u => u.Games)
.HasForeignKey(g => g.OwnerId)
.OnDelete(DeleteBehavior.Cascade);
// Every list query filters by owner first, then narrows or sorts on
// these columns, so they lead the composite indexes.
game.HasIndex(g => new { g.OwnerId, g.Title });
game.HasIndex(g => new { g.OwnerId, g.System });
game.HasIndex(g => new { g.OwnerId, g.Genre });
});
}
public override int SaveChanges()
{
StampTimestamps();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
StampTimestamps();
return base.SaveChangesAsync(cancellationToken);
}
private void StampTimestamps()
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in ChangeTracker.Entries<Game>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
}
}
}
}
@@ -0,0 +1,367 @@
// <auto-generated />
using System;
using LudosData.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
[DbContext(typeof(LudosDbContext))]
[Migration("20260803220157_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("Developer")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("Dumped")
.HasColumnType("INTEGER");
b.Property<bool>("Finished")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("Own")
.HasColumnType("INTEGER");
b.Property<string>("OwnerId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Played")
.HasColumnType("INTEGER");
b.Property<string>("Publisher")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("System")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Year")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerId", "Genre");
b.HasIndex("OwnerId", "System");
b.HasIndex("OwnerId", "Title");
b.ToTable("Games");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", "Owner")
.WithMany("Games")
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Navigation("Games");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,277 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
FirstName = table.Column<string>(type: "TEXT", nullable: true),
LastName = table.Column<string>(type: "TEXT", nullable: true),
Art = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RoleId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
ProviderKey = table.Column<string>(type: "TEXT", nullable: false),
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
RoleId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Games",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
System = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
Genre = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
Year = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
Developer = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
Publisher = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
Art = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
Description = table.Column<string>(type: "TEXT", nullable: true),
Own = table.Column<bool>(type: "INTEGER", nullable: false),
Dumped = table.Column<bool>(type: "INTEGER", nullable: false),
Played = table.Column<bool>(type: "INTEGER", nullable: false),
Finished = table.Column<bool>(type: "INTEGER", nullable: false),
OwnerId = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Games", x => x.Id);
table.ForeignKey(
name: "FK_Games_AspNetUsers_OwnerId",
column: x => x.OwnerId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Games_OwnerId_Genre",
table: "Games",
columns: new[] { "OwnerId", "Genre" });
migrationBuilder.CreateIndex(
name: "IX_Games_OwnerId_System",
table: "Games",
columns: new[] { "OwnerId", "System" });
migrationBuilder.CreateIndex(
name: "IX_Games_OwnerId_Title",
table: "Games",
columns: new[] { "OwnerId", "Title" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "Games");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}
@@ -0,0 +1,364 @@
// <auto-generated />
using System;
using LudosData.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
[DbContext(typeof(LudosDbContext))]
partial class LudosDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("Developer")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("Dumped")
.HasColumnType("INTEGER");
b.Property<bool>("Finished")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("Own")
.HasColumnType("INTEGER");
b.Property<string>("OwnerId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Played")
.HasColumnType("INTEGER");
b.Property<string>("Publisher")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("System")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Year")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerId", "Genre");
b.HasIndex("OwnerId", "System");
b.HasIndex("OwnerId", "Title");
b.ToTable("Games");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", "Owner")
.WithMany("Games")
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Navigation("Games");
});
#pragma warning restore 612, 618
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Identity;
namespace LudosData.Api.Domain;
/// <summary>
/// Application user. Extends IdentityUser, which supplies Id, UserName, Email,
/// PasswordHash (PBKDF2 with a per-user salt), lockout and security stamp.
/// </summary>
public class AppUser : IdentityUser
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
/// <summary>Filename of the user's avatar, relative to their upload folder.</summary>
public string? Art { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public ICollection<Game> Games { get; set; } = new List<Game>();
}
+60
View File
@@ -0,0 +1,60 @@
using System.ComponentModel.DataAnnotations;
namespace LudosData.Api.Domain;
/// <summary>
/// A single entry in a user's game library. Mirrors the columns of the original
/// MySQL `games` table so the 2018 dump imports without transformation, with the
/// addition of ownership and audit fields.
/// </summary>
public class Game
{
public int Id { get; set; }
[Required]
[MaxLength(200)]
public string Title { get; set; } = string.Empty;
/// <summary>Console/platform the game runs on, e.g. "SNES", "PS2".</summary>
[MaxLength(50)]
public string? System { get; set; }
[MaxLength(50)]
public string? Genre { get; set; }
/// <summary>
/// Release year. Kept as a string rather than an int: the original column was
/// varchar(50) and holds values like "" and "1996" — some entries were never
/// filled in, and a few real-world cases want ranges.
/// </summary>
[MaxLength(50)]
public string? Year { get; set; }
[MaxLength(100)]
public string? Developer { get; set; }
[MaxLength(100)]
public string? Publisher { get; set; }
/// <summary>Filename of the uploaded box art, relative to the owner's upload folder.</summary>
[MaxLength(200)]
public string? Art { get; set; }
public string? Description { get; set; }
public bool Own { get; set; }
public bool Dumped { get; set; }
public bool Played { get; set; }
public bool Finished { get; set; }
/// <summary>
/// Owning user. Every query is filtered on this server-side, from the JWT subject —
/// it is never accepted from the client.
/// </summary>
[Required]
public string OwnerId { get; set; } = string.Empty;
public AppUser? Owner { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="SkiaSharp" Version="4.151.0" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.151.0" />
</ItemGroup>
<ItemGroup>
<!-- Transitive pins that lift two high-severity advisories out of the graph.
Both stay within the same major version the framework packages expect.
GHSA-v5pm-xwqc-g5wc: Microsoft.AspNetCore.OpenApi 10.0.10 pulls
Microsoft.OpenApi 2.0.0; the fix landed in 2.7.5.
GHSA-2m69-gcr7-jv3q: EF Core's SQLite provider pulls lib.e_sqlite3
2.1.11, which bundles a vulnerable SQLite; 2.1.12 is outside the range. -->
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
</ItemGroup>
<ItemGroup>
<!-- The 105 games recovered from the 2018 MySQL dump, read by DbSeeder at startup.
Update, not Include: the SDK already globs JSON files in as Content. -->
<Content Update="Data\Seed\games.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+168
View File
@@ -0,0 +1,168 @@
using System.Text;
using LudosData.Api.Auth;
using LudosData.Api.Data;
using LudosData.Api.Domain;
using LudosData.Api.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
// ---------------------------------------------------------------------------
// Options
// ---------------------------------------------------------------------------
builder.Services.AddOptions<JwtOptions>()
.Bind(builder.Configuration.GetSection(JwtOptions.SectionName))
.ValidateDataAnnotations()
// Validating on start means a deployment with a missing or too-short signing
// key fails immediately and loudly, instead of issuing weak tokens.
.ValidateOnStart();
builder.Services.Configure<ImageStorageOptions>(
builder.Configuration.GetSection(ImageStorageOptions.SectionName));
builder.Services.Configure<SeedOptions>(
builder.Configuration.GetSection(SeedOptions.SectionName));
// ---------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------
var connectionString = builder.Configuration.GetConnectionString("Default")
?? "Data Source=data/ludos.db";
// Make sure the SQLite file's directory exists before EF tries to open it.
var dataSource = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder(connectionString).DataSource;
var dataDirectory = Path.GetDirectoryName(Path.GetFullPath(dataSource));
if (!string.IsNullOrEmpty(dataDirectory))
{
Directory.CreateDirectory(dataDirectory);
}
builder.Services.AddDbContext<LudosDbContext>(options => options.UseSqlite(connectionString));
// Keep Data Protection keys on the same persistent volume as the database.
// Without this they live in the container filesystem and are regenerated on
// every restart, which silently invalidates Identity-issued tokens such as
// password-reset and email-confirmation links.
var keysDirectory = builder.Configuration["DataProtection:KeysPath"]
?? Path.Combine(dataDirectory ?? ".", "keys");
Directory.CreateDirectory(keysDirectory);
builder.Services
.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keysDirectory))
.SetApplicationName("LudosData");
// ---------------------------------------------------------------------------
// Identity + JWT
// ---------------------------------------------------------------------------
builder.Services
.AddIdentityCore<AppUser>(options =>
{
options.User.RequireUniqueEmail = true;
options.Password.RequiredLength = 12;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 10;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
})
.AddSignInManager()
.AddEntityFrameworkStores<LudosDbContext>();
var jwtSection = builder.Configuration.GetSection(JwtOptions.SectionName);
var signingKey = jwtSection["Key"] ?? string.Empty;
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 = jwtSection["Issuer"] ?? "LudosData",
ValidAudience = jwtSection["Audience"] ?? "LudosData",
// A real key is required by JwtOptions validation on start; this
// placeholder only exists so DI can build before that check runs.
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(signingKey.Length >= 32 ? signingKey : new string('0', 32))),
ClockSkew = TimeSpan.FromMinutes(1),
};
});
builder.Services.AddAuthorization();
// ---------------------------------------------------------------------------
// Application services
// ---------------------------------------------------------------------------
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddSingleton<IImageStorage, ImageStorage>();
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
const string SpaCorsPolicy = "spa";
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
?? ["http://localhost:4200"];
builder.Services.AddCors(options => options.AddPolicy(SpaCorsPolicy, policy => policy
// Explicit origins, not AllowAnyOrigin. The old API sent
// `Access-Control-Allow-Origin: *` on every response.
.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()));
builder.Services.AddHealthChecks();
var app = builder.Build();
// ---------------------------------------------------------------------------
// Pipeline
// ---------------------------------------------------------------------------
app.UseExceptionHandler();
app.UseStatusCodePages();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseCors(SpaCorsPolicy);
// Serve uploaded box art from the configured folder (a Docker volume in
// production) rather than from wwwroot, so user content and app files stay apart.
var uploadOptions = app.Services.GetRequiredService<IOptions<ImageStorageOptions>>().Value;
var uploadRoot = Path.GetFullPath(uploadOptions.RootPath);
Directory.CreateDirectory(uploadRoot);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(uploadRoot),
RequestPath = uploadOptions.RequestPath,
ServeUnknownFileTypes = false,
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health").AllowAnonymous();
await DbSeeder.MigrateAndSeedAsync(app.Services);
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5044",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7008;http://localhost:5044",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,115 @@
using Microsoft.Extensions.Options;
using SkiaSharp;
namespace LudosData.Api.Services;
public interface IImageStorage
{
Task<string> SaveAsync(Stream source, string ownerId, CancellationToken ct = default);
string? BuildUrl(string ownerId, string? fileName);
}
public class ImageStorageOptions
{
public const string SectionName = "Uploads";
/// <summary>Filesystem root for uploads. In Docker this is a mounted volume.</summary>
public string RootPath { get; set; } = "uploads";
/// <summary>Public URL prefix these files are served under.</summary>
public string RequestPath { get; set; } = "/uploads";
/// <summary>Max accepted upload size. Enforced again at the endpoint.</summary>
public long MaxBytes { get; set; } = 5 * 1024 * 1024;
/// <summary>Stored images are downscaled to at most this width, preserving aspect.</summary>
public int MaxWidth { get; set; } = 500;
/// <summary>WebP quality, 1-100.</summary>
public int Quality { get; set; } = 82;
}
/// <summary>
/// Stores box art on disk as WebP, re-encoded from whatever was uploaded.
///
/// Unlike the PHP version this replaces, the uploaded filename is never used to
/// build the destination path — the name is generated server-side and the
/// extension is fixed, so a crafted filename cannot traverse directories or land
/// an executable in a served folder. Decoding the bytes and re-encoding them also
/// means only pixel data survives: any payload smuggled in metadata is dropped.
/// </summary>
public class ImageStorage(
IOptions<ImageStorageOptions> options,
ILogger<ImageStorage> logger) : IImageStorage
{
private readonly ImageStorageOptions _options = options.Value;
public async Task<string> SaveAsync(Stream source, string ownerId, CancellationToken ct = default)
{
// Buffer first: SKBitmap.Decode wants a seekable stream, and the caller's
// request stream is not. The endpoint has already bounded the length.
using var buffer = new MemoryStream();
await source.CopyToAsync(buffer, ct);
buffer.Position = 0;
// Decoding is the real content check — anything Skia cannot parse as an
// image returns null here, before a byte is persisted.
using var decoded = SKBitmap.Decode(buffer)
?? throw new InvalidDataException("The uploaded bytes are not a decodable image.");
using var final = Downscale(decoded);
var directory = DirectoryFor(ownerId);
Directory.CreateDirectory(directory);
var fileName = $"{Guid.NewGuid():N}.webp";
var fullPath = Path.Combine(directory, fileName);
using (var image = SKImage.FromBitmap(final))
using (var data = image.Encode(SKEncodedImageFormat.Webp, _options.Quality))
{
if (data is null)
{
throw new InvalidDataException("The image could not be encoded as WebP.");
}
await using var output = File.Create(fullPath);
data.SaveTo(output);
}
logger.LogInformation("Stored upload {FileName} for user {OwnerId}", fileName, ownerId);
return fileName;
}
public string? BuildUrl(string ownerId, string? fileName) =>
string.IsNullOrWhiteSpace(fileName)
? null
: $"{_options.RequestPath}/{ownerId}/{fileName}";
private SKBitmap Downscale(SKBitmap source)
{
if (source.Width <= _options.MaxWidth)
{
return source.Copy();
}
var height = (int)Math.Round(source.Height * (_options.MaxWidth / (double)source.Width));
var info = new SKImageInfo(_options.MaxWidth, Math.Max(1, height));
return source.Resize(info, new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear))
?? throw new InvalidDataException("The image could not be resized.");
}
private string DirectoryFor(string ownerId)
{
// ownerId is an Identity-generated GUID string, but this is defence in
// depth: only the bare filename component is ever joined onto the root.
var safeOwner = Path.GetFileName(ownerId);
if (string.IsNullOrWhiteSpace(safeOwner))
{
throw new ArgumentException("Invalid owner id.", nameof(ownerId));
}
return Path.Combine(Path.GetFullPath(_options.RootPath), safeOwner);
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,29 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Data Source=data/ludos.db"
},
"Jwt": {
"Issuer": "LudosData",
"Audience": "LudosData",
"LifetimeMinutes": 720
},
"Uploads": {
"RootPath": "uploads",
"RequestPath": "/uploads",
"MaxBytes": 5242880,
"MaxWidth": 500
},
"Cors": {
"AllowedOrigins": [ "http://localhost:4200", "http://localhost:8080" ]
},
"Seed": {
"Enabled": true
}
}