diff --git a/.gitignore b/.gitignore index 8a30d25..e8cb0b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ + + +#SQLite files +*.db +*.db-shm +*.db-wal + ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## diff --git a/ComiServ.csproj b/ComiServ.csproj index ea03ccb..3c5f336 100644 --- a/ComiServ.csproj +++ b/ComiServ.csproj @@ -16,6 +16,7 @@ + diff --git a/ComicsContext.cs b/ComicsContext.cs index 2022f11..d32b80b 100644 --- a/ComicsContext.cs +++ b/ComicsContext.cs @@ -35,6 +35,9 @@ namespace ComiServ 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) { @@ -48,6 +51,17 @@ namespace ComiServ 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 diff --git a/Controllers/ComicController.cs b/Controllers/ComicController.cs index 3bae5eb..cab323b 100644 --- a/Controllers/ComicController.cs +++ b/Controllers/ComicController.cs @@ -8,18 +8,26 @@ using Microsoft.EntityFrameworkCore.Migrations; using ComiServ.Entities; using ComiServ.Background; using System.ComponentModel; +using ComiServ.Extensions; +using System.Runtime.InteropServices; +using ComiServ.Services; +using System.Security.Cryptography.X509Certificates; +using SQLitePCL; namespace ComiServ.Controllers { - [Route("api/v1/comics")] + [Route(ROUTE)] [ApiController] - public class ComicController(ComicsContext context, ILogger logger, IConfigService config, IComicAnalyzer analyzer) + 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)] @@ -39,6 +47,9 @@ namespace ComiServ.Controllers [FromQuery] bool? exists, [FromQuery] + [DefaultValue(null)] + bool? read, + [FromQuery] [DefaultValue(0)] int page, [FromQuery] @@ -46,7 +57,6 @@ namespace ComiServ.Controllers int pageSize ) { - //throw new NotImplementedException(); var results = _context.Comics .Include("ComicAuthors.Author") .Include("ComicTags.Tag"); @@ -54,6 +64,18 @@ namespace ComiServ.Controllers { 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))); @@ -112,17 +134,18 @@ namespace ComiServ.Controllers } if (titleSearch is not null) { - //results = results.Where(c => EF.Functions.Like(c.Title, $"*{titleSearch}*")); - results = results.Where(c => c.Title.Contains(titleSearch)); + titleSearch = titleSearch.Trim(); + results = results.Where(c => EF.Functions.Like(c.Title, $"%{titleSearch}%")); } if (descSearch is not null) { - //results = results.Where(c => EF.Functions.Like(c.Description, $"*{descSearch}*")); - results = results.Where(c => c.Description.Contains(descSearch)); + descSearch = descSearch.Trim(); + results = results.Where(c => EF.Functions.Like(c.Description, $"%{descSearch}%")); } int offset = page * pageSize; - return Ok(new Paginated(pageSize, page, results.Skip(offset) - .Select(c => new ComicData(c)))); + return Ok(new Paginated(pageSize, page, results + .OrderBy(c => c.Id) + .Select(c => new ComicData(c)))); } [HttpDelete] [ProducesResponseType(StatusCodes.Status200OK)] @@ -136,13 +159,13 @@ namespace ComiServ.Controllers } [HttpGet("{handle}")] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public IActionResult GetSingleComicInfo(string handle) { - _logger.LogInformation("GetSingleComicInfo: {handle}", handle); - handle = handle.Trim().ToUpper(); - if (handle.Length != ComicsContext.HANDLE_LENGTH) + //_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") @@ -156,13 +179,13 @@ namespace ComiServ.Controllers [HttpPatch("{handle}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult UpdateComicMetadata(string handle, [FromBody] ComicMetadataUpdate metadata) + public IActionResult UpdateComicMetadata(string handle, [FromBody] ComicMetadataUpdateRequest metadata) { - //throw new NotImplementedException(); if (handle.Length != ComicsContext.HANDLE_LENGTH) return BadRequest(RequestError.InvalidHandle); - //using var transaction = _context.Database.BeginTransaction(); var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle); if (comic is Comic actualComic) { @@ -171,9 +194,7 @@ namespace ComiServ.Controllers if (metadata.Authors is List authors) { //make sure all authors exist, without changing Id of pre-existing authors - //TODO try to batch these - authors.ForEach(author => _context.Database.ExecuteSql( - $"INSERT OR IGNORE INTO [Authors] (Name) VALUES ({author})")); + _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 @@ -184,9 +205,7 @@ namespace ComiServ.Controllers if (metadata.Tags is List tags) { //make sure all tags exist, without changing Id of pre-existing tags - //TODO try to batch these - tags.ForEach(tag => _context.Database.ExecuteSql( - $"INSERT OR IGNORE INTO [Tags] (Name) VALUES ({tag})")); + _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 @@ -200,20 +219,95 @@ namespace ComiServ.Controllers else return NotFound(RequestError.ComicNotFound); } - //[HttpDelete("{handle}")] - //public IActionResult DeleteComic(string handle) - //{ - // throw new NotImplementedException(); - //} + [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 + }; + context.InsertOrIgnore(comicRead, ignorePrimaryKey: false); + context.SaveChanges(); + return Ok(); + } + [HttpPatch("{handle}/markunread")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public IActionResult MarkComicAsUnread( + 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 = 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(); + } + [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 (_auth.User is null) + { + HttpContext.Response.Headers.WWWAuthenticate = "Basic"; + return Unauthorized(RequestError.NoAccess); + } + 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 = handle.Trim().ToUpper(); - if (handle.Length != ComicsContext.HANDLE_LENGTH) + //_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) @@ -227,7 +321,7 @@ namespace ComiServ.Controllers [ProducesResponseType(StatusCodes.Status404NotFound)] public IActionResult GetComicCover(string handle) { - _logger.LogInformation($"{nameof(GetComicCover)}: {handle}"); + //_logger.LogInformation(nameof(GetComicCover) + ": {handle}", handle); var validated = ComicsContext.CleanValidateHandle(handle); if (validated is null) return BadRequest(RequestError.InvalidHandle); @@ -248,9 +342,18 @@ namespace ComiServ.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult GetComicPage(string handle, int page) + 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}"); + //_logger.LogInformation(nameof(GetComicPage) + ": {handle} {page}", handle, page); var validated = ComicsContext.CleanValidateHandle(handle); if (validated is null) return BadRequest(RequestError.InvalidHandle); @@ -261,24 +364,112 @@ namespace ComiServ.Controllers if (comicPage is null) //TODO rethink error code return NotFound(RequestError.PageNotFound); - return File(comicPage.Data, comicPage.Mime); + 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() { - //Since ComicAuthors uses foreign keys _context.Authors - .Include("ComicAuthors") + .Include(a => a.ComicAuthors) .Where(a => a.ComicAuthors.Count == 0) .ExecuteDelete(); _context.Tags - .Include("ComicTags") + .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() + )); + } } } diff --git a/Controllers/MiscController.cs b/Controllers/MiscController.cs new file mode 100644 index 0000000..c0a4ad3 --- /dev/null +++ b/Controllers/MiscController.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using ComiServ.Entities; +using Microsoft.AspNetCore.Mvc; +using ComiServ.Services; +using ComiServ.Background; +using ComiServ.Models; +using System.ComponentModel; + +namespace ComiServ.Controllers +{ + [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 IActionResult GetAuthors( + [FromQuery] + [DefaultValue(0)] + int page, + [FromQuery] + [DefaultValue(20)] + int pageSize + ) + { + 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)); + } + [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 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)); + } + } +} diff --git a/Controllers/TaskController.cs b/Controllers/TaskController.cs index c199396..4bcee6e 100644 --- a/Controllers/TaskController.cs +++ b/Controllers/TaskController.cs @@ -1,12 +1,15 @@ using ComiServ.Background; using ComiServ.Models; +using ComiServ.Services; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using System.ComponentModel; +using System.Security.Policy; namespace ComiServ.Controllers { - [Route("api/v1/tasks")] + [Route(ROUTE)] [ApiController] public class TaskController( ComicsContext context @@ -15,6 +18,7 @@ namespace ComiServ.Controllers ,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; @@ -37,5 +41,21 @@ namespace ComiServ.Controllers _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(); + } } } diff --git a/Controllers/UserController.cs b/Controllers/UserController.cs new file mode 100644 index 0000000..c909988 --- /dev/null +++ b/Controllers/UserController.cs @@ -0,0 +1,152 @@ +using ComiServ.Entities; +using ComiServ.Models; +using ComiServ.Services; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel; +using System.Text; + +namespace ComiServ.Controllers +{ + [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 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) + { + 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)))); + } + [HttpPost("create")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public IActionResult AddUser(IAuthenticationService auth, + ComicsContext context, + [FromBody] + UserCreateRequest req) + { + 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(); + } + [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 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) + { + //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(); + } + } +} diff --git a/Entities/Author.cs b/Entities/Author.cs index 73d3d25..dae3800 100644 --- a/Entities/Author.cs +++ b/Entities/Author.cs @@ -12,6 +12,6 @@ namespace ComiServ.Entities public int Id { get; set; } [Required] public string Name { get; set; } = null!; - public ICollection ComicAuthors = null!; + public ICollection ComicAuthors { get; set; } = null!; } } diff --git a/Entities/Comic.cs b/Entities/Comic.cs index 258bc29..ac92c6f 100644 --- a/Entities/Comic.cs +++ b/Entities/Comic.cs @@ -24,9 +24,12 @@ namespace ComiServ.Entities 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/ComicRead.cs b/Entities/ComicRead.cs new file mode 100644 index 0000000..5595918 --- /dev/null +++ b/Entities/ComicRead.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations.Schema; + +namespace ComiServ.Entities +{ + [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; } + } +} diff --git a/Entities/Tag.cs b/Entities/Tag.cs index 4b52368..779204a 100644 --- a/Entities/Tag.cs +++ b/Entities/Tag.cs @@ -11,6 +11,6 @@ namespace ComiServ.Entities public int Id { get; set; } [Required] public string Name { get; set; } = null!; - public ICollection ComicTags = null!; + public ICollection ComicTags { get; set; } = null!; } } diff --git a/Entities/User.cs b/Entities/User.cs new file mode 100644 index 0000000..dd5f0a8 --- /dev/null +++ b/Entities/User.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Security.Cryptography; + +namespace ComiServ.Entities +{ + [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() + { + 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 new file mode 100644 index 0000000..beaade7 --- /dev/null +++ b/Entities/UserType.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace ComiServ.Entities +{ + [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; } + } +} diff --git a/Extensions/DatabaseExtensions.cs b/Extensions/DatabaseExtensions.cs new file mode 100644 index 0000000..d069541 --- /dev/null +++ b/Extensions/DatabaseExtensions.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using System.Runtime.CompilerServices; + +//https://stackoverflow.com/a/42467710/25956209 +//https://archive.ph/RvjOy +namespace ComiServ.Extensions +{ + 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) + { + 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); + } + } +} diff --git a/Extensions/StreamExtentions.cs b/Extensions/StreamExtentions.cs new file mode 100644 index 0000000..d692794 --- /dev/null +++ b/Extensions/StreamExtentions.cs @@ -0,0 +1,19 @@ +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(); + + using (var memoryStream = new MemoryStream()) + { + instream.CopyTo(memoryStream); + return memoryStream.ToArray(); + } + } + } +} diff --git a/Middleware/BasicAuthenticationMiddleware.cs b/Middleware/BasicAuthenticationMiddleware.cs new file mode 100644 index 0000000..07a4b88 --- /dev/null +++ b/Middleware/BasicAuthenticationMiddleware.cs @@ -0,0 +1,77 @@ +using ComiServ.Entities; +using ComiServ.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; +using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using System.Text; + +namespace ComiServ.Middleware +{ + //only user of a type in `authorized` are permitted past this middleware + //auth header is only checked once, so you can place multiple in the pipeline to further restrict + //some endpoints + public class BasicAuthenticationMiddleware(RequestDelegate next, UserTypeEnum[] authorized) + { + private readonly RequestDelegate _next = next; + + public async Task InvokeAsync(HttpContext httpContext, ComicsContext context, IAuthenticationService auth) + { + if (!auth.Tested) + { + var authHeader = httpContext.Request.Headers.Authorization.SingleOrDefault(); + if (authHeader is string header) + { + if (header.StartsWith("Basic")) + { + header = header[5..].Trim(); + byte[] data = Convert.FromBase64String(header); + string decoded = Encoding.UTF8.GetString(data); + var split = decoded.Split(':', 2); + if (split.Length == 2) + { + var user = split[0]; + var pass = split[1]; + var userCon = context.Users + .Include(u => u.UserType) + .SingleOrDefault(u => EF.Functions.Like(u.Username, user)); + if (userCon is not null && userCon.UserTypeId != UserTypeEnum.Disabled) + { + var bPass = Encoding.UTF8.GetBytes(pass); + var salt = userCon.Salt; + var hashed = User.Hash(bPass, salt); + if (hashed.SequenceEqual(userCon.HashedPassword)) + auth.Authenticate(userCon); + } + } + } + //handle other schemes here maybe + } + else + { + auth.FailAuth(); + } + } + if (authorized.Length == 0 || authorized.Contains(auth.User?.UserTypeId ?? UserTypeEnum.Invalid)) + { + await _next(httpContext); + } + else if (auth.User is not null) + { + httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; + } + else + { + httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; + httpContext.Response.Headers.WWWAuthenticate = "Basic"; + } + } + } + public static class BasicAuthenticationMiddlewareExtensions + { + public static IApplicationBuilder UseBasicAuthentication(this IApplicationBuilder builder, IEnumerable authorized) + { + //keep a private copy of the array + return builder.UseMiddleware(authorized.ToArray()); + } + } +} diff --git a/Models/AuthorResponse.cs b/Models/AuthorResponse.cs new file mode 100644 index 0000000..896319c --- /dev/null +++ b/Models/AuthorResponse.cs @@ -0,0 +1,7 @@ +namespace ComiServ.Models +{ + public record class AuthorResponse(string Name, int WorkCount) + { + + } +} diff --git a/Models/ComicDeleteRequest.cs b/Models/ComicDeleteRequest.cs new file mode 100644 index 0000000..06e4a2c --- /dev/null +++ b/Models/ComicDeleteRequest.cs @@ -0,0 +1,8 @@ +namespace ComiServ.Models +{ + //handle is taken from URL + public record class ComicDeleteRequest + ( + bool DeleteIfFileExists + ); +} diff --git a/Models/ComicDuplicateList.cs b/Models/ComicDuplicateList.cs new file mode 100644 index 0000000..d4c7233 --- /dev/null +++ b/Models/ComicDuplicateList.cs @@ -0,0 +1,23 @@ +using ComiServ.Entities; + +namespace ComiServ.Models +{ + public class ComicDuplicateList + { + 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; + } + } +} diff --git a/Models/ComicMetadataUpdate.cs b/Models/ComicMetadataUpdateRequest.cs similarity index 84% rename from Models/ComicMetadataUpdate.cs rename to Models/ComicMetadataUpdateRequest.cs index 83bbe73..1024988 100644 --- a/Models/ComicMetadataUpdate.cs +++ b/Models/ComicMetadataUpdateRequest.cs @@ -1,6 +1,6 @@ namespace ComiServ.Models { - public class ComicMetadataUpdate + public class ComicMetadataUpdateRequest { public string? Title { get; set; } public string? Description { get; set; } diff --git a/Models/LibraryResponse.cs b/Models/LibraryResponse.cs new file mode 100644 index 0000000..b8475fc --- /dev/null +++ b/Models/LibraryResponse.cs @@ -0,0 +1,6 @@ +namespace ComiServ.Models +{ + public record class LibraryResponse(int ComicCount, int UniqueFiles) + { + } +} diff --git a/Models/Paginated.cs b/Models/Paginated.cs index 92a08f1..c6cca98 100644 --- a/Models/Paginated.cs +++ b/Models/Paginated.cs @@ -19,7 +19,7 @@ { throw new ArgumentOutOfRangeException(nameof(page), page, "must be greater than or equal to 0"); } - Items = iter.Take(max + 1).ToList(); + Items = iter.Skip(max * page).Take(max + 1).ToList(); if (Items.Count > max) { Last = false; diff --git a/Models/RequestError.cs b/Models/RequestError.cs index 2256a50..5140a40 100644 --- a/Models/RequestError.cs +++ b/Models/RequestError.cs @@ -1,18 +1,29 @@ -namespace ComiServ.Models -{ - public class RequestError - { +using System.Collections; +namespace ComiServ.Models +{ + 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) { Errors = [ErrorMessage]; } + public RequestError() + { + Errors = []; + } public RequestError(IEnumerable ErrorMessages) { Errors = ErrorMessages.ToArray(); @@ -27,8 +38,15 @@ } public RequestError And(IEnumerable other) { - return new RequestError(Errors.Concat(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 new file mode 100644 index 0000000..8dd3317 --- /dev/null +++ b/Models/TagResponse.cs @@ -0,0 +1,7 @@ +namespace ComiServ.Models +{ + public record class TagResponse(string Name, int WorkCount) + { + + } +} diff --git a/Models/Truncated.cs b/Models/Truncated.cs index c99c674..7016239 100644 --- a/Models/Truncated.cs +++ b/Models/Truncated.cs @@ -19,11 +19,10 @@ namespace ComiServ.Models if (Items.Count <= max) { Complete = true; - if (Items.Count > 0) - Items.RemoveAt(max); } else { + Items.RemoveAt(max); Complete = false; } Count = Items.Count; diff --git a/Models/UserCreateRequest.cs b/Models/UserCreateRequest.cs new file mode 100644 index 0000000..95be75b --- /dev/null +++ b/Models/UserCreateRequest.cs @@ -0,0 +1,12 @@ +using ComiServ.Entities; + +namespace ComiServ.Models +{ + 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; } + } +} diff --git a/Models/UserDescription.cs b/Models/UserDescription.cs new file mode 100644 index 0000000..f9c11d3 --- /dev/null +++ b/Models/UserDescription.cs @@ -0,0 +1,4 @@ +namespace ComiServ.Models +{ + public record class UserDescription(string Username, string Usertype); +} diff --git a/Models/UserModifyRequest.cs b/Models/UserModifyRequest.cs new file mode 100644 index 0000000..951b068 --- /dev/null +++ b/Models/UserModifyRequest.cs @@ -0,0 +1,11 @@ +using ComiServ.Entities; + +namespace ComiServ.Models +{ + public class UserModifyRequest + { + public string Username { get; set; } + public string? NewUsername { get; set; } + public UserTypeEnum? NewUserType { get; set; } + } +} diff --git a/Program.cs b/Program.cs index 1aec2f2..62d8c97 100644 --- a/Program.cs +++ b/Program.cs @@ -5,17 +5,54 @@ using Microsoft.Extensions.DependencyInjection; using ComiServ.Background; using Swashbuckle.AspNetCore.SwaggerGen; using ComiServ.Entities; - -var builder = WebApplication.CreateBuilder(args); +using ComiServ.Services; +using ComiServ.Middleware; +using ComiServ.Controllers; +using System.Text; var CONFIG_FILEPATH = "config.json"; -var configService = new ConfigService(CONFIG_FILEPATH); +var configService = new JsonConfigService(CONFIG_FILEPATH); var config = configService.Config; var ConnectionString = $"Data Source={config.DatabaseFile};Mode=ReadWriteCreate"; -// Add services to the container. +for (int i = 0; i < args.Length; i++) +{ + if (args[i] == "--addadmin") + { + string username; + if (args.ElementAtOrDefault(i + 1) is string _username) + { + username = _username; + } + else + { + Console.Write("Username: "); + username = Console.ReadLine() + ?? throw new Exception("must provide a username"); + } + username = username.Trim(); + Console.Write("Password: "); + string password = Console.ReadLine()?.Trim() + ?? throw new Exception("must provide a username"); + var salt = User.MakeSalt(); + var hashed = User.Hash(Encoding.UTF8.GetBytes(password), salt); + using var context = new ComicsContext( + new DbContextOptionsBuilder() + .UseSqlite(ConnectionString).Options); + context.Users.Add(new User() + { + Username = username, + Salt = salt, + HashedPassword = hashed, + UserTypeId = UserTypeEnum.Administrator + }); + context.SaveChanges(); + return; + } +} + +var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { @@ -32,11 +69,11 @@ builder.Services.AddSingleton(sp => logger: sp.GetRequiredService>())); builder.Services.AddSingleton(sp => new ComicScanner(provider: sp)); +builder.Services.AddSingleton( + new ResharperPictureConverter(true)); builder.Services.AddHttpLogging(o => { }); -//builder.Services.AddRazorPages().AddRazorPagesOptions(o => -//{ -// o.RootDirectory = "/Pages"; -//}); +builder.Services.AddScoped( + sp => new AuthenticationService()); builder.Services.AddLogging(config => { config.AddConsole(); @@ -68,7 +105,23 @@ scanner.ScheduleRepeatedLibraryScans(TimeSpan.FromDays(1)); app.UseHttpsRedirection(); -app.UseAuthorization(); +//ensures that the user is authenticated (if auth is provided) but does not restrict access to any routes +app.UseBasicAuthentication([]); +//require user or admin account to access any comic resource (uses the authentication +app.UseWhen(context => context.Request.Path.StartsWithSegments(ComicController.ROUTE), appBuilder => +{ + appBuilder.UseBasicAuthentication([UserTypeEnum.User, UserTypeEnum.Administrator]); +}); +//require user or admin account to access any user resource +app.UseWhen(context => context.Request.Path.StartsWithSegments(UserController.ROUTE), appBuilder => +{ + appBuilder.UseBasicAuthentication([UserTypeEnum.User, UserTypeEnum.Administrator]); +}); +//require admin account to access any task resource +app.UseWhen(context => context.Request.Path.StartsWithSegments(TaskController.ROUTE), appBuilder => +{ + appBuilder.UseBasicAuthentication([UserTypeEnum.Administrator]); +}); app.MapControllers(); diff --git a/Services/AuthenticationService.cs b/Services/AuthenticationService.cs new file mode 100644 index 0000000..79f0802 --- /dev/null +++ b/Services/AuthenticationService.cs @@ -0,0 +1,33 @@ +using ComiServ.Entities; + +namespace ComiServ.Services +{ + 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 void Authenticate(User user) + { + User = user; + Tested = true; + } + public void FailAuth() + { + User = null; + Tested = true; + } + } +} diff --git a/Background/ComicAnalyzer.cs b/Services/ComicAnalyzer.cs similarity index 93% rename from Background/ComicAnalyzer.cs rename to Services/ComicAnalyzer.cs index 6c5820b..e207ad7 100644 --- a/Background/ComicAnalyzer.cs +++ b/Services/ComicAnalyzer.cs @@ -27,6 +27,8 @@ namespace ComiServ.Background 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); @@ -36,9 +38,9 @@ namespace ComiServ.Background //returns null for ALL UNRECOGNIZED OR NON-IMAGES public static string? GetImageMime(string filename) { - if (new FileExtensionContentTypeProvider().TryGetContentType(filename, out string _mime)) + if (new FileExtensionContentTypeProvider().TryGetContentType(filename, out string? _mime)) { - if (_mime.StartsWith("image")) + if (_mime?.StartsWith("image") ?? false) return _mime; } return null; @@ -49,6 +51,21 @@ namespace ComiServ.Background : 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}"); diff --git a/Background/ComicScanner.cs b/Services/ComicScanner.cs similarity index 83% rename from Background/ComicScanner.cs rename to Services/ComicScanner.cs index 727bd87..ba8e65c 100644 --- a/Background/ComicScanner.cs +++ b/Services/ComicScanner.cs @@ -2,6 +2,11 @@ using System.Runtime.InteropServices; using ComiServ.Controllers; using ComiServ.Entities; +using ComiServ.Extensions; +using ComiServ.Services; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration.Ini; using Microsoft.OpenApi.Writers; namespace ComiServ.Background @@ -135,8 +140,12 @@ namespace ComiServ.Background $"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) @@ -152,13 +161,29 @@ namespace ComiServ.Background var page = _analyzer.GetComicPage(filepath, 1); if (page is null) return; - context.Covers.Add(new() + Cover cover = new() { FileXxhash64 = hash, Filename = page.Filename, CoverFile = page.Data - }); - context.SaveChanges(); + }; + 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() { diff --git a/ConfigService.cs b/Services/JsonConfigService.cs similarity index 81% rename from ConfigService.cs rename to Services/JsonConfigService.cs index 2b8c65b..0fb11ff 100644 --- a/ConfigService.cs +++ b/Services/JsonConfigService.cs @@ -1,11 +1,12 @@ using System.Text.Json; -namespace ComiServ +namespace ComiServ.Services { 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 @@ -15,12 +16,12 @@ namespace ComiServ { public Configuration Config { get; } } - public class ConfigService : IConfigService + public class JsonConfigService : IConfigService { public Configuration _Config; //protect original public Configuration Config => _Config.Copy(); - public ConfigService(string filepath) + public JsonConfigService(string filepath) { using var fileStream = File.OpenRead(filepath); _Config = JsonSerializer.Deserialize(fileStream) diff --git a/Services/PictureConverter.cs b/Services/PictureConverter.cs new file mode 100644 index 0000000..15041e0 --- /dev/null +++ b/Services/PictureConverter.cs @@ -0,0 +1,162 @@ +//using System.Drawing; +using System.Drawing.Imaging; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Webp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Bmp; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.StaticFiles; + +namespace ComiServ.Background +{ + [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 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) + { + 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); + } + } +} diff --git a/Background/TaskManager.cs b/Services/TaskManager.cs similarity index 95% rename from Background/TaskManager.cs rename to Services/TaskManager.cs index dcbdf50..e0f8e9a 100644 --- a/Background/TaskManager.cs +++ b/Services/TaskManager.cs @@ -6,6 +6,7 @@ namespace ComiServ.Background { Scan, GetCover, + MakeThumbnail, } //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) @@ -26,7 +27,7 @@ namespace ComiServ.Background : ITaskManager { private readonly ConcurrentDictionary ActiveTasks = []; - private readonly CancellationTokenSource MasterToken = new(); + private CancellationTokenSource MasterToken { get; set; } = new(); private readonly ILogger? _logger = logger; private readonly ConcurrentDictionary Scheduled = []; public void StartTask(TaskItem taskItem) @@ -65,6 +66,8 @@ namespace ComiServ.Background public void CancelAll() { MasterToken.Cancel(); + MasterToken.Dispose(); + MasterToken = new CancellationTokenSource(); } public void ManageFinishedTasks() { diff --git a/appsettings.Development.json b/appsettings.Development.json index 0c208ae..33e2405 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -1,8 +1,8 @@ { "Logging": { "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Default": "Trace", + "Microsoft.AspNetCore": "Trace" } } } diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/config.json b/config.json index ccd0225..84fefa2 100644 --- a/config.json +++ b/config.json @@ -1,4 +1,5 @@ { "LibraryRoot": "./Library", - "DatabaseFile": "ComiServ.db" + "DatabaseFile": "ComiServ.db", + "AutoScanPeriodHours": 0.03 } \ No newline at end of file