mirror of
https://github.com/Ikatono/ComiServ.git
synced 2025-10-28 20:45:35 -05:00
v1.0 release
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,3 +1,10 @@
|
||||
|
||||
|
||||
#SQLite files
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.4" />
|
||||
<PackageReference Include="SharpCompress" Version="0.37.2" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.1" />
|
||||
<PackageReference Include="System.IO.Hashing" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -35,6 +35,9 @@ namespace ComiServ
|
||||
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)
|
||||
{
|
||||
@@ -48,6 +51,17 @@ namespace ComiServ
|
||||
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
|
||||
|
||||
@@ -8,18 +8,26 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using ComiServ.Entities;
|
||||
using ComiServ.Background;
|
||||
using System.ComponentModel;
|
||||
using ComiServ.Extensions;
|
||||
using System.Runtime.InteropServices;
|
||||
using ComiServ.Services;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using SQLitePCL;
|
||||
|
||||
namespace ComiServ.Controllers
|
||||
{
|
||||
[Route("api/v1/comics")]
|
||||
[Route(ROUTE)]
|
||||
[ApiController]
|
||||
public class ComicController(ComicsContext context, ILogger<ComicController> logger, IConfigService config, IComicAnalyzer analyzer)
|
||||
public class ComicController(ComicsContext context, ILogger<ComicController> logger, IConfigService config, IComicAnalyzer analyzer, IPictureConverter converter, IAuthenticationService _auth)
|
||||
: ControllerBase
|
||||
{
|
||||
public const string ROUTE = "/api/v1/comics";
|
||||
private readonly ComicsContext _context = context;
|
||||
private readonly ILogger<ComicController> _logger = logger;
|
||||
private readonly Configuration _config = config.Config;
|
||||
private readonly IComicAnalyzer _analyzer = analyzer;
|
||||
private readonly IPictureConverter _converter = converter;
|
||||
private readonly IAuthenticationService _auth = _auth;
|
||||
//TODO search parameters
|
||||
[HttpGet]
|
||||
[ProducesResponseType<Paginated<ComicData>>(StatusCodes.Status200OK)]
|
||||
@@ -39,6 +47,9 @@ namespace ComiServ.Controllers
|
||||
[FromQuery]
|
||||
bool? exists,
|
||||
[FromQuery]
|
||||
[DefaultValue(null)]
|
||||
bool? read,
|
||||
[FromQuery]
|
||||
[DefaultValue(0)]
|
||||
int page,
|
||||
[FromQuery]
|
||||
@@ -46,7 +57,6 @@ namespace ComiServ.Controllers
|
||||
int pageSize
|
||||
)
|
||||
{
|
||||
//throw new NotImplementedException();
|
||||
var results = _context.Comics
|
||||
.Include("ComicAuthors.Author")
|
||||
.Include("ComicTags.Tag");
|
||||
@@ -54,6 +64,18 @@ namespace ComiServ.Controllers
|
||||
{
|
||||
results = results.Where(c => c.Exists == exists);
|
||||
}
|
||||
string username;
|
||||
if (_auth.User is null)
|
||||
{
|
||||
return Unauthorized(RequestError.NotAuthenticated);
|
||||
}
|
||||
if (read is bool readStatus)
|
||||
{
|
||||
if (readStatus)
|
||||
results = results.Where(c => c.ReadBy.Any(u => EF.Functions.Like(_auth.User.Username, u.User.Username)));
|
||||
else
|
||||
results = results.Where(c => c.ReadBy.All(u => !EF.Functions.Like(_auth.User.Username, u.User.Username)));
|
||||
}
|
||||
foreach (var author in authors)
|
||||
{
|
||||
results = results.Where(c => c.ComicAuthors.Any(ca => EF.Functions.Like(ca.Author.Name, author)));
|
||||
@@ -112,17 +134,18 @@ namespace ComiServ.Controllers
|
||||
}
|
||||
if (titleSearch is not null)
|
||||
{
|
||||
//results = results.Where(c => EF.Functions.Like(c.Title, $"*{titleSearch}*"));
|
||||
results = results.Where(c => c.Title.Contains(titleSearch));
|
||||
titleSearch = titleSearch.Trim();
|
||||
results = results.Where(c => EF.Functions.Like(c.Title, $"%{titleSearch}%"));
|
||||
}
|
||||
if (descSearch is not null)
|
||||
{
|
||||
//results = results.Where(c => EF.Functions.Like(c.Description, $"*{descSearch}*"));
|
||||
results = results.Where(c => c.Description.Contains(descSearch));
|
||||
descSearch = descSearch.Trim();
|
||||
results = results.Where(c => EF.Functions.Like(c.Description, $"%{descSearch}%"));
|
||||
}
|
||||
int offset = page * pageSize;
|
||||
return Ok(new Paginated<ComicData>(pageSize, page, results.Skip(offset)
|
||||
.Select(c => new ComicData(c))));
|
||||
return Ok(new Paginated<ComicData>(pageSize, page, results
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => new ComicData(c))));
|
||||
}
|
||||
[HttpDelete]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
@@ -136,13 +159,13 @@ namespace ComiServ.Controllers
|
||||
}
|
||||
[HttpGet("{handle}")]
|
||||
[ProducesResponseType<ComicData>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetSingleComicInfo(string handle)
|
||||
{
|
||||
_logger.LogInformation("GetSingleComicInfo: {handle}", handle);
|
||||
handle = handle.Trim().ToUpper();
|
||||
if (handle.Length != ComicsContext.HANDLE_LENGTH)
|
||||
//_logger.LogInformation("GetSingleComicInfo: {handle}", handle);
|
||||
var validated = ComicsContext.CleanValidateHandle(handle);
|
||||
if (validated is null)
|
||||
return BadRequest(RequestError.InvalidHandle);
|
||||
var comic = _context.Comics
|
||||
.Include("ComicAuthors.Author")
|
||||
@@ -156,13 +179,13 @@ namespace ComiServ.Controllers
|
||||
[HttpPatch("{handle}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
public IActionResult UpdateComicMetadata(string handle, [FromBody] ComicMetadataUpdate metadata)
|
||||
public IActionResult UpdateComicMetadata(string handle, [FromBody] ComicMetadataUpdateRequest metadata)
|
||||
{
|
||||
//throw new NotImplementedException();
|
||||
if (handle.Length != ComicsContext.HANDLE_LENGTH)
|
||||
return BadRequest(RequestError.InvalidHandle);
|
||||
//using var transaction = _context.Database.BeginTransaction();
|
||||
var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle);
|
||||
if (comic is Comic actualComic)
|
||||
{
|
||||
@@ -171,9 +194,7 @@ namespace ComiServ.Controllers
|
||||
if (metadata.Authors is List<string> authors)
|
||||
{
|
||||
//make sure all authors exist, without changing Id of pre-existing authors
|
||||
//TODO try to batch these
|
||||
authors.ForEach(author => _context.Database.ExecuteSql(
|
||||
$"INSERT OR IGNORE INTO [Authors] (Name) VALUES ({author})"));
|
||||
_context.InsertOrIgnore(authors.Select(author => new Author() { Name = author }), ignorePrimaryKey: true);
|
||||
//get the Id of needed authors
|
||||
var authorEntities = _context.Authors.Where(a => authors.Contains(a.Name)).ToList();
|
||||
//delete existing author mappings
|
||||
@@ -184,9 +205,7 @@ namespace ComiServ.Controllers
|
||||
if (metadata.Tags is List<string> tags)
|
||||
{
|
||||
//make sure all tags exist, without changing Id of pre-existing tags
|
||||
//TODO try to batch these
|
||||
tags.ForEach(tag => _context.Database.ExecuteSql(
|
||||
$"INSERT OR IGNORE INTO [Tags] (Name) VALUES ({tag})"));
|
||||
_context.InsertOrIgnore(tags.Select(t => new Tag() { Name = t }), ignorePrimaryKey: true);
|
||||
//get the needed tags
|
||||
var tagEntities = _context.Tags.Where(t => tags.Contains(t.Name)).ToList();
|
||||
//delete existing tag mappings
|
||||
@@ -200,20 +219,95 @@ namespace ComiServ.Controllers
|
||||
else
|
||||
return NotFound(RequestError.ComicNotFound);
|
||||
}
|
||||
//[HttpDelete("{handle}")]
|
||||
//public IActionResult DeleteComic(string handle)
|
||||
//{
|
||||
// throw new NotImplementedException();
|
||||
//}
|
||||
[HttpPatch("{handle}/markread")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<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,
|
||||
ComicId = comic.Id
|
||||
};
|
||||
context.InsertOrIgnore(comicRead, ignorePrimaryKey: false);
|
||||
context.SaveChanges();
|
||||
return Ok();
|
||||
}
|
||||
[HttpPatch("{handle}/markunread")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
|
||||
public IActionResult MarkComicAsUnread(
|
||||
ComicsContext context,
|
||||
string handle)
|
||||
{
|
||||
var validated = ComicsContext.CleanValidateHandle(handle);
|
||||
if (validated is null)
|
||||
return BadRequest(RequestError.InvalidHandle);
|
||||
var comic = context.Comics.SingleOrDefault(c => c.Handle == validated);
|
||||
if (comic is null)
|
||||
return NotFound(RequestError.ComicNotFound);
|
||||
if (_auth.User is null)
|
||||
//user shouldn't have passed authentication if username doesn't match
|
||||
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||
var comicRead = context.ComicsRead.SingleOrDefault(cr =>
|
||||
cr.ComicId == comic.Id && cr.UserId == _auth.User.Id);
|
||||
if (comicRead is null)
|
||||
return Ok();
|
||||
context.ComicsRead.Remove(comicRead);
|
||||
context.SaveChanges();
|
||||
return Ok();
|
||||
}
|
||||
[HttpDelete("{handle}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<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)
|
||||
{
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
}
|
||||
if (_auth.User.UserTypeId != UserTypeEnum.Administrator)
|
||||
return Forbid();
|
||||
var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle);
|
||||
if (comic is null)
|
||||
return NotFound(RequestError.ComicNotFound);
|
||||
comic.Exists = _analyzer.ComicFileExists(string.Join(config.Config.LibraryRoot, comic.Filepath));
|
||||
if (comic.Exists && !req.DeleteIfFileExists)
|
||||
return BadRequest(RequestError.ComicFileExists);
|
||||
_context.Comics.Remove(comic);
|
||||
_context.SaveChanges();
|
||||
_analyzer.DeleteComicFile(string.Join(config.Config.LibraryRoot, comic.Filepath));
|
||||
return Ok();
|
||||
}
|
||||
[HttpGet("{handle}/file")]
|
||||
[ProducesResponseType<byte[]>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetComicFile(string handle)
|
||||
{
|
||||
_logger.LogInformation($"{nameof(GetComicFile)}: {handle}");
|
||||
handle = handle.Trim().ToUpper();
|
||||
if (handle.Length != ComicsContext.HANDLE_LENGTH)
|
||||
//_logger.LogInformation(nameof(GetComicFile) + ": {handle}", handle);
|
||||
var validated = ComicsContext.CleanValidateHandle(handle);
|
||||
if (validated is null)
|
||||
return BadRequest(RequestError.InvalidHandle);
|
||||
var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle);
|
||||
if (comic is null)
|
||||
@@ -227,7 +321,7 @@ namespace ComiServ.Controllers
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetComicCover(string handle)
|
||||
{
|
||||
_logger.LogInformation($"{nameof(GetComicCover)}: {handle}");
|
||||
//_logger.LogInformation(nameof(GetComicCover) + ": {handle}", handle);
|
||||
var validated = ComicsContext.CleanValidateHandle(handle);
|
||||
if (validated is null)
|
||||
return BadRequest(RequestError.InvalidHandle);
|
||||
@@ -248,9 +342,18 @@ namespace ComiServ.Controllers
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetComicPage(string handle, int page)
|
||||
public IActionResult GetComicPage(string handle, int page,
|
||||
[FromQuery]
|
||||
[DefaultValue(0)]
|
||||
int? maxWidth,
|
||||
[FromQuery]
|
||||
[DefaultValue(0)]
|
||||
int? maxHeight,
|
||||
[FromQuery]
|
||||
[DefaultValue(null)]
|
||||
PictureFormats? format)
|
||||
{
|
||||
_logger.LogInformation($"{nameof(GetComicPage)}: {handle} {page}");
|
||||
//_logger.LogInformation(nameof(GetComicPage) + ": {handle} {page}", handle, page);
|
||||
var validated = ComicsContext.CleanValidateHandle(handle);
|
||||
if (validated is null)
|
||||
return BadRequest(RequestError.InvalidHandle);
|
||||
@@ -261,24 +364,112 @@ namespace ComiServ.Controllers
|
||||
if (comicPage is null)
|
||||
//TODO rethink error code
|
||||
return NotFound(RequestError.PageNotFound);
|
||||
return File(comicPage.Data, comicPage.Mime);
|
||||
var limitWidth = maxWidth ?? -1;
|
||||
var limitHeight = maxHeight ?? -1;
|
||||
if (maxWidth > 0 || maxHeight > 0 || format is not null)
|
||||
{
|
||||
//TODO this copy is not strictly necessary, but avoiding it would mean keeping the comic file
|
||||
//open after GetComicPage returns to keep the stream. Not unreasonable (that's what IDisposable
|
||||
//is for) but need to be careful.
|
||||
using var stream = new MemoryStream(comicPage.Data);
|
||||
System.Drawing.Size limit = new(
|
||||
limitWidth > 0 ? limitWidth : int.MaxValue,
|
||||
limitHeight > 0 ? limitHeight : int.MaxValue
|
||||
);
|
||||
string mime = format switch
|
||||
{
|
||||
PictureFormats f => IPictureConverter.GetMime(f),
|
||||
null => comicPage.Mime,
|
||||
};
|
||||
//TODO using the stream directly throws but I think it should be valid, need to debug
|
||||
var arr = _converter.ResizeIfBigger(stream, limit, format).ReadAllBytes();
|
||||
return File(arr, mime);
|
||||
}
|
||||
else
|
||||
return File(comicPage.Data, comicPage.Mime);
|
||||
}
|
||||
[HttpGet("{handle}/thumbnail")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<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()
|
||||
{
|
||||
//Since ComicAuthors uses foreign keys
|
||||
_context.Authors
|
||||
.Include("ComicAuthors")
|
||||
.Include(a => a.ComicAuthors)
|
||||
.Where(a => a.ComicAuthors.Count == 0)
|
||||
.ExecuteDelete();
|
||||
_context.Tags
|
||||
.Include("ComicTags")
|
||||
.Include(a => a.ComicTags)
|
||||
.Where(a => a.ComicTags.Count == 0)
|
||||
.ExecuteDelete();
|
||||
//ExecuteDelete doesn't wait for SaveChanges
|
||||
//_context.SaveChanges();
|
||||
return Ok();
|
||||
}
|
||||
[HttpGet("duplicates")]
|
||||
[ProducesResponseType<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()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
72
Controllers/MiscController.cs
Normal file
72
Controllers/MiscController.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ComiServ.Entities;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ComiServ.Services;
|
||||
using ComiServ.Background;
|
||||
using ComiServ.Models;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace ComiServ.Controllers
|
||||
{
|
||||
[Route(ROUTE)]
|
||||
[ApiController]
|
||||
public class MiscController(ComicsContext context, ILogger<MiscController> logger, IConfigService config, IAuthenticationService auth)
|
||||
: ControllerBase
|
||||
{
|
||||
public const string ROUTE = "/api/v1/";
|
||||
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);
|
||||
}
|
||||
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")]
|
||||
[ProducesResponseType<Paginated<TagResponse>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public IActionResult GetTags(
|
||||
[FromQuery]
|
||||
[DefaultValue(0)]
|
||||
int page,
|
||||
[FromQuery]
|
||||
[DefaultValue(20)]
|
||||
int pageSize
|
||||
)
|
||||
{
|
||||
if (_auth.User is null)
|
||||
{
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
}
|
||||
if (_auth.User.UserTypeId != UserTypeEnum.Administrator)
|
||||
return Forbid();
|
||||
var items = context.Tags
|
||||
.OrderBy(t => t.ComicTags.Count())
|
||||
.Select(t => new TagResponse(t.Name, t.ComicTags.Count()));
|
||||
return Ok(new Paginated<TagResponse>(pageSize, page, items));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
using ComiServ.Background;
|
||||
using ComiServ.Models;
|
||||
using ComiServ.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.ComponentModel;
|
||||
using System.Security.Policy;
|
||||
|
||||
namespace ComiServ.Controllers
|
||||
{
|
||||
[Route("api/v1/tasks")]
|
||||
[Route(ROUTE)]
|
||||
[ApiController]
|
||||
public class TaskController(
|
||||
ComicsContext context
|
||||
@@ -15,6 +18,7 @@ namespace ComiServ.Controllers
|
||||
,ILogger<TaskController> logger
|
||||
) : ControllerBase
|
||||
{
|
||||
public const string ROUTE = "/api/v1/tasks";
|
||||
private readonly ComicsContext _context = context;
|
||||
private readonly ITaskManager _manager = manager;
|
||||
private readonly IComicScanner _scanner = scanner;
|
||||
@@ -37,5 +41,21 @@ namespace ComiServ.Controllers
|
||||
_scanner.TriggerLibraryScan();
|
||||
return Ok();
|
||||
}
|
||||
[HttpPost("cancelall")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
152
Controllers/UserController.cs
Normal file
152
Controllers/UserController.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using ComiServ.Entities;
|
||||
using ComiServ.Models;
|
||||
using ComiServ.Services;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
|
||||
namespace ComiServ.Controllers
|
||||
{
|
||||
[Route(ROUTE)]
|
||||
[ApiController]
|
||||
public class UserController
|
||||
: ControllerBase
|
||||
{
|
||||
public const string ROUTE = "/api/v1/users";
|
||||
[HttpGet]
|
||||
[ProducesResponseType<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);
|
||||
}
|
||||
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")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public IActionResult AddUser(IAuthenticationService auth,
|
||||
ComicsContext context,
|
||||
[FromBody]
|
||||
UserCreateRequest req)
|
||||
{
|
||||
if (auth.User is null)
|
||||
{
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
}
|
||||
if (auth.User.UserTypeId != UserTypeEnum.Administrator)
|
||||
return Forbid();
|
||||
var salt = Entities.User.MakeSalt();
|
||||
var bPass = Encoding.UTF8.GetBytes(req.Password);
|
||||
var newUser = new Entities.User()
|
||||
{
|
||||
Username = req.Username,
|
||||
Salt = salt,
|
||||
HashedPassword = Entities.User.Hash(password: bPass, salt: salt),
|
||||
UserTypeId = req.UserType
|
||||
};
|
||||
context.Users.Add(newUser);
|
||||
context.SaveChanges();
|
||||
return Ok();
|
||||
}
|
||||
[HttpDelete("delete")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public IActionResult DeleteUser(IAuthenticationService auth,
|
||||
ComicsContext context,
|
||||
[FromBody]
|
||||
string username)
|
||||
{
|
||||
if (auth.User is null)
|
||||
{
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
}
|
||||
if (auth.User.UserTypeId != UserTypeEnum.Administrator)
|
||||
return Forbid();
|
||||
username = username.Trim();
|
||||
var user = context.Users.SingleOrDefault(u => EF.Functions.Like(u.Username, $"{username}"));
|
||||
if (user is null)
|
||||
return BadRequest();
|
||||
context.Users.Remove(user);
|
||||
return Ok();
|
||||
}
|
||||
[HttpPost("modify")]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public IActionResult ModifyUser(IAuthenticationService auth,
|
||||
ComicsContext context,
|
||||
[FromBody]
|
||||
UserModifyRequest req)
|
||||
{
|
||||
//must be authenticated
|
||||
if (auth.User is null)
|
||||
{
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
}
|
||||
req.Username = req.Username.Trim();
|
||||
//must be an admin or changing own username
|
||||
if (!req.Username.Equals(auth.User.Username, StringComparison.CurrentCultureIgnoreCase)
|
||||
&& auth.User.UserTypeId != UserTypeEnum.Administrator)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
//only admins can change user type
|
||||
if (auth.User.UserTypeId != UserTypeEnum.Administrator
|
||||
&& req.NewUserType is not null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
var user = context.Users
|
||||
.SingleOrDefault(u => EF.Functions.Like(u.Username, req.Username));
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(RequestError.UserNotFound);
|
||||
}
|
||||
if (req.NewUsername is not null)
|
||||
{
|
||||
user.Username = req.NewUsername.Trim();
|
||||
}
|
||||
if (req.NewUserType is UserTypeEnum nut)
|
||||
{
|
||||
user.UserTypeId = nut;
|
||||
}
|
||||
context.SaveChanges();
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,6 @@ namespace ComiServ.Entities
|
||||
public int Id { get; set; }
|
||||
[Required]
|
||||
public string Name { get; set; } = null!;
|
||||
public ICollection<ComicAuthor> ComicAuthors = null!;
|
||||
public ICollection<ComicAuthor> ComicAuthors { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,12 @@ namespace ComiServ.Entities
|
||||
public int PageCount { get; set; }
|
||||
public long SizeBytes { get; set; }
|
||||
public long FileXxhash64 { get; set; }
|
||||
public byte[]? ThumbnailWebp { get; set; }
|
||||
[InverseProperty("Comic")]
|
||||
public ICollection<ComicTag> ComicTags { get; set; } = [];
|
||||
[InverseProperty("Comic")]
|
||||
public ICollection<ComicAuthor> ComicAuthors { get; set; } = [];
|
||||
[InverseProperty("Comic")]
|
||||
public ICollection<ComicRead> ReadBy { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
16
Entities/ComicRead.cs
Normal file
16
Entities/ComicRead.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace ComiServ.Entities
|
||||
{
|
||||
[PrimaryKey(nameof(UserId), nameof(ComicId))]
|
||||
[Index(nameof(UserId))]
|
||||
[Index(nameof(ComicId))]
|
||||
public class ComicRead
|
||||
{
|
||||
public int UserId { get; set; }
|
||||
public User User { get; set; }
|
||||
public int ComicId { get; set; }
|
||||
public Comic Comic { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,6 @@ namespace ComiServ.Entities
|
||||
public int Id { get; set; }
|
||||
[Required]
|
||||
public string Name { get; set; } = null!;
|
||||
public ICollection<ComicTag> ComicTags = null!;
|
||||
public ICollection<ComicTag> ComicTags { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
|
||||
38
Entities/User.cs
Normal file
38
Entities/User.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace ComiServ.Entities
|
||||
{
|
||||
[PrimaryKey(nameof(Id))]
|
||||
[Index(nameof(Username), IsUnique = true)]
|
||||
public class User
|
||||
{
|
||||
public const int HashLengthBytes = 512 / 8;
|
||||
public const int SaltLengthBytes = HashLengthBytes;
|
||||
public int Id { get; set; }
|
||||
[MaxLength(20)]
|
||||
public string Username { get; set; }
|
||||
[MaxLength(SaltLengthBytes)]
|
||||
public byte[] Salt { get; set; }
|
||||
[MaxLength(HashLengthBytes)]
|
||||
public byte[] HashedPassword { get; set; }
|
||||
public UserType UserType { get; set; }
|
||||
public UserTypeEnum UserTypeId { get; set; }
|
||||
[InverseProperty("User")]
|
||||
public ICollection<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
Entities/UserType.cs
Normal file
28
Entities/UserType.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ComiServ.Entities
|
||||
{
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum UserTypeEnum
|
||||
{
|
||||
//important that this is 0 as a safety precaution,
|
||||
//in case it's accidentally left as default
|
||||
Invalid = 0,
|
||||
//can create accounts
|
||||
Administrator = 1,
|
||||
//has basic access
|
||||
User = 2,
|
||||
//authenticates but does not give access
|
||||
Restricted = 3,
|
||||
//refuses to authenticate but maintains records
|
||||
Disabled = 4,
|
||||
}
|
||||
public class UserType
|
||||
{
|
||||
public UserTypeEnum Id { get; set; }
|
||||
[MaxLength(26)]
|
||||
public string Name { get; set; }
|
||||
public ICollection<User> Users { get; set; }
|
||||
}
|
||||
}
|
||||
62
Extensions/DatabaseExtensions.cs
Normal file
62
Extensions/DatabaseExtensions.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
//https://stackoverflow.com/a/42467710/25956209
|
||||
//https://archive.ph/RvjOy
|
||||
namespace ComiServ.Extensions
|
||||
{
|
||||
public static class DatabaseExtensions
|
||||
{
|
||||
//with a compound primary key, `ignorePrimaryKey` will ignore all of them
|
||||
public static int InsertOrIgnore<T>(this DbContext context, T item, bool ignorePrimaryKey = false)
|
||||
{
|
||||
var entityType = context.Model.FindEntityType(typeof(T));
|
||||
var tableName = entityType.GetTableName();
|
||||
//var tableSchema = entityType.GetSchema();
|
||||
|
||||
var cols = entityType.GetProperties()
|
||||
.Where(c => !ignorePrimaryKey || !c.IsPrimaryKey())
|
||||
.Select(c => new {
|
||||
Name = c.GetColumnName(),
|
||||
//Type = c.GetColumnType(),
|
||||
Value = c.PropertyInfo.GetValue(item)
|
||||
})
|
||||
.ToList();
|
||||
var query = "INSERT OR IGNORE INTO " + tableName
|
||||
+ " (" + string.Join(", ", cols.Select(c => c.Name)) + ") " +
|
||||
"VALUES (" + string.Join(", ", cols.Select((c,i) => "{" + i + "}")) + ")";
|
||||
var args = cols.Select(c => c.Value).ToArray();
|
||||
var formattable = FormattableStringFactory.Create(query, args);
|
||||
return context.Database.ExecuteSql(formattable);
|
||||
}
|
||||
public static int InsertOrIgnore<T>(this DbContext context, IEnumerable<T> items, bool ignorePrimaryKey = false)
|
||||
{
|
||||
var entityType = context.Model.FindEntityType(typeof(T));
|
||||
var tableName = entityType.GetTableName();
|
||||
//var tableSchema = entityType.GetSchema();
|
||||
|
||||
var colProps = entityType.GetProperties().Where(c => !ignorePrimaryKey || !c.IsPrimaryKey()).ToList();
|
||||
var colNames = colProps.Select(c => c.Name).ToList();
|
||||
if (colNames.Count == 0)
|
||||
throw new InvalidOperationException("No columns to insert");
|
||||
var rows = items
|
||||
.Select(item =>
|
||||
colProps.Select(c =>
|
||||
c.PropertyInfo.GetValue(item))
|
||||
.ToList())
|
||||
.ToList();
|
||||
int count = 0;
|
||||
var query = "INSERT OR IGNORE INTO " + tableName
|
||||
+ "(" + string.Join(',', colNames) + ")"
|
||||
+ "VALUES" + string.Join(',', rows.Select(row =>
|
||||
"(" + string.Join(',', row.Select(v => "{"
|
||||
+ count++
|
||||
+ "}")) + ")"
|
||||
));
|
||||
var args = rows.SelectMany(row => row).ToArray();
|
||||
var formattable = FormattableStringFactory.Create(query, args);
|
||||
return context.Database.ExecuteSql(formattable);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
Extensions/StreamExtentions.cs
Normal file
19
Extensions/StreamExtentions.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace ComiServ.Extensions
|
||||
{
|
||||
public static class StreamExtensions
|
||||
{
|
||||
//https://stackoverflow.com/questions/1080442/how-do-i-convert-a-stream-into-a-byte-in-c
|
||||
//https://archive.ph/QUKys
|
||||
public static byte[] ReadAllBytes(this Stream instream)
|
||||
{
|
||||
if (instream is MemoryStream)
|
||||
return ((MemoryStream)instream).ToArray();
|
||||
|
||||
using (var memoryStream = new MemoryStream())
|
||||
{
|
||||
instream.CopyTo(memoryStream);
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
Middleware/BasicAuthenticationMiddleware.cs
Normal file
77
Middleware/BasicAuthenticationMiddleware.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using ComiServ.Entities;
|
||||
using ComiServ.Services;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SixLabors.ImageSharp.Metadata.Profiles.Exif;
|
||||
using System.Text;
|
||||
|
||||
namespace ComiServ.Middleware
|
||||
{
|
||||
//only user of a type in `authorized` are permitted past this middleware
|
||||
//auth header is only checked once, so you can place multiple in the pipeline to further restrict
|
||||
//some endpoints
|
||||
public class BasicAuthenticationMiddleware(RequestDelegate next, UserTypeEnum[] authorized)
|
||||
{
|
||||
private readonly RequestDelegate _next = next;
|
||||
|
||||
public async Task InvokeAsync(HttpContext httpContext, ComicsContext context, IAuthenticationService auth)
|
||||
{
|
||||
if (!auth.Tested)
|
||||
{
|
||||
var authHeader = httpContext.Request.Headers.Authorization.SingleOrDefault();
|
||||
if (authHeader is string header)
|
||||
{
|
||||
if (header.StartsWith("Basic"))
|
||||
{
|
||||
header = header[5..].Trim();
|
||||
byte[] data = Convert.FromBase64String(header);
|
||||
string decoded = Encoding.UTF8.GetString(data);
|
||||
var split = decoded.Split(':', 2);
|
||||
if (split.Length == 2)
|
||||
{
|
||||
var user = split[0];
|
||||
var pass = split[1];
|
||||
var userCon = context.Users
|
||||
.Include(u => u.UserType)
|
||||
.SingleOrDefault(u => EF.Functions.Like(u.Username, user));
|
||||
if (userCon is not null && userCon.UserTypeId != UserTypeEnum.Disabled)
|
||||
{
|
||||
var bPass = Encoding.UTF8.GetBytes(pass);
|
||||
var salt = userCon.Salt;
|
||||
var hashed = User.Hash(bPass, salt);
|
||||
if (hashed.SequenceEqual(userCon.HashedPassword))
|
||||
auth.Authenticate(userCon);
|
||||
}
|
||||
}
|
||||
}
|
||||
//handle other schemes here maybe
|
||||
}
|
||||
else
|
||||
{
|
||||
auth.FailAuth();
|
||||
}
|
||||
}
|
||||
if (authorized.Length == 0 || authorized.Contains(auth.User?.UserTypeId ?? UserTypeEnum.Invalid))
|
||||
{
|
||||
await _next(httpContext);
|
||||
}
|
||||
else if (auth.User is not null)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
}
|
||||
else
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
httpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
}
|
||||
}
|
||||
}
|
||||
public static class BasicAuthenticationMiddlewareExtensions
|
||||
{
|
||||
public static IApplicationBuilder UseBasicAuthentication(this IApplicationBuilder builder, IEnumerable<UserTypeEnum> authorized)
|
||||
{
|
||||
//keep a private copy of the array
|
||||
return builder.UseMiddleware<BasicAuthenticationMiddleware>(authorized.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
7
Models/AuthorResponse.cs
Normal file
7
Models/AuthorResponse.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
public record class AuthorResponse(string Name, int WorkCount)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
8
Models/ComicDeleteRequest.cs
Normal file
8
Models/ComicDeleteRequest.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
//handle is taken from URL
|
||||
public record class ComicDeleteRequest
|
||||
(
|
||||
bool DeleteIfFileExists
|
||||
);
|
||||
}
|
||||
23
Models/ComicDuplicateList.cs
Normal file
23
Models/ComicDuplicateList.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using ComiServ.Entities;
|
||||
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
public class ComicDuplicateList
|
||||
{
|
||||
public long Hash { get; set; }
|
||||
public int Count { get; set; }
|
||||
public List<ComicData> Comics { get; set; }
|
||||
public ComicDuplicateList(long hash, IEnumerable<Comic> comics)
|
||||
{
|
||||
Hash = hash;
|
||||
Comics = comics.Select(c => new ComicData(c)).ToList();
|
||||
Count = Comics.Count;
|
||||
}
|
||||
public ComicDuplicateList(long hash, IEnumerable<ComicData> comics)
|
||||
{
|
||||
Hash = hash;
|
||||
Comics = comics.ToList();
|
||||
Count = Comics.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
public class ComicMetadataUpdate
|
||||
public class ComicMetadataUpdateRequest
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
6
Models/LibraryResponse.cs
Normal file
6
Models/LibraryResponse.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
public record class LibraryResponse(int ComicCount, int UniqueFiles)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(page), page, "must be greater than or equal to 0");
|
||||
}
|
||||
Items = iter.Take(max + 1).ToList();
|
||||
Items = iter.Skip(max * page).Take(max + 1).ToList();
|
||||
if (Items.Count > max)
|
||||
{
|
||||
Last = false;
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
public class RequestError
|
||||
{
|
||||
using System.Collections;
|
||||
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
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)
|
||||
{
|
||||
Errors = [ErrorMessage];
|
||||
}
|
||||
public RequestError()
|
||||
{
|
||||
Errors = [];
|
||||
}
|
||||
public RequestError(IEnumerable<string> ErrorMessages)
|
||||
{
|
||||
Errors = ErrorMessages.ToArray();
|
||||
@@ -27,8 +38,15 @@
|
||||
}
|
||||
public RequestError And(IEnumerable<string> other)
|
||||
{
|
||||
return new RequestError(Errors.Concat(other))
|
||||
;
|
||||
return new RequestError(Errors.Concat(other));
|
||||
}
|
||||
public IEnumerator<string> GetEnumerator()
|
||||
{
|
||||
return ((IEnumerable<string>)Errors).GetEnumerator();
|
||||
}
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return GetEnumerator();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
Models/TagResponse.cs
Normal file
7
Models/TagResponse.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
public record class TagResponse(string Name, int WorkCount)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,10 @@ namespace ComiServ.Models
|
||||
if (Items.Count <= max)
|
||||
{
|
||||
Complete = true;
|
||||
if (Items.Count > 0)
|
||||
Items.RemoveAt(max);
|
||||
}
|
||||
else
|
||||
{
|
||||
Items.RemoveAt(max);
|
||||
Complete = false;
|
||||
}
|
||||
Count = Items.Count;
|
||||
|
||||
12
Models/UserCreateRequest.cs
Normal file
12
Models/UserCreateRequest.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using ComiServ.Entities;
|
||||
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
public class UserCreateRequest
|
||||
{
|
||||
public string Username { get; set; }
|
||||
public UserTypeEnum UserType { get; set; }
|
||||
//NOT HASHED do not persist this object
|
||||
public string Password { get; set; }
|
||||
}
|
||||
}
|
||||
4
Models/UserDescription.cs
Normal file
4
Models/UserDescription.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
public record class UserDescription(string Username, string Usertype);
|
||||
}
|
||||
11
Models/UserModifyRequest.cs
Normal file
11
Models/UserModifyRequest.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using ComiServ.Entities;
|
||||
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
public class UserModifyRequest
|
||||
{
|
||||
public string Username { get; set; }
|
||||
public string? NewUsername { get; set; }
|
||||
public UserTypeEnum? NewUserType { get; set; }
|
||||
}
|
||||
}
|
||||
73
Program.cs
73
Program.cs
@@ -5,17 +5,54 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using ComiServ.Background;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using ComiServ.Entities;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
using ComiServ.Services;
|
||||
using ComiServ.Middleware;
|
||||
using ComiServ.Controllers;
|
||||
using System.Text;
|
||||
|
||||
var CONFIG_FILEPATH = "config.json";
|
||||
var configService = new ConfigService(CONFIG_FILEPATH);
|
||||
var configService = new JsonConfigService(CONFIG_FILEPATH);
|
||||
var config = configService.Config;
|
||||
var ConnectionString = $"Data Source={config.DatabaseFile};Mode=ReadWriteCreate";
|
||||
// Add services to the container.
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
if (args[i] == "--addadmin")
|
||||
{
|
||||
string username;
|
||||
if (args.ElementAtOrDefault(i + 1) is string _username)
|
||||
{
|
||||
username = _username;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Write("Username: ");
|
||||
username = Console.ReadLine()
|
||||
?? throw new Exception("must provide a username");
|
||||
}
|
||||
username = username.Trim();
|
||||
Console.Write("Password: ");
|
||||
string password = Console.ReadLine()?.Trim()
|
||||
?? throw new Exception("must provide a username");
|
||||
var salt = User.MakeSalt();
|
||||
var hashed = User.Hash(Encoding.UTF8.GetBytes(password), salt);
|
||||
using var context = new ComicsContext(
|
||||
new DbContextOptionsBuilder<ComicsContext>()
|
||||
.UseSqlite(ConnectionString).Options);
|
||||
context.Users.Add(new User()
|
||||
{
|
||||
Username = username,
|
||||
Salt = salt,
|
||||
HashedPassword = hashed,
|
||||
UserTypeId = UserTypeEnum.Administrator
|
||||
});
|
||||
context.SaveChanges();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddControllers();
|
||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
@@ -32,11 +69,11 @@ builder.Services.AddSingleton<IComicAnalyzer>(sp =>
|
||||
logger: sp.GetRequiredService<ILogger<IComicAnalyzer>>()));
|
||||
builder.Services.AddSingleton<IComicScanner>(sp =>
|
||||
new ComicScanner(provider: sp));
|
||||
builder.Services.AddSingleton<IPictureConverter>(
|
||||
new ResharperPictureConverter(true));
|
||||
builder.Services.AddHttpLogging(o => { });
|
||||
//builder.Services.AddRazorPages().AddRazorPagesOptions(o =>
|
||||
//{
|
||||
// o.RootDirectory = "/Pages";
|
||||
//});
|
||||
builder.Services.AddScoped<IAuthenticationService>(
|
||||
sp => new AuthenticationService());
|
||||
builder.Services.AddLogging(config =>
|
||||
{
|
||||
config.AddConsole();
|
||||
@@ -68,7 +105,23 @@ scanner.ScheduleRepeatedLibraryScans(TimeSpan.FromDays(1));
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthorization();
|
||||
//ensures that the user is authenticated (if auth is provided) but does not restrict access to any routes
|
||||
app.UseBasicAuthentication([]);
|
||||
//require user or admin account to access any comic resource (uses the authentication
|
||||
app.UseWhen(context => context.Request.Path.StartsWithSegments(ComicController.ROUTE), appBuilder =>
|
||||
{
|
||||
appBuilder.UseBasicAuthentication([UserTypeEnum.User, UserTypeEnum.Administrator]);
|
||||
});
|
||||
//require user or admin account to access any user resource
|
||||
app.UseWhen(context => context.Request.Path.StartsWithSegments(UserController.ROUTE), appBuilder =>
|
||||
{
|
||||
appBuilder.UseBasicAuthentication([UserTypeEnum.User, UserTypeEnum.Administrator]);
|
||||
});
|
||||
//require admin account to access any task resource
|
||||
app.UseWhen(context => context.Request.Path.StartsWithSegments(TaskController.ROUTE), appBuilder =>
|
||||
{
|
||||
appBuilder.UseBasicAuthentication([UserTypeEnum.Administrator]);
|
||||
});
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
|
||||
33
Services/AuthenticationService.cs
Normal file
33
Services/AuthenticationService.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using ComiServ.Entities;
|
||||
|
||||
namespace ComiServ.Services
|
||||
{
|
||||
public interface IAuthenticationService
|
||||
{
|
||||
public bool Tested { get; }
|
||||
public User? User { get; }
|
||||
public void Authenticate(User user);
|
||||
public void FailAuth();
|
||||
}
|
||||
//acts as a per-request container of authentication info
|
||||
public class AuthenticationService : IAuthenticationService
|
||||
{
|
||||
public bool Tested { get; private set; } = false;
|
||||
|
||||
public User? User { get; private set; }
|
||||
public AuthenticationService()
|
||||
{
|
||||
|
||||
}
|
||||
public void Authenticate(User user)
|
||||
{
|
||||
User = user;
|
||||
Tested = true;
|
||||
}
|
||||
public void FailAuth()
|
||||
{
|
||||
User = null;
|
||||
Tested = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ namespace ComiServ.Background
|
||||
public static readonly IReadOnlyList<string> ZIP_EXTS = [".cbz", ".zip"];
|
||||
public static readonly IReadOnlyList<string> RAR_EXTS = [".cbr", ".rar"];
|
||||
public static readonly IReadOnlyList<string> ZIP7_EXTS = [".cb7", ".7z"];
|
||||
public bool ComicFileExists(string filename);
|
||||
public void DeleteComicFile(string filename);
|
||||
//returns null on invalid filetype, throws on analysis error
|
||||
public ComicAnalysis? AnalyzeComic(string filename);
|
||||
public Task<ComicAnalysis?> AnalyzeComicAsync(string filename);
|
||||
@@ -36,9 +38,9 @@ namespace ComiServ.Background
|
||||
//returns null for ALL UNRECOGNIZED OR NON-IMAGES
|
||||
public static string? GetImageMime(string filename)
|
||||
{
|
||||
if (new FileExtensionContentTypeProvider().TryGetContentType(filename, out string _mime))
|
||||
if (new FileExtensionContentTypeProvider().TryGetContentType(filename, out string? _mime))
|
||||
{
|
||||
if (_mime.StartsWith("image"))
|
||||
if (_mime?.StartsWith("image") ?? false)
|
||||
return _mime;
|
||||
}
|
||||
return null;
|
||||
@@ -49,6 +51,21 @@ namespace ComiServ.Background
|
||||
: IComicAnalyzer
|
||||
{
|
||||
private readonly ILogger<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}");
|
||||
@@ -2,6 +2,11 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using ComiServ.Controllers;
|
||||
using ComiServ.Entities;
|
||||
using ComiServ.Extensions;
|
||||
using ComiServ.Services;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration.Ini;
|
||||
using Microsoft.OpenApi.Writers;
|
||||
|
||||
namespace ComiServ.Background
|
||||
@@ -135,8 +140,12 @@ namespace ComiServ.Background
|
||||
$"Get Cover: {c.Title}",
|
||||
token => InsertCover(Path.Join(_config.LibraryRoot, c.Filepath), c.FileXxhash64)
|
||||
)));
|
||||
newComics.ForEach(c => _manager.StartTask(new(
|
||||
TaskTypes.MakeThumbnail,
|
||||
$"Make Thumbnail: {c.Title}",
|
||||
token => InsertThumbnail(c.Handle, Path.Join(_config.LibraryRoot, c.Filepath), 1)
|
||||
)));
|
||||
context.Comics.AddRange(newComics);
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
protected void InsertCover(string filepath, long hash)
|
||||
@@ -152,13 +161,29 @@ namespace ComiServ.Background
|
||||
var page = _analyzer.GetComicPage(filepath, 1);
|
||||
if (page is null)
|
||||
return;
|
||||
context.Covers.Add(new()
|
||||
Cover cover = new()
|
||||
{
|
||||
FileXxhash64 = hash,
|
||||
Filename = page.Filename,
|
||||
CoverFile = page.Data
|
||||
});
|
||||
context.SaveChanges();
|
||||
};
|
||||
context.InsertOrIgnore(cover, true);
|
||||
}
|
||||
protected void InsertThumbnail(string handle, string filepath, int page = 1)
|
||||
{
|
||||
using var scope = _provider.CreateScope();
|
||||
var services = scope.ServiceProvider;
|
||||
using var context = services.GetRequiredService<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()
|
||||
{
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ComiServ
|
||||
namespace ComiServ.Services
|
||||
{
|
||||
public class Configuration
|
||||
{
|
||||
public string LibraryRoot { get; set; }
|
||||
public string DatabaseFile { get; set; }
|
||||
public double AutoScanPeriodHours { get; set; }
|
||||
public Configuration Copy()
|
||||
=> MemberwiseClone() as Configuration
|
||||
//this really shouldn't be possible
|
||||
@@ -15,12 +16,12 @@ namespace ComiServ
|
||||
{
|
||||
public Configuration Config { get; }
|
||||
}
|
||||
public class ConfigService : IConfigService
|
||||
public class JsonConfigService : IConfigService
|
||||
{
|
||||
public Configuration _Config;
|
||||
//protect original
|
||||
public Configuration Config => _Config.Copy();
|
||||
public ConfigService(string filepath)
|
||||
public JsonConfigService(string filepath)
|
||||
{
|
||||
using var fileStream = File.OpenRead(filepath);
|
||||
_Config = JsonSerializer.Deserialize<Configuration>(fileStream)
|
||||
162
Services/PictureConverter.cs
Normal file
162
Services/PictureConverter.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
//using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using SixLabors.ImageSharp.Formats;
|
||||
using SixLabors.ImageSharp.Formats.Webp;
|
||||
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||
using SixLabors.ImageSharp.Formats.Png;
|
||||
using SixLabors.ImageSharp.Formats.Gif;
|
||||
using SixLabors.ImageSharp.Formats.Bmp;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
|
||||
namespace ComiServ.Background
|
||||
{
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum PictureFormats
|
||||
{
|
||||
Webp,
|
||||
Jpg,
|
||||
Png,
|
||||
Gif,
|
||||
Bmp,
|
||||
}
|
||||
//never closes stream!
|
||||
public interface IPictureConverter
|
||||
{
|
||||
public static System.Drawing.Size ThumbnailResolution => new(200, 320);
|
||||
public static PictureFormats ThumbnailFormat => PictureFormats.Webp;
|
||||
//keeps aspect ratio, crops to horizontally to center, vertically to top
|
||||
//uses System.Drawing.Size so interface isn't dependant on ImageSharp
|
||||
public Stream Resize(Stream image, System.Drawing.Size newSize, PictureFormats? newFormat = null);
|
||||
public Stream ResizeIfBigger(Stream image, System.Drawing.Size maxSize, PictureFormats? newFormat = null);
|
||||
public Stream MakeThumbnail(Stream image);
|
||||
public static string GetMime(PictureFormats format)
|
||||
{
|
||||
switch (format)
|
||||
{
|
||||
case PictureFormats.Webp:
|
||||
return "image/webp";
|
||||
case PictureFormats.Gif:
|
||||
return "image/gif";
|
||||
case PictureFormats.Jpg:
|
||||
return "image/jpeg";
|
||||
case PictureFormats.Bmp:
|
||||
return "image/bmp";
|
||||
case PictureFormats.Png:
|
||||
return "image/png";
|
||||
default:
|
||||
throw new ArgumentException("Cannot handle this format", nameof(format));
|
||||
}
|
||||
}
|
||||
}
|
||||
public class ResharperPictureConverter(bool webpLossless = false)
|
||||
: IPictureConverter
|
||||
{
|
||||
public static IImageFormat ConvertFormatEnum(PictureFormats format)
|
||||
{
|
||||
switch (format)
|
||||
{
|
||||
case PictureFormats.Webp:
|
||||
return WebpFormat.Instance;
|
||||
case PictureFormats.Jpg:
|
||||
return JpegFormat.Instance;
|
||||
case PictureFormats.Png:
|
||||
return PngFormat.Instance;
|
||||
case PictureFormats.Gif:
|
||||
return GifFormat.Instance;
|
||||
case PictureFormats.Bmp:
|
||||
return BmpFormat.Instance;
|
||||
default:
|
||||
throw new ArgumentException("Cannot handle this format", nameof(format));
|
||||
}
|
||||
}
|
||||
public bool WebpLossless { get; } = webpLossless;
|
||||
public Stream Resize(Stream image, System.Drawing.Size newSize, PictureFormats? newFormat = null)
|
||||
{
|
||||
using var img = Image.Load(image);
|
||||
IImageFormat format;
|
||||
if (newFormat is PictureFormats nf)
|
||||
format = ConvertFormatEnum(nf);
|
||||
else if (img.Metadata.DecodedImageFormat is IImageFormat iif)
|
||||
format = img.Metadata.DecodedImageFormat;
|
||||
else
|
||||
format = WebpFormat.Instance;
|
||||
double oldAspect = ((double)img.Height) / img.Width;
|
||||
double newAspect = ((double)newSize.Height) / newSize.Width;
|
||||
Rectangle sourceRect;
|
||||
if (newAspect > oldAspect)
|
||||
{
|
||||
var y = 0;
|
||||
var h = newSize.Height;
|
||||
var w = (int)(h / newAspect);
|
||||
var x = (img.Width - w) / 2;
|
||||
sourceRect = new Rectangle(x, y, w, h);
|
||||
}
|
||||
else
|
||||
{
|
||||
var x = 0;
|
||||
var w = newSize.Width;
|
||||
var h = (int)(w * newAspect);
|
||||
var y = 0;
|
||||
sourceRect = new Rectangle(x, y, w, h);
|
||||
}
|
||||
img.Mutate(c => c.Crop(sourceRect).Resize(new Size(newSize.Width, newSize.Height)));
|
||||
var outStream = new MemoryStream();
|
||||
if (format is WebpFormat)
|
||||
{
|
||||
var enc = new WebpEncoder()
|
||||
{
|
||||
FileFormat = WebpLossless ? WebpFileFormatType.Lossless : WebpFileFormatType.Lossy
|
||||
};
|
||||
img.Save(outStream, enc);
|
||||
}
|
||||
else
|
||||
{
|
||||
img.Save(outStream, format);
|
||||
}
|
||||
return outStream;
|
||||
}
|
||||
public Stream ResizeIfBigger(Stream image, System.Drawing.Size maxSize, PictureFormats? newFormat = null)
|
||||
{
|
||||
using Image img = Image.Load(image);
|
||||
IImageFormat format;
|
||||
if (newFormat is PictureFormats nf)
|
||||
format = ConvertFormatEnum(nf);
|
||||
else if (img.Metadata.DecodedImageFormat is IImageFormat iif)
|
||||
format = img.Metadata.DecodedImageFormat;
|
||||
else
|
||||
format = WebpFormat.Instance;
|
||||
double scale = 1;
|
||||
if (img.Size.Width > maxSize.Width)
|
||||
{
|
||||
scale = Math.Min(scale, ((double)maxSize.Width) / img.Size.Width);
|
||||
}
|
||||
if (img.Size.Height > maxSize.Height)
|
||||
{
|
||||
scale = Math.Min(scale, ((double)maxSize.Height) / img.Size.Height);
|
||||
}
|
||||
Size newSize = new((int)(img.Size.Width * scale), (int)(img.Size.Height * scale));
|
||||
img.Mutate(c => c.Resize(newSize));
|
||||
var outStream = new MemoryStream();
|
||||
if (format is WebpFormat)
|
||||
{
|
||||
var enc = new WebpEncoder()
|
||||
{
|
||||
FileFormat = WebpLossless ? WebpFileFormatType.Lossless : WebpFileFormatType.Lossy
|
||||
};
|
||||
img.Save(outStream, enc);
|
||||
}
|
||||
else
|
||||
{
|
||||
img.Save(outStream, format);
|
||||
}
|
||||
return outStream;
|
||||
}
|
||||
public Stream MakeThumbnail(Stream image)
|
||||
{
|
||||
return Resize(image, IPictureConverter.ThumbnailResolution, IPictureConverter.ThumbnailFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace ComiServ.Background
|
||||
{
|
||||
Scan,
|
||||
GetCover,
|
||||
MakeThumbnail,
|
||||
}
|
||||
//task needs to use the token parameter rather than its own token, because it gets merged with the master token
|
||||
public class TaskItem(TaskTypes type, string name, Action<CancellationToken?> action, CancellationToken? token = null)
|
||||
@@ -26,7 +27,7 @@ namespace ComiServ.Background
|
||||
: ITaskManager
|
||||
{
|
||||
private readonly ConcurrentDictionary<Task, TaskItem> ActiveTasks = [];
|
||||
private readonly CancellationTokenSource MasterToken = new();
|
||||
private CancellationTokenSource MasterToken { get; set; } = new();
|
||||
private readonly ILogger<ITaskManager>? _logger = logger;
|
||||
private readonly ConcurrentDictionary<System.Timers.Timer,TaskItem> Scheduled = [];
|
||||
public void StartTask(TaskItem taskItem)
|
||||
@@ -65,6 +66,8 @@ namespace ComiServ.Background
|
||||
public void CancelAll()
|
||||
{
|
||||
MasterToken.Cancel();
|
||||
MasterToken.Dispose();
|
||||
MasterToken = new CancellationTokenSource();
|
||||
}
|
||||
public void ManageFinishedTasks()
|
||||
{
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Default": "Trace",
|
||||
"Microsoft.AspNetCore": "Trace"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
appsettings.json
Normal file
9
appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"LibraryRoot": "./Library",
|
||||
"DatabaseFile": "ComiServ.db"
|
||||
"DatabaseFile": "ComiServ.db",
|
||||
"AutoScanPeriodHours": 0.03
|
||||
}
|
||||
Reference in New Issue
Block a user