mirror of
https://github.com/Ikatono/ComiServ.git
synced 2025-10-28 20:45:35 -05:00
Rearranged repo to make room for additional projects
This commit is contained in:
24
ComiServ/ComiServ.csproj
Normal file
24
ComiServ/ComiServ.csproj
Normal file
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</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>
|
||||
|
||||
</Project>
|
||||
80
ComiServ/ComicsContext.cs
Normal file
80
ComiServ/ComicsContext.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ComiServ.Entities;
|
||||
|
||||
namespace ComiServ;
|
||||
|
||||
public class ComicsContext : DbContext
|
||||
{
|
||||
//TODO is this the best place for this to live?
|
||||
public const int HANDLE_LENGTH = 12;
|
||||
//relies on low probability of repeat handles in a short period of time
|
||||
//duplicate handles could be created before either of them are commited
|
||||
public string CreateHandle()
|
||||
{
|
||||
char ToChar(int i)
|
||||
{
|
||||
if (i < 10)
|
||||
return (char)('0' + i);
|
||||
if (i - 10 + 'A' < 'O')
|
||||
return (char)('A' + i - 10);
|
||||
else
|
||||
//skip 'O'
|
||||
return (char)('A' + i - 9);
|
||||
}
|
||||
string handle = "";
|
||||
do
|
||||
{
|
||||
handle = string.Join("", Enumerable.Repeat(0, HANDLE_LENGTH)
|
||||
.Select(_ => ToChar(Random.Shared.Next(0, 35))));
|
||||
} while (Comics.Any(c => c.Handle == handle));
|
||||
return handle;
|
||||
}
|
||||
public DbSet<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();
|
||||
}
|
||||
}
|
||||
476
ComiServ/Controllers/ComicController.cs
Normal file
476
ComiServ/Controllers/ComicController.cs
Normal file
@@ -0,0 +1,476 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ComiServ.Models;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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 System.Data;
|
||||
using SQLitePCL;
|
||||
|
||||
namespace ComiServ.Controllers;
|
||||
|
||||
[Route(ROUTE)]
|
||||
[ApiController]
|
||||
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)]
|
||||
public async Task<IActionResult> SearchComics(
|
||||
[FromQuery(Name = "title")]
|
||||
string? titleSearch,
|
||||
[FromQuery(Name = "description")]
|
||||
string? descSearch,
|
||||
[FromQuery]
|
||||
string[] authors,
|
||||
[FromQuery]
|
||||
string[] tags,
|
||||
[FromQuery]
|
||||
string? pages,
|
||||
[FromQuery(Name = "hash")]
|
||||
string? xxhash64Hex,
|
||||
[FromQuery]
|
||||
bool? exists,
|
||||
[FromQuery]
|
||||
[DefaultValue(null)]
|
||||
bool? read,
|
||||
[FromQuery]
|
||||
[DefaultValue(0)]
|
||||
int page,
|
||||
[FromQuery]
|
||||
[DefaultValue(20)]
|
||||
int pageSize
|
||||
)
|
||||
{
|
||||
var results = _context.Comics
|
||||
.Include("ComicAuthors.Author")
|
||||
.Include("ComicTags.Tag");
|
||||
if (exists is not null)
|
||||
{
|
||||
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(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,
|
||||
};
|
||||
//TODO using the stream directly throws but I think it should be valid, need to debug
|
||||
using var resizedStream = await _converter.ResizeIfBigger(stream, limit, format);
|
||||
var arr = await resizedStream.ReadAllBytesAsync();
|
||||
return File(arr, mime);
|
||||
}
|
||||
else
|
||||
return File(comicPage.Data, comicPage.Mime);
|
||||
}
|
||||
[HttpGet("{handle}/thumbnail")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[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)
|
||||
{
|
||||
return File(img, "application/webp");
|
||||
}
|
||||
if (fallbackToCover)
|
||||
{
|
||||
var cover = await _context.Covers.SingleOrDefaultAsync(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 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()
|
||||
));
|
||||
}
|
||||
}
|
||||
71
ComiServ/Controllers/MiscController.cs
Normal file
71
ComiServ/Controllers/MiscController.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
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 async Task<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(await Paginated<AuthorResponse>.CreateAsync(pageSize, page, items));
|
||||
}
|
||||
[HttpGet("tags")]
|
||||
[ProducesResponseType<Paginated<TagResponse>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<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(await Paginated<TagResponse>.CreateAsync(pageSize, page, items));
|
||||
}
|
||||
}
|
||||
60
ComiServ/Controllers/TaskController.cs
Normal file
60
ComiServ/Controllers/TaskController.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
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(ROUTE)]
|
||||
[ApiController]
|
||||
public class TaskController(
|
||||
ComicsContext context
|
||||
,ITaskManager manager
|
||||
,IComicScanner scanner
|
||||
,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;
|
||||
private readonly ILogger<TaskController> _logger = logger;
|
||||
private readonly CancellationTokenSource cancellationToken = new();
|
||||
[HttpGet]
|
||||
[ProducesResponseType<Truncated<string>>(StatusCodes.Status200OK)]
|
||||
public IActionResult GetTasks(
|
||||
[FromQuery]
|
||||
[DefaultValue(20)]
|
||||
int limit
|
||||
)
|
||||
{
|
||||
return Ok(new Truncated<string>(limit, _manager.GetTasks(limit+1)));
|
||||
}
|
||||
[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();
|
||||
}
|
||||
}
|
||||
152
ComiServ/Controllers/UserController.cs
Normal file
152
ComiServ/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 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)
|
||||
{
|
||||
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(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)
|
||||
{
|
||||
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);
|
||||
await context.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
[HttpDelete("delete")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<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 = 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();
|
||||
}
|
||||
}
|
||||
12
ComiServ/Controllers/WebappController.cs
Normal file
12
ComiServ/Controllers/WebappController.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ComiServ.Controllers
|
||||
{
|
||||
[Route("app")]
|
||||
[Controller]
|
||||
public class WebappController
|
||||
: ControllerBase
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
16
ComiServ/Entities/Author.cs
Normal file
16
ComiServ/Entities/Author.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
//using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace ComiServ.Entities;
|
||||
|
||||
[Index(nameof(Name), IsUnique = true)]
|
||||
public class Author
|
||||
{
|
||||
public int Id { get; set; }
|
||||
[Required]
|
||||
public string Name { get; set; } = null!;
|
||||
public ICollection<ComicAuthor> ComicAuthors { get; set; } = null!;
|
||||
}
|
||||
34
ComiServ/Entities/Comic.cs
Normal file
34
ComiServ/Entities/Comic.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using ComiServ.Controllers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace ComiServ.Entities;
|
||||
|
||||
[Index(nameof(Handle), IsUnique = true)]
|
||||
[Index(nameof(Filepath), IsUnique = true)]
|
||||
public class Comic
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public bool Exists { get; set; }
|
||||
//id exposed through the API
|
||||
[Required]
|
||||
[StringLength(ComicsContext.HANDLE_LENGTH)]
|
||||
public string Handle { get; set; } = null!;
|
||||
[Required]
|
||||
public string Filepath { get; set; } = null!;
|
||||
[Required]
|
||||
public string Title { get; set; } = null!;
|
||||
[Required]
|
||||
public string Description { get; set; } = null!;
|
||||
public int PageCount { get; set; }
|
||||
public long SizeBytes { get; set; }
|
||||
public long FileXxhash64 { get; set; }
|
||||
public byte[]? ThumbnailWebp { get; set; }
|
||||
[InverseProperty("Comic")]
|
||||
public ICollection<ComicTag> ComicTags { get; set; } = [];
|
||||
[InverseProperty("Comic")]
|
||||
public ICollection<ComicAuthor> ComicAuthors { get; set; } = [];
|
||||
[InverseProperty("Comic")]
|
||||
public ICollection<ComicRead> ReadBy { get; set; } = [];
|
||||
}
|
||||
20
ComiServ/Entities/ComicAuthor.cs
Normal file
20
ComiServ/Entities/ComicAuthor.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace ComiServ.Entities;
|
||||
|
||||
[PrimaryKey("ComicId", "AuthorId")]
|
||||
[Index("ComicId")]
|
||||
[Index("AuthorId")]
|
||||
public class ComicAuthor
|
||||
{
|
||||
[ForeignKey(nameof(Comic))]
|
||||
public int ComicId { get; set; }
|
||||
[Required]
|
||||
public Comic Comic { get; set; } = null!;
|
||||
[ForeignKey(nameof(Author))]
|
||||
public int AuthorId { get; set; }
|
||||
[Required]
|
||||
public Author Author { get; set; } = null!;
|
||||
}
|
||||
15
ComiServ/Entities/ComicRead.cs
Normal file
15
ComiServ/Entities/ComicRead.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
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; }
|
||||
}
|
||||
14
ComiServ/Entities/ComicTag.cs
Normal file
14
ComiServ/Entities/ComicTag.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ComiServ.Entities;
|
||||
|
||||
[PrimaryKey("ComicId", "TagId")]
|
||||
[Index("ComicId")]
|
||||
[Index("TagId")]
|
||||
public class ComicTag
|
||||
{
|
||||
public int ComicId { get; set; }
|
||||
public Comic Comic { get; set; } = null!;
|
||||
public int TagId { get; set; }
|
||||
public Tag Tag { get; set; } = null!;
|
||||
}
|
||||
11
ComiServ/Entities/Cover.cs
Normal file
11
ComiServ/Entities/Cover.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ComiServ.Entities;
|
||||
|
||||
[PrimaryKey("FileXxhash64")]
|
||||
public class Cover
|
||||
{
|
||||
public long FileXxhash64 { get; set; }
|
||||
public string Filename { get; set; } = null!;
|
||||
public byte[] CoverFile { get; set; } = null!;
|
||||
}
|
||||
36
ComiServ/Entities/EntitySwaggerFilter.cs
Normal file
36
ComiServ/Entities/EntitySwaggerFilter.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Internal;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
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
|
||||
{
|
||||
public readonly static string[] FILTER = [
|
||||
nameof(Author),
|
||||
nameof(Comic),
|
||||
nameof(ComicAuthor),
|
||||
nameof(ComicTag),
|
||||
nameof(Cover),
|
||||
nameof(Tag),
|
||||
nameof(User),
|
||||
nameof(UserType)
|
||||
];
|
||||
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
|
||||
{
|
||||
return;
|
||||
//foreach (var item in context.SchemaRepository.Schemas.Keys)
|
||||
//{
|
||||
// if (FILTER.Contains(item))
|
||||
// {
|
||||
// context.SchemaRepository.Schemas.Remove(item);
|
||||
// }
|
||||
//}
|
||||
}
|
||||
}
|
||||
15
ComiServ/Entities/Tag.cs
Normal file
15
ComiServ/Entities/Tag.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace ComiServ.Entities;
|
||||
|
||||
[Index(nameof(Name), IsUnique = true)]
|
||||
public class Tag
|
||||
{
|
||||
//[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public int Id { get; set; }
|
||||
[Required]
|
||||
public string Name { get; set; } = null!;
|
||||
public ICollection<ComicTag> ComicTags { get; set; } = null!;
|
||||
}
|
||||
37
ComiServ/Entities/User.cs
Normal file
37
ComiServ/Entities/User.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
27
ComiServ/Entities/UserType.cs
Normal file
27
ComiServ/Entities/UserType.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
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; }
|
||||
}
|
||||
61
ComiServ/Extensions/DatabaseExtensions.cs
Normal file
61
ComiServ/Extensions/DatabaseExtensions.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
25
ComiServ/Extensions/StreamExtentions.cs
Normal file
25
ComiServ/Extensions/StreamExtentions.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace ComiServ.Extensions;
|
||||
|
||||
public static class StreamExtensions
|
||||
{
|
||||
//https://stackoverflow.com/questions/1080442/how-do-i-convert-a-stream-into-a-byte-in-c
|
||||
//https://archive.ph/QUKys
|
||||
public static byte[] ReadAllBytes(this Stream instream)
|
||||
{
|
||||
if (instream is MemoryStream)
|
||||
return ((MemoryStream)instream).ToArray();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
674
ComiServ/LICENSE
Normal file
674
ComiServ/LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
6
ComiServ/Logging/Events.cs
Normal file
6
ComiServ/Logging/Events.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ComiServ.Logging;
|
||||
|
||||
public static class Events
|
||||
{
|
||||
|
||||
}
|
||||
77
ComiServ/Middleware/BasicAuthenticationMiddleware.cs
Normal file
77
ComiServ/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());
|
||||
}
|
||||
}
|
||||
}
|
||||
6
ComiServ/Models/AuthorResponse.cs
Normal file
6
ComiServ/Models/AuthorResponse.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ComiServ.Models;
|
||||
|
||||
public record class AuthorResponse(string Name, int WorkCount)
|
||||
{
|
||||
|
||||
}
|
||||
40
ComiServ/Models/ComicData.cs
Normal file
40
ComiServ/Models/ComicData.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using ComiServ.Entities;
|
||||
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
public class ComicData
|
||||
{
|
||||
public string Handle { get; set; }
|
||||
public bool Exists { get; set; }
|
||||
public string Filepath { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Description { get; set; }
|
||||
public int PageCount { get; set; }
|
||||
public long SizeBytes { get; set; }
|
||||
public string FileXxhash64 { get; set; }
|
||||
public List<string> Authors { get; set; }
|
||||
public List<string> Tags { get; set; }
|
||||
public ComicData(Comic comic)
|
||||
{
|
||||
Handle = comic.Handle;
|
||||
Exists = comic.Exists;
|
||||
Filepath = comic.Filepath;
|
||||
Title = comic.Title;
|
||||
PageCount = comic.PageCount;
|
||||
SizeBytes = comic.SizeBytes;
|
||||
FileXxhash64 = "";
|
||||
var unsigned = (UInt64)comic.FileXxhash64;
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
var c = unsigned % 16;
|
||||
if (c < 10)
|
||||
FileXxhash64 += ((char)('0' + c)).ToString();
|
||||
else
|
||||
FileXxhash64 += ((char)('A' + c - 10)).ToString();
|
||||
unsigned /= 16;
|
||||
}
|
||||
Authors = comic.ComicAuthors.Select(a => a.Author.Name).ToList();
|
||||
Tags = comic.ComicTags.Select(a => a.Tag.Name).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
7
ComiServ/Models/ComicDeleteRequest.cs
Normal file
7
ComiServ/Models/ComicDeleteRequest.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ComiServ.Models;
|
||||
|
||||
//handle is taken from URL
|
||||
public record class ComicDeleteRequest
|
||||
(
|
||||
bool DeleteIfFileExists
|
||||
);
|
||||
22
ComiServ/Models/ComicDuplicateList.cs
Normal file
22
ComiServ/Models/ComicDuplicateList.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
9
ComiServ/Models/ComicMetadataUpdateRequest.cs
Normal file
9
ComiServ/Models/ComicMetadataUpdateRequest.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace ComiServ.Models;
|
||||
|
||||
public class ComicMetadataUpdateRequest
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
public List<string>? Authors { get; set; }
|
||||
}
|
||||
5
ComiServ/Models/LibraryResponse.cs
Normal file
5
ComiServ/Models/LibraryResponse.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace ComiServ.Models;
|
||||
|
||||
public record class LibraryResponse(int ComicCount, int UniqueFiles)
|
||||
{
|
||||
}
|
||||
95
ComiServ/Models/Paginated.cs
Normal file
95
ComiServ/Models/Paginated.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ComiServ.Models;
|
||||
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)
|
||||
{
|
||||
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");
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
51
ComiServ/Models/RequestError.cs
Normal file
51
ComiServ/Models/RequestError.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
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();
|
||||
}
|
||||
public RequestError And(RequestError other)
|
||||
{
|
||||
return new RequestError(Errors.Concat(other.Errors));
|
||||
}
|
||||
public RequestError And(string other)
|
||||
{
|
||||
return new RequestError(Errors.Append(other));
|
||||
}
|
||||
public RequestError And(IEnumerable<string> other)
|
||||
{
|
||||
return new RequestError(Errors.Concat(other));
|
||||
}
|
||||
public IEnumerator<string> GetEnumerator()
|
||||
{
|
||||
return ((IEnumerable<string>)Errors).GetEnumerator();
|
||||
}
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return GetEnumerator();
|
||||
}
|
||||
}
|
||||
6
ComiServ/Models/TagResponse.cs
Normal file
6
ComiServ/Models/TagResponse.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ComiServ.Models;
|
||||
|
||||
public record class TagResponse(string Name, int WorkCount)
|
||||
{
|
||||
|
||||
}
|
||||
75
ComiServ/Models/Truncated.cs
Normal file
75
ComiServ/Models/Truncated.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Reflection.PortableExecutable;
|
||||
|
||||
namespace ComiServ.Models;
|
||||
|
||||
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)
|
||||
{
|
||||
if (max <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0");
|
||||
}
|
||||
Max = max;
|
||||
Items = iter.Take(max+1).ToList();
|
||||
if (Items.Count <= max)
|
||||
{
|
||||
Complete = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Items.RemoveAt(max);
|
||||
Complete = false;
|
||||
}
|
||||
Count = Items.Count;
|
||||
}
|
||||
private Truncated(int max, bool complete, List<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);
|
||||
}
|
||||
}
|
||||
11
ComiServ/Models/UserCreateRequest.cs
Normal file
11
ComiServ/Models/UserCreateRequest.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
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; }
|
||||
}
|
||||
6
ComiServ/Models/UserDescription.cs
Normal file
6
ComiServ/Models/UserDescription.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ComiServ.Models;
|
||||
|
||||
public record class UserDescription(string Username, string Usertype)
|
||||
{
|
||||
|
||||
}
|
||||
10
ComiServ/Models/UserModifyRequest.cs
Normal file
10
ComiServ/Models/UserModifyRequest.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using ComiServ.Entities;
|
||||
|
||||
namespace ComiServ.Models;
|
||||
|
||||
public class UserModifyRequest
|
||||
{
|
||||
public string Username { get; set; }
|
||||
public string? NewUsername { get; set; }
|
||||
public UserTypeEnum? NewUserType { get; set; }
|
||||
}
|
||||
127
ComiServ/Program.cs
Normal file
127
ComiServ/Program.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using ComiServ;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ComiServ.Background;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using ComiServ.Entities;
|
||||
using ComiServ.Services;
|
||||
using ComiServ.Middleware;
|
||||
using ComiServ.Controllers;
|
||||
using System.Text;
|
||||
|
||||
var CONFIG_FILEPATH = "config.json";
|
||||
var configService = new JsonConfigService(CONFIG_FILEPATH);
|
||||
var config = configService.Config;
|
||||
var ConnectionString = $"Data Source={config.DatabaseFile};Mode=ReadWriteCreate";
|
||||
|
||||
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();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SchemaFilter<EntitySwaggerFilter>();
|
||||
});
|
||||
builder.Services.AddSingleton<IConfigService>(configService);
|
||||
builder.Services.AddDbContext<ComicsContext>(options =>
|
||||
options.UseSqlite(ConnectionString));
|
||||
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
||||
builder.Services.AddSingleton<ITaskManager>(sp =>
|
||||
new TaskManager(sp.GetService<ILogger<ITaskManager>>()));
|
||||
builder.Services.AddSingleton<IComicAnalyzer>(sp =>
|
||||
new SynchronousComicAnalyzer(
|
||||
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.AddScoped<IAuthenticationService>(
|
||||
sp => new AuthenticationService());
|
||||
builder.Services.AddLogging(config =>
|
||||
{
|
||||
config.AddConsole();
|
||||
config.AddDebug();
|
||||
});
|
||||
var app = builder.Build();
|
||||
app.UseHttpLogging();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseDeveloperExceptionPage();
|
||||
app.UseMigrationsEndPoint();
|
||||
}
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var services = scope.ServiceProvider;
|
||||
using var context = services.GetRequiredService<ComicsContext>();
|
||||
context.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
var scanner = app.Services.GetRequiredService<IComicScanner>();
|
||||
scanner.TriggerLibraryScan();
|
||||
scanner.ScheduleRepeatedLibraryScans(TimeSpan.FromDays(1));
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
//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
|
||||
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();
|
||||
|
||||
app.Run();
|
||||
41
ComiServ/Properties/launchSettings.json
Normal file
41
ComiServ/Properties/launchSettings.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:32904",
|
||||
"sslPort": 44308
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5265",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7020;http://localhost:5265",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
ComiServ/README.md
Normal file
13
ComiServ/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# ComiServ
|
||||
|
||||
An educational project to create a comic server in Asp.Net Core and Entity Framework Core. Scans a library folder for comic files, and exposes an API to:
|
||||
|
||||
- Get/Set metadata for any comic file (author, tags, description, etc.)
|
||||
- Search comics using any metadata
|
||||
- Track read status for each user
|
||||
- Serve entire comic files, or individual pages (with optional resizing and re-encoding)
|
||||
- identify duplicate files in the library
|
||||
|
||||
API is thoroughly documented through Swagger. In progress is a web app that consumes the API and provides a convenient user interface.
|
||||
|
||||
(If this sounds like something you want to run on your machine, you should check out komga: https://github.com/gotson/komga)
|
||||
32
ComiServ/Services/AuthenticationService.cs
Normal file
32
ComiServ/Services/AuthenticationService.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
234
ComiServ/Services/ComicAnalyzer.cs
Normal file
234
ComiServ/Services/ComicAnalyzer.cs
Normal file
@@ -0,0 +1,234 @@
|
||||
using Microsoft.AspNetCore.Routing.Constraints;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using SharpCompress.Archives;
|
||||
using SharpCompress.Archives.Rar;
|
||||
using SharpCompress.Archives.SevenZip;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.IO.Hashing;
|
||||
using System.Linq;
|
||||
|
||||
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 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);
|
||||
//returns null if out of range, throws for file error
|
||||
public ComicPage? GetComicPage(string filepath, int page);
|
||||
public Task<ComicPage?> GetComicPageAsync(string filepath, int page);
|
||||
//based purely on filename, doesn't try to open file
|
||||
//returns null for ALL UNRECOGNIZED OR NON-IMAGES
|
||||
public static string? GetImageMime(string filename)
|
||||
{
|
||||
if (new FileExtensionContentTypeProvider().TryGetContentType(filename, out string? _mime))
|
||||
{
|
||||
if (_mime?.StartsWith("image") ?? false)
|
||||
return _mime;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
//async methods actually just block
|
||||
public class SynchronousComicAnalyzer(ILogger<IComicAnalyzer>? logger)
|
||||
: 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}");
|
||||
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)
|
||||
{
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
209
ComiServ/Services/ComicScanner.cs
Normal file
209
ComiServ/Services/ComicScanner.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
using System.Collections.Generic;
|
||||
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;
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
30
ComiServ/Services/JsonConfigService.cs
Normal file
30
ComiServ/Services/JsonConfigService.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json;
|
||||
|
||||
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
|
||||
?? 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");
|
||||
}
|
||||
}
|
||||
161
ComiServ/Services/PictureConverter.cs
Normal file
161
ComiServ/Services/PictureConverter.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
//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 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)
|
||||
{
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
165
ComiServ/Services/TaskManager.cs
Normal file
165
ComiServ/Services/TaskManager.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using NuGet.Common;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace ComiServ.Services;
|
||||
|
||||
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)
|
||||
{
|
||||
Type = type;
|
||||
Name = name;
|
||||
Token = token ?? CancellationToken.None;
|
||||
}
|
||||
}
|
||||
//task needs to use the token parameter rather than its own token, because it gets merged with the master token
|
||||
public class SyncTaskItem
|
||||
: BaseTaskItem
|
||||
{
|
||||
public readonly Action<CancellationToken?> Action;
|
||||
public SyncTaskItem(TaskTypes type, string name, Action<CancellationToken?> action, CancellationToken? token = null)
|
||||
: base(type, name, token)
|
||||
{
|
||||
Action = action;
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
AsyncAction = asyncAction;
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
//_logger?.LogTrace($"Start Task: {taskItem.Name}");
|
||||
var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token);
|
||||
var newTask = Task.Run(() => taskItem.Action(tokenSource.Token),
|
||||
tokenSource.Token);
|
||||
if (!ActiveTasks.TryAdd(newTask, taskItem))
|
||||
{
|
||||
//TODO better exception
|
||||
throw new Exception("failed to add task");
|
||||
}
|
||||
//TODO should master token actually cancel followup?
|
||||
newTask.ContinueWith(ManageFinishedTasks, MasterToken.Token);
|
||||
}
|
||||
public void StartTask(AsyncTaskItem taskItem)
|
||||
{
|
||||
var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token);
|
||||
var newTask = Task.Run(() => taskItem.AsyncAction(tokenSource.Token),
|
||||
tokenSource.Token);
|
||||
if (!ActiveTasks.TryAdd(newTask, taskItem))
|
||||
{
|
||||
//TODO better exception
|
||||
throw new Exception("failed to add task");
|
||||
}
|
||||
//TODO should master token actually cancel followup?
|
||||
newTask.ContinueWith(ManageFinishedTasks, MasterToken.Token);
|
||||
}
|
||||
public void ScheduleTask(SyncTaskItem taskItem, TimeSpan interval)
|
||||
{
|
||||
//var timer = new Timer((_) => StartTask(taskItem), null, dueTime, period ?? Timeout.InfiniteTimeSpan);
|
||||
var timer = new System.Timers.Timer(interval);
|
||||
var token = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token);
|
||||
Scheduled.TryAdd(timer, taskItem);
|
||||
token.Token.Register(() =>
|
||||
{
|
||||
timer.Stop();
|
||||
Scheduled.TryRemove(timer, out var _);
|
||||
});
|
||||
timer.Elapsed += (_, _) => taskItem.Action(token.Token);
|
||||
timer.Start();
|
||||
}
|
||||
public void ScheduleTask(BaseTaskItem taskItem, TimeSpan interval)
|
||||
{
|
||||
var timer = new System.Timers.Timer(interval);
|
||||
var token = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token);
|
||||
Scheduled.TryAdd(timer, taskItem);
|
||||
token.Token.Register(() =>
|
||||
{
|
||||
timer.Stop();
|
||||
Scheduled.TryRemove(timer, out var _);
|
||||
});
|
||||
if (taskItem is AsyncTaskItem ati)
|
||||
timer.Elapsed += async (_, _) =>
|
||||
{
|
||||
var task = ati.AsyncAction(token.Token);
|
||||
if (task != null)
|
||||
await task;
|
||||
};
|
||||
else if (taskItem is SyncTaskItem sti)
|
||||
timer.Elapsed += (_, _) => sti.Action(token.Token);
|
||||
timer.Start();
|
||||
}
|
||||
public string[] GetTasks(int limit)
|
||||
{
|
||||
return ActiveTasks.Select(p => p.Value.Name).Take(limit).ToArray();
|
||||
}
|
||||
|
||||
public void CancelAll()
|
||||
{
|
||||
MasterToken.Cancel();
|
||||
MasterToken.Dispose();
|
||||
MasterToken = new CancellationTokenSource();
|
||||
}
|
||||
public void ManageFinishedTasks()
|
||||
{
|
||||
ManageFinishedTasks(null);
|
||||
}
|
||||
private readonly object _TaskCleanupLock = new();
|
||||
protected void ManageFinishedTasks(Task? cause = null)
|
||||
{
|
||||
//there shouldn't really be concerns with running multiple simultaneously but might as well
|
||||
lock (_TaskCleanupLock)
|
||||
{
|
||||
//cache first because we're modifying the dictionary
|
||||
foreach (var pair in ActiveTasks.ToArray())
|
||||
{
|
||||
if (pair.Key.IsCompleted)
|
||||
{
|
||||
bool taskRemoved = ActiveTasks.TryRemove(pair.Key, out _);
|
||||
if (taskRemoved)
|
||||
{
|
||||
_logger?.LogTrace("Removed Task: {TaskName}", pair.Value.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
MasterToken?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
8
ComiServ/appsettings.Development.json
Normal file
8
ComiServ/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Trace",
|
||||
"Microsoft.AspNetCore": "Trace"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
ComiServ/appsettings.json
Normal file
9
ComiServ/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
5
ComiServ/config.json
Normal file
5
ComiServ/config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"LibraryRoot": "./Library",
|
||||
"DatabaseFile": "ComiServ.db",
|
||||
"AutoScanPeriodHours": 0.03
|
||||
}
|
||||
44
ComiServ/package-lock.json
generated
Normal file
44
ComiServ/package-lock.json
generated
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "ComiServ",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@types/react": "^18.3.4",
|
||||
"@types/react-dom": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
|
||||
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.4.tgz",
|
||||
"integrity": "sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
|
||||
"integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
ComiServ/package.json
Normal file
6
ComiServ/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@types/react": "^18.3.4",
|
||||
"@types/react-dom": "^18.3.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user