diff --git a/ComicsContext.cs b/ComicsContext.cs index d32b80b..feb9b08 100644 --- a/ComicsContext.cs +++ b/ComicsContext.cs @@ -1,81 +1,80 @@ using Microsoft.EntityFrameworkCore; using ComiServ.Entities; -namespace ComiServ -{ - public class ComicsContext : DbContext - { - //TODO is this the best place for this to live? - public const int HANDLE_LENGTH = 12; - //relies on low probability of repeat handles in a short period of time - //duplicate handles could be created before either of them are commited - public string CreateHandle() - { - char ToChar(int i) - { - if (i < 10) - return (char)('0' + i); - if (i - 10 + 'A' < 'O') - return (char)('A' + i - 10); - else - //skip 'O' - return (char)('A' + i - 9); - } - string handle = ""; - do - { - handle = string.Join("", Enumerable.Repeat(0, HANDLE_LENGTH) - .Select(_ => ToChar(Random.Shared.Next(0, 35)))); - } while (Comics.Any(c => c.Handle == handle)); - return handle; - } - public DbSet Comics { get; set; } - public DbSet ComicTags { get; set; } - public DbSet Tags { get; set; } - public DbSet ComicAuthors { get; set; } - public DbSet Authors { get; set; } - public DbSet Covers { get; set; } - public DbSet Users { get; set; } - public DbSet UserTypes { get; set; } - public DbSet ComicsRead { get; set; } - public ComicsContext(DbContextOptions options) - : base(options) - { +namespace ComiServ; - } - protected override void OnModelCreating(ModelBuilder modelBuilder) +public class ComicsContext : DbContext +{ + //TODO is this the best place for this to live? + public const int HANDLE_LENGTH = 12; + //relies on low probability of repeat handles in a short period of time + //duplicate handles could be created before either of them are commited + public string CreateHandle() + { + char ToChar(int i) { - modelBuilder.Entity().ToTable("Comics"); - modelBuilder.Entity().ToTable("ComicTags"); - modelBuilder.Entity().ToTable("Tags"); - modelBuilder.Entity().ToTable("ComicAuthors"); - modelBuilder.Entity().ToTable("Authors"); - modelBuilder.Entity().ToTable("Covers"); - modelBuilder.Entity().ToTable("Users"); - modelBuilder.Entity().ToTable("UserTypes") - .HasData( - Enum.GetValues(typeof(UserTypeEnum)) - .Cast() - .Select(e => new UserType() - { - Id = e, - Name = e.ToString() - }) - ); + if (i < 10) + return (char)('0' + i); + if (i - 10 + 'A' < 'O') + return (char)('A' + i - 10); + else + //skip 'O' + return (char)('A' + i - 9); } - /// - /// puts a user-provided handle into the proper form - /// - /// - /// formatted handle or null if invalid - public static string? CleanValidateHandle(string? handle) + string handle = ""; + do { - if (handle is null) - return null; - handle = handle.Trim(); - if (handle.Length != HANDLE_LENGTH) - return null; - return handle.ToUpper(); - } + handle = string.Join("", Enumerable.Repeat(0, HANDLE_LENGTH) + .Select(_ => ToChar(Random.Shared.Next(0, 35)))); + } while (Comics.Any(c => c.Handle == handle)); + return handle; + } + public DbSet Comics { get; set; } + public DbSet ComicTags { get; set; } + public DbSet Tags { get; set; } + public DbSet ComicAuthors { get; set; } + public DbSet Authors { get; set; } + public DbSet Covers { get; set; } + public DbSet Users { get; set; } + public DbSet UserTypes { get; set; } + public DbSet ComicsRead { get; set; } + public ComicsContext(DbContextOptions options) + : base(options) + { + + } + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().ToTable("Comics"); + modelBuilder.Entity().ToTable("ComicTags"); + modelBuilder.Entity().ToTable("Tags"); + modelBuilder.Entity().ToTable("ComicAuthors"); + modelBuilder.Entity().ToTable("Authors"); + modelBuilder.Entity().ToTable("Covers"); + modelBuilder.Entity().ToTable("Users"); + modelBuilder.Entity().ToTable("UserTypes") + .HasData( + Enum.GetValues(typeof(UserTypeEnum)) + .Cast() + .Select(e => new UserType() + { + Id = e, + Name = e.ToString() + }) + ); + } + /// + /// puts a user-provided handle into the proper form + /// + /// + /// formatted handle or null if invalid + public static string? CleanValidateHandle(string? handle) + { + if (handle is null) + return null; + handle = handle.Trim(); + if (handle.Length != HANDLE_LENGTH) + return null; + return handle.ToUpper(); } } diff --git a/Controllers/ComicController.cs b/Controllers/ComicController.cs index cab323b..c714e2f 100644 --- a/Controllers/ComicController.cs +++ b/Controllers/ComicController.cs @@ -12,464 +12,465 @@ using ComiServ.Extensions; using System.Runtime.InteropServices; using ComiServ.Services; using System.Security.Cryptography.X509Certificates; +using System.Data; using SQLitePCL; -namespace ComiServ.Controllers +namespace ComiServ.Controllers; + +[Route(ROUTE)] +[ApiController] +public class ComicController(ComicsContext context, ILogger logger, IConfigService config, IComicAnalyzer analyzer, IPictureConverter converter, IAuthenticationService _auth) + : ControllerBase { - [Route(ROUTE)] - [ApiController] - public class ComicController(ComicsContext context, ILogger logger, IConfigService config, IComicAnalyzer analyzer, IPictureConverter converter, IAuthenticationService _auth) - : ControllerBase + public const string ROUTE = "/api/v1/comics"; + private readonly ComicsContext _context = context; + private readonly ILogger _logger = logger; + private readonly Configuration _config = config.Config; + private readonly IComicAnalyzer _analyzer = analyzer; + private readonly IPictureConverter _converter = converter; + private readonly IAuthenticationService _auth = _auth; + //TODO search parameters + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task SearchComics( + [FromQuery(Name = "TitleSearch")] + string? titleSearch, + [FromQuery(Name = "DescriptionSearch")] + string? descSearch, + [FromQuery] + string[] authors, + [FromQuery] + string[] tags, + [FromQuery] + string? pages, + [FromQuery] + string? xxhash64Hex, + [FromQuery] + bool? exists, + [FromQuery] + [DefaultValue(null)] + bool? read, + [FromQuery] + [DefaultValue(0)] + int page, + [FromQuery] + [DefaultValue(20)] + int pageSize + ) { - public const string ROUTE = "/api/v1/comics"; - private readonly ComicsContext _context = context; - private readonly ILogger _logger = logger; - private readonly Configuration _config = config.Config; - private readonly IComicAnalyzer _analyzer = analyzer; - private readonly IPictureConverter _converter = converter; - private readonly IAuthenticationService _auth = _auth; - //TODO search parameters - [HttpGet] - [ProducesResponseType>(StatusCodes.Status200OK)] - public IActionResult SearchComics( - [FromQuery(Name = "TitleSearch")] - string? titleSearch, - [FromQuery(Name = "DescriptionSearch")] - string? descSearch, - [FromQuery] - string[] authors, - [FromQuery] - string[] tags, - [FromQuery] - string? pages, - [FromQuery] - string? xxhash64Hex, - [FromQuery] - bool? exists, - [FromQuery] - [DefaultValue(null)] - bool? read, - [FromQuery] - [DefaultValue(0)] - int page, - [FromQuery] - [DefaultValue(20)] - int pageSize - ) + var results = _context.Comics + .Include("ComicAuthors.Author") + .Include("ComicTags.Tag"); + if (exists is not null) { - var results = _context.Comics - .Include("ComicAuthors.Author") - .Include("ComicTags.Tag"); - if (exists is not null) - { - results = results.Where(c => c.Exists == exists); - } - string username; - if (_auth.User is null) - { - return Unauthorized(RequestError.NotAuthenticated); - } - if (read is bool readStatus) - { - if (readStatus) - results = results.Where(c => c.ReadBy.Any(u => EF.Functions.Like(_auth.User.Username, u.User.Username))); - else - results = results.Where(c => c.ReadBy.All(u => !EF.Functions.Like(_auth.User.Username, u.User.Username))); - } - foreach (var author in authors) - { - results = results.Where(c => c.ComicAuthors.Any(ca => EF.Functions.Like(ca.Author.Name, author))); - } - foreach (var tag in tags) - { - results = results.Where(c => c.ComicTags.Any(ct => EF.Functions.Like(ct.Tag.Name, tag))); - } - if (pages is not null) - { - pages = pages.Trim(); - if (pages.StartsWith("<=")) - { - var pageMax = int.Parse(pages.Substring(2)); - results = results.Where(c => c.PageCount <= pageMax); - } - else if (pages.StartsWith('<')) - { - var pageMax = int.Parse(pages.Substring(1)); - results = results.Where(c => c.PageCount < pageMax); - } - else if (pages.StartsWith(">=")) - { - var pageMin = int.Parse(pages.Substring(2)); - results = results.Where(c => c.PageCount >= pageMin); - } - else if (pages.StartsWith('>')) - { - var pageMin = int.Parse(pages.Substring(1)); - results = results.Where(c => c.PageCount > pageMin); - } - else - { - if (pages.StartsWith('=')) - pages = pages.Substring(1); - var pageExact = int.Parse(pages); - results = results.Where(c => c.PageCount == pageExact); - } - } - if (xxhash64Hex is not null) - { - xxhash64Hex = xxhash64Hex.Trim().ToUpper(); - if (!xxhash64Hex.All(c => (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) - return BadRequest(); - Int64 hash = 0; - foreach (char c in xxhash64Hex) - { - if (c >= '0' && c <= '9') - hash = hash * 16 + (c - '0'); - else if (c >= 'A' && c <= 'F') - hash = hash * 16 + (c - 'A' + 10); - else - throw new ArgumentException("Invalid hex character bypassed filter"); - } - results = results.Where(c => c.FileXxhash64 == hash); - } - if (titleSearch is not null) - { - titleSearch = titleSearch.Trim(); - results = results.Where(c => EF.Functions.Like(c.Title, $"%{titleSearch}%")); - } - if (descSearch is not null) - { - descSearch = descSearch.Trim(); - results = results.Where(c => EF.Functions.Like(c.Description, $"%{descSearch}%")); - } - int offset = page * pageSize; - return Ok(new Paginated(pageSize, page, results - .OrderBy(c => c.Id) - .Select(c => new ComicData(c)))); + results = results.Where(c => c.Exists == exists); } - [HttpDelete] - [ProducesResponseType(StatusCodes.Status200OK)] - public IActionResult DeleteComicsThatDontExist() + string username; + if (_auth.User is null) { - var search = _context.Comics.Where(c => !c.Exists); - var nonExtant = search.ToList(); - search.ExecuteDelete(); - _context.SaveChanges(); - return Ok(search.Select(c => new ComicData(c))); + return Unauthorized(RequestError.NotAuthenticated); } - [HttpGet("{handle}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult GetSingleComicInfo(string handle) + if (read is bool readStatus) { - //_logger.LogInformation("GetSingleComicInfo: {handle}", handle); - var validated = ComicsContext.CleanValidateHandle(handle); - if (validated is null) - return BadRequest(RequestError.InvalidHandle); - var comic = _context.Comics - .Include("ComicAuthors.Author") - .Include("ComicTags.Tag") - .SingleOrDefault(c => c.Handle == handle); - if (comic is Comic actualComic) - return Ok(new ComicData(comic)); + if (readStatus) + results = results.Where(c => c.ReadBy.Any(u => EF.Functions.Like(_auth.User.Username, u.User.Username))); else - return NotFound(RequestError.ComicNotFound); + results = results.Where(c => c.ReadBy.All(u => !EF.Functions.Like(_auth.User.Username, u.User.Username))); } - [HttpPatch("{handle}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult UpdateComicMetadata(string handle, [FromBody] ComicMetadataUpdateRequest metadata) + foreach (var author in authors) { - if (handle.Length != ComicsContext.HANDLE_LENGTH) - return BadRequest(RequestError.InvalidHandle); - var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle); - if (comic is Comic actualComic) + results = results.Where(c => c.ComicAuthors.Any(ca => EF.Functions.Like(ca.Author.Name, author))); + } + foreach (var tag in tags) + { + results = results.Where(c => c.ComicTags.Any(ct => EF.Functions.Like(ct.Tag.Name, tag))); + } + if (pages is not null) + { + pages = pages.Trim(); + if (pages.StartsWith("<=")) { - if (metadata.Title != null) - actualComic.Title = metadata.Title; - if (metadata.Authors is List authors) - { - //make sure all authors exist, without changing Id of pre-existing authors - _context.InsertOrIgnore(authors.Select(author => new Author() { Name = author }), ignorePrimaryKey: true); - //get the Id of needed authors - var authorEntities = _context.Authors.Where(a => authors.Contains(a.Name)).ToList(); - //delete existing author mappings - _context.ComicAuthors.RemoveRange(_context.ComicAuthors.Where(ca => ca.Comic.Id == comic.Id)); - //add all author mappings - _context.ComicAuthors.AddRange(authorEntities.Select(a => new ComicAuthor { Comic = comic, Author = a })); - } - if (metadata.Tags is List tags) - { - //make sure all tags exist, without changing Id of pre-existing tags - _context.InsertOrIgnore(tags.Select(t => new Tag() { Name = t }), ignorePrimaryKey: true); - //get the needed tags - var tagEntities = _context.Tags.Where(t => tags.Contains(t.Name)).ToList(); - //delete existing tag mappings - _context.ComicTags.RemoveRange(_context.ComicTags.Where(ta => ta.Comic.Id == comic.Id)); - //add all tag mappings - _context.ComicTags.AddRange(tagEntities.Select(t => new ComicTag { Comic = comic, Tag = t })); - } - _context.SaveChanges(); - return Ok(); + var pageMax = int.Parse(pages.Substring(2)); + results = results.Where(c => c.PageCount <= pageMax); + } + else if (pages.StartsWith('<')) + { + var pageMax = int.Parse(pages.Substring(1)); + results = results.Where(c => c.PageCount < pageMax); + } + else if (pages.StartsWith(">=")) + { + var pageMin = int.Parse(pages.Substring(2)); + results = results.Where(c => c.PageCount >= pageMin); + } + else if (pages.StartsWith('>')) + { + var pageMin = int.Parse(pages.Substring(1)); + results = results.Where(c => c.PageCount > pageMin); } else - return NotFound(RequestError.ComicNotFound); - } - [HttpPatch("{handle}/markread")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public IActionResult MarkComicAsRead( - ComicsContext context, - string handle) - { - var validated = ComicsContext.CleanValidateHandle(handle); - if (validated is null) - return BadRequest(RequestError.InvalidHandle); - var comic = context.Comics.SingleOrDefault(c => c.Handle == validated); - if (comic is null) - return NotFound(RequestError.ComicNotFound); - if (_auth.User is null) - //user shouldn't have passed authentication if username doesn't match - return StatusCode(StatusCodes.Status500InternalServerError); - var comicRead = new ComicRead() { - UserId = _auth.User.Id, - ComicId = comic.Id + if (pages.StartsWith('=')) + pages = pages.Substring(1); + var pageExact = int.Parse(pages); + results = results.Where(c => c.PageCount == pageExact); + } + } + if (xxhash64Hex is not null) + { + xxhash64Hex = xxhash64Hex.Trim().ToUpper(); + if (!xxhash64Hex.All(c => (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) + return BadRequest(); + Int64 hash = 0; + foreach (char c in xxhash64Hex) + { + if (c >= '0' && c <= '9') + hash = hash * 16 + (c - '0'); + else if (c >= 'A' && c <= 'F') + hash = hash * 16 + (c - 'A' + 10); + else + throw new ArgumentException("Invalid hex character bypassed filter"); + } + results = results.Where(c => c.FileXxhash64 == hash); + } + if (titleSearch is not null) + { + titleSearch = titleSearch.Trim(); + results = results.Where(c => EF.Functions.Like(c.Title, $"%{titleSearch}%")); + } + if (descSearch is not null) + { + descSearch = descSearch.Trim(); + results = results.Where(c => EF.Functions.Like(c.Description, $"%{descSearch}%")); + } + int offset = page * pageSize; + return Ok(await Paginated.CreateAsync(pageSize, page, results + .OrderBy(c => c.Id) + .Select(c => new ComicData(c)))); + } + [HttpDelete] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task DeleteComicsThatDontExist() + { + var search = _context.Comics.Where(c => !c.Exists); + var nonExtant = await search.ToListAsync(); + search.ExecuteDelete(); + _context.SaveChanges(); + return Ok(search.Select(c => new ComicData(c))); + } + [HttpGet("{handle}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetSingleComicInfo(string handle) + { + //_logger.LogInformation("GetSingleComicInfo: {handle}", handle); + var validated = ComicsContext.CleanValidateHandle(handle); + if (validated is null) + return BadRequest(RequestError.InvalidHandle); + var comic = await _context.Comics + .Include("ComicAuthors.Author") + .Include("ComicTags.Tag") + .SingleOrDefaultAsync(c => c.Handle == handle); + if (comic is Comic actualComic) + return Ok(new ComicData(comic)); + else + return NotFound(RequestError.ComicNotFound); + } + [HttpPatch("{handle}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateComicMetadata(string handle, [FromBody] ComicMetadataUpdateRequest metadata) + { + if (handle.Length != ComicsContext.HANDLE_LENGTH) + return BadRequest(RequestError.InvalidHandle); + var comic = await _context.Comics.SingleOrDefaultAsync(c => c.Handle == handle); + if (comic is Comic actualComic) + { + if (metadata.Title != null) + actualComic.Title = metadata.Title; + if (metadata.Authors is List authors) + { + //make sure all authors exist, without changing Id of pre-existing authors + _context.InsertOrIgnore(authors.Select(author => new Author() { Name = author }), ignorePrimaryKey: true); + //get the Id of needed authors + var authorEntities = await _context.Authors.Where(a => authors.Contains(a.Name)).ToListAsync(); + //delete existing author mappings + _context.ComicAuthors.RemoveRange(_context.ComicAuthors.Where(ca => ca.Comic.Id == comic.Id)); + //add all author mappings + _context.ComicAuthors.AddRange(authorEntities.Select(a => new ComicAuthor { Comic = comic, Author = a })); + } + if (metadata.Tags is List tags) + { + //make sure all tags exist, without changing Id of pre-existing tags + _context.InsertOrIgnore(tags.Select(t => new Tag() { Name = t }), ignorePrimaryKey: true); + //get the needed tags + var tagEntities = await _context.Tags.Where(t => tags.Contains(t.Name)).ToListAsync(); + //delete existing tag mappings + _context.ComicTags.RemoveRange(_context.ComicTags.Where(ta => ta.Comic.Id == comic.Id)); + //add all tag mappings + _context.ComicTags.AddRange(tagEntities.Select(t => new ComicTag { Comic = comic, Tag = t })); + } + await _context.SaveChangesAsync(); + return Ok(); + } + else + return NotFound(RequestError.ComicNotFound); + } + [HttpPatch("{handle}/markread")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task MarkComicAsRead( + ComicsContext context, + string handle) + { + var validated = ComicsContext.CleanValidateHandle(handle); + if (validated is null) + return BadRequest(RequestError.InvalidHandle); + var comic = await context.Comics.SingleOrDefaultAsync(c => c.Handle == validated); + if (comic is null) + return NotFound(RequestError.ComicNotFound); + if (_auth.User is null) + //user shouldn't have passed authentication if username doesn't match + return StatusCode(StatusCodes.Status500InternalServerError); + var comicRead = new ComicRead() + { + UserId = _auth.User.Id, + ComicId = comic.Id + }; + context.InsertOrIgnore(comicRead, ignorePrimaryKey: false); + await context.SaveChangesAsync(); + return Ok(); + } + [HttpPatch("{handle}/markunread")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task MarkComicAsUnread( + ComicsContext context, + string handle) + { + var validated = ComicsContext.CleanValidateHandle(handle); + if (validated is null) + return BadRequest(RequestError.InvalidHandle); + var comic = await context.Comics.SingleOrDefaultAsync(c => c.Handle == validated); + if (comic is null) + return NotFound(RequestError.ComicNotFound); + if (_auth.User is null) + //user shouldn't have passed authentication if username doesn't match + return StatusCode(StatusCodes.Status500InternalServerError); + var comicRead = await context.ComicsRead.SingleOrDefaultAsync(cr => + cr.ComicId == comic.Id && cr.UserId == _auth.User.Id); + if (comicRead is null) + return Ok(); + context.ComicsRead.Remove(comicRead); + await context.SaveChangesAsync(); + return Ok(); + } + [HttpDelete("{handle}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteComic( + string handle, + [FromBody] + ComicDeleteRequest req) + { + if (_auth.User is null) + { + HttpContext.Response.Headers.WWWAuthenticate = "Basic"; + return Unauthorized(RequestError.NoAccess); + } + if (_auth.User.UserTypeId != UserTypeEnum.Administrator) + return Forbid(); + var comic = await _context.Comics.SingleOrDefaultAsync(c => c.Handle == handle); + if (comic is null) + return NotFound(RequestError.ComicNotFound); + comic.Exists = _analyzer.ComicFileExists(string.Join(config.Config.LibraryRoot, comic.Filepath)); + if (comic.Exists && !req.DeleteIfFileExists) + return BadRequest(RequestError.ComicFileExists); + _context.Comics.Remove(comic); + await _context.SaveChangesAsync(); + _analyzer.DeleteComicFile(string.Join(config.Config.LibraryRoot, comic.Filepath)); + return Ok(); + } + [HttpGet("{handle}/file")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetComicFile(string handle) + { + //_logger.LogInformation(nameof(GetComicFile) + ": {handle}", handle); + var validated = ComicsContext.CleanValidateHandle(handle); + if (validated is null) + return BadRequest(RequestError.InvalidHandle); + var comic = await _context.Comics.SingleOrDefaultAsync(c => c.Handle == handle); + if (comic is null) + return NotFound(RequestError.ComicNotFound); + var data = await System.IO.File.ReadAllBytesAsync(Path.Join(_config.LibraryRoot, comic.Filepath)); + return File(data, "application/octet-stream", new FileInfo(comic.Filepath).Name); + } + [HttpGet("{handle}/cover")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetComicCover(string handle) + { + //_logger.LogInformation(nameof(GetComicCover) + ": {handle}", handle); + var validated = ComicsContext.CleanValidateHandle(handle); + if (validated is null) + return BadRequest(RequestError.InvalidHandle); + var comic = await _context.Comics + .SingleOrDefaultAsync(c => c.Handle == validated); + if (comic is null) + return NotFound(RequestError.ComicNotFound); + var cover = await _context.Covers + .SingleOrDefaultAsync(cov => cov.FileXxhash64 == comic.FileXxhash64); + if (cover is null) + return NotFound(RequestError.CoverNotFound); + var mime = IComicAnalyzer.GetImageMime(cover.Filename); + if (mime is null) + return File(cover.CoverFile, "application/octet-stream", cover.Filename); + return File(cover.CoverFile, mime); + } + [HttpGet("{handle}/page/{page}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetComicPage(string handle, int page, + [FromQuery] + [DefaultValue(0)] + int? maxWidth, + [FromQuery] + [DefaultValue(0)] + int? maxHeight, + [FromQuery] + [DefaultValue(null)] + PictureFormats? format) + { + //_logger.LogInformation(nameof(GetComicPage) + ": {handle} {page}", handle, page); + var validated = ComicsContext.CleanValidateHandle(handle); + if (validated is null) + return BadRequest(RequestError.InvalidHandle); + var comic = await _context.Comics.SingleOrDefaultAsync(c => c.Handle == validated); + if (comic is null) + return NotFound(RequestError.ComicNotFound); + var comicPage = await _analyzer.GetComicPageAsync(Path.Join(_config.LibraryRoot, comic.Filepath), page); + if (comicPage is null) + //TODO rethink error code + return NotFound(RequestError.PageNotFound); + var limitWidth = maxWidth ?? -1; + var limitHeight = maxHeight ?? -1; + if (maxWidth > 0 || maxHeight > 0 || format is not null) + { + //TODO this copy is not strictly necessary, but avoiding it would mean keeping the comic file + //open after GetComicPage returns to keep the stream. Not unreasonable (that's what IDisposable + //is for) but need to be careful. + using var stream = new MemoryStream(comicPage.Data); + System.Drawing.Size limit = new( + limitWidth > 0 ? limitWidth : int.MaxValue, + limitHeight > 0 ? limitHeight : int.MaxValue + ); + string mime = format switch + { + PictureFormats f => IPictureConverter.GetMime(f), + null => comicPage.Mime, }; - context.InsertOrIgnore(comicRead, ignorePrimaryKey: false); - context.SaveChanges(); - return Ok(); + //TODO using the stream directly throws but I think it should be valid, need to debug + using var resizedStream = await _converter.ResizeIfBigger(stream, limit, format); + var arr = await resizedStream.ReadAllBytesAsync(); + return File(arr, mime); } - [HttpPatch("{handle}/markunread")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public IActionResult MarkComicAsUnread( - ComicsContext context, - string handle) + else + return File(comicPage.Data, comicPage.Mime); + } + [HttpGet("{handle}/thumbnail")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetComicThumbnail( + string handle, + [FromQuery] + [DefaultValue(false)] + //if thumbnail doesn't exist, try to find a cover + bool fallbackToCover) + { + RequestError accErrors = new(); + var validated = ComicsContext.CleanValidateHandle(handle); + if (validated is null) + return BadRequest(RequestError.InvalidHandle); + var comic = await _context.Comics.SingleOrDefaultAsync(c => c.Handle == validated); + if (comic is null) + return NotFound(RequestError.ComicNotFound); + if (comic.ThumbnailWebp is byte[] img) { - var validated = ComicsContext.CleanValidateHandle(handle); - if (validated is null) - return BadRequest(RequestError.InvalidHandle); - var comic = context.Comics.SingleOrDefault(c => c.Handle == validated); - if (comic is null) - return NotFound(RequestError.ComicNotFound); - if (_auth.User is null) - //user shouldn't have passed authentication if username doesn't match - return StatusCode(StatusCodes.Status500InternalServerError); - var comicRead = context.ComicsRead.SingleOrDefault(cr => - cr.ComicId == comic.Id && cr.UserId == _auth.User.Id); - if (comicRead is null) - return Ok(); - context.ComicsRead.Remove(comicRead); - context.SaveChanges(); - return Ok(); + return File(img, "application/webp"); } - [HttpDelete("{handle}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult DeleteComic( - string handle, - [FromBody] - ComicDeleteRequest req) + if (fallbackToCover) { - if (_auth.User is null) + var cover = await _context.Covers.SingleOrDefaultAsync(c => c.FileXxhash64 == comic.FileXxhash64); + if (cover is not null) { - HttpContext.Response.Headers.WWWAuthenticate = "Basic"; - return Unauthorized(RequestError.NoAccess); + //TODO should this convert to a thumbnail on the fly? + return File(cover.CoverFile, IComicAnalyzer.GetImageMime(cover.Filename) ?? "application/octet-stream"); } - if (_auth.User.UserTypeId != UserTypeEnum.Administrator) - return Forbid(); - var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle); - if (comic is null) - return NotFound(RequestError.ComicNotFound); - comic.Exists = _analyzer.ComicFileExists(string.Join(config.Config.LibraryRoot, comic.Filepath)); - if (comic.Exists && !req.DeleteIfFileExists) - return BadRequest(RequestError.ComicFileExists); - _context.Comics.Remove(comic); - _context.SaveChanges(); - _analyzer.DeleteComicFile(string.Join(config.Config.LibraryRoot, comic.Filepath)); - return Ok(); - } - [HttpGet("{handle}/file")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult GetComicFile(string handle) - { - //_logger.LogInformation(nameof(GetComicFile) + ": {handle}", handle); - var validated = ComicsContext.CleanValidateHandle(handle); - if (validated is null) - return BadRequest(RequestError.InvalidHandle); - var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle); - if (comic is null) - return NotFound(RequestError.ComicNotFound); - var data = System.IO.File.ReadAllBytes(Path.Join(_config.LibraryRoot, comic.Filepath)); - return File(data, "application/octet-stream", new FileInfo(comic.Filepath).Name); - } - [HttpGet("{handle}/cover")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult GetComicCover(string handle) - { - //_logger.LogInformation(nameof(GetComicCover) + ": {handle}", handle); - var validated = ComicsContext.CleanValidateHandle(handle); - if (validated is null) - return BadRequest(RequestError.InvalidHandle); - var comic = _context.Comics - .SingleOrDefault(c => c.Handle == validated); - if (comic is null) - return NotFound(RequestError.ComicNotFound); - var cover = _context.Covers - .SingleOrDefault(cov => cov.FileXxhash64 == comic.FileXxhash64); - if (cover is null) - return NotFound(RequestError.CoverNotFound); - var mime = IComicAnalyzer.GetImageMime(cover.Filename); - if (mime is null) - return File(cover.CoverFile, "application/octet-stream", cover.Filename); - return File(cover.CoverFile, mime); - } - [HttpGet("{handle}/page/{page}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult GetComicPage(string handle, int page, - [FromQuery] - [DefaultValue(0)] - int? maxWidth, - [FromQuery] - [DefaultValue(0)] - int? maxHeight, - [FromQuery] - [DefaultValue(null)] - PictureFormats? format) - { - //_logger.LogInformation(nameof(GetComicPage) + ": {handle} {page}", handle, page); - var validated = ComicsContext.CleanValidateHandle(handle); - if (validated is null) - return BadRequest(RequestError.InvalidHandle); - var comic = _context.Comics.SingleOrDefault(c => c.Handle == validated); - if (comic is null) - return NotFound(RequestError.ComicNotFound); - var comicPage = _analyzer.GetComicPage(Path.Join(_config.LibraryRoot, comic.Filepath), page); - if (comicPage is null) - //TODO rethink error code - return NotFound(RequestError.PageNotFound); - var limitWidth = maxWidth ?? -1; - var limitHeight = maxHeight ?? -1; - if (maxWidth > 0 || maxHeight > 0 || format is not null) - { - //TODO this copy is not strictly necessary, but avoiding it would mean keeping the comic file - //open after GetComicPage returns to keep the stream. Not unreasonable (that's what IDisposable - //is for) but need to be careful. - using var stream = new MemoryStream(comicPage.Data); - System.Drawing.Size limit = new( - limitWidth > 0 ? limitWidth : int.MaxValue, - limitHeight > 0 ? limitHeight : int.MaxValue - ); - string mime = format switch - { - PictureFormats f => IPictureConverter.GetMime(f), - null => comicPage.Mime, - }; - //TODO using the stream directly throws but I think it should be valid, need to debug - var arr = _converter.ResizeIfBigger(stream, limit, format).ReadAllBytes(); - return File(arr, mime); - } - else - return File(comicPage.Data, comicPage.Mime); - } - [HttpGet("{handle}/thumbnail")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult GetComicThumbnail( - string handle, - [FromQuery] - [DefaultValue(false)] - //if thumbnail doesn't exist, try to find a cover - bool fallbackToCover) - { - RequestError accErrors = new(); - var validated = ComicsContext.CleanValidateHandle(handle); - if (validated is null) - return BadRequest(RequestError.InvalidHandle); - var comic = _context.Comics.SingleOrDefault(c => c.Handle == validated); - if (comic is null) - return NotFound(RequestError.ComicNotFound); - if (comic.ThumbnailWebp is byte[] img) - { - return File(img, "application/webp"); - } - if (fallbackToCover) - { - var cover = _context.Covers.SingleOrDefault(c => c.FileXxhash64 == comic.FileXxhash64); - if (cover is not null) - { - //TODO should this convert to a thumbnail on the fly? - return File(cover.CoverFile, IComicAnalyzer.GetImageMime(cover.Filename) ?? "application/octet-stream"); - } - accErrors = accErrors.And(RequestError.CoverNotFound); - } - return NotFound(RequestError.ThumbnailNotFound.And(accErrors)); - } - [HttpPost("cleandb")] - [ProducesResponseType(StatusCodes.Status200OK)] - public IActionResult CleanUnusedTagAuthors() - { - _context.Authors - .Include(a => a.ComicAuthors) - .Where(a => a.ComicAuthors.Count == 0) - .ExecuteDelete(); - _context.Tags - .Include(a => a.ComicTags) - .Where(a => a.ComicTags.Count == 0) - .ExecuteDelete(); - //ExecuteDelete doesn't wait for SaveChanges - //_context.SaveChanges(); - return Ok(); - } - [HttpGet("duplicates")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public IActionResult GetDuplicateFiles( - [FromQuery] - [DefaultValue(0)] - int page, - [FromQuery] - [DefaultValue(20)] - int pageSize) - { - var groups = _context.Comics - .Include("ComicAuthors.Author") - .Include("ComicTags.Tag") - .GroupBy(c => c.FileXxhash64) - .Where(g => g.Count() > 1) - .OrderBy(g => g.Key); - var ret = new Paginated(pageSize, page, - groups.Select(g => - new ComicDuplicateList(g.Key, g.Select(g => g)) - )); - return Ok(ret); - } - [HttpGet("library")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult GetLibraryStats() - { - return Ok(new LibraryResponse( - _context.Comics.Count(), - _context.Comics.Select(c => c.FileXxhash64).Distinct().Count() - )); + accErrors = accErrors.And(RequestError.CoverNotFound); } + return NotFound(RequestError.ThumbnailNotFound.And(accErrors)); + } + [HttpPost("cleandb")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task CleanUnusedTagAuthors() + { + await _context.Authors + .Include(a => a.ComicAuthors) + .Where(a => a.ComicAuthors.Count == 0) + .ExecuteDeleteAsync(); + await _context.Tags + .Include(a => a.ComicTags) + .Where(a => a.ComicTags.Count == 0) + .ExecuteDeleteAsync(); + //ExecuteDelete doesn't wait for SaveChanges + //_context.SaveChanges(); + return Ok(); + } + [HttpGet("duplicates")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task GetDuplicateFiles( + [FromQuery] + [DefaultValue(0)] + int page, + [FromQuery] + [DefaultValue(20)] + int pageSize) + { + var groups = _context.Comics + .Include("ComicAuthors.Author") + .Include("ComicTags.Tag") + .GroupBy(c => c.FileXxhash64) + .Where(g => g.Count() > 1) + .OrderBy(g => g.Key); + var ret = await Paginated.CreateAsync(pageSize, page, + groups.Select(g => + new ComicDuplicateList(g.Key, g.Select(g => g)) + )); + return Ok(ret); + } + [HttpGet("library")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetLibraryStats() + { + return Ok(new LibraryResponse( + await _context.Comics.CountAsync(), + await _context.Comics.Select(c => c.FileXxhash64).Distinct().CountAsync() + )); } } diff --git a/Controllers/MiscController.cs b/Controllers/MiscController.cs index c0a4ad3..78be5b5 100644 --- a/Controllers/MiscController.cs +++ b/Controllers/MiscController.cs @@ -6,67 +6,66 @@ using ComiServ.Background; using ComiServ.Models; using System.ComponentModel; -namespace ComiServ.Controllers +namespace ComiServ.Controllers; + +[Route(ROUTE)] +[ApiController] +public class MiscController(ComicsContext context, ILogger logger, IConfigService config, IAuthenticationService auth) + : ControllerBase { - [Route(ROUTE)] - [ApiController] - public class MiscController(ComicsContext context, ILogger logger, IConfigService config, IAuthenticationService auth) - : ControllerBase + public const string ROUTE = "/api/v1/"; + ComicsContext _context = context; + ILogger _logger = logger; + IConfigService _config = config; + IAuthenticationService _auth = auth; + [HttpGet("authors")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetAuthors( + [FromQuery] + [DefaultValue(0)] + int page, + [FromQuery] + [DefaultValue(20)] + int pageSize + ) { - public const string ROUTE = "/api/v1/"; - ComicsContext _context = context; - ILogger _logger = logger; - IConfigService _config = config; - IAuthenticationService _auth = auth; - [HttpGet("authors")] - [ProducesResponseType>(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult GetAuthors( - [FromQuery] - [DefaultValue(0)] - int page, - [FromQuery] - [DefaultValue(20)] - int pageSize - ) + if (_auth.User is null) { - if (_auth.User is null) - { - HttpContext.Response.Headers.WWWAuthenticate = "Basic"; - return Unauthorized(RequestError.NoAccess); - } - if (_auth.User.UserTypeId != UserTypeEnum.Administrator) - return Forbid(); - var items = context.Authors - .OrderBy(a => a.ComicAuthors.Count()) - .Select(a => new AuthorResponse(a.Name, a.ComicAuthors.Count())); - return Ok(new Paginated(pageSize, page, items)); + HttpContext.Response.Headers.WWWAuthenticate = "Basic"; + return Unauthorized(RequestError.NoAccess); } - [HttpGet("tags")] - [ProducesResponseType>(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult GetTags( - [FromQuery] - [DefaultValue(0)] - int page, - [FromQuery] - [DefaultValue(20)] - int pageSize - ) + if (_auth.User.UserTypeId != UserTypeEnum.Administrator) + return Forbid(); + var items = _context.Authors + .OrderBy(a => a.ComicAuthors.Count()) + .Select(a => new AuthorResponse(a.Name, a.ComicAuthors.Count())); + return Ok(await Paginated.CreateAsync(pageSize, page, items)); + } + [HttpGet("tags")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetTags( + [FromQuery] + [DefaultValue(0)] + int page, + [FromQuery] + [DefaultValue(20)] + int pageSize + ) + { + if (_auth.User is null) { - if (_auth.User is null) - { - HttpContext.Response.Headers.WWWAuthenticate = "Basic"; - return Unauthorized(RequestError.NoAccess); - } - if (_auth.User.UserTypeId != UserTypeEnum.Administrator) - return Forbid(); - var items = context.Tags - .OrderBy(t => t.ComicTags.Count()) - .Select(t => new TagResponse(t.Name, t.ComicTags.Count())); - return Ok(new Paginated(pageSize, page, items)); + HttpContext.Response.Headers.WWWAuthenticate = "Basic"; + return Unauthorized(RequestError.NoAccess); } + if (_auth.User.UserTypeId != UserTypeEnum.Administrator) + return Forbid(); + var items = _context.Tags + .OrderBy(t => t.ComicTags.Count()) + .Select(t => new TagResponse(t.Name, t.ComicTags.Count())); + return Ok(await Paginated.CreateAsync(pageSize, page, items)); } } diff --git a/Controllers/TaskController.cs b/Controllers/TaskController.cs index 4bcee6e..4d038dc 100644 --- a/Controllers/TaskController.cs +++ b/Controllers/TaskController.cs @@ -7,55 +7,54 @@ using Microsoft.AspNetCore.Mvc; using System.ComponentModel; using System.Security.Policy; -namespace ComiServ.Controllers +namespace ComiServ.Controllers; + +[Route(ROUTE)] +[ApiController] +public class TaskController( + ComicsContext context + ,ITaskManager manager + ,IComicScanner scanner + ,ILogger logger + ) : ControllerBase { - [Route(ROUTE)] - [ApiController] - public class TaskController( - ComicsContext context - ,ITaskManager manager - ,IComicScanner scanner - ,ILogger logger - ) : ControllerBase + public const string ROUTE = "/api/v1/tasks"; + private readonly ComicsContext _context = context; + private readonly ITaskManager _manager = manager; + private readonly IComicScanner _scanner = scanner; + private readonly ILogger _logger = logger; + private readonly CancellationTokenSource cancellationToken = new(); + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + public Task GetTasks( + [FromQuery] + [DefaultValue(20)] + int limit + ) { - public const string ROUTE = "/api/v1/tasks"; - private readonly ComicsContext _context = context; - private readonly ITaskManager _manager = manager; - private readonly IComicScanner _scanner = scanner; - private readonly ILogger _logger = logger; - private readonly CancellationTokenSource cancellationToken = new(); - [HttpGet] - [ProducesResponseType>(StatusCodes.Status200OK)] - public IActionResult GetTasks( - [FromQuery] - [DefaultValue(20)] - int limit - ) + return Ok(new Truncated(limit, _manager.GetTasks(limit+1))); + } + [HttpPost("scan")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult StartScan() + { + _scanner.TriggerLibraryScan(); + return Ok(); + } + [HttpPost("cancelall")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public IActionResult CancelAllTasks(Services.IAuthenticationService auth, ITaskManager manager) + { + if (auth.User is null) { - return Ok(new Truncated(limit, _manager.GetTasks(limit+1))); - } - [HttpPost("scan")] - [ProducesResponseType(StatusCodes.Status200OK)] - public IActionResult StartScan() - { - _scanner.TriggerLibraryScan(); - return Ok(); - } - [HttpPost("cancelall")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult CancelAllTasks(Services.IAuthenticationService auth, ITaskManager manager) - { - if (auth.User is null) - { - HttpContext.Response.Headers.WWWAuthenticate = "Basic"; - return Unauthorized(RequestError.NoAccess); - } - if (auth.User.UserTypeId != Entities.UserTypeEnum.Administrator) - return Forbid(); - manager.CancelAll(); - return Ok(); + HttpContext.Response.Headers.WWWAuthenticate = "Basic"; + return Unauthorized(RequestError.NoAccess); } + if (auth.User.UserTypeId != Entities.UserTypeEnum.Administrator) + return Forbid(); + manager.CancelAll(); + return Ok(); } } diff --git a/Controllers/UserController.cs b/Controllers/UserController.cs index c909988..4c2e34a 100644 --- a/Controllers/UserController.cs +++ b/Controllers/UserController.cs @@ -7,146 +7,146 @@ using Microsoft.EntityFrameworkCore; using System.ComponentModel; using System.Text; -namespace ComiServ.Controllers +namespace ComiServ.Controllers; + +[Route(ROUTE)] +[ApiController] +public class UserController + : ControllerBase { - [Route(ROUTE)] - [ApiController] - public class UserController - : ControllerBase + public const string ROUTE = "/api/v1/users"; + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetUsers(IAuthenticationService auth, + ComicsContext context, + [FromQuery] + [DefaultValue(null)] + string? search, + [FromQuery] + [DefaultValue(null)] + UserTypeEnum? type, + [FromQuery] + [DefaultValue(0)] + int page, + [FromQuery] + [DefaultValue(20)] + int pageSize) { - public const string ROUTE = "/api/v1/users"; - [HttpGet] - [ProducesResponseType>(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult GetUsers(IAuthenticationService auth, - ComicsContext context, - [FromQuery] - [DefaultValue(null)] - string? search, - [FromQuery] - [DefaultValue(null)] - UserTypeEnum? type, - [FromQuery] - [DefaultValue(0)] - int page, - [FromQuery] - [DefaultValue(20)] - int pageSize) + if (auth.User is null) { - if (auth.User is null) - { - HttpContext.Response.Headers.WWWAuthenticate = "Basic"; - return Unauthorized(RequestError.NoAccess); - } - if (auth.User.UserTypeId != UserTypeEnum.Administrator) - return Forbid(); - IQueryable users = context.Users; - if (type is UserTypeEnum t) - users = users.Where(u => u.UserTypeId == t); - if (!string.IsNullOrWhiteSpace(search)) - users = users.Where(u => EF.Functions.Like(u.Username, $"%{search}%")); - return Ok(new Paginated(pageSize, page, users - .Include(u => u.UserType) - .Select(u => new UserDescription(u.Username, u.UserType.Name)))); + HttpContext.Response.Headers.WWWAuthenticate = "Basic"; + return Unauthorized(RequestError.NoAccess); } - [HttpPost("create")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult AddUser(IAuthenticationService auth, - ComicsContext context, - [FromBody] - UserCreateRequest req) + if (auth.User.UserTypeId != UserTypeEnum.Administrator) + return Forbid(); + IQueryable users = context.Users; + if (type is UserTypeEnum t) + users = users.Where(u => u.UserTypeId == t); + if (!string.IsNullOrWhiteSpace(search)) + users = users.Where(u => EF.Functions.Like(u.Username, $"%{search}%")); + return Ok(await Paginated.CreateAsync(pageSize, page, users + .Include(u => u.UserType) + .Select(u => new UserDescription(u.Username, u.UserType.Name)))); + } + [HttpPost("create")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task AddUser(IAuthenticationService auth, + ComicsContext context, + [FromBody] + UserCreateRequest req) + { + if (auth.User is null) { - if (auth.User is null) - { - HttpContext.Response.Headers.WWWAuthenticate = "Basic"; - return Unauthorized(RequestError.NoAccess); - } - if (auth.User.UserTypeId != UserTypeEnum.Administrator) - return Forbid(); - var salt = Entities.User.MakeSalt(); - var bPass = Encoding.UTF8.GetBytes(req.Password); - var newUser = new Entities.User() - { - Username = req.Username, - Salt = salt, - HashedPassword = Entities.User.Hash(password: bPass, salt: salt), - UserTypeId = req.UserType - }; - context.Users.Add(newUser); - context.SaveChanges(); - return Ok(); + HttpContext.Response.Headers.WWWAuthenticate = "Basic"; + return Unauthorized(RequestError.NoAccess); } - [HttpDelete("delete")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult DeleteUser(IAuthenticationService auth, - ComicsContext context, - [FromBody] - string username) + if (auth.User.UserTypeId != UserTypeEnum.Administrator) + return Forbid(); + var salt = Entities.User.MakeSalt(); + var bPass = Encoding.UTF8.GetBytes(req.Password); + var newUser = new Entities.User() { - if (auth.User is null) - { - HttpContext.Response.Headers.WWWAuthenticate = "Basic"; - return Unauthorized(RequestError.NoAccess); - } - if (auth.User.UserTypeId != UserTypeEnum.Administrator) - return Forbid(); - username = username.Trim(); - var user = context.Users.SingleOrDefault(u => EF.Functions.Like(u.Username, $"{username}")); - if (user is null) - return BadRequest(); - context.Users.Remove(user); - return Ok(); - } - [HttpPost("modify")] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult ModifyUser(IAuthenticationService auth, - ComicsContext context, - [FromBody] - UserModifyRequest req) + Username = req.Username, + Salt = salt, + HashedPassword = Entities.User.Hash(password: bPass, salt: salt), + UserTypeId = req.UserType + }; + context.Users.Add(newUser); + await context.SaveChangesAsync(); + return Ok(); + } + [HttpDelete("delete")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task DeleteUser(IAuthenticationService auth, + ComicsContext context, + [FromBody] + string username) + { + if (auth.User is null) { - //must be authenticated - if (auth.User is null) - { - HttpContext.Response.Headers.WWWAuthenticate = "Basic"; - return Unauthorized(RequestError.NoAccess); - } - req.Username = req.Username.Trim(); - //must be an admin or changing own username - if (!req.Username.Equals(auth.User.Username, StringComparison.CurrentCultureIgnoreCase) - && auth.User.UserTypeId != UserTypeEnum.Administrator) - { - return Forbid(); - } - //only admins can change user type - if (auth.User.UserTypeId != UserTypeEnum.Administrator - && req.NewUserType is not null) - { - return Forbid(); - } - var user = context.Users - .SingleOrDefault(u => EF.Functions.Like(u.Username, req.Username)); - if (user is null) - { - return BadRequest(RequestError.UserNotFound); - } - if (req.NewUsername is not null) - { - user.Username = req.NewUsername.Trim(); - } - if (req.NewUserType is UserTypeEnum nut) - { - user.UserTypeId = nut; - } - context.SaveChanges(); - return Ok(); + HttpContext.Response.Headers.WWWAuthenticate = "Basic"; + return Unauthorized(RequestError.NoAccess); } + if (auth.User.UserTypeId != UserTypeEnum.Administrator) + return Forbid(); + username = username.Trim(); + var user = await context.Users.SingleOrDefaultAsync(u => EF.Functions.Like(u.Username, $"{username}")); + if (user is null) + return BadRequest(); + context.Users.Remove(user); + await context.SaveChangesAsync(); + return Ok(); + } + [HttpPost("modify")] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ModifyUser(IAuthenticationService auth, + ComicsContext context, + [FromBody] + UserModifyRequest req) + { + //must be authenticated + if (auth.User is null) + { + HttpContext.Response.Headers.WWWAuthenticate = "Basic"; + return Unauthorized(RequestError.NoAccess); + } + req.Username = req.Username.Trim(); + //must be an admin or changing own username + if (!req.Username.Equals(auth.User.Username, StringComparison.CurrentCultureIgnoreCase) + && auth.User.UserTypeId != UserTypeEnum.Administrator) + { + return Forbid(); + } + //only admins can change user type + if (auth.User.UserTypeId != UserTypeEnum.Administrator + && req.NewUserType is not null) + { + return Forbid(); + } + var user = await context.Users + .SingleOrDefaultAsync(u => EF.Functions.Like(u.Username, req.Username)); + if (user is null) + { + return BadRequest(RequestError.UserNotFound); + } + if (req.NewUsername is not null) + { + user.Username = req.NewUsername.Trim(); + } + if (req.NewUserType is UserTypeEnum nut) + { + user.UserTypeId = nut; + } + await context.SaveChangesAsync(); + return Ok(); } } diff --git a/Entities/Author.cs b/Entities/Author.cs index dae3800..9a33506 100644 --- a/Entities/Author.cs +++ b/Entities/Author.cs @@ -4,14 +4,13 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; //using System.ComponentModel.DataAnnotations.Schema; -namespace ComiServ.Entities +namespace ComiServ.Entities; + +[Index(nameof(Name), IsUnique = true)] +public class Author { - [Index(nameof(Name), IsUnique = true)] - public class Author - { - public int Id { get; set; } - [Required] - public string Name { get; set; } = null!; - public ICollection ComicAuthors { get; set; } = null!; - } + public int Id { get; set; } + [Required] + public string Name { get; set; } = null!; + public ICollection ComicAuthors { get; set; } = null!; } diff --git a/Entities/Comic.cs b/Entities/Comic.cs index ac92c6f..eb90005 100644 --- a/Entities/Comic.cs +++ b/Entities/Comic.cs @@ -3,33 +3,32 @@ using Microsoft.EntityFrameworkCore; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace ComiServ.Entities +namespace ComiServ.Entities; + +[Index(nameof(Handle), IsUnique = true)] +[Index(nameof(Filepath), IsUnique = true)] +public class Comic { - [Index(nameof(Handle), IsUnique = true)] - [Index(nameof(Filepath), IsUnique = true)] - public class Comic - { - public int Id { get; set; } - public bool Exists { get; set; } - //id exposed through the API - [Required] - [StringLength(ComicsContext.HANDLE_LENGTH)] - public string Handle { get; set; } = null!; - [Required] - public string Filepath { get; set; } = null!; - [Required] - public string Title { get; set; } = null!; - [Required] - public string Description { get; set; } = null!; - public int PageCount { get; set; } - public long SizeBytes { get; set; } - public long FileXxhash64 { get; set; } - public byte[]? ThumbnailWebp { get; set; } - [InverseProperty("Comic")] - public ICollection ComicTags { get; set; } = []; - [InverseProperty("Comic")] - public ICollection ComicAuthors { get; set; } = []; - [InverseProperty("Comic")] - public ICollection ReadBy { get; set; } = []; - } + public int Id { get; set; } + public bool Exists { get; set; } + //id exposed through the API + [Required] + [StringLength(ComicsContext.HANDLE_LENGTH)] + public string Handle { get; set; } = null!; + [Required] + public string Filepath { get; set; } = null!; + [Required] + public string Title { get; set; } = null!; + [Required] + public string Description { get; set; } = null!; + public int PageCount { get; set; } + public long SizeBytes { get; set; } + public long FileXxhash64 { get; set; } + public byte[]? ThumbnailWebp { get; set; } + [InverseProperty("Comic")] + public ICollection ComicTags { get; set; } = []; + [InverseProperty("Comic")] + public ICollection ComicAuthors { get; set; } = []; + [InverseProperty("Comic")] + public ICollection ReadBy { get; set; } = []; } diff --git a/Entities/ComicAuthor.cs b/Entities/ComicAuthor.cs index 5813b97..941f9a9 100644 --- a/Entities/ComicAuthor.cs +++ b/Entities/ComicAuthor.cs @@ -2,20 +2,19 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace ComiServ.Entities +namespace ComiServ.Entities; + +[PrimaryKey("ComicId", "AuthorId")] +[Index("ComicId")] +[Index("AuthorId")] +public class ComicAuthor { - [PrimaryKey("ComicId", "AuthorId")] - [Index("ComicId")] - [Index("AuthorId")] - public class ComicAuthor - { - [ForeignKey(nameof(Comic))] - public int ComicId { get; set; } - [Required] - public Comic Comic { get; set; } = null!; - [ForeignKey(nameof(Author))] - public int AuthorId { get; set; } - [Required] - public Author Author { get; set; } = null!; - } + [ForeignKey(nameof(Comic))] + public int ComicId { get; set; } + [Required] + public Comic Comic { get; set; } = null!; + [ForeignKey(nameof(Author))] + public int AuthorId { get; set; } + [Required] + public Author Author { get; set; } = null!; } diff --git a/Entities/ComicRead.cs b/Entities/ComicRead.cs index 5595918..757c7a8 100644 --- a/Entities/ComicRead.cs +++ b/Entities/ComicRead.cs @@ -1,16 +1,15 @@ using Microsoft.EntityFrameworkCore; using System.ComponentModel.DataAnnotations.Schema; -namespace ComiServ.Entities +namespace ComiServ.Entities; + +[PrimaryKey(nameof(UserId), nameof(ComicId))] +[Index(nameof(UserId))] +[Index(nameof(ComicId))] +public class ComicRead { - [PrimaryKey(nameof(UserId), nameof(ComicId))] - [Index(nameof(UserId))] - [Index(nameof(ComicId))] - public class ComicRead - { - public int UserId { get; set; } - public User User { get; set; } - public int ComicId { get; set; } - public Comic Comic { get; set; } - } + public int UserId { get; set; } + public User User { get; set; } + public int ComicId { get; set; } + public Comic Comic { get; set; } } diff --git a/Entities/ComicTag.cs b/Entities/ComicTag.cs index 69022f4..b42b84d 100644 --- a/Entities/ComicTag.cs +++ b/Entities/ComicTag.cs @@ -1,15 +1,14 @@ using Microsoft.EntityFrameworkCore; -namespace ComiServ.Entities +namespace ComiServ.Entities; + +[PrimaryKey("ComicId", "TagId")] +[Index("ComicId")] +[Index("TagId")] +public class ComicTag { - [PrimaryKey("ComicId", "TagId")] - [Index("ComicId")] - [Index("TagId")] - public class ComicTag - { - public int ComicId { get; set; } - public Comic Comic { get; set; } = null!; - public int TagId { get; set; } - public Tag Tag { get; set; } = null!; - } + public int ComicId { get; set; } + public Comic Comic { get; set; } = null!; + public int TagId { get; set; } + public Tag Tag { get; set; } = null!; } diff --git a/Entities/Cover.cs b/Entities/Cover.cs index 7dc43f2..f5fdbf8 100644 --- a/Entities/Cover.cs +++ b/Entities/Cover.cs @@ -1,12 +1,11 @@ using Microsoft.EntityFrameworkCore; -namespace ComiServ.Entities +namespace ComiServ.Entities; + +[PrimaryKey("FileXxhash64")] +public class Cover { - [PrimaryKey("FileXxhash64")] - public class Cover - { - public long FileXxhash64 { get; set; } - public string Filename { get; set; } = null!; - public byte[] CoverFile { get; set; } = null!; - } + public long FileXxhash64 { get; set; } + public string Filename { get; set; } = null!; + public byte[] CoverFile { get; set; } = null!; } diff --git a/Entities/EntitySwaggerFilter.cs b/Entities/EntitySwaggerFilter.cs index 4103882..c024cc9 100644 --- a/Entities/EntitySwaggerFilter.cs +++ b/Entities/EntitySwaggerFilter.cs @@ -2,36 +2,35 @@ using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; -namespace ComiServ.Entities +namespace ComiServ.Entities; + +/// +/// This was originally made to remove Entity types that were being added to the Swagger schema. +/// I found that there was a bug in `ProducesResponseTypeAttribute` that caused it, and this is +/// no longer necessary. I changed Apply to a nop but am keeping this around as an example and +/// in case I actually need something like this in the future. +/// +public class EntitySwaggerFilter : ISchemaFilter { - /// - /// This was originally made to remove Entity types that were being added to the Swagger schema. - /// I found that there was a bug in `ProducesResponseTypeAttribute` that caused it, and this is - /// no longer necessary. I changed Apply to a nop but am keeping this around as an example and - /// in case I actually need something like this in the future. - /// - public class EntitySwaggerFilter : ISchemaFilter + public readonly static string[] FILTER = [ + nameof(Author), + nameof(Comic), + nameof(ComicAuthor), + nameof(ComicTag), + nameof(Cover), + nameof(Tag), + nameof(User), + nameof(UserType) + ]; + public void Apply(OpenApiSchema schema, SchemaFilterContext context) { - public readonly static string[] FILTER = [ - nameof(Author), - nameof(Comic), - nameof(ComicAuthor), - nameof(ComicTag), - nameof(Cover), - nameof(Tag), - nameof(User), - nameof(UserType) - ]; - public void Apply(OpenApiSchema schema, SchemaFilterContext context) - { - return; - foreach (var item in context.SchemaRepository.Schemas.Keys) - { - if (FILTER.Contains(item)) - { - context.SchemaRepository.Schemas.Remove(item); - } - } - } + return; + //foreach (var item in context.SchemaRepository.Schemas.Keys) + //{ + // if (FILTER.Contains(item)) + // { + // context.SchemaRepository.Schemas.Remove(item); + // } + //} } } diff --git a/Entities/Tag.cs b/Entities/Tag.cs index 779204a..3921545 100644 --- a/Entities/Tag.cs +++ b/Entities/Tag.cs @@ -2,15 +2,14 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace ComiServ.Entities +namespace ComiServ.Entities; + +[Index(nameof(Name), IsUnique = true)] +public class Tag { - [Index(nameof(Name), IsUnique = true)] - public class Tag - { - //[DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; set; } - [Required] - public string Name { get; set; } = null!; - public ICollection ComicTags { get; set; } = null!; - } + //[DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + [Required] + public string Name { get; set; } = null!; + public ICollection ComicTags { get; set; } = null!; } diff --git a/Entities/User.cs b/Entities/User.cs index dd5f0a8..3a3dfa1 100644 --- a/Entities/User.cs +++ b/Entities/User.cs @@ -3,36 +3,35 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Security.Cryptography; -namespace ComiServ.Entities +namespace ComiServ.Entities; + +[PrimaryKey(nameof(Id))] +[Index(nameof(Username), IsUnique = true)] +public class User { - [PrimaryKey(nameof(Id))] - [Index(nameof(Username), IsUnique = true)] - public class User + public const int HashLengthBytes = 512 / 8; + public const int SaltLengthBytes = HashLengthBytes; + public int Id { get; set; } + [MaxLength(20)] + public string Username { get; set; } + [MaxLength(SaltLengthBytes)] + public byte[] Salt { get; set; } + [MaxLength(HashLengthBytes)] + public byte[] HashedPassword { get; set; } + public UserType UserType { get; set; } + public UserTypeEnum UserTypeId { get; set; } + [InverseProperty("User")] + public ICollection ComicsRead { get; set; } = []; + //cryptography should probably be in a different class + public static byte[] MakeSalt() { - public const int HashLengthBytes = 512 / 8; - public const int SaltLengthBytes = HashLengthBytes; - public int Id { get; set; } - [MaxLength(20)] - public string Username { get; set; } - [MaxLength(SaltLengthBytes)] - public byte[] Salt { get; set; } - [MaxLength(HashLengthBytes)] - public byte[] HashedPassword { get; set; } - public UserType UserType { get; set; } - public UserTypeEnum UserTypeId { get; set; } - [InverseProperty("User")] - public ICollection ComicsRead { get; set; } = []; - //cryptography should probably be in a different class - public static byte[] MakeSalt() - { - byte[] arr = new byte[SaltLengthBytes]; - RandomNumberGenerator.Fill(new Span(arr)); - return arr; - } - public static byte[] Hash(byte[] password, byte[] salt) - { - var salted = salt.Append((byte)':').Concat(password).ToArray(); - return SHA512.HashData(salted); - } + byte[] arr = new byte[SaltLengthBytes]; + RandomNumberGenerator.Fill(new Span(arr)); + return arr; + } + public static byte[] Hash(byte[] password, byte[] salt) + { + var salted = salt.Append((byte)':').Concat(password).ToArray(); + return SHA512.HashData(salted); } } diff --git a/Entities/UserType.cs b/Entities/UserType.cs index beaade7..5ff7133 100644 --- a/Entities/UserType.cs +++ b/Entities/UserType.cs @@ -1,28 +1,27 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -namespace ComiServ.Entities +namespace ComiServ.Entities; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum UserTypeEnum { - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum UserTypeEnum - { - //important that this is 0 as a safety precaution, - //in case it's accidentally left as default - Invalid = 0, - //can create accounts - Administrator = 1, - //has basic access - User = 2, - //authenticates but does not give access - Restricted = 3, - //refuses to authenticate but maintains records - Disabled = 4, - } - public class UserType - { - public UserTypeEnum Id { get; set; } - [MaxLength(26)] - public string Name { get; set; } - public ICollection Users { get; set; } - } + //important that this is 0 as a safety precaution, + //in case it's accidentally left as default + Invalid = 0, + //can create accounts + Administrator = 1, + //has basic access + User = 2, + //authenticates but does not give access + Restricted = 3, + //refuses to authenticate but maintains records + Disabled = 4, +} +public class UserType +{ + public UserTypeEnum Id { get; set; } + [MaxLength(26)] + public string Name { get; set; } + public ICollection Users { get; set; } } diff --git a/Extensions/DatabaseExtensions.cs b/Extensions/DatabaseExtensions.cs index d069541..b5387b3 100644 --- a/Extensions/DatabaseExtensions.cs +++ b/Extensions/DatabaseExtensions.cs @@ -4,59 +4,58 @@ using System.Runtime.CompilerServices; //https://stackoverflow.com/a/42467710/25956209 //https://archive.ph/RvjOy -namespace ComiServ.Extensions +namespace ComiServ.Extensions; + +public static class DatabaseExtensions { - public static class DatabaseExtensions + //with a compound primary key, `ignorePrimaryKey` will ignore all of them + public static int InsertOrIgnore(this DbContext context, T item, bool ignorePrimaryKey = false) { - //with a compound primary key, `ignorePrimaryKey` will ignore all of them - public static int InsertOrIgnore(this DbContext context, T item, bool ignorePrimaryKey = false) - { - var entityType = context.Model.FindEntityType(typeof(T)); - var tableName = entityType.GetTableName(); - //var tableSchema = entityType.GetSchema(); + var entityType = context.Model.FindEntityType(typeof(T)); + var tableName = entityType.GetTableName(); + //var tableSchema = entityType.GetSchema(); - var cols = entityType.GetProperties() - .Where(c => !ignorePrimaryKey || !c.IsPrimaryKey()) - .Select(c => new { - Name = c.GetColumnName(), - //Type = c.GetColumnType(), - Value = c.PropertyInfo.GetValue(item) - }) - .ToList(); - var query = "INSERT OR IGNORE INTO " + tableName - + " (" + string.Join(", ", cols.Select(c => c.Name)) + ") " + - "VALUES (" + string.Join(", ", cols.Select((c,i) => "{" + i + "}")) + ")"; - var args = cols.Select(c => c.Value).ToArray(); - var formattable = FormattableStringFactory.Create(query, args); - return context.Database.ExecuteSql(formattable); - } - public static int InsertOrIgnore(this DbContext context, IEnumerable items, bool ignorePrimaryKey = false) - { - var entityType = context.Model.FindEntityType(typeof(T)); - var tableName = entityType.GetTableName(); - //var tableSchema = entityType.GetSchema(); + var cols = entityType.GetProperties() + .Where(c => !ignorePrimaryKey || !c.IsPrimaryKey()) + .Select(c => new { + Name = c.GetColumnName(), + //Type = c.GetColumnType(), + Value = c.PropertyInfo.GetValue(item) + }) + .ToList(); + var query = "INSERT OR IGNORE INTO " + tableName + + " (" + string.Join(", ", cols.Select(c => c.Name)) + ") " + + "VALUES (" + string.Join(", ", cols.Select((c,i) => "{" + i + "}")) + ")"; + var args = cols.Select(c => c.Value).ToArray(); + var formattable = FormattableStringFactory.Create(query, args); + return context.Database.ExecuteSql(formattable); + } + public static int InsertOrIgnore(this DbContext context, IEnumerable items, bool ignorePrimaryKey = false) + { + var entityType = context.Model.FindEntityType(typeof(T)); + var tableName = entityType.GetTableName(); + //var tableSchema = entityType.GetSchema(); - var colProps = entityType.GetProperties().Where(c => !ignorePrimaryKey || !c.IsPrimaryKey()).ToList(); - var colNames = colProps.Select(c => c.Name).ToList(); - if (colNames.Count == 0) - throw new InvalidOperationException("No columns to insert"); - var rows = items - .Select(item => - colProps.Select(c => - c.PropertyInfo.GetValue(item)) - .ToList()) - .ToList(); - int count = 0; - var query = "INSERT OR IGNORE INTO " + tableName - + "(" + string.Join(',', colNames) + ")" - + "VALUES" + string.Join(',', rows.Select(row => - "(" + string.Join(',', row.Select(v => "{" - + count++ - + "}")) + ")" - )); - var args = rows.SelectMany(row => row).ToArray(); - var formattable = FormattableStringFactory.Create(query, args); - return context.Database.ExecuteSql(formattable); - } + var colProps = entityType.GetProperties().Where(c => !ignorePrimaryKey || !c.IsPrimaryKey()).ToList(); + var colNames = colProps.Select(c => c.Name).ToList(); + if (colNames.Count == 0) + throw new InvalidOperationException("No columns to insert"); + var rows = items + .Select(item => + colProps.Select(c => + c.PropertyInfo.GetValue(item)) + .ToList()) + .ToList(); + int count = 0; + var query = "INSERT OR IGNORE INTO " + tableName + + "(" + string.Join(',', colNames) + ")" + + "VALUES" + string.Join(',', rows.Select(row => + "(" + string.Join(',', row.Select(v => "{" + + count++ + + "}")) + ")" + )); + var args = rows.SelectMany(row => row).ToArray(); + var formattable = FormattableStringFactory.Create(query, args); + return context.Database.ExecuteSql(formattable); } } diff --git a/Extensions/StreamExtentions.cs b/Extensions/StreamExtentions.cs index d692794..aeea1a5 100644 --- a/Extensions/StreamExtentions.cs +++ b/Extensions/StreamExtentions.cs @@ -1,19 +1,25 @@ -namespace ComiServ.Extensions -{ - public static class StreamExtensions - { - //https://stackoverflow.com/questions/1080442/how-do-i-convert-a-stream-into-a-byte-in-c - //https://archive.ph/QUKys - public static byte[] ReadAllBytes(this Stream instream) - { - if (instream is MemoryStream) - return ((MemoryStream)instream).ToArray(); +namespace ComiServ.Extensions; - using (var memoryStream = new MemoryStream()) - { - instream.CopyTo(memoryStream); - return memoryStream.ToArray(); - } - } +public static class StreamExtensions +{ + //https://stackoverflow.com/questions/1080442/how-do-i-convert-a-stream-into-a-byte-in-c + //https://archive.ph/QUKys + public static byte[] ReadAllBytes(this Stream instream) + { + if (instream is MemoryStream) + return ((MemoryStream)instream).ToArray(); + + using var memoryStream = new MemoryStream(); + instream.CopyTo(memoryStream); + return memoryStream.ToArray(); + } + public static async Task ReadAllBytesAsync(this Stream instream) + { + if (instream is MemoryStream) + return ((MemoryStream)instream).ToArray(); + + using var memoryStream = new MemoryStream(); + await instream.CopyToAsync(memoryStream); + return memoryStream.ToArray(); } } diff --git a/Logging/Events.cs b/Logging/Events.cs index 140b3ea..7e0016a 100644 --- a/Logging/Events.cs +++ b/Logging/Events.cs @@ -1,7 +1,6 @@ -namespace ComiServ.Logging -{ - public static class Events - { +namespace ComiServ.Logging; + +public static class Events +{ - } } diff --git a/Models/AuthorResponse.cs b/Models/AuthorResponse.cs index 896319c..4c9c8ab 100644 --- a/Models/AuthorResponse.cs +++ b/Models/AuthorResponse.cs @@ -1,7 +1,6 @@ -namespace ComiServ.Models -{ - public record class AuthorResponse(string Name, int WorkCount) - { +namespace ComiServ.Models; + +public record class AuthorResponse(string Name, int WorkCount) +{ - } } diff --git a/Models/ComicDeleteRequest.cs b/Models/ComicDeleteRequest.cs index 06e4a2c..4d200f8 100644 --- a/Models/ComicDeleteRequest.cs +++ b/Models/ComicDeleteRequest.cs @@ -1,8 +1,7 @@ -namespace ComiServ.Models -{ - //handle is taken from URL - public record class ComicDeleteRequest - ( - bool DeleteIfFileExists - ); -} +namespace ComiServ.Models; + +//handle is taken from URL +public record class ComicDeleteRequest +( + bool DeleteIfFileExists +); diff --git a/Models/ComicDuplicateList.cs b/Models/ComicDuplicateList.cs index d4c7233..04f77de 100644 --- a/Models/ComicDuplicateList.cs +++ b/Models/ComicDuplicateList.cs @@ -1,23 +1,22 @@ using ComiServ.Entities; -namespace ComiServ.Models +namespace ComiServ.Models; + +public class ComicDuplicateList { - public class ComicDuplicateList + public long Hash { get; set; } + public int Count { get; set; } + public List Comics { get; set; } + public ComicDuplicateList(long hash, IEnumerable comics) { - public long Hash { get; set; } - public int Count { get; set; } - public List Comics { get; set; } - public ComicDuplicateList(long hash, IEnumerable comics) - { - Hash = hash; - Comics = comics.Select(c => new ComicData(c)).ToList(); - Count = Comics.Count; - } - public ComicDuplicateList(long hash, IEnumerable comics) - { - Hash = hash; - Comics = comics.ToList(); - Count = Comics.Count; - } + Hash = hash; + Comics = comics.Select(c => new ComicData(c)).ToList(); + Count = Comics.Count; + } + public ComicDuplicateList(long hash, IEnumerable comics) + { + Hash = hash; + Comics = comics.ToList(); + Count = Comics.Count; } } diff --git a/Models/ComicMetadataUpdateRequest.cs b/Models/ComicMetadataUpdateRequest.cs index 1024988..8737aa1 100644 --- a/Models/ComicMetadataUpdateRequest.cs +++ b/Models/ComicMetadataUpdateRequest.cs @@ -1,10 +1,9 @@ -namespace ComiServ.Models +namespace ComiServ.Models; + +public class ComicMetadataUpdateRequest { - public class ComicMetadataUpdateRequest - { - public string? Title { get; set; } - public string? Description { get; set; } - public List? Tags { get; set; } - public List? Authors { get; set; } - } + public string? Title { get; set; } + public string? Description { get; set; } + public List? Tags { get; set; } + public List? Authors { get; set; } } diff --git a/Models/LibraryResponse.cs b/Models/LibraryResponse.cs index b8475fc..1a0bd5f 100644 --- a/Models/LibraryResponse.cs +++ b/Models/LibraryResponse.cs @@ -1,6 +1,5 @@ -namespace ComiServ.Models +namespace ComiServ.Models; + +public record class LibraryResponse(int ComicCount, int UniqueFiles) { - public record class LibraryResponse(int ComicCount, int UniqueFiles) - { - } } diff --git a/Models/Paginated.cs b/Models/Paginated.cs index c6cca98..386aa4d 100644 --- a/Models/Paginated.cs +++ b/Models/Paginated.cs @@ -1,35 +1,95 @@ -namespace ComiServ.Models +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.EntityFrameworkCore; + +namespace ComiServ.Models; +public class Paginated { - public class Paginated + public int Max { get; } + public int Page { get; } + public bool Last { get; } + public int Count { get; } + public List Items { get; } + public Paginated(int max, int page, IEnumerable iter) { - public int Max { get; } - public int Page { get;} - public bool Last { get; } - public int Count { get; } - public List Items { get; } - public Paginated(int max, int page, IEnumerable iter) + if (max <= 0) { - Max = max; - Page = page; - if (max <= 0) - { - throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0"); - } - if (page < 0) - { - throw new ArgumentOutOfRangeException(nameof(page), page, "must be greater than or equal to 0"); - } - Items = iter.Skip(max * page).Take(max + 1).ToList(); - if (Items.Count > max) - { - Last = false; - Items.RemoveAt(max); - } - else - { - Last = true; - } - Count = Items.Count; + throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0"); } + if (page < 0) + { + throw new ArgumentOutOfRangeException(nameof(page), page, "must be greater than or equal to 0"); + } + Max = max; + Page = page; + Items = iter.Skip(max * page).Take(max + 1).ToList(); + if (Items.Count > max) + { + Last = false; + Items.RemoveAt(max); + } + else + { + Last = true; + } + Count = Items.Count; + } + private Paginated(int max, int page, bool last, List items) + { + Max = max; + Page = page; + Last = last; + Items = items; + Count = Items.Count; + } + public static async Task> CreateAsync(int max, int page, IQueryable iter) + { + if (max <= 0) + { + throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0"); + } + if (page < 0) + { + throw new ArgumentOutOfRangeException(nameof(page), page, "must be greater than or equal to 0"); + } + var items = await iter.Skip(max * page).Take(max + 1).ToListAsync(); + bool last = true; + if (items.Count > max) + { + last = false; + items.RemoveAt(max); + } + return new(max, page, last, items); + } + public static async Task> CreateAsync(int max, int page, IAsyncEnumerable iter) + { + if (max <= 0) + { + throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0"); + } + if (page < 0) + { + throw new ArgumentOutOfRangeException(nameof(page), page, "must be greater than or equal to 0"); + } + List items = []; + var skip = max * page; + await foreach (T item in iter) + { + if (skip > 0) + { + skip--; + continue; + } + items.Add(item); + if (items.Count >= max + 1) + break; + } + var last = true; + if (items.Count > max) + { + last = false; + items.RemoveAt(max); + } + return new(max, page, last, items); } } diff --git a/Models/RequestError.cs b/Models/RequestError.cs index 5140a40..3717673 100644 --- a/Models/RequestError.cs +++ b/Models/RequestError.cs @@ -1,52 +1,51 @@ using System.Collections; -namespace ComiServ.Models +namespace ComiServ.Models; + +public class RequestError : IEnumerable { - public class RequestError : IEnumerable + public static RequestError InvalidHandle => new("Invalid handle"); + public static RequestError ComicNotFound => new("Comic not found"); + public static RequestError CoverNotFound => new("Cover not found"); + public static RequestError PageNotFound => new("Page not found"); + public static RequestError FileNotFound => new("File not found"); + public static RequestError ThumbnailNotFound => new("Thumbnail not found"); + public static RequestError NotAuthenticated => new("Not authenticated"); + public static RequestError NoAccess => new("User does not have access to this resource"); + public static RequestError UserNotFound => new("User not found"); + public static RequestError ComicFileExists => new("Comic file exists so comic not deleted"); + public static RequestError UserSpecificEndpoint => new("Endpoint is user-specific, requires login"); + public string[] Errors { get; } + public RequestError(string ErrorMessage) { - public static RequestError InvalidHandle => new("Invalid handle"); - public static RequestError ComicNotFound => new("Comic not found"); - public static RequestError CoverNotFound => new("Cover not found"); - public static RequestError PageNotFound => new("Page not found"); - public static RequestError FileNotFound => new("File not found"); - public static RequestError ThumbnailNotFound => new("Thumbnail not found"); - public static RequestError NotAuthenticated => new("Not authenticated"); - public static RequestError NoAccess => new("User does not have access to this resource"); - public static RequestError UserNotFound => new("User not found"); - public static RequestError ComicFileExists => new("Comic file exists so comic not deleted"); - public static RequestError UserSpecificEndpoint => new("Endpoint is user-specific, requires login"); - public string[] Errors { get; } - public RequestError(string ErrorMessage) - { - Errors = [ErrorMessage]; - } - public RequestError() - { - Errors = []; - } - public RequestError(IEnumerable ErrorMessages) - { - Errors = ErrorMessages.ToArray(); - } - public RequestError And(RequestError other) - { - return new RequestError(Errors.Concat(other.Errors)); - } - public RequestError And(string other) - { - return new RequestError(Errors.Append(other)); - } - public RequestError And(IEnumerable other) - { - return new RequestError(Errors.Concat(other)); - } - public IEnumerator GetEnumerator() - { - return ((IEnumerable)Errors).GetEnumerator(); - } - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } + Errors = [ErrorMessage]; + } + public RequestError() + { + Errors = []; + } + public RequestError(IEnumerable ErrorMessages) + { + Errors = ErrorMessages.ToArray(); + } + public RequestError And(RequestError other) + { + return new RequestError(Errors.Concat(other.Errors)); + } + public RequestError And(string other) + { + return new RequestError(Errors.Append(other)); + } + public RequestError And(IEnumerable other) + { + return new RequestError(Errors.Concat(other)); + } + public IEnumerator GetEnumerator() + { + return ((IEnumerable)Errors).GetEnumerator(); + } + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); } } diff --git a/Models/TagResponse.cs b/Models/TagResponse.cs index 8dd3317..14a513f 100644 --- a/Models/TagResponse.cs +++ b/Models/TagResponse.cs @@ -1,7 +1,6 @@ -namespace ComiServ.Models -{ - public record class TagResponse(string Name, int WorkCount) - { +namespace ComiServ.Models; + +public record class TagResponse(string Name, int WorkCount) +{ - } } diff --git a/Models/Truncated.cs b/Models/Truncated.cs index 7016239..c9ae94f 100644 --- a/Models/Truncated.cs +++ b/Models/Truncated.cs @@ -1,31 +1,75 @@ -using System.Reflection.PortableExecutable; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.EntityFrameworkCore; +using System.Reflection.PortableExecutable; -namespace ComiServ.Models +namespace ComiServ.Models; + +public class Truncated { - public class Truncated + public int Max { get; } + public int Count { get; } + public bool Complete { get; } + public List Items { get; } + public Truncated(int max, IEnumerable iter) { - public int Max { get; } - public int Count { get; } - public bool Complete { get; } - public List Items { get; } - public Truncated(int max, IEnumerable items) + if (max <= 0) { - if (max <= 0) - { - throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0"); - } - Max = max; - Items = items.Take(max+1).ToList(); - if (Items.Count <= max) - { - Complete = true; - } - else - { - Items.RemoveAt(max); - Complete = false; - } - Count = Items.Count; + throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0"); } + Max = max; + Items = iter.Take(max+1).ToList(); + if (Items.Count <= max) + { + Complete = true; + } + else + { + Items.RemoveAt(max); + Complete = false; + } + Count = Items.Count; + } + private Truncated(int max, bool complete, List items) + { + Max = max; + Complete = complete; + Count = items.Count; + Items = items; + } + public static async Task> CreateAsync(int max, IQueryable iter) + { + if (max <= 0) + { + throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0"); + } + var items = await iter.Take(max+1).ToListAsync(); + var complete = true; + if (items.Count < max) + { + items.RemoveAt(max); + complete = false; + } + return new(max, complete, items); + } + public static async Task> CreateAsync(int max, IAsyncEnumerable iter) + { + if (max <= 0) + { + throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0"); + } + List items = []; + await foreach (T item in iter) + { + items.Add(item); + if (items.Count > max) + break; + } + var complete = true; + if (items.Count <= max) + { + items.RemoveAt(max); + complete = false; + } + return new Truncated(max, complete, items); } } diff --git a/Models/UserCreateRequest.cs b/Models/UserCreateRequest.cs index 95be75b..ceb7f68 100644 --- a/Models/UserCreateRequest.cs +++ b/Models/UserCreateRequest.cs @@ -1,12 +1,11 @@ using ComiServ.Entities; -namespace ComiServ.Models +namespace ComiServ.Models; + +public class UserCreateRequest { - public class UserCreateRequest - { - public string Username { get; set; } - public UserTypeEnum UserType { get; set; } - //NOT HASHED do not persist this object - public string Password { get; set; } - } + public string Username { get; set; } + public UserTypeEnum UserType { get; set; } + //NOT HASHED do not persist this object + public string Password { get; set; } } diff --git a/Models/UserDescription.cs b/Models/UserDescription.cs index f9c11d3..6318176 100644 --- a/Models/UserDescription.cs +++ b/Models/UserDescription.cs @@ -1,4 +1,6 @@ -namespace ComiServ.Models +namespace ComiServ.Models; + +public record class UserDescription(string Username, string Usertype) { - public record class UserDescription(string Username, string Usertype); + } diff --git a/Models/UserModifyRequest.cs b/Models/UserModifyRequest.cs index 951b068..35ec75b 100644 --- a/Models/UserModifyRequest.cs +++ b/Models/UserModifyRequest.cs @@ -1,11 +1,10 @@ using ComiServ.Entities; -namespace ComiServ.Models +namespace ComiServ.Models; + +public class UserModifyRequest { - public class UserModifyRequest - { - public string Username { get; set; } - public string? NewUsername { get; set; } - public UserTypeEnum? NewUserType { get; set; } - } + public string Username { get; set; } + public string? NewUsername { get; set; } + public UserTypeEnum? NewUserType { get; set; } } diff --git a/Services/AuthenticationService.cs b/Services/AuthenticationService.cs index 79f0802..7003fbb 100644 --- a/Services/AuthenticationService.cs +++ b/Services/AuthenticationService.cs @@ -1,33 +1,32 @@ using ComiServ.Entities; -namespace ComiServ.Services +namespace ComiServ.Services; + +public interface IAuthenticationService { - public interface IAuthenticationService + public bool Tested { get; } + public User? User { get; } + public void Authenticate(User user); + public void FailAuth(); +} +//acts as a per-request container of authentication info +public class AuthenticationService : IAuthenticationService +{ + public bool Tested { get; private set; } = false; + + public User? User { get; private set; } + public AuthenticationService() { - public bool Tested { get; } - public User? User { get; } - public void Authenticate(User user); - public void FailAuth(); + } - //acts as a per-request container of authentication info - public class AuthenticationService : IAuthenticationService + public void Authenticate(User user) { - public bool Tested { get; private set; } = false; - - public User? User { get; private set; } - public AuthenticationService() - { - - } - public void Authenticate(User user) - { - User = user; - Tested = true; - } - public void FailAuth() - { - User = null; - Tested = true; - } + User = user; + Tested = true; + } + public void FailAuth() + { + User = null; + Tested = true; } } diff --git a/Services/ComicAnalyzer.cs b/Services/ComicAnalyzer.cs index e207ad7..c208cb2 100644 --- a/Services/ComicAnalyzer.cs +++ b/Services/ComicAnalyzer.cs @@ -8,221 +8,227 @@ using System.IO.Compression; using System.IO.Hashing; using System.Linq; -namespace ComiServ.Background +namespace ComiServ.Background; + +public record class ComicAnalysis +( + long FileSizeBytes, + int PageCount, + Int64 Xxhash +); +public record class ComicPage +( + string Filename, + string Mime, + byte[] Data +); +public interface IComicAnalyzer { - public record class ComicAnalysis - ( - long FileSizeBytes, - int PageCount, - Int64 Xxhash - ); - public record class ComicPage - ( - string Filename, - string Mime, - byte[] Data - ); - public interface IComicAnalyzer + public static readonly IReadOnlyList ZIP_EXTS = [".cbz", ".zip"]; + public static readonly IReadOnlyList RAR_EXTS = [".cbr", ".rar"]; + public static readonly IReadOnlyList ZIP7_EXTS = [".cb7", ".7z"]; + public bool ComicFileExists(string filename); + public void DeleteComicFile(string filename); + //returns null on invalid filetype, throws on analysis error + public ComicAnalysis? AnalyzeComic(string filename); + public Task AnalyzeComicAsync(string filename); + //returns null if out of range, throws for file error + public ComicPage? GetComicPage(string filepath, int page); + public Task GetComicPageAsync(string filepath, int page); + //based purely on filename, doesn't try to open file + //returns null for ALL UNRECOGNIZED OR NON-IMAGES + public static string? GetImageMime(string filename) { - public static readonly IReadOnlyList ZIP_EXTS = [".cbz", ".zip"]; - public static readonly IReadOnlyList RAR_EXTS = [".cbr", ".rar"]; - public static readonly IReadOnlyList ZIP7_EXTS = [".cb7", ".7z"]; - public bool ComicFileExists(string filename); - public void DeleteComicFile(string filename); - //returns null on invalid filetype, throws on analysis error - public ComicAnalysis? AnalyzeComic(string filename); - public Task AnalyzeComicAsync(string filename); - //returns null if out of range, throws for file error - public ComicPage? GetComicPage(string filepath, int page); - //based purely on filename, doesn't try to open file - //returns null for ALL UNRECOGNIZED OR NON-IMAGES - public static string? GetImageMime(string filename) + if (new FileExtensionContentTypeProvider().TryGetContentType(filename, out string? _mime)) + { + if (_mime?.StartsWith("image") ?? false) + return _mime; + } + return null; + } +} +//async methods actually just block +public class SynchronousComicAnalyzer(ILogger? logger) + : IComicAnalyzer +{ + private readonly ILogger? _logger = logger; + public bool ComicFileExists(string filename) + { + return File.Exists(filename); + } + public void DeleteComicFile(string filename) + { + try + { + File.Delete(filename); + } + catch (DirectoryNotFoundException) + { + return; + } + } + public ComicAnalysis? AnalyzeComic(string filepath) + { + _logger?.LogTrace($"Analyzing comic: {filepath}"); + var ext = new FileInfo(filepath).Extension.ToLower(); + if (IComicAnalyzer.ZIP_EXTS.Contains(ext)) + return ZipAnalyze(filepath); + else if (IComicAnalyzer.RAR_EXTS.Contains(ext)) + return RarAnalyze(filepath); + else if (IComicAnalyzer.ZIP7_EXTS.Contains(ext)) + return Zip7Analyze(filepath); + else + //throw new ArgumentException("Cannot analyze this file type"); + return null; + } +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async Task AnalyzeComicAsync(string filename) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + return AnalyzeComic(filename); + } + protected ComicAnalysis ZipAnalyze(string filepath) + { + var filedata = File.ReadAllBytes(filepath); + var hash = ComputeHash(filedata); + using var stream = new MemoryStream(filedata); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, false); + return new + ( + FileSizeBytes: filedata.LongLength, + PageCount: archive.Entries.Count, + Xxhash: hash + ); + } + protected ComicAnalysis RarAnalyze(string filepath) + { + var filedata = File.ReadAllBytes(filepath); + var hash = ComputeHash(filedata); + using var stream = new MemoryStream(filedata); + using var rar = RarArchive.Open(stream, new SharpCompress.Readers.ReaderOptions() + { + LeaveStreamOpen = false + }); + return new + ( + FileSizeBytes: filedata.LongLength, + PageCount: rar.Entries.Count, + Xxhash: hash + ); + } + protected ComicAnalysis Zip7Analyze(string filepath) + { + var filedata = File.ReadAllBytes(filepath); + var hash = ComputeHash(filedata); + using var stream = new MemoryStream(filedata); + using var zip7 = SevenZipArchive.Open(stream, new SharpCompress.Readers.ReaderOptions() + { + LeaveStreamOpen = false + }); + return new + ( + FileSizeBytes: filedata.LongLength, + PageCount: zip7.Entries.Count, + Xxhash: hash + ); + } + protected static Int64 ComputeHash(ReadOnlySpan data) + => unchecked((Int64)XxHash64.HashToUInt64(data)); + + public ComicPage? GetComicPage(string filepath, int page) + { + var fi = new FileInfo(filepath); + var ext = fi.Extension; + if (IComicAnalyzer.ZIP_EXTS.Contains(ext)) + return GetPageZip(filepath, page); + else if (IComicAnalyzer.RAR_EXTS.Contains(ext)) + return GetPageRar(filepath, page); + else if (IComicAnalyzer.ZIP7_EXTS.Contains(ext)) + return GetPage7Zip(filepath, page); + else return null; + } +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async Task GetComicPageAsync(string filepath, int page) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + return GetComicPage(filepath, page); + } + protected ComicPage? GetPageZip(string filepath, int page) + { + Debug.Assert(page >= 1, "Page number must be positive"); + try + { + using var fileStream = new FileStream(filepath, FileMode.Open); + using var arc = new ZipArchive(fileStream, ZipArchiveMode.Read, false); + (var entry, var mime) = arc.Entries + .Select((ZipArchiveEntry e) => (e, IComicAnalyzer.GetImageMime(e.Name))) + .Where(static pair => pair.Item2 is not null) + .OrderBy(static pair => pair.Item1.FullName) + .Skip(page - 1) + .FirstOrDefault(); + if (entry is null || mime is null) + return null; + using var pageStream = entry.Open(); + using var pageStream2 = new MemoryStream(); + pageStream.CopyTo(pageStream2); + pageStream2.Seek(0, SeekOrigin.Begin); + var pageData = pageStream2.ToArray(); + return new + ( + Filename: entry.Name, + Mime: mime, + Data: pageData + ); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) { - if (new FileExtensionContentTypeProvider().TryGetContentType(filename, out string? _mime)) - { - if (_mime?.StartsWith("image") ?? false) - return _mime; - } return null; } } - //async methods actually just block - public class SynchronousComicAnalyzer(ILogger? logger) - : IComicAnalyzer + protected ComicPage? GetPageRar(string filepath, int page) { - private readonly ILogger? _logger = logger; - public bool ComicFileExists(string filename) - { - return File.Exists(filename); - } - public void DeleteComicFile(string filename) - { - try - { - File.Delete(filename); - } - catch (DirectoryNotFoundException) - { - return; - } - } - public ComicAnalysis? AnalyzeComic(string filepath) - { - _logger?.LogTrace($"Analyzing comic: {filepath}"); - var ext = new FileInfo(filepath).Extension.ToLower(); - if (IComicAnalyzer.ZIP_EXTS.Contains(ext)) - return ZipAnalyze(filepath); - else if (IComicAnalyzer.RAR_EXTS.Contains(ext)) - return RarAnalyze(filepath); - else if (IComicAnalyzer.ZIP7_EXTS.Contains(ext)) - return Zip7Analyze(filepath); - else - //throw new ArgumentException("Cannot analyze this file type"); - return null; - } -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - public async Task AnalyzeComicAsync(string filename) -#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously - { - return AnalyzeComic(filename); - } - protected ComicAnalysis ZipAnalyze(string filepath) - { - var filedata = File.ReadAllBytes(filepath); - var hash = ComputeHash(filedata); - using var stream = new MemoryStream(filedata); - using var archive = new ZipArchive(stream, ZipArchiveMode.Read, false); - return new - ( - FileSizeBytes: filedata.LongLength, - PageCount: archive.Entries.Count, - Xxhash: hash - ); - } - protected ComicAnalysis RarAnalyze(string filepath) - { - var filedata = File.ReadAllBytes(filepath); - var hash = ComputeHash(filedata); - using var stream = new MemoryStream(filedata); - using var rar = RarArchive.Open(stream, new SharpCompress.Readers.ReaderOptions() - { - LeaveStreamOpen = false - }); - return new - ( - FileSizeBytes: filedata.LongLength, - PageCount: rar.Entries.Count, - Xxhash: hash - ); - } - protected ComicAnalysis Zip7Analyze(string filepath) - { - var filedata = File.ReadAllBytes(filepath); - var hash = ComputeHash(filedata); - using var stream = new MemoryStream(filedata); - using var zip7 = SevenZipArchive.Open(stream, new SharpCompress.Readers.ReaderOptions() - { - LeaveStreamOpen = false - }); - return new - ( - FileSizeBytes: filedata.LongLength, - PageCount: zip7.Entries.Count, - Xxhash: hash - ); - } - protected static Int64 ComputeHash(ReadOnlySpan data) - => unchecked((Int64)XxHash64.HashToUInt64(data)); - - public ComicPage? GetComicPage(string filepath, int page) - { - var fi = new FileInfo(filepath); - var ext = fi.Extension; - if (IComicAnalyzer.ZIP_EXTS.Contains(ext)) - return GetPageZip(filepath, page); - else if (IComicAnalyzer.RAR_EXTS.Contains(ext)) - return GetPageRar(filepath, page); - else if (IComicAnalyzer.ZIP7_EXTS.Contains(ext)) - return GetPage7Zip(filepath, page); - else return null; - } - protected ComicPage? GetPageZip(string filepath, int page) - { - Debug.Assert(page >= 1, "Page number must be positive"); - try - { - using var fileStream = new FileStream(filepath, FileMode.Open); - using var arc = new ZipArchive(fileStream, ZipArchiveMode.Read, false); - (var entry, var mime) = arc.Entries - .Select((ZipArchiveEntry e) => (e, IComicAnalyzer.GetImageMime(e.Name))) - .Where(static pair => pair.Item2 is not null) - .OrderBy(static pair => pair.Item1.FullName) - .Skip(page - 1) - .FirstOrDefault(); - if (entry is null || mime is null) - return null; - using var pageStream = entry.Open(); - using var pageStream2 = new MemoryStream(); - pageStream.CopyTo(pageStream2); - pageStream2.Seek(0, SeekOrigin.Begin); - var pageData = pageStream2.ToArray(); - return new - ( - Filename: entry.Name, - Mime: mime, - Data: pageData - ); - } - catch (FileNotFoundException) - { - return null; - } - catch (DirectoryNotFoundException) - { - return null; - } - } - protected ComicPage? GetPageRar(string filepath, int page) - { - using var rar = RarArchive.Open(filepath); - (var entry, var mime) = rar.Entries - .Select((RarArchiveEntry e) => (e, IComicAnalyzer.GetImageMime(e.Key))) - .Where(static pair => pair.Item2 is not null) - .OrderBy(static pair => pair.Item1.Key) - .Skip(page - 1) - .FirstOrDefault(); - if (entry is null || mime is null) - return null; - using var stream = new MemoryStream(); - entry.WriteTo(stream); - var pageData = stream.ToArray(); - return new - ( - Filename: entry.Key ?? "", - Mime: mime, - Data: pageData - ); - } - protected ComicPage? GetPage7Zip(string filepath, int page) - { - using var zip7 = SevenZipArchive.Open(filepath); - (var entry, var mime) = zip7.Entries - .Select((SevenZipArchiveEntry e) => (e, IComicAnalyzer.GetImageMime(e.Key))) - .Where(static pair => pair.Item2 is not null) - .OrderBy(static pair => pair.Item1.Key) - .Skip(page - 1) - .FirstOrDefault(); - if (entry is null || mime is null) - return null; - using var stream = new MemoryStream(); - entry.WriteTo(stream); - var pageData = stream.ToArray(); - return new - ( - Filename: entry.Key ?? "", - Mime: mime, - Data: pageData - ); - } + using var rar = RarArchive.Open(filepath); + (var entry, var mime) = rar.Entries + .Select((RarArchiveEntry e) => (e, IComicAnalyzer.GetImageMime(e.Key))) + .Where(static pair => pair.Item2 is not null) + .OrderBy(static pair => pair.Item1.Key) + .Skip(page - 1) + .FirstOrDefault(); + if (entry is null || mime is null) + return null; + using var stream = new MemoryStream(); + entry.WriteTo(stream); + var pageData = stream.ToArray(); + return new + ( + Filename: entry.Key ?? "", + Mime: mime, + Data: pageData + ); + } + protected ComicPage? GetPage7Zip(string filepath, int page) + { + using var zip7 = SevenZipArchive.Open(filepath); + (var entry, var mime) = zip7.Entries + .Select((SevenZipArchiveEntry e) => (e, IComicAnalyzer.GetImageMime(e.Key))) + .Where(static pair => pair.Item2 is not null) + .OrderBy(static pair => pair.Item1.Key) + .Skip(page - 1) + .FirstOrDefault(); + if (entry is null || mime is null) + return null; + using var stream = new MemoryStream(); + entry.WriteTo(stream); + var pageData = stream.ToArray(); + return new + ( + Filename: entry.Key ?? "", + Mime: mime, + Data: pageData + ); } } diff --git a/Services/ComicScanner.cs b/Services/ComicScanner.cs index ba8e65c..249ea9c 100644 --- a/Services/ComicScanner.cs +++ b/Services/ComicScanner.cs @@ -9,185 +9,201 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration.Ini; using Microsoft.OpenApi.Writers; -namespace ComiServ.Background -{ - public record class ComicScanItem - ( - string Filepath, - long FileSizeBytes, - Int64 Xxhash, - int PageCount - ); - public interface IComicScanner : IDisposable - { - //TODO should be configurable - public static readonly IReadOnlyList COMIC_EXTENSIONS = [ - "cbz", "zip", - "cbr", "rar", - "cb7", "7zip", - ]; - public void TriggerLibraryScan(); - public void ScheduleRepeatedLibraryScans(TimeSpan period); - public IDictionary PerfomLibraryScan(CancellationToken? token = null); - } - public class ComicScanner( - IServiceProvider provider - ) : IComicScanner - { - //private readonly ComicsContext _context = context; - private readonly ITaskManager _manager = provider.GetRequiredService(); - private readonly Configuration _config = provider.GetRequiredService().Config; - private readonly IComicAnalyzer _analyzer = provider.GetRequiredService(); - private readonly IServiceProvider _provider = provider; +namespace ComiServ.Background; - public IDictionary PerfomLibraryScan(CancellationToken? token = null) - { - return new DirectoryInfo(_config.LibraryRoot).EnumerateFiles("*", SearchOption.AllDirectories) - .Select(fi => - { - token?.ThrowIfCancellationRequested(); - var path = Path.GetRelativePath(_config.LibraryRoot, fi.FullName); - var analysis = _analyzer.AnalyzeComic(fi.FullName); - if (analysis is null) - //null will be filtered - return (path, null); - return (path, new ComicScanItem - ( - Filepath: path, - FileSizeBytes: analysis.FileSizeBytes, - Xxhash: analysis.Xxhash, - PageCount: analysis.PageCount - )); - }) - //ignore files of the wrong extension - .Where(p => p.Item2 is not null) - .ToDictionary(); - } - public void TriggerLibraryScan() - { - TaskItem ti = new( - TaskTypes.Scan, - "Library Scan", - token => - { - var items = PerfomLibraryScan(token); - token?.ThrowIfCancellationRequested(); - UpdateDatabaseWithScanResults(items); - }, - null); - _manager.StartTask(ti); - } - private CancellationTokenSource? RepeatedLibraryScanTokenSource = null; - public void ScheduleRepeatedLibraryScans(TimeSpan interval) - { - RepeatedLibraryScanTokenSource?.Cancel(); - RepeatedLibraryScanTokenSource?.Dispose(); - RepeatedLibraryScanTokenSource = new(); - TaskItem ti = new( - TaskTypes.Scan, - "Scheduled Library Scan", - token => - { - var items = PerfomLibraryScan(token); - token?.ThrowIfCancellationRequested(); - UpdateDatabaseWithScanResults(items); - }, - RepeatedLibraryScanTokenSource.Token); - _manager.ScheduleTask(ti, interval); - } - public void UpdateDatabaseWithScanResults(IDictionary items) - { - using var scope = _provider.CreateScope(); - var services = scope.ServiceProvider; - using var context = services.GetRequiredService(); - //not an ideal algorithm - //need to go through every comic in the database to update `Exists` - //also need to go through every discovered comic to add new ones - //and should make sure not to double up on the overlaps - //there should be a faster method than using ExceptBy but I don't it's urgent - //TODO profile on large database - SortedSet alreadyExistingFiles = []; - foreach (var comic in context.Comics) - { - ComicScanItem info; - if (items.TryGetValue(comic.Filepath, out info)) - { - comic.FileXxhash64 = info.Xxhash; - comic.Exists = true; - comic.PageCount = info.PageCount; - comic.SizeBytes = info.FileSizeBytes; - alreadyExistingFiles.Add(comic.Filepath); - } - else - { - comic.Exists = false; - } - } - var newComics = items.ExceptBy(alreadyExistingFiles, p => p.Key).Select(p => - new Comic() - { - Handle = context.CreateHandle(), - Exists = true, - Filepath = p.Value.Filepath, - Title = new FileInfo(p.Value.Filepath).Name, - Description = "", - SizeBytes = p.Value.FileSizeBytes, - FileXxhash64 = p.Value.Xxhash, - PageCount = p.Value.PageCount - }).ToList(); - newComics.ForEach(c => _manager.StartTask(new( - TaskTypes.GetCover, - $"Get Cover: {c.Title}", - token => InsertCover(Path.Join(_config.LibraryRoot, c.Filepath), c.FileXxhash64) - ))); - newComics.ForEach(c => _manager.StartTask(new( - TaskTypes.MakeThumbnail, - $"Make Thumbnail: {c.Title}", - token => InsertThumbnail(c.Handle, Path.Join(_config.LibraryRoot, c.Filepath), 1) - ))); - context.Comics.AddRange(newComics); - context.SaveChanges(); - } - protected void InsertCover(string filepath, long hash) - { - using var scope = _provider.CreateScope(); - var services = scope.ServiceProvider; - using var context = services.GetRequiredService(); - var existing = context.Covers.SingleOrDefault(c => c.FileXxhash64 == hash); - //assuming no hash overlap - //if you already have a cover, assume it's correct - if (existing is not null) - return; - var page = _analyzer.GetComicPage(filepath, 1); - if (page is null) - return; - Cover cover = new() - { - FileXxhash64 = hash, - Filename = page.Filename, - CoverFile = page.Data - }; - context.InsertOrIgnore(cover, true); - } - protected void InsertThumbnail(string handle, string filepath, int page = 1) - { - using var scope = _provider.CreateScope(); - var services = scope.ServiceProvider; - using var context = services.GetRequiredService(); - var comic = context.Comics.SingleOrDefault(c => c.Handle == handle); - if (comic?.ThumbnailWebp is null) - return; - var comicPage = _analyzer.GetComicPage(filepath, page); - if (comicPage is null) - return; - var converter = services.GetRequiredService(); - using var inStream = new MemoryStream(comicPage.Data); - var outStream = converter.MakeThumbnail(inStream); - comic.ThumbnailWebp = outStream.ReadAllBytes(); - } - public void Dispose() - { - RepeatedLibraryScanTokenSource?.Dispose(); - } - } +public record class ComicScanItem +( + string Filepath, + long FileSizeBytes, + Int64 Xxhash, + int PageCount +); +public interface IComicScanner : IDisposable +{ + //TODO should be configurable + public static readonly IReadOnlyList COMIC_EXTENSIONS = [ + "cbz", "zip", + "cbr", "rar", + "cb7", "7zip", + ]; + public void TriggerLibraryScan(); + public void ScheduleRepeatedLibraryScans(TimeSpan period); + public IDictionary PerfomLibraryScan(CancellationToken? token = null); } +public class ComicScanner( + IServiceProvider provider + ) : IComicScanner +{ + //private readonly ComicsContext _context = context; + private readonly ITaskManager _manager = provider.GetRequiredService(); + private readonly Configuration _config = provider.GetRequiredService().Config; + private readonly IComicAnalyzer _analyzer = provider.GetRequiredService(); + private readonly IServiceProvider _provider = provider; + + public IDictionary PerfomLibraryScan(CancellationToken? token = null) + { + return new DirectoryInfo(_config.LibraryRoot).EnumerateFiles("*", SearchOption.AllDirectories) + .Select(fi => + { + token?.ThrowIfCancellationRequested(); + var path = Path.GetRelativePath(_config.LibraryRoot, fi.FullName); + var analysis = _analyzer.AnalyzeComic(fi.FullName); + if (analysis is null) + //null will be filtered + return (path, null); + return (path, new ComicScanItem + ( + Filepath: path, + FileSizeBytes: analysis.FileSizeBytes, + Xxhash: analysis.Xxhash, + PageCount: analysis.PageCount + )); + }) + //ignore files of the wrong extension + .Where(p => p.Item2 is not null) + .ToDictionary(); + } + public void TriggerLibraryScan() + { + SyncTaskItem ti = new( + TaskTypes.Scan, + "Library Scan", + async token => + { + var items = PerfomLibraryScan(token); + token?.ThrowIfCancellationRequested(); + await UpdateDatabaseWithScanResults(items); + }, + null); + _manager.StartTask(ti); + } + private CancellationTokenSource? RepeatedLibraryScanTokenSource = null; + public void ScheduleRepeatedLibraryScans(TimeSpan interval) + { + RepeatedLibraryScanTokenSource?.Cancel(); + RepeatedLibraryScanTokenSource?.Dispose(); + RepeatedLibraryScanTokenSource = new(); + AsyncTaskItem ti = new( + TaskTypes.Scan, + "Scheduled Library Scan", + async token => + { + var items = PerfomLibraryScan(token); + token?.ThrowIfCancellationRequested(); + await UpdateDatabaseWithScanResults(items); + }, + RepeatedLibraryScanTokenSource.Token); + _manager.ScheduleTask(ti, interval); + } + public async Task UpdateDatabaseWithScanResults(IDictionary items) + { + using var scope = _provider.CreateScope(); + var services = scope.ServiceProvider; + using var context = services.GetRequiredService(); + //not an ideal algorithm + //need to go through every comic in the database to update `Exists` + //also need to go through every discovered comic to add new ones + //and should make sure not to double up on the overlaps + //there should be a faster method than using ExceptBy but I don't it's urgent + //TODO profile on large database + SortedSet alreadyExistingFiles = []; + foreach (var comic in context.Comics) + { + ComicScanItem info; + if (items.TryGetValue(comic.Filepath, out info)) + { + comic.FileXxhash64 = info.Xxhash; + comic.Exists = true; + comic.PageCount = info.PageCount; + comic.SizeBytes = info.FileSizeBytes; + alreadyExistingFiles.Add(comic.Filepath); + } + else + { + comic.Exists = false; + } + } + var newComics = items.ExceptBy(alreadyExistingFiles, p => p.Key).Select(p => + new Comic() + { + Handle = context.CreateHandle(), + Exists = true, + Filepath = p.Value.Filepath, + Title = new FileInfo(p.Value.Filepath).Name, + Description = "", + SizeBytes = p.Value.FileSizeBytes, + FileXxhash64 = p.Value.Xxhash, + PageCount = p.Value.PageCount + }).ToList(); + //newComics.ForEach(c => _manager.StartTask(new( + // TaskTypes.GetCover, + // $"Get Cover: {c.Title}", + // token => InsertCover(Path.Join(_config.LibraryRoot, c.Filepath), c.FileXxhash64) + // ))); + foreach (var comic in newComics) + { + _manager.StartTask((AsyncTaskItem)new( + TaskTypes.GetCover, + $"Get Cover: {comic.Title}", + token => InsertCover(Path.Join(_config.LibraryRoot, comic.Filepath), comic.FileXxhash64) + )); + } + //newComics.ForEach(c => _manager.StartTask(new( + // TaskTypes.MakeThumbnail, + // $"Make Thumbnail: {c.Title}", + // token => InsertThumbnail(c.Handle, Path.Join(_config.LibraryRoot, c.Filepath), 1) + // ))); + foreach (var comic in newComics) + { + _manager.StartTask((AsyncTaskItem)new( + TaskTypes.MakeThumbnail, + $"Make Thumbnail: {comic.Title}", + token => InsertThumbnail(comic.Handle, Path.Join(_config.LibraryRoot, comic.Filepath), 1) + )); + } + context.Comics.AddRange(newComics); + await context.SaveChangesAsync(); + } + protected async Task InsertCover(string filepath, long hash) + { + using var scope = _provider.CreateScope(); + var services = scope.ServiceProvider; + using var context = services.GetRequiredService(); + var existing = await context.Covers.SingleOrDefaultAsync(c => c.FileXxhash64 == hash); + //assuming no hash overlap + //if you already have a cover, assume it's correct + if (existing is not null) + return; + var page = await _analyzer.GetComicPageAsync(filepath, 1); + if (page is null) + return; + Cover cover = new() + { + FileXxhash64 = hash, + Filename = page.Filename, + CoverFile = page.Data + }; + context.InsertOrIgnore(cover, true); + } + protected async Task InsertThumbnail(string handle, string filepath, int page = 1) + { + using var scope = _provider.CreateScope(); + var services = scope.ServiceProvider; + using var context = services.GetRequiredService(); + var comic = await context.Comics.SingleOrDefaultAsync(c => c.Handle == handle); + if (comic?.ThumbnailWebp is null) + return; + var comicPage = _analyzer.GetComicPage(filepath, page); + if (comicPage is null) + return; + var converter = services.GetRequiredService(); + using var inStream = new MemoryStream(comicPage.Data); + var outStream = await converter.MakeThumbnail(inStream); + comic.ThumbnailWebp = outStream.ReadAllBytes(); + } + public void Dispose() + { + RepeatedLibraryScanTokenSource?.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/Services/JsonConfigService.cs b/Services/JsonConfigService.cs index 0fb11ff..5a00a52 100644 --- a/Services/JsonConfigService.cs +++ b/Services/JsonConfigService.cs @@ -1,31 +1,30 @@ using System.Text.Json; -namespace ComiServ.Services +namespace ComiServ.Services; + +public class Configuration { - public class Configuration + public string LibraryRoot { get; set; } + public string DatabaseFile { get; set; } + public double AutoScanPeriodHours { get; set; } + public Configuration Copy() + => MemberwiseClone() as Configuration + //this really shouldn't be possible + ?? throw new Exception("Failed to clone configuration"); +} +public interface IConfigService +{ + public Configuration Config { get; } +} +public class JsonConfigService : IConfigService +{ + public Configuration _Config; + //protect original + public Configuration Config => _Config.Copy(); + public JsonConfigService(string filepath) { - public string LibraryRoot { get; set; } - public string DatabaseFile { get; set; } - public double AutoScanPeriodHours { get; set; } - public Configuration Copy() - => MemberwiseClone() as Configuration - //this really shouldn't be possible - ?? throw new Exception("Failed to clone configuration"); - } - public interface IConfigService - { - public Configuration Config { get; } - } - public class JsonConfigService : IConfigService - { - public Configuration _Config; - //protect original - public Configuration Config => _Config.Copy(); - public JsonConfigService(string filepath) - { - using var fileStream = File.OpenRead(filepath); - _Config = JsonSerializer.Deserialize(fileStream) - ?? throw new ArgumentException("Failed to parse config file"); - } + using var fileStream = File.OpenRead(filepath); + _Config = JsonSerializer.Deserialize(fileStream) + ?? throw new ArgumentException("Failed to parse config file"); } } diff --git a/Services/PictureConverter.cs b/Services/PictureConverter.cs index 15041e0..7cd5328 100644 --- a/Services/PictureConverter.cs +++ b/Services/PictureConverter.cs @@ -11,152 +11,151 @@ using SixLabors.ImageSharp.Formats.Bmp; using System.Text.Json.Serialization; using Microsoft.AspNetCore.StaticFiles; -namespace ComiServ.Background +namespace ComiServ.Background; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PictureFormats { - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum PictureFormats + Webp, + Jpg, + Png, + Gif, + Bmp, +} +//never closes stream! +public interface IPictureConverter +{ + public static System.Drawing.Size ThumbnailResolution => new(200, 320); + public static PictureFormats ThumbnailFormat => PictureFormats.Webp; + //keeps aspect ratio, crops to horizontally to center, vertically to top + //uses System.Drawing.Size so interface isn't dependant on ImageSharp + public Task Resize(Stream image, System.Drawing.Size newSize, PictureFormats? newFormat = null); + public Task ResizeIfBigger(Stream image, System.Drawing.Size maxSize, PictureFormats? newFormat = null); + public Task MakeThumbnail(Stream image); + public static string GetMime(PictureFormats format) { - Webp, - Jpg, - Png, - Gif, - Bmp, - } - //never closes stream! - public interface IPictureConverter - { - public static System.Drawing.Size ThumbnailResolution => new(200, 320); - public static PictureFormats ThumbnailFormat => PictureFormats.Webp; - //keeps aspect ratio, crops to horizontally to center, vertically to top - //uses System.Drawing.Size so interface isn't dependant on ImageSharp - public Stream Resize(Stream image, System.Drawing.Size newSize, PictureFormats? newFormat = null); - public Stream ResizeIfBigger(Stream image, System.Drawing.Size maxSize, PictureFormats? newFormat = null); - public Stream MakeThumbnail(Stream image); - public static string GetMime(PictureFormats format) + switch (format) { - switch (format) - { - case PictureFormats.Webp: - return "image/webp"; - case PictureFormats.Gif: - return "image/gif"; - case PictureFormats.Jpg: - return "image/jpeg"; - case PictureFormats.Bmp: - return "image/bmp"; - case PictureFormats.Png: - return "image/png"; - default: - throw new ArgumentException("Cannot handle this format", nameof(format)); - } - } - } - public class ResharperPictureConverter(bool webpLossless = false) - : IPictureConverter - { - public static IImageFormat ConvertFormatEnum(PictureFormats format) - { - switch (format) - { - case PictureFormats.Webp: - return WebpFormat.Instance; - case PictureFormats.Jpg: - return JpegFormat.Instance; - case PictureFormats.Png: - return PngFormat.Instance; - case PictureFormats.Gif: - return GifFormat.Instance; - case PictureFormats.Bmp: - return BmpFormat.Instance; - default: - throw new ArgumentException("Cannot handle this format", nameof(format)); - } - } - public bool WebpLossless { get; } = webpLossless; - public Stream Resize(Stream image, System.Drawing.Size newSize, PictureFormats? newFormat = null) - { - using var img = Image.Load(image); - IImageFormat format; - if (newFormat is PictureFormats nf) - format = ConvertFormatEnum(nf); - else if (img.Metadata.DecodedImageFormat is IImageFormat iif) - format = img.Metadata.DecodedImageFormat; - else - format = WebpFormat.Instance; - double oldAspect = ((double)img.Height) / img.Width; - double newAspect = ((double)newSize.Height) / newSize.Width; - Rectangle sourceRect; - if (newAspect > oldAspect) - { - var y = 0; - var h = newSize.Height; - var w = (int)(h / newAspect); - var x = (img.Width - w) / 2; - sourceRect = new Rectangle(x, y, w, h); - } - else - { - var x = 0; - var w = newSize.Width; - var h = (int)(w * newAspect); - var y = 0; - sourceRect = new Rectangle(x, y, w, h); - } - img.Mutate(c => c.Crop(sourceRect).Resize(new Size(newSize.Width, newSize.Height))); - var outStream = new MemoryStream(); - if (format is WebpFormat) - { - var enc = new WebpEncoder() - { - FileFormat = WebpLossless ? WebpFileFormatType.Lossless : WebpFileFormatType.Lossy - }; - img.Save(outStream, enc); - } - else - { - img.Save(outStream, format); - } - return outStream; - } - public Stream ResizeIfBigger(Stream image, System.Drawing.Size maxSize, PictureFormats? newFormat = null) - { - using Image img = Image.Load(image); - IImageFormat format; - if (newFormat is PictureFormats nf) - format = ConvertFormatEnum(nf); - else if (img.Metadata.DecodedImageFormat is IImageFormat iif) - format = img.Metadata.DecodedImageFormat; - else - format = WebpFormat.Instance; - double scale = 1; - if (img.Size.Width > maxSize.Width) - { - scale = Math.Min(scale, ((double)maxSize.Width) / img.Size.Width); - } - if (img.Size.Height > maxSize.Height) - { - scale = Math.Min(scale, ((double)maxSize.Height) / img.Size.Height); - } - Size newSize = new((int)(img.Size.Width * scale), (int)(img.Size.Height * scale)); - img.Mutate(c => c.Resize(newSize)); - var outStream = new MemoryStream(); - if (format is WebpFormat) - { - var enc = new WebpEncoder() - { - FileFormat = WebpLossless ? WebpFileFormatType.Lossless : WebpFileFormatType.Lossy - }; - img.Save(outStream, enc); - } - else - { - img.Save(outStream, format); - } - return outStream; - } - public Stream MakeThumbnail(Stream image) - { - return Resize(image, IPictureConverter.ThumbnailResolution, IPictureConverter.ThumbnailFormat); + case PictureFormats.Webp: + return "image/webp"; + case PictureFormats.Gif: + return "image/gif"; + case PictureFormats.Jpg: + return "image/jpeg"; + case PictureFormats.Bmp: + return "image/bmp"; + case PictureFormats.Png: + return "image/png"; + default: + throw new ArgumentException("Cannot handle this format", nameof(format)); } } } +public class ResharperPictureConverter(bool webpLossless = false) + : IPictureConverter +{ + public static IImageFormat ConvertFormatEnum(PictureFormats format) + { + switch (format) + { + case PictureFormats.Webp: + return WebpFormat.Instance; + case PictureFormats.Jpg: + return JpegFormat.Instance; + case PictureFormats.Png: + return PngFormat.Instance; + case PictureFormats.Gif: + return GifFormat.Instance; + case PictureFormats.Bmp: + return BmpFormat.Instance; + default: + throw new ArgumentException("Cannot handle this format", nameof(format)); + } + } + public bool WebpLossless { get; } = webpLossless; + public async Task Resize(Stream image, System.Drawing.Size newSize, PictureFormats? newFormat = null) + { + using var img = Image.Load(image); + IImageFormat format; + if (newFormat is PictureFormats nf) + format = ConvertFormatEnum(nf); + else if (img.Metadata.DecodedImageFormat is IImageFormat iif) + format = img.Metadata.DecodedImageFormat; + else + format = WebpFormat.Instance; + double oldAspect = ((double)img.Height) / img.Width; + double newAspect = ((double)newSize.Height) / newSize.Width; + Rectangle sourceRect; + if (newAspect > oldAspect) + { + var y = 0; + var h = newSize.Height; + var w = (int)(h / newAspect); + var x = (img.Width - w) / 2; + sourceRect = new Rectangle(x, y, w, h); + } + else + { + var x = 0; + var w = newSize.Width; + var h = (int)(w * newAspect); + var y = 0; + sourceRect = new Rectangle(x, y, w, h); + } + img.Mutate(c => c.Crop(sourceRect).Resize(new Size(newSize.Width, newSize.Height))); + var outStream = new MemoryStream(); + if (format is WebpFormat) + { + var enc = new WebpEncoder() + { + FileFormat = WebpLossless ? WebpFileFormatType.Lossless : WebpFileFormatType.Lossy + }; + await img.SaveAsync(outStream, enc); + } + else + { + await img.SaveAsync(outStream, format); + } + return outStream; + } + public async Task ResizeIfBigger(Stream image, System.Drawing.Size maxSize, PictureFormats? newFormat = null) + { + using Image img = Image.Load(image); + IImageFormat format; + if (newFormat is PictureFormats nf) + format = ConvertFormatEnum(nf); + else if (img.Metadata.DecodedImageFormat is IImageFormat iif) + format = img.Metadata.DecodedImageFormat; + else + format = WebpFormat.Instance; + double scale = 1; + if (img.Size.Width > maxSize.Width) + { + scale = Math.Min(scale, ((double)maxSize.Width) / img.Size.Width); + } + if (img.Size.Height > maxSize.Height) + { + scale = Math.Min(scale, ((double)maxSize.Height) / img.Size.Height); + } + Size newSize = new((int)(img.Size.Width * scale), (int)(img.Size.Height * scale)); + img.Mutate(c => c.Resize(newSize)); + var outStream = new MemoryStream(); + if (format is WebpFormat) + { + var enc = new WebpEncoder() + { + FileFormat = WebpLossless ? WebpFileFormatType.Lossless : WebpFileFormatType.Lossy + }; + await img.SaveAsync(outStream, enc); + } + else + { + await img.SaveAsync(outStream, format); + } + return outStream; + } + public async Task MakeThumbnail(Stream image) + { + return await Resize(image, IPictureConverter.ThumbnailResolution, IPictureConverter.ThumbnailFormat); + } +} diff --git a/Services/TaskManager.cs b/Services/TaskManager.cs index e0f8e9a..de81ece 100644 --- a/Services/TaskManager.cs +++ b/Services/TaskManager.cs @@ -1,101 +1,165 @@ -using System.Collections.Concurrent; +using NuGet.Common; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Xml.Linq; -namespace ComiServ.Background +namespace ComiServ.Services; + +public enum TaskTypes { - public enum TaskTypes + Scan, + GetCover, + MakeThumbnail, +} +public abstract class BaseTaskItem +{ + public readonly TaskTypes Type; + public readonly string Name; + public readonly CancellationToken Token; + protected BaseTaskItem(TaskTypes type, string name, CancellationToken? token = null) { - Scan, - GetCover, - MakeThumbnail, + Type = type; + Name = name; + Token = token ?? CancellationToken.None; } - //task needs to use the token parameter rather than its own token, because it gets merged with the master token - public class TaskItem(TaskTypes type, string name, Action action, CancellationToken? token = null) +} +//task needs to use the token parameter rather than its own token, because it gets merged with the master token +public class SyncTaskItem + : BaseTaskItem +{ + public readonly Action Action; + public SyncTaskItem(TaskTypes type, string name, Action action, CancellationToken? token = null) + : base(type, name, token) { - public readonly TaskTypes Type = type; - public readonly string Name = name; - public readonly Action Action = action; - public readonly CancellationToken Token = token ?? CancellationToken.None; + Action = action; } - public interface ITaskManager : IDisposable +} +public class AsyncTaskItem + : BaseTaskItem +{ + public readonly Func AsyncAction; + public AsyncTaskItem(TaskTypes type, string name, Func asyncAction, CancellationToken? token = null) + : base(type, name, token) { - public void StartTask(TaskItem taskItem); - public void ScheduleTask(TaskItem taskItem, TimeSpan interval); - public string[] GetTasks(int limit); - public void CancelAll(); + AsyncAction = asyncAction; } - public class TaskManager(ILogger? logger) - : ITaskManager +} +public interface ITaskManager : IDisposable +{ + public void StartTask(SyncTaskItem taskItem); + public void StartTask(AsyncTaskItem taskItem); + public void ScheduleTask(BaseTaskItem taskItem, TimeSpan interval); + public string[] GetTasks(int limit); + public void CancelAll(); +} +public class TaskManager(ILogger? logger) + : ITaskManager +{ + private readonly ConcurrentDictionary ActiveTasks = []; + private CancellationTokenSource MasterToken { get; set; } = new(); + private readonly ILogger? _logger = logger; + private readonly ConcurrentDictionary Scheduled = []; + public void StartTask(SyncTaskItem taskItem) { - private readonly ConcurrentDictionary ActiveTasks = []; - private CancellationTokenSource MasterToken { get; set; } = new(); - private readonly ILogger? _logger = logger; - private readonly ConcurrentDictionary Scheduled = []; - public void StartTask(TaskItem taskItem) + //_logger?.LogTrace($"Start Task: {taskItem.Name}"); + var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token); + var newTask = Task.Run(() => taskItem.Action(tokenSource.Token), + tokenSource.Token); + if (!ActiveTasks.TryAdd(newTask, taskItem)) { - _logger?.LogTrace($"Start Task: {taskItem.Name}"); - var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token); - var newTask = Task.Run(() => taskItem.Action(tokenSource.Token), - tokenSource.Token); - if (!ActiveTasks.TryAdd(newTask, taskItem)) + //TODO better exception + throw new Exception("failed to add task"); + } + //TODO should master token actually cancel followup? + newTask.ContinueWith(ManageFinishedTasks, MasterToken.Token); + } + public void StartTask(AsyncTaskItem taskItem) + { + var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token); + var newTask = Task.Run(() => taskItem.AsyncAction(tokenSource.Token), + tokenSource.Token); + if (!ActiveTasks.TryAdd(newTask, taskItem)) + { + //TODO better exception + throw new Exception("failed to add task"); + } + //TODO should master token actually cancel followup? + newTask.ContinueWith(ManageFinishedTasks, MasterToken.Token); + } + public void ScheduleTask(SyncTaskItem taskItem, TimeSpan interval) + { + //var timer = new Timer((_) => StartTask(taskItem), null, dueTime, period ?? Timeout.InfiniteTimeSpan); + var timer = new System.Timers.Timer(interval); + var token = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token); + Scheduled.TryAdd(timer, taskItem); + token.Token.Register(() => + { + timer.Stop(); + Scheduled.TryRemove(timer, out var _); + }); + timer.Elapsed += (_, _) => taskItem.Action(token.Token); + timer.Start(); + } + public void ScheduleTask(BaseTaskItem taskItem, TimeSpan interval) + { + var timer = new System.Timers.Timer(interval); + var token = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token); + Scheduled.TryAdd(timer, taskItem); + token.Token.Register(() => + { + timer.Stop(); + Scheduled.TryRemove(timer, out var _); + }); + if (taskItem is AsyncTaskItem ati) + timer.Elapsed += async (_, _) => { - //TODO better exception - throw new Exception("failed to add task"); - } - //TODO should master token actually cancel followup? - newTask.ContinueWith(ManageFinishedTasks, MasterToken.Token); - } - public void ScheduleTask(TaskItem taskItem, TimeSpan interval) - { - //var timer = new Timer((_) => StartTask(taskItem), null, dueTime, period ?? Timeout.InfiniteTimeSpan); - var timer = new System.Timers.Timer(interval); - var token = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token); - Scheduled.TryAdd(timer, taskItem); - token.Token.Register(() => - { - timer.Stop(); - Scheduled.TryRemove(timer, out var _); - }); - timer.Elapsed += (_, _) => taskItem.Action(token.Token); - timer.Start(); - } - public string[] GetTasks(int limit) - { - return ActiveTasks.Select(p => p.Value.Name).Take(limit).ToArray(); - } + var task = ati.AsyncAction(token.Token); + if (task != null) + await task; + }; + else if (taskItem is SyncTaskItem sti) + timer.Elapsed += (_, _) => sti.Action(token.Token); + timer.Start(); + } + public string[] GetTasks(int limit) + { + return ActiveTasks.Select(p => p.Value.Name).Take(limit).ToArray(); + } - public void CancelAll() + public void CancelAll() + { + MasterToken.Cancel(); + MasterToken.Dispose(); + MasterToken = new CancellationTokenSource(); + } + public void ManageFinishedTasks() + { + ManageFinishedTasks(null); + } + private readonly object _TaskCleanupLock = new(); + protected void ManageFinishedTasks(Task? cause = null) + { + //there shouldn't really be concerns with running multiple simultaneously but might as well + lock (_TaskCleanupLock) { - MasterToken.Cancel(); - MasterToken.Dispose(); - MasterToken = new CancellationTokenSource(); - } - public void ManageFinishedTasks() - { - ManageFinishedTasks(null); - } - private readonly object _TaskCleanupLock = new(); - protected void ManageFinishedTasks(Task? cause = null) - { - //there shouldn't really be concerns with running multiple simultaneously but might as well - lock (_TaskCleanupLock) + //cache first because we're modifying the dictionary + foreach (var pair in ActiveTasks.ToArray()) { - //cache first because we're modifying the dictionary - foreach (var pair in ActiveTasks.ToArray()) + if (pair.Key.IsCompleted) { - if (pair.Key.IsCompleted) + bool taskRemoved = ActiveTasks.TryRemove(pair.Key, out _); + if (taskRemoved) { - bool taskRemoved = ActiveTasks.TryRemove(pair.Key, out _); - if (taskRemoved) - { - _logger?.LogTrace($"Removed Task: {pair.Value.Name}"); - } + _logger?.LogTrace("Removed Task: {TaskName}", pair.Value.Name); } } } } - public void Dispose() - { - MasterToken?.Dispose(); - } + } + public void Dispose() + { + MasterToken?.Dispose(); + GC.SuppressFinalize(this); } }