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