updated controllers and some services to use async/await

This commit is contained in:
Cameron
2024-08-29 05:36:01 -05:00
parent 18de6d599b
commit 8302e3ea61
36 changed files with 1981 additions and 1809 deletions

View File

@@ -1,81 +1,80 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ComiServ.Entities; using ComiServ.Entities;
namespace ComiServ 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<Comic> Comics { get; set; }
public DbSet<ComicTag> ComicTags { get; set; }
public DbSet<Tag> Tags { get; set; }
public DbSet<ComicAuthor> ComicAuthors { get; set; }
public DbSet<Author> Authors { get; set; }
public DbSet<Cover> Covers { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<UserType> UserTypes { get; set; }
public DbSet<ComicRead> ComicsRead { get; set; }
public ComicsContext(DbContextOptions<ComicsContext> options)
: base(options)
{
} public class ComicsContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder) {
//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<Comic>().ToTable("Comics"); if (i < 10)
modelBuilder.Entity<ComicTag>().ToTable("ComicTags"); return (char)('0' + i);
modelBuilder.Entity<Tag>().ToTable("Tags"); if (i - 10 + 'A' < 'O')
modelBuilder.Entity<ComicAuthor>().ToTable("ComicAuthors"); return (char)('A' + i - 10);
modelBuilder.Entity<Author>().ToTable("Authors"); else
modelBuilder.Entity<Cover>().ToTable("Covers"); //skip 'O'
modelBuilder.Entity<User>().ToTable("Users"); return (char)('A' + i - 9);
modelBuilder.Entity<UserType>().ToTable("UserTypes")
.HasData(
Enum.GetValues(typeof(UserTypeEnum))
.Cast<UserTypeEnum>()
.Select(e => new UserType()
{
Id = e,
Name = e.ToString()
})
);
} }
/// <summary> string handle = "";
/// puts a user-provided handle into the proper form do
/// </summary>
/// <param name="handle"></param>
/// <returns>formatted handle or null if invalid</returns>
public static string? CleanValidateHandle(string? handle)
{ {
if (handle is null) handle = string.Join("", Enumerable.Repeat(0, HANDLE_LENGTH)
return null; .Select(_ => ToChar(Random.Shared.Next(0, 35))));
handle = handle.Trim(); } while (Comics.Any(c => c.Handle == handle));
if (handle.Length != HANDLE_LENGTH) return handle;
return null; }
return handle.ToUpper(); public DbSet<Comic> Comics { get; set; }
} public DbSet<ComicTag> ComicTags { get; set; }
public DbSet<Tag> Tags { get; set; }
public DbSet<ComicAuthor> ComicAuthors { get; set; }
public DbSet<Author> Authors { get; set; }
public DbSet<Cover> Covers { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<UserType> UserTypes { get; set; }
public DbSet<ComicRead> ComicsRead { get; set; }
public ComicsContext(DbContextOptions<ComicsContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Comic>().ToTable("Comics");
modelBuilder.Entity<ComicTag>().ToTable("ComicTags");
modelBuilder.Entity<Tag>().ToTable("Tags");
modelBuilder.Entity<ComicAuthor>().ToTable("ComicAuthors");
modelBuilder.Entity<Author>().ToTable("Authors");
modelBuilder.Entity<Cover>().ToTable("Covers");
modelBuilder.Entity<User>().ToTable("Users");
modelBuilder.Entity<UserType>().ToTable("UserTypes")
.HasData(
Enum.GetValues(typeof(UserTypeEnum))
.Cast<UserTypeEnum>()
.Select(e => new UserType()
{
Id = e,
Name = e.ToString()
})
);
}
/// <summary>
/// puts a user-provided handle into the proper form
/// </summary>
/// <param name="handle"></param>
/// <returns>formatted handle or null if invalid</returns>
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();
} }
} }

View File

