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:
@@ -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." });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+367
@@ -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>();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user