@@ -12,464 +12,465 @@ using ComiServ.Extensions;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using ComiServ.Services; using ComiServ.Services;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using System.Data;
using SQLitePCL; using SQLitePCL;
namespace ComiServ.Controllers namespace ComiServ.Controllers;
[Route(ROUTE)]
[ApiController]
public class ComicController(ComicsContext context, ILogger<ComicController> logger, IConfigService config, IComicAnalyzer analyzer, IPictureConverter converter, IAuthenticationService _auth)
: ControllerBase
{ {
[Route(ROUTE)] public const string ROUTE = "/api/v1/comics";
[ApiController] private readonly ComicsContext _context = context;
public class ComicController(ComicsContext context, ILogger<ComicController> logger, IConfigService config, IComicAnalyzer analyzer, IPictureConverter converter, IAuthenticationService _auth) private readonly ILogger<ComicController> _logger = logger;
: ControllerBase 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<Paginated<ComicData>>(StatusCodes.Status200OK)]
public async Task<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
)
{ {
public const string ROUTE = "/api/v1/comics"; var results = _context.Comics
private readonly ComicsContext _context = context; .Include("ComicAuthors.Author")
private readonly ILogger<ComicController> _logger = logger; .Include("ComicTags.Tag");
private readonly Configuration _config = config.Config; if (exists is not null)
private readonly IComicAnalyzer _analyzer = analyzer;
private readonly IPictureConverter _converter = converter;
private readonly IAuthenticationService _auth = _auth;
//TODO search parameters
[HttpGet]
[ProducesResponseType<Paginated<ComicData>>(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 results = results.Where(c => c.Exists == exists);
.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<ComicData>(pageSize, page, results
.OrderBy(c => c.Id)
.Select(c => new ComicData(c))));
} }
[HttpDelete] string username;
[ProducesResponseType(StatusCodes.Status200OK)] if (_auth.User is null)
public IActionResult DeleteComicsThatDontExist()
{ {
var search = _context.Comics.Where(c => !c.Exists); return Unauthorized(RequestError.NotAuthenticated);
var nonExtant = search.ToList();
search.ExecuteDelete();
_context.SaveChanges();
return Ok(search.Select(c => new ComicData(c)));
} }
[HttpGet("{handle}")] if (read is bool readStatus)
[ProducesResponseType<ComicData>(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public IActionResult GetSingleComicInfo(string handle)
{ {
//_logger.LogInformation("GetSingleComicInfo: {handle}", handle); if (readStatus)
var validated = ComicsContext.CleanValidateHandle(handle); results = results.Where(c => c.ReadBy.Any(u => EF.Functions.Like(_auth.User.Username, u.User.Username)));
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));
else else
return NotFound(RequestError.ComicNotFound); results = results.Where(c => c.ReadBy.All(u => !EF.Functions.Like(_auth.User.Username, u.User.Username)));
} }
[HttpPatch("{handle}")] foreach (var author in authors)
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public IActionResult UpdateComicMetadata(string handle, [FromBody] ComicMetadataUpdateRequest metadata)
{ {
if (handle.Length != ComicsContext.HANDLE_LENGTH) results = results.Where(c => c.ComicAuthors.Any(ca => EF.Functions.Like(ca.Author.Name, author)));
return BadRequest(RequestError.InvalidHandle); }
var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle); foreach (var tag in tags)
if (comic is Comic actualComic) {
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) var pageMax = int.Parse(pages.Substring(2));
actualComic.Title = metadata.Title; results = results.Where(c => c.PageCount <= pageMax);
if (metadata.Authors is List<string> authors) }
{ else if (pages.StartsWith('<'))
//make sure all authors exist, without changing Id of pre-existing authors {
_context.InsertOrIgnore(authors.Select(author => new Author() { Name = author }), ignorePrimaryKey: true); var pageMax = int.Parse(pages.Substring(1));
//get the Id of needed authors results = results.Where(c => c.PageCount < pageMax);
var authorEntities = _context.Authors.Where(a => authors.Contains(a.Name)).ToList(); }
//delete existing author mappings else if (pages.StartsWith(">="))
_context.ComicAuthors.RemoveRange(_context.ComicAuthors.Where(ca => ca.Comic.Id == comic.Id)); {
//add all author mappings var pageMin = int.Parse(pages.Substring(2));
_context.ComicAuthors.AddRange(authorEntities.Select(a => new ComicAuthor { Comic = comic, Author = a })); results = results.Where(c => c.PageCount >= pageMin);
} }
if (metadata.Tags is List<string> tags) else if (pages.StartsWith('>'))
{ {
//make sure all tags exist, without changing Id of pre-existing tags var pageMin = int.Parse(pages.Substring(1));
_context.InsertOrIgnore(tags.Select(t => new Tag() { Name = t }), ignorePrimaryKey: true); results = results.Where(c => c.PageCount > pageMin);
//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();
} }
else else
return NotFound(RequestError.ComicNotFound);
}
[HttpPatch("{handle}/markread")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(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, if (pages.StartsWith('='))
ComicId = comic.Id 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<ComicData>.CreateAsync(pageSize, page, results
.OrderBy(c => c.Id)
.Select(c => new ComicData(c))));
}
[HttpDelete]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> 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<ComicData>(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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<string> 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<string> 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<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> 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<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> 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<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public async Task<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 = 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<byte[]>(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public async Task<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 = 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<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public async Task<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 = 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<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public async Task<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 = 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); //TODO using the stream directly throws but I think it should be valid, need to debug
context.SaveChanges(); using var resizedStream = await _converter.ResizeIfBigger(stream, limit, format);
return Ok(); var arr = await resizedStream.ReadAllBytesAsync();
return File(arr, mime);
} }
[HttpPatch("{handle}/markunread")] else
[ProducesResponseType(StatusCodes.Status200OK)] return File(comicPage.Data, comicPage.Mime);
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)] }
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)] [HttpGet("{handle}/thumbnail")]
public IActionResult MarkComicAsUnread( [ProducesResponseType(StatusCodes.Status200OK)]
ComicsContext context, [ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
string handle) [ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public async Task<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 = 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); return File(img, "application/webp");
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}")] if (fallbackToCover)
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public IActionResult DeleteComic(
string handle,
[FromBody]
ComicDeleteRequest req)
{ {
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"; //TODO should this convert to a thumbnail on the fly?
return Unauthorized(RequestError.NoAccess); return File(cover.CoverFile, IComicAnalyzer.GetImageMime(cover.Filename) ?? "application/octet-stream");
} }
if (_auth.User.UserTypeId != UserTypeEnum.Administrator) accErrors = accErrors.And(RequestError.CoverNotFound);
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<byte[]>(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(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<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(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<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(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<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(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<Paginated<ComicDuplicateList>>(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<ComicDuplicateList>(pageSize, page,
groups.Select(g =>
new ComicDuplicateList(g.Key, g.Select(g => g))
));
return Ok(ret);
}
[HttpGet("library")]
[ProducesResponseType<LibraryResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public IActionResult GetLibraryStats()
{
return Ok(new LibraryResponse(
_context.Comics.Count(),
_context.Comics.Select(c => c.FileXxhash64).Distinct().Count()
));
} }
return NotFound(RequestError.ThumbnailNotFound.And(accErrors));
}
[HttpPost("cleandb")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> 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<Paginated<ComicDuplicateList>>(StatusCodes.Status200OK)]
public async Task<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 = await Paginated<ComicDuplicateList>.CreateAsync(pageSize, page,
groups.Select(g =>
new ComicDuplicateList(g.Key, g.Select(g => g))
));
return Ok(ret);
}
[HttpGet("library")]
[ProducesResponseType<LibraryResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetLibraryStats()
{
return Ok(new LibraryResponse(
await _context.Comics.CountAsync(),
await _context.Comics.Select(c => c.FileXxhash64).Distinct().CountAsync()
));
} }
} }

View File

@@ -6,67 +6,66 @@ using ComiServ.Background;
using ComiServ.Models; using ComiServ.Models;
using System.ComponentModel; using System.ComponentModel;
namespace ComiServ.Controllers namespace ComiServ.Controllers;
[Route(ROUTE)]
[ApiController]
public class MiscController(ComicsContext context, ILogger<MiscController> logger, IConfigService config, IAuthenticationService auth)
: ControllerBase
{ {
[Route(ROUTE)] public const string ROUTE = "/api/v1/";
[ApiController] ComicsContext _context = context;
public class MiscController(ComicsContext context, ILogger<MiscController> logger, IConfigService config, IAuthenticationService auth) ILogger<MiscController> _logger = logger;
: ControllerBase IConfigService _config = config;
IAuthenticationService _auth = auth;
[HttpGet("authors")]
[ProducesResponseType<Paginated<AuthorResponse>>(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetAuthors(
[FromQuery]
[DefaultValue(0)]
int page,
[FromQuery]
[DefaultValue(20)]
int pageSize
)
{ {
public const string ROUTE = "/api/v1/"; if (_auth.User is null)
ComicsContext _context = context;
ILogger<MiscController> _logger = logger;
IConfigService _config = config;
IAuthenticationService _auth = auth;
[HttpGet("authors")]
[ProducesResponseType<Paginated<AuthorResponse>>(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(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);
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<AuthorResponse>(pageSize, page, items));
} }
[HttpGet("tags")] if (_auth.User.UserTypeId != UserTypeEnum.Administrator)
[ProducesResponseType<Paginated<TagResponse>>(StatusCodes.Status200OK)] return Forbid();
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)] var items = _context.Authors
[ProducesResponseType(StatusCodes.Status403Forbidden)] .OrderBy(a => a.ComicAuthors.Count())
public IActionResult GetTags( .Select(a => new AuthorResponse(a.Name, a.ComicAuthors.Count()));
[FromQuery] return Ok(await Paginated<AuthorResponse>.CreateAsync(pageSize, page, items));
[DefaultValue(0)] }
int page, [HttpGet("tags")]
[FromQuery] [ProducesResponseType<Paginated<TagResponse>>(StatusCodes.Status200OK)]
[DefaultValue(20)] [ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
int pageSize [ProducesResponseType(StatusCodes.Status403Forbidden)]
) public async Task<IActionResult> 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);
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<TagResponse>(pageSize, page, items));
} }
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<TagResponse>.CreateAsync(pageSize, page, items));
} }
} }

View File

@@ -7,55 +7,54 @@ using Microsoft.AspNetCore.Mvc;
using System.ComponentModel; using System.ComponentModel;
using System.Security.Policy; using System.Security.Policy;
namespace ComiServ.Controllers namespace ComiServ.Controllers;
[Route(ROUTE)]
[ApiController]
public class TaskController(
ComicsContext context
,ITaskManager manager
,IComicScanner scanner
,ILogger<TaskController> logger
) : ControllerBase
{ {
[Route(ROUTE)] public const string ROUTE = "/api/v1/tasks";
[ApiController] private readonly ComicsContext _context = context;
public class TaskController( private readonly ITaskManager _manager = manager;
ComicsContext context private readonly IComicScanner _scanner = scanner;
,ITaskManager manager private readonly ILogger<TaskController> _logger = logger;
,IComicScanner scanner private readonly CancellationTokenSource cancellationToken = new();
,ILogger<TaskController> logger [HttpGet]
) : ControllerBase [ProducesResponseType<Truncated<string>>(StatusCodes.Status200OK)]
public Task<IActionResult> GetTasks(
[FromQuery]
[DefaultValue(20)]
int limit
)
{ {
public const string ROUTE = "/api/v1/tasks"; return Ok(new Truncated<string>(limit, _manager.GetTasks(limit+1)));
private readonly ComicsContext _context = context; }
private readonly ITaskManager _manager = manager; [HttpPost("scan")]
private readonly IComicScanner _scanner = scanner; [ProducesResponseType(StatusCodes.Status200OK)]
private readonly ILogger<TaskController> _logger = logger; public IActionResult StartScan()
private readonly CancellationTokenSource cancellationToken = new(); {
[HttpGet] _scanner.TriggerLibraryScan();
[ProducesResponseType<Truncated<string>>(StatusCodes.Status200OK)] return Ok();
public IActionResult GetTasks( }
[FromQuery] [HttpPost("cancelall")]
[DefaultValue(20)] [ProducesResponseType(StatusCodes.Status200OK)]
int limit [ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
) [ProducesResponseType(StatusCodes.Status403Forbidden)]
public IActionResult CancelAllTasks(Services.IAuthenticationService auth, ITaskManager manager)
{
if (auth.User is null)
{ {
return Ok(new Truncated<string>(limit, _manager.GetTasks(limit+1))); HttpContext.Response.Headers.WWWAuthenticate = "Basic";
} return Unauthorized(RequestError.NoAccess);
[HttpPost("scan")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult StartScan()
{
_scanner.TriggerLibraryScan();
return Ok();
}
[HttpPost("cancelall")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(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();
} }
if (auth.User.UserTypeId != Entities.UserTypeEnum.Administrator)
return Forbid();
manager.CancelAll();
return Ok();
} }
} }

View File

@@ -7,146 +7,146 @@ using Microsoft.EntityFrameworkCore;
using System.ComponentModel; using System.ComponentModel;
using System.Text; using System.Text;
namespace ComiServ.Controllers namespace ComiServ.Controllers;
[Route(ROUTE)]
[ApiController]
public class UserController
: ControllerBase
{ {
[Route(ROUTE)] public const string ROUTE = "/api/v1/users";
[ApiController] [HttpGet]
public class UserController [ProducesResponseType<Paginated<UserDescription>>(StatusCodes.Status200OK)]
: ControllerBase [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<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)
{ {
public const string ROUTE = "/api/v1/users"; if (auth.User is null)
[HttpGet]
[ProducesResponseType<Paginated<UserDescription>>(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);
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
return Unauthorized(RequestError.NoAccess);
}
if (auth.User.UserTypeId != UserTypeEnum.Administrator)
return Forbid();
IQueryable<User> 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<UserDescription>(pageSize, page, users
.Include(u => u.UserType)
.Select(u => new UserDescription(u.Username, u.UserType.Name))));
} }
[HttpPost("create")] if (auth.User.UserTypeId != UserTypeEnum.Administrator)
[ProducesResponseType(StatusCodes.Status200OK)] return Forbid();
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)] IQueryable<User> users = context.Users;
[ProducesResponseType(StatusCodes.Status403Forbidden)] if (type is UserTypeEnum t)
public IActionResult AddUser(IAuthenticationService auth, users = users.Where(u => u.UserTypeId == t);
ComicsContext context, if (!string.IsNullOrWhiteSpace(search))
[FromBody] users = users.Where(u => EF.Functions.Like(u.Username, $"%{search}%"));
UserCreateRequest req) return Ok(await Paginated<UserDescription>.CreateAsync(pageSize, page, users
.Include(u => u.UserType)
.Select(u => new UserDescription(u.Username, u.UserType.Name))));
}
[HttpPost("create")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> 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);
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")] if (auth.User.UserTypeId != UserTypeEnum.Administrator)
[ProducesResponseType(StatusCodes.Status200OK)] return Forbid();
[ProducesResponseType(StatusCodes.Status400BadRequest)] var salt = Entities.User.MakeSalt();
[ProducesResponseType(StatusCodes.Status401Unauthorized)] var bPass = Encoding.UTF8.GetBytes(req.Password);
[ProducesResponseType(StatusCodes.Status403Forbidden)] var newUser = new Entities.User()
public IActionResult DeleteUser(IAuthenticationService auth,
ComicsContext context,
[FromBody]
string username)
{ {
if (auth.User is null) Username = req.Username,
{ Salt = salt,
HttpContext.Response.Headers.WWWAuthenticate = "Basic"; HashedPassword = Entities.User.Hash(password: bPass, salt: salt),
return Unauthorized(RequestError.NoAccess); UserTypeId = req.UserType
} };
if (auth.User.UserTypeId != UserTypeEnum.Administrator) context.Users.Add(newUser);
return Forbid(); await context.SaveChangesAsync();
username = username.Trim(); return Ok();
var user = context.Users.SingleOrDefault(u => EF.Functions.Like(u.Username, $"{username}")); }
if (user is null) [HttpDelete("delete")]
return BadRequest(); [ProducesResponseType(StatusCodes.Status200OK)]
context.Users.Remove(user); [ProducesResponseType(StatusCodes.Status400BadRequest)]
return Ok(); [ProducesResponseType(StatusCodes.Status401Unauthorized)]
} [ProducesResponseType(StatusCodes.Status403Forbidden)]
[HttpPost("modify")] public async Task<IActionResult> DeleteUser(IAuthenticationService auth,
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)] ComicsContext context,
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [FromBody]
[ProducesResponseType(StatusCodes.Status403Forbidden)] string username)
public IActionResult ModifyUser(IAuthenticationService auth, {
ComicsContext context, if (auth.User is null)
[FromBody]
UserModifyRequest req)
{ {
//must be authenticated HttpContext.Response.Headers.WWWAuthenticate = "Basic";
if (auth.User is null) return Unauthorized(RequestError.NoAccess);
{
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();
} }
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<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<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 = 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();
} }
} }

View File

@@ -4,14 +4,13 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
//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 int Id { get; set; }
public class Author [Required]
{ public string Name { get; set; } = null!;
public int Id { get; set; } public ICollection<ComicAuthor> ComicAuthors { get; set; } = null!;
[Required]
public string Name { get; set; } = null!;
public ICollection<ComicAuthor> ComicAuthors { get; set; } = null!;
}
} }

View File

@@ -3,33 +3,32 @@ using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; 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)] public int Id { get; set; }
[Index(nameof(Filepath), IsUnique = true)] public bool Exists { get; set; }
public class Comic //id exposed through the API
{ [Required]
public int Id { get; set; } [StringLength(ComicsContext.HANDLE_LENGTH)]
public bool Exists { get; set; } public string Handle { get; set; } = null!;
//id exposed through the API [Required]
[Required] public string Filepath { get; set; } = null!;
[StringLength(ComicsContext.HANDLE_LENGTH)] [Required]
public string Handle { get; set; } = null!; public string Title { get; set; } = null!;
[Required] [Required]
public string Filepath { get; set; } = null!; public string Description { get; set; } = null!;
[Required] public int PageCount { get; set; }
public string Title { get; set; } = null!; public long SizeBytes { get; set; }
[Required] public long FileXxhash64 { get; set; }
public string Description { get; set; } = null!; public byte[]? ThumbnailWebp { get; set; }
public int PageCount { get; set; } [InverseProperty("Comic")]
public long SizeBytes { get; set; } public ICollection<ComicTag> ComicTags { get; set; } = [];
public long FileXxhash64 { get; set; } [InverseProperty("Comic")]
public byte[]? ThumbnailWebp { get; set; } public ICollection<ComicAuthor> ComicAuthors { get; set; } = [];
[InverseProperty("Comic")] [InverseProperty("Comic")]
public ICollection<ComicTag> ComicTags { get; set; } = []; public ICollection<ComicRead> ReadBy { get; set; } = [];
[InverseProperty("Comic")]
public ICollection<ComicAuthor> ComicAuthors { get; set; } = [];
[InverseProperty("Comic")]
public ICollection<ComicRead> ReadBy { get; set; } = [];
}
} }

View File

@@ -2,20 +2,19 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
namespace ComiServ.Entities namespace ComiServ.Entities;
[PrimaryKey("ComicId", "AuthorId")]
[Index("ComicId")]
[Index("AuthorId")]
public class ComicAuthor
{ {
[PrimaryKey("ComicId", "AuthorId")] [ForeignKey(nameof(Comic))]
[Index("ComicId")] public int ComicId { get; set; }
[Index("AuthorId")] [Required]
public class ComicAuthor public Comic Comic { get; set; } = null!;
{ [ForeignKey(nameof(Author))]
[ForeignKey(nameof(Comic))] public int AuthorId { get; set; }
public int ComicId { get; set; } [Required]
[Required] public Author Author { get; set; } = null!;
public Comic Comic { get; set; } = null!;
[ForeignKey(nameof(Author))]
public int AuthorId { get; set; }
[Required]
public Author Author { get; set; } = null!;
}
} }

View File

@@ -1,16 +1,15 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations.Schema; 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))] public int UserId { get; set; }
[Index(nameof(UserId))] public User User { get; set; }
[Index(nameof(ComicId))] public int ComicId { get; set; }
public class ComicRead 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; }
}
} }

View File

@@ -1,15 +1,14 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ComiServ.Entities namespace ComiServ.Entities;
[PrimaryKey("ComicId", "TagId")]
[Index("ComicId")]
[Index("TagId")]
public class ComicTag
{ {
[PrimaryKey("ComicId", "TagId")] public int ComicId { get; set; }
[Index("ComicId")] public Comic Comic { get; set; } = null!;
[Index("TagId")] public int TagId { get; set; }
public class ComicTag 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!;
}
} }

View File

@@ -1,12 +1,11 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ComiServ.Entities namespace ComiServ.Entities;
[PrimaryKey("FileXxhash64")]
public class Cover
{ {
[PrimaryKey("FileXxhash64")] public long FileXxhash64 { get; set; }
public class Cover 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!;
}
} }

View File

@@ -2,36 +2,35 @@
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.SwaggerGen;
namespace ComiServ.Entities namespace ComiServ.Entities;
/// <summary>
/// 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.
/// </summary>
public class EntitySwaggerFilter : ISchemaFilter
{ {
/// <summary> public readonly static string[] FILTER = [
/// This was originally made to remove Entity types that were being added to the Swagger schema. nameof(Author),
/// I found that there was a bug in `ProducesResponseTypeAttribute` that caused it, and this is nameof(Comic),
/// no longer necessary. I changed Apply to a nop but am keeping this around as an example and nameof(ComicAuthor),
/// in case I actually need something like this in the future. nameof(ComicTag),
/// </summary> nameof(Cover),
public class EntitySwaggerFilter : ISchemaFilter nameof(Tag),
nameof(User),
nameof(UserType)
];
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{ {
public readonly static string[] FILTER = [ return;
nameof(Author), //foreach (var item in context.SchemaRepository.Schemas.Keys)
nameof(Comic), //{
nameof(ComicAuthor), // if (FILTER.Contains(item))
nameof(ComicTag), // {
nameof(Cover), // context.SchemaRepository.Schemas.Remove(item);
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);
}
}
}
} }
} }

View File

@@ -2,15 +2,14 @@
using System.ComponentModel.DataAnnotations; 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 Tag
{ {
[Index(nameof(Name), IsUnique = true)] //[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public class Tag public int Id { get; set; }
{ [Required]
//[DatabaseGenerated(DatabaseGeneratedOption.Identity)] public string Name { get; set; } = null!;
public int Id { get; set; } public ICollection<ComicTag> ComicTags { get; set; } = null!;
[Required]
public string Name { get; set; } = null!;
public ICollection<ComicTag> ComicTags { get; set; } = null!;
}
} }

View File

@@ -3,36 +3,35 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Security.Cryptography; using System.Security.Cryptography;
namespace ComiServ.Entities namespace ComiServ.Entities;
[PrimaryKey(nameof(Id))]
[Index(nameof(Username), IsUnique = true)]
public class User
{ {
[PrimaryKey(nameof(Id))] public const int HashLengthBytes = 512 / 8;
[Index(nameof(Username), IsUnique = true)] public const int SaltLengthBytes = HashLengthBytes;
public class User 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<ComicRead> ComicsRead { get; set; } = [];
//cryptography should probably be in a different class
public static byte[] MakeSalt()
{ {
public const int HashLengthBytes = 512 / 8; byte[] arr = new byte[SaltLengthBytes];
public const int SaltLengthBytes = HashLengthBytes; RandomNumberGenerator.Fill(new Span<byte>(arr));
public int Id { get; set; } return arr;
[MaxLength(20)] }
public string Username { get; set; } public static byte[] Hash(byte[] password, byte[] salt)
[MaxLength(SaltLengthBytes)] {
public byte[] Salt { get; set; } var salted = salt.Append((byte)':').Concat(password).ToArray();
[MaxLength(HashLengthBytes)] return SHA512.HashData(salted);
public byte[] HashedPassword { get; set; }
public UserType UserType { get; set; }
public UserTypeEnum UserTypeId { get; set; }
[InverseProperty("User")]
public ICollection<ComicRead> ComicsRead { get; set; } = [];
//cryptography should probably be in a different class
public static byte[] MakeSalt()
{
byte[] arr = new byte[SaltLengthBytes];
RandomNumberGenerator.Fill(new Span<byte>(arr));
return arr;
}
public static byte[] Hash(byte[] password, byte[] salt)
{
var salted = salt.Append((byte)':').Concat(password).ToArray();
return SHA512.HashData(salted);
}
} }
} }

View File

@@ -1,28 +1,27 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace ComiServ.Entities namespace ComiServ.Entities;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum UserTypeEnum
{ {
[JsonConverter(typeof(JsonStringEnumConverter))] //important that this is 0 as a safety precaution,
public enum UserTypeEnum //in case it's accidentally left as default
{ Invalid = 0,
//important that this is 0 as a safety precaution, //can create accounts
//in case it's accidentally left as default Administrator = 1,
Invalid = 0, //has basic access
//can create accounts User = 2,
Administrator = 1, //authenticates but does not give access
//has basic access Restricted = 3,
User = 2, //refuses to authenticate but maintains records
//authenticates but does not give access Disabled = 4,
Restricted = 3, }
//refuses to authenticate but maintains records public class UserType
Disabled = 4, {
} public UserTypeEnum Id { get; set; }
public class UserType [MaxLength(26)]
{ public string Name { get; set; }
public UserTypeEnum Id { get; set; } public ICollection<User> Users { get; set; }
[MaxLength(26)]
public string Name { get; set; }
public ICollection<User> Users { get; set; }
}
} }

View File

@@ -4,59 +4,58 @@ using System.Runtime.CompilerServices;
//https://stackoverflow.com/a/42467710/25956209 //https://stackoverflow.com/a/42467710/25956209
//https://archive.ph/RvjOy //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<T>(this DbContext context, T item, bool ignorePrimaryKey = false)
{ {
//with a compound primary key, `ignorePrimaryKey` will ignore all of them var entityType = context.Model.FindEntityType(typeof(T));
public static int InsertOrIgnore<T>(this DbContext context, T item, bool ignorePrimaryKey = false) 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() var cols = entityType.GetProperties()
.Where(c => !ignorePrimaryKey || !c.IsPrimaryKey()) .Where(c => !ignorePrimaryKey || !c.IsPrimaryKey())
.Select(c => new { .Select(c => new {
Name = c.GetColumnName(), Name = c.GetColumnName(),
//Type = c.GetColumnType(), //Type = c.GetColumnType(),
Value = c.PropertyInfo.GetValue(item) Value = c.PropertyInfo.GetValue(item)
}) })
.ToList(); .ToList();
var query = "INSERT OR IGNORE INTO " + tableName var query = "INSERT OR IGNORE INTO " + tableName
+ " (" + string.Join(", ", cols.Select(c => c.Name)) + ") " + + " (" + string.Join(", ", cols.Select(c => c.Name)) + ") " +
"VALUES (" + string.Join(", ", cols.Select((c,i) => "{" + i + "}")) + ")"; "VALUES (" + string.Join(", ", cols.Select((c,i) => "{" + i + "}")) + ")";
var args = cols.Select(c => c.Value).ToArray(); var args = cols.Select(c => c.Value).ToArray();
var formattable = FormattableStringFactory.Create(query, args); var formattable = FormattableStringFactory.Create(query, args);
return context.Database.ExecuteSql(formattable); return context.Database.ExecuteSql(formattable);
} }
public static int InsertOrIgnore<T>(this DbContext context, IEnumerable<T> items, bool ignorePrimaryKey = false) public static int InsertOrIgnore<T>(this DbContext context, IEnumerable<T> items, bool ignorePrimaryKey = false)
{ {
var entityType = context.Model.FindEntityType(typeof(T)); var entityType = context.Model.FindEntityType(typeof(T));
var tableName = entityType.GetTableName(); var tableName = entityType.GetTableName();
//var tableSchema = entityType.GetSchema(); //var tableSchema = entityType.GetSchema();
var colProps = entityType.GetProperties().Where(c => !ignorePrimaryKey || !c.IsPrimaryKey()).ToList(); var colProps = entityType.GetProperties().Where(c => !ignorePrimaryKey || !c.IsPrimaryKey()).ToList();
var colNames = colProps.Select(c => c.Name).ToList(); var colNames = colProps.Select(c => c.Name).ToList();
if (colNames.Count == 0) if (colNames.Count == 0)
throw new InvalidOperationException("No columns to insert"); throw new InvalidOperationException("No columns to insert");
var rows = items var rows = items
.Select(item => .Select(item =>
colProps.Select(c => colProps.Select(c =>
c.PropertyInfo.GetValue(item)) c.PropertyInfo.GetValue(item))
.ToList()) .ToList())
.ToList(); .ToList();
int count = 0; int count = 0;
var query = "INSERT OR IGNORE INTO " + tableName var query = "INSERT OR IGNORE INTO " + tableName
+ "(" + string.Join(',', colNames) + ")" + "(" + string.Join(',', colNames) + ")"
+ "VALUES" + string.Join(',', rows.Select(row => + "VALUES" + string.Join(',', rows.Select(row =>
"(" + string.Join(',', row.Select(v => "{" "(" + string.Join(',', row.Select(v => "{"
+ count++ + count++
+ "}")) + ")" + "}")) + ")"
)); ));
var args = rows.SelectMany(row => row).ToArray(); var args = rows.SelectMany(row => row).ToArray();
var formattable = FormattableStringFactory.Create(query, args); var formattable = FormattableStringFactory.Create(query, args);
return context.Database.ExecuteSql(formattable); return context.Database.ExecuteSql(formattable);
}
} }
} }

View File

@@ -1,19 +1,25 @@
namespace ComiServ.Extensions 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()) public static class StreamExtensions
{ {
instream.CopyTo(memoryStream); //https://stackoverflow.com/questions/1080442/how-do-i-convert-a-stream-into-a-byte-in-c
return memoryStream.ToArray(); //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<byte[]> ReadAllBytesAsync(this Stream instream)
{
if (instream is MemoryStream)
return ((MemoryStream)instream).ToArray();
using var memoryStream = new MemoryStream();
await instream.CopyToAsync(memoryStream);
return memoryStream.ToArray();
} }
} }

View File

@@ -1,7 +1,6 @@
namespace ComiServ.Logging namespace ComiServ.Logging;
{
public static class Events public static class Events
{ {
}
} }

View File

@@ -1,7 +1,6 @@
namespace ComiServ.Models namespace ComiServ.Models;
{
public record class AuthorResponse(string Name, int WorkCount) public record class AuthorResponse(string Name, int WorkCount)
{ {
}
} }

View File

@@ -1,8 +1,7 @@
namespace ComiServ.Models namespace ComiServ.Models;
{
//handle is taken from URL //handle is taken from URL
public record class ComicDeleteRequest public record class ComicDeleteRequest
( (
bool DeleteIfFileExists bool DeleteIfFileExists
); );
}

View File

@@ -1,23 +1,22 @@
using ComiServ.Entities; 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<ComicData> Comics { get; set; }
public ComicDuplicateList(long hash, IEnumerable<Comic> comics)
{ {
public long Hash { get; set; } Hash = hash;
public int Count { get; set; } Comics = comics.Select(c => new ComicData(c)).ToList();
public List<ComicData> Comics { get; set; } Count = Comics.Count;
public ComicDuplicateList(long hash, IEnumerable<Comic> comics) }
{ public ComicDuplicateList(long hash, IEnumerable<ComicData> comics)
Hash = hash; {
Comics = comics.Select(c => new ComicData(c)).ToList(); Hash = hash;
Count = Comics.Count; Comics = comics.ToList();
} Count = Comics.Count;
public ComicDuplicateList(long hash, IEnumerable<ComicData> comics)
{
Hash = hash;
Comics = comics.ToList();
Count = Comics.Count;
}
} }
} }

View File

@@ -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 string? Title { get; set; } public List<string>? Tags { get; set; }
public string? Description { get; set; } public List<string>? Authors { get; set; }
public List<string>? Tags { get; set; }
public List<string>? Authors { get; set; }
}
} }

View File

@@ -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)
{
}
} }

View File

@@ -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<T>
{ {
public class Paginated<T> public int Max { get; }
public int Page { get; }
public bool Last { get; }
public int Count { get; }
public List<T> Items { get; }
public Paginated(int max, int page, IEnumerable<T> iter)
{ {
public int Max { get; } if (max <= 0)
public int Page { get;}
public bool Last { get; }
public int Count { get; }
public List<T> Items { get; }
public Paginated(int max, int page, IEnumerable<T> iter)
{ {
Max = max; throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0");
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;
} }
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<T> items)
{
Max = max;
Page = page;
Last = last;
Items = items;
Count = Items.Count;
}
public static async Task<Paginated<T>> CreateAsync(int max, int page, IQueryable<T> 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<Paginated<T>> CreateAsync(int max, int page, IAsyncEnumerable<T> 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<T> 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);
} }
} }

View File

@@ -1,52 +1,51 @@
using System.Collections; using System.Collections;
namespace ComiServ.Models namespace ComiServ.Models;
public class RequestError : IEnumerable<string>
{ {
public class RequestError : IEnumerable<string> 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"); Errors = [ErrorMessage];
public static RequestError ComicNotFound => new("Comic not found"); }
public static RequestError CoverNotFound => new("Cover not found"); public RequestError()
public static RequestError PageNotFound => new("Page not found"); {
public static RequestError FileNotFound => new("File not found"); Errors = [];
public static RequestError ThumbnailNotFound => new("Thumbnail not found"); }
public static RequestError NotAuthenticated => new("Not authenticated"); public RequestError(IEnumerable<string> ErrorMessages)
public static RequestError NoAccess => new("User does not have access to this resource"); {
public static RequestError UserNotFound => new("User not found"); Errors = ErrorMessages.ToArray();
public static RequestError ComicFileExists => new("Comic file exists so comic not deleted"); }
public static RequestError UserSpecificEndpoint => new("Endpoint is user-specific, requires login"); public RequestError And(RequestError other)
public string[] Errors { get; } {
public RequestError(string ErrorMessage) return new RequestError(Errors.Concat(other.Errors));
{ }
Errors = [ErrorMessage]; public RequestError And(string other)
} {
public RequestError() return new RequestError(Errors.Append(other));
{ }
Errors = []; public RequestError And(IEnumerable<string> other)
} {
public RequestError(IEnumerable<string> ErrorMessages) return new RequestError(Errors.Concat(other));
{ }
Errors = ErrorMessages.ToArray(); public IEnumerator<string> GetEnumerator()
} {
public RequestError And(RequestError other) return ((IEnumerable<string>)Errors).GetEnumerator();
{ }
return new RequestError(Errors.Concat(other.Errors)); IEnumerator IEnumerable.GetEnumerator()
} {
public RequestError And(string other) return GetEnumerator();
{
return new RequestError(Errors.Append(other));
}
public RequestError And(IEnumerable<string> other)
{
return new RequestError(Errors.Concat(other));
}
public IEnumerator<string> GetEnumerator()
{
return ((IEnumerable<string>)Errors).GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
} }
} }

View File

@@ -1,7 +1,6 @@
namespace ComiServ.Models namespace ComiServ.Models;
{
public record class TagResponse(string Name, int WorkCount) public record class TagResponse(string Name, int WorkCount)
{ {
}
} }

View File

@@ -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<T>
{ {
public class Truncated<T> public int Max { get; }
public int Count { get; }
public bool Complete { get; }
public List<T> Items { get; }
public Truncated(int max, IEnumerable<T> iter)
{ {
public int Max { get; } if (max <= 0)
public int Count { get; }
public bool Complete { get; }
public List<T> Items { get; }
public Truncated(int max, IEnumerable<T> items)
{ {
if (max <= 0) throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 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;
} }
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<T> items)
{
Max = max;
Complete = complete;
Count = items.Count;
Items = items;
}
public static async Task<Truncated<T>> CreateAsync(int max, IQueryable<T> 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<Truncated<T>> CreateAsync(int max, IAsyncEnumerable<T> iter)
{
if (max <= 0)
{
throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0");
}
List<T> 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<T>(max, complete, items);
} }
} }

View File

@@ -1,12 +1,11 @@
using ComiServ.Entities; 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; }
public string Username { get; set; } //NOT HASHED do not persist this object
public UserTypeEnum UserType { get; set; } public string Password { get; set; }
//NOT HASHED do not persist this object
public string Password { get; set; }
}
} }

View File

@@ -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);
} }

View File

@@ -1,11 +1,10 @@
using ComiServ.Entities; 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 string Username { get; set; } public UserTypeEnum? NewUserType { get; set; }
public string? NewUsername { get; set; }
public UserTypeEnum? NewUserType { get; set; }
}
} }

View File

@@ -1,33 +1,32 @@
using ComiServ.Entities; 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 void Authenticate(User user)
public class AuthenticationService : IAuthenticationService
{ {
public bool Tested { get; private set; } = false; User = user;
Tested = true;
public User? User { get; private set; } }
public AuthenticationService() public void FailAuth()
{ {
User = null;
} Tested = true;
public void Authenticate(User user)
{
User = user;
Tested = true;
}
public void FailAuth()
{
User = null;
Tested = true;
}
} }
} }

View File

@@ -8,221 +8,227 @@ using System.IO.Compression;
using System.IO.Hashing; using System.IO.Hashing;
using System.Linq; 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 public static readonly IReadOnlyList<string> ZIP_EXTS = [".cbz", ".zip"];
( public static readonly IReadOnlyList<string> RAR_EXTS = [".cbr", ".rar"];
long FileSizeBytes, public static readonly IReadOnlyList<string> ZIP7_EXTS = [".cb7", ".7z"];
int PageCount, public bool ComicFileExists(string filename);
Int64 Xxhash public void DeleteComicFile(string filename);
); //returns null on invalid filetype, throws on analysis error
public record class ComicPage public ComicAnalysis? AnalyzeComic(string filename);
( public Task<ComicAnalysis?> AnalyzeComicAsync(string filename);
string Filename, //returns null if out of range, throws for file error
string Mime, public ComicPage? GetComicPage(string filepath, int page);
byte[] Data public Task<ComicPage?> GetComicPageAsync(string filepath, int page);
); //based purely on filename, doesn't try to open file
public interface IComicAnalyzer //returns null for ALL UNRECOGNIZED OR NON-IMAGES
public static string? GetImageMime(string filename)
{ {
public static readonly IReadOnlyList<string> ZIP_EXTS = [".cbz", ".zip"]; if (new FileExtensionContentTypeProvider().TryGetContentType(filename, out string? _mime))
public static readonly IReadOnlyList<string> RAR_EXTS = [".cbr", ".rar"]; {
public static readonly IReadOnlyList<string> ZIP7_EXTS = [".cb7", ".7z"]; if (_mime?.StartsWith("image") ?? false)
public bool ComicFileExists(string filename); return _mime;
public void DeleteComicFile(string filename); }
//returns null on invalid filetype, throws on analysis error return null;
public ComicAnalysis? AnalyzeComic(string filename); }
public Task<ComicAnalysis?> AnalyzeComicAsync(string filename); }
//returns null if out of range, throws for file error //async methods actually just block
public ComicPage? GetComicPage(string filepath, int page); public class SynchronousComicAnalyzer(ILogger<IComicAnalyzer>? logger)
//based purely on filename, doesn't try to open file : IComicAnalyzer
//returns null for ALL UNRECOGNIZED OR NON-IMAGES {
public static string? GetImageMime(string filename) private readonly ILogger<IComicAnalyzer>? _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<ComicAnalysis?> 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<byte> 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<ComicPage?> 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; return null;
} }
} }
//async methods actually just block protected ComicPage? GetPageRar(string filepath, int page)
public class SynchronousComicAnalyzer(ILogger<IComicAnalyzer>? logger)
: IComicAnalyzer
{ {
private readonly ILogger<IComicAnalyzer>? _logger = logger; using var rar = RarArchive.Open(filepath);
public bool ComicFileExists(string filename) (var entry, var mime) = rar.Entries
{ .Select((RarArchiveEntry e) => (e, IComicAnalyzer.GetImageMime(e.Key)))
return File.Exists(filename); .Where(static pair => pair.Item2 is not null)
} .OrderBy(static pair => pair.Item1.Key)
public void DeleteComicFile(string filename) .Skip(page - 1)
{ .FirstOrDefault();
try if (entry is null || mime is null)
{ return null;
File.Delete(filename); using var stream = new MemoryStream();
} entry.WriteTo(stream);
catch (DirectoryNotFoundException) var pageData = stream.ToArray();
{ return new
return; (
} Filename: entry.Key ?? "",
} Mime: mime,
public ComicAnalysis? AnalyzeComic(string filepath) Data: pageData
{ );
_logger?.LogTrace($"Analyzing comic: {filepath}"); }
var ext = new FileInfo(filepath).Extension.ToLower(); protected ComicPage? GetPage7Zip(string filepath, int page)
if (IComicAnalyzer.ZIP_EXTS.Contains(ext)) {
return ZipAnalyze(filepath); using var zip7 = SevenZipArchive.Open(filepath);
else if (IComicAnalyzer.RAR_EXTS.Contains(ext)) (var entry, var mime) = zip7.Entries
return RarAnalyze(filepath); .Select((SevenZipArchiveEntry e) => (e, IComicAnalyzer.GetImageMime(e.Key)))
else if (IComicAnalyzer.ZIP7_EXTS.Contains(ext)) .Where(static pair => pair.Item2 is not null)
return Zip7Analyze(filepath); .OrderBy(static pair => pair.Item1.Key)
else .Skip(page - 1)
//throw new ArgumentException("Cannot analyze this file type"); .FirstOrDefault();
return null; if (entry is null || mime is null)
} return null;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously using var stream = new MemoryStream();
public async Task<ComicAnalysis?> AnalyzeComicAsync(string filename) entry.WriteTo(stream);
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously var pageData = stream.ToArray();
{ return new
return AnalyzeComic(filename); (
} Filename: entry.Key ?? "",
protected ComicAnalysis ZipAnalyze(string filepath) Mime: mime,
{ Data: pageData
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<byte> 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
);
}
} }
} }

View File

@@ -9,185 +9,201 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration.Ini; using Microsoft.Extensions.Configuration.Ini;
using Microsoft.OpenApi.Writers; using Microsoft.OpenApi.Writers;
namespace ComiServ.Background 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<string> COMIC_EXTENSIONS = [
"cbz", "zip",
"cbr", "rar",
"cb7", "7zip",
];
public void TriggerLibraryScan();
public void ScheduleRepeatedLibraryScans(TimeSpan period);
public IDictionary<string, ComicScanItem> PerfomLibraryScan(CancellationToken? token = null);
}
public class ComicScanner(
IServiceProvider provider
) : IComicScanner
{
//private readonly ComicsContext _context = context;
private readonly ITaskManager _manager = provider.GetRequiredService<ITaskManager>();
private readonly Configuration _config = provider.GetRequiredService<IConfigService>().Config;
private readonly IComicAnalyzer _analyzer = provider.GetRequiredService<IComicAnalyzer>();
private readonly IServiceProvider _provider = provider;
public IDictionary<string, ComicScanItem> PerfomLibraryScan(CancellationToken? token = null) public record class ComicScanItem
{ (
return new DirectoryInfo(_config.LibraryRoot).EnumerateFiles("*", SearchOption.AllDirectories) string Filepath,
.Select(fi => long FileSizeBytes,
{ Int64 Xxhash,
token?.ThrowIfCancellationRequested(); int PageCount
var path = Path.GetRelativePath(_config.LibraryRoot, fi.FullName); );
var analysis = _analyzer.AnalyzeComic(fi.FullName); public interface IComicScanner : IDisposable
if (analysis is null) {
//null will be filtered //TODO should be configurable
return (path, null); public static readonly IReadOnlyList<string> COMIC_EXTENSIONS = [
return (path, new ComicScanItem "cbz", "zip",
( "cbr", "rar",
Filepath: path, "cb7", "7zip",
FileSizeBytes: analysis.FileSizeBytes, ];
Xxhash: analysis.Xxhash, public void TriggerLibraryScan();
PageCount: analysis.PageCount public void ScheduleRepeatedLibraryScans(TimeSpan period);
)); public IDictionary<string, ComicScanItem> PerfomLibraryScan(CancellationToken? token = null);
})
//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<string, ComicScanItem> items)
{
using var scope = _provider.CreateScope();
var services = scope.ServiceProvider;
using var context = services.GetRequiredService<ComicsContext>();
//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<string> 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<ComicsContext>();
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<ComicsContext>();
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<IPictureConverter>();
using var inStream = new MemoryStream(comicPage.Data);
var outStream = converter.MakeThumbnail(inStream);
comic.ThumbnailWebp = outStream.ReadAllBytes();
}
public void Dispose()
{
RepeatedLibraryScanTokenSource?.Dispose();
}
}
} }
public class ComicScanner(
IServiceProvider provider
) : IComicScanner
{
//private readonly ComicsContext _context = context;
private readonly ITaskManager _manager = provider.GetRequiredService<ITaskManager>();
private readonly Configuration _config = provider.GetRequiredService<IConfigService>().Config;
private readonly IComicAnalyzer _analyzer = provider.GetRequiredService<IComicAnalyzer>();
private readonly IServiceProvider _provider = provider;
public IDictionary<string, ComicScanItem> 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<string, ComicScanItem> items)
{
using var scope = _provider.CreateScope();
var services = scope.ServiceProvider;
using var context = services.GetRequiredService<ComicsContext>();
//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<string> 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<ComicsContext>();
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<ComicsContext>();
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<IPictureConverter>();
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);
}
}

View File

@@ -1,31 +1,30 @@
using System.Text.Json; 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; } using var fileStream = File.OpenRead(filepath);
public string DatabaseFile { get; set; } _Config = JsonSerializer.Deserialize<Configuration>(fileStream)
public double AutoScanPeriodHours { get; set; } ?? throw new ArgumentException("Failed to parse config file");
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<Configuration>(fileStream)
?? throw new ArgumentException("Failed to parse config file");
}
} }
} }

View File

@@ -11,152 +11,151 @@ using SixLabors.ImageSharp.Formats.Bmp;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
namespace ComiServ.Background namespace ComiServ.Background;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PictureFormats
{ {
[JsonConverter(typeof(JsonStringEnumConverter))] Webp,
public enum PictureFormats 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<Stream> Resize(Stream image, System.Drawing.Size newSize, PictureFormats? newFormat = null);
public Task<Stream> ResizeIfBigger(Stream image, System.Drawing.Size maxSize, PictureFormats? newFormat = null);
public Task<Stream> MakeThumbnail(Stream image);
public static string GetMime(PictureFormats format)
{ {
Webp, switch (format)
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.Webp: case PictureFormats.Gif:
return "image/webp"; return "image/gif";
case PictureFormats.Gif: case PictureFormats.Jpg:
return "image/gif"; return "image/jpeg";
case PictureFormats.Jpg: case PictureFormats.Bmp:
return "image/jpeg"; return "image/bmp";
case PictureFormats.Bmp: case PictureFormats.Png:
return "image/bmp"; return "image/png";
case PictureFormats.Png: default:
return "image/png"; throw new ArgumentException("Cannot handle this format", nameof(format));
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);
} }
} }
} }
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<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
};
await img.SaveAsync(outStream, enc);
}
else
{
await img.SaveAsync(outStream, format);
}
return outStream;
}
public async Task<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
};
await img.SaveAsync(outStream, enc);
}
else
{
await img.SaveAsync(outStream, format);
}
return outStream;
}
public async Task<Stream> MakeThumbnail(Stream image)
{
return await Resize(image, IPictureConverter.ThumbnailResolution, IPictureConverter.ThumbnailFormat);
}
}

View File

@@ -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, Type = type;
GetCover, Name = name;
MakeThumbnail, 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<CancellationToken?> 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<CancellationToken?> Action;
public SyncTaskItem(TaskTypes type, string name, Action<CancellationToken?> action, CancellationToken? token = null)
: base(type, name, token)
{ {
public readonly TaskTypes Type = type; Action = action;
public readonly string Name = name;
public readonly Action<CancellationToken?> Action = action;
public readonly CancellationToken Token = token ?? CancellationToken.None;
} }
public interface ITaskManager : IDisposable }
public class AsyncTaskItem
: BaseTaskItem
{
public readonly Func<CancellationToken?, Task?> AsyncAction;
public AsyncTaskItem(TaskTypes type, string name, Func<CancellationToken?, Task?> asyncAction, CancellationToken? token = null)
: base(type, name, token)
{ {
public void StartTask(TaskItem taskItem); AsyncAction = asyncAction;
public void ScheduleTask(TaskItem taskItem, TimeSpan interval);
public string[] GetTasks(int limit);
public void CancelAll();
} }
public class TaskManager(ILogger<ITaskManager>? 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<ITaskManager>? logger)
: ITaskManager
{
private readonly ConcurrentDictionary<Task, BaseTaskItem> ActiveTasks = [];
private CancellationTokenSource MasterToken { get; set; } = new();
private readonly ILogger<ITaskManager>? _logger = logger;
private readonly ConcurrentDictionary<System.Timers.Timer, BaseTaskItem> Scheduled = [];
public void StartTask(SyncTaskItem taskItem)
{ {
private readonly ConcurrentDictionary<Task, TaskItem> ActiveTasks = []; //_logger?.LogTrace($"Start Task: {taskItem.Name}");
private CancellationTokenSource MasterToken { get; set; } = new(); var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token);
private readonly ILogger<ITaskManager>? _logger = logger; var newTask = Task.Run(() => taskItem.Action(tokenSource.Token),
private readonly ConcurrentDictionary<System.Timers.Timer,TaskItem> Scheduled = []; tokenSource.Token);
public void StartTask(TaskItem taskItem) if (!ActiveTasks.TryAdd(newTask, taskItem))
{ {
_logger?.LogTrace($"Start Task: {taskItem.Name}"); //TODO better exception
var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token); throw new Exception("failed to add task");
var newTask = Task.Run(() => taskItem.Action(tokenSource.Token), }
tokenSource.Token); //TODO should master token actually cancel followup?
if (!ActiveTasks.TryAdd(newTask, taskItem)) 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 var task = ati.AsyncAction(token.Token);
throw new Exception("failed to add task"); if (task != null)
} await task;
//TODO should master token actually cancel followup? };
newTask.ContinueWith(ManageFinishedTasks, MasterToken.Token); else if (taskItem is SyncTaskItem sti)
} timer.Elapsed += (_, _) => sti.Action(token.Token);
public void ScheduleTask(TaskItem taskItem, TimeSpan interval) timer.Start();
{ }
//var timer = new Timer((_) => StartTask(taskItem), null, dueTime, period ?? Timeout.InfiniteTimeSpan); public string[] GetTasks(int limit)
var timer = new System.Timers.Timer(interval); {
var token = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token); return ActiveTasks.Select(p => p.Value.Name).Take(limit).ToArray();
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();
}
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(); //cache first because we're modifying the dictionary
MasterToken.Dispose(); foreach (var pair in ActiveTasks.ToArray())
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 if (pair.Key.IsCompleted)
foreach (var pair in ActiveTasks.ToArray())
{ {
if (pair.Key.IsCompleted) bool taskRemoved = ActiveTasks.TryRemove(pair.Key, out _);
if (taskRemoved)
{ {
bool taskRemoved = ActiveTasks.TryRemove(pair.Key, out _); _logger?.LogTrace("Removed Task: {TaskName}", pair.Value.Name);
if (taskRemoved)
{
_logger?.LogTrace($"Removed Task: {pair.Value.Name}");
}
} }
} }
} }
} }
public void Dispose() }
{ public void Dispose()
MasterToken?.Dispose(); {
} MasterToken?.Dispose();
GC.SuppressFinalize(this);
} }
} }