mirror of
https://github.com/Ikatono/ComiServ.git
synced 2025-10-28 20:45:35 -05:00
updated controllers and some services to use async/await
This commit is contained in:
@@ -12,464 +12,465 @@ using ComiServ.Extensions;
|
||||
using System.Runtime.InteropServices;
|
||||
using ComiServ.Services;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Data;
|
||||
using SQLitePCL;
|
||||
|
||||
namespace ComiServ.Controllers
|
||||
namespace ComiServ.Controllers;
|
||||
|
||||
[Route(ROUTE)]
|
||||
[ApiController]
|
||||
public class ComicController(ComicsContext context, ILogger<ComicController> logger, IConfigService config, IComicAnalyzer analyzer, IPictureConverter converter, IAuthenticationService _auth)
|
||||
: ControllerBase
|
||||
{
|
||||
[Route(ROUTE)]
|
||||
[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 = "TitleSearch")]
|
||||
string? titleSearch,
|
||||
[FromQuery(Name = "DescriptionSearch")]
|
||||
string? descSearch,
|
||||
[FromQuery]
|
||||
string[] authors,
|
||||
[FromQuery]
|
||||
string[] tags,
|
||||
[FromQuery]
|
||||
string? pages,
|
||||
[FromQuery]
|
||||
string? xxhash64Hex,
|
||||
[FromQuery]
|
||||
bool? exists,
|
||||
[FromQuery]
|
||||
[DefaultValue(null)]
|
||||
bool? read,
|
||||
[FromQuery]
|
||||
[DefaultValue(0)]
|
||||
int page,
|
||||
[FromQuery]
|
||||
[DefaultValue(20)]
|
||||
int pageSize
|
||||
)
|
||||
{
|
||||
public const string ROUTE = "/api/v1/comics";
|
||||
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 IActionResult SearchComics(
|
||||
[FromQuery(Name = "TitleSearch")]
|
||||
string? titleSearch,
|
||||
[FromQuery(Name = "DescriptionSearch")]
|
||||
string? descSearch,
|
||||
[FromQuery]
|
||||
string[] authors,
|
||||
[FromQuery]
|
||||
string[] tags,
|
||||
[FromQuery]
|
||||
string? pages,
|
||||
[FromQuery]
|
||||
string? xxhash64Hex,
|
||||
[FromQuery]
|
||||
bool? exists,
|
||||
[FromQuery]
|
||||
[DefaultValue(null)]
|
||||
bool? read,
|
||||
[FromQuery]
|
||||
[DefaultValue(0)]
|
||||
int page,
|
||||
[FromQuery]
|
||||
[DefaultValue(20)]
|
||||
int pageSize
|
||||
)
|
||||
var results = _context.Comics
|
||||
.Include("ComicAuthors.Author")
|
||||
.Include("ComicTags.Tag");
|
||||
if (exists is not null)
|
||||
{
|
||||
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(new Paginated<ComicData>(pageSize, page, results
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => new ComicData(c))));
|
||||
results = results.Where(c => c.Exists == exists);
|
||||
}
|
||||
[HttpDelete]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult DeleteComicsThatDontExist()
|
||||
string username;
|
||||
if (_auth.User is null)
|
||||
{
|
||||
var search = _context.Comics.Where(c => !c.Exists);
|
||||
var nonExtant = search.ToList();
|
||||
search.ExecuteDelete();
|
||||
_context.SaveChanges();
|
||||
return Ok(search.Select(c => new ComicData(c)));
|
||||
return Unauthorized(RequestError.NotAuthenticated);
|
||||
}
|
||||
[HttpGet("{handle}")]
|
||||
[ProducesResponseType<ComicData>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetSingleComicInfo(string handle)
|
||||
if (read is bool readStatus)
|
||||
{
|
||||
//_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")
|
||||
.Include("ComicTags.Tag")
|
||||
.SingleOrDefault(c => c.Handle == handle);
|
||||
if (comic is Comic actualComic)
|
||||
return Ok(new ComicData(comic));
|
||||
if (readStatus)
|
||||
results = results.Where(c => c.ReadBy.Any(u => EF.Functions.Like(_auth.User.Username, u.User.Username)));
|
||||
else
|
||||
return NotFound(RequestError.ComicNotFound);
|
||||
results = results.Where(c => c.ReadBy.All(u => !EF.Functions.Like(_auth.User.Username, u.User.Username)));
|
||||
}
|
||||
[HttpPatch("{handle}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
public IActionResult UpdateComicMetadata(string handle, [FromBody] ComicMetadataUpdateRequest metadata)
|
||||
foreach (var author in authors)
|
||||
{
|
||||
if (handle.Length != ComicsContext.HANDLE_LENGTH)
|
||||
return BadRequest(RequestError.InvalidHandle);
|
||||
var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle);
|
||||
if (comic is Comic actualComic)
|
||||
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("<="))
|
||||
{
|
||||
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 = _context.Authors.Where(a => authors.Contains(a.Name)).ToList();
|
||||
//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 = _context.Tags.Where(t => tags.Contains(t.Name)).ToList();
|
||||
//delete existing tag mappings
|
||||
_context.ComicTags.RemoveRange(_context.ComicTags.Where(ta => ta.Comic.Id == comic.Id));
|
||||
//add all tag mappings
|
||||
_context.ComicTags.AddRange(tagEntities.Select(t => new ComicTag { Comic = comic, Tag = t }));
|
||||
}
|
||||
_context.SaveChanges();
|
||||
return Ok();
|
||||
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
|
||||
return NotFound(RequestError.ComicNotFound);
|
||||
}
|
||||
[HttpPatch("{handle}/markread")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
|
||||
public IActionResult MarkComicAsRead(
|
||||
ComicsContext context,
|
||||
string handle)
|
||||
{
|
||||
var validated = ComicsContext.CleanValidateHandle(handle);
|
||||
if (validated is null)
|
||||
return BadRequest(RequestError.InvalidHandle);
|
||||
var comic = context.Comics.SingleOrDefault(c => c.Handle == validated);
|
||||
if (comic is null)
|
||||
return NotFound(RequestError.ComicNotFound);
|
||||
if (_auth.User is null)
|
||||
//user shouldn't have passed authentication if username doesn't match
|
||||
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||
var comicRead = new ComicRead()
|
||||
{
|
||||
UserId = _auth.User.Id,
|
||||
ComicId = comic.Id
|
||||
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,
|
||||
};
|
||||
context.InsertOrIgnore(comicRead, ignorePrimaryKey: false);
|
||||
context.SaveChanges();
|
||||
return Ok();
|
||||
//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);
|
||||
}
|
||||
[HttpPatch("{handle}/markunread")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
|
||||
public IActionResult MarkComicAsUnread(
|
||||
ComicsContext context,
|
||||
string handle)
|
||||
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)
|
||||
{
|
||||
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();
|
||||
return File(img, "application/webp");
|
||||
}
|
||||
[HttpDelete("{handle}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
public IActionResult DeleteComic(
|
||||
string handle,
|
||||
[FromBody]
|
||||
ComicDeleteRequest req)
|
||||
if (fallbackToCover)
|
||||
{
|
||||
if (_auth.User is null)
|
||||
var cover = await _context.Covers.SingleOrDefaultAsync(c => c.FileXxhash64 == comic.FileXxhash64);
|
||||
if (cover is not null)
|
||||
{
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
//TODO should this convert to a thumbnail on the fly?
|
||||
return File(cover.CoverFile, IComicAnalyzer.GetImageMime(cover.Filename) ?? "application/octet-stream");
|
||||
}
|
||||
if (_auth.User.UserTypeId != UserTypeEnum.Administrator)
|
||||
return Forbid();
|
||||
var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle);
|
||||
if (comic is null)
|
||||
return NotFound(RequestError.ComicNotFound);
|
||||
comic.Exists = _analyzer.ComicFileExists(string.Join(config.Config.LibraryRoot, comic.Filepath));
|
||||
if (comic.Exists && !req.DeleteIfFileExists)
|
||||
return BadRequest(RequestError.ComicFileExists);
|
||||
_context.Comics.Remove(comic);
|
||||
_context.SaveChanges();
|
||||
_analyzer.DeleteComicFile(string.Join(config.Config.LibraryRoot, comic.Filepath));
|
||||
return Ok();
|
||||
}
|
||||
[HttpGet("{handle}/file")]
|
||||
[ProducesResponseType<byte[]>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetComicFile(string handle)
|
||||
{
|
||||
//_logger.LogInformation(nameof(GetComicFile) + ": {handle}", handle);
|
||||
var validated = ComicsContext.CleanValidateHandle(handle);
|
||||
if (validated is null)
|
||||
return BadRequest(RequestError.InvalidHandle);
|
||||
var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle);
|
||||
if (comic is null)
|
||||
return NotFound(RequestError.ComicNotFound);
|
||||
var data = System.IO.File.ReadAllBytes(Path.Join(_config.LibraryRoot, comic.Filepath));
|
||||
return File(data, "application/octet-stream", new FileInfo(comic.Filepath).Name);
|
||||
}
|
||||
[HttpGet("{handle}/cover")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetComicCover(string handle)
|
||||
{
|
||||
//_logger.LogInformation(nameof(GetComicCover) + ": {handle}", handle);
|
||||
var validated = ComicsContext.CleanValidateHandle(handle);
|
||||
if (validated is null)
|
||||
return BadRequest(RequestError.InvalidHandle);
|
||||
var comic = _context.Comics
|
||||
.SingleOrDefault(c => c.Handle == validated);
|
||||
if (comic is null)
|
||||
return NotFound(RequestError.ComicNotFound);
|
||||
var cover = _context.Covers
|
||||
.SingleOrDefault(cov => cov.FileXxhash64 == comic.FileXxhash64);
|
||||
if (cover is null)
|
||||
return NotFound(RequestError.CoverNotFound);
|
||||
var mime = IComicAnalyzer.GetImageMime(cover.Filename);
|
||||
if (mime is null)
|
||||
return File(cover.CoverFile, "application/octet-stream", cover.Filename);
|
||||
return File(cover.CoverFile, mime);
|
||||
}
|
||||
[HttpGet("{handle}/page/{page}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetComicPage(string handle, int page,
|
||||
[FromQuery]
|
||||
[DefaultValue(0)]
|
||||
int? maxWidth,
|
||||
[FromQuery]
|
||||
[DefaultValue(0)]
|
||||
int? maxHeight,
|
||||
[FromQuery]
|
||||
[DefaultValue(null)]
|
||||
PictureFormats? format)
|
||||
{
|
||||
//_logger.LogInformation(nameof(GetComicPage) + ": {handle} {page}", handle, page);
|
||||
var validated = ComicsContext.CleanValidateHandle(handle);
|
||||
if (validated is null)
|
||||
return BadRequest(RequestError.InvalidHandle);
|
||||
var comic = _context.Comics.SingleOrDefault(c => c.Handle == validated);
|
||||
if (comic is null)
|
||||
return NotFound(RequestError.ComicNotFound);
|
||||
var comicPage = _analyzer.GetComicPage(Path.Join(_config.LibraryRoot, comic.Filepath), page);
|
||||
if (comicPage is null)
|
||||
//TODO rethink error code
|
||||
return NotFound(RequestError.PageNotFound);
|
||||
var limitWidth = maxWidth ?? -1;
|
||||
var limitHeight = maxHeight ?? -1;
|
||||
if (maxWidth > 0 || maxHeight > 0 || format is not null)
|
||||
{
|
||||
//TODO this copy is not strictly necessary, but avoiding it would mean keeping the comic file
|
||||
//open after GetComicPage returns to keep the stream. Not unreasonable (that's what IDisposable
|
||||
//is for) but need to be careful.
|
||||
using var stream = new MemoryStream(comicPage.Data);
|
||||
System.Drawing.Size limit = new(
|
||||
limitWidth > 0 ? limitWidth : int.MaxValue,
|
||||
limitHeight > 0 ? limitHeight : int.MaxValue
|
||||
);
|
||||
string mime = format switch
|
||||
{
|
||||
PictureFormats f => IPictureConverter.GetMime(f),
|
||||
null => comicPage.Mime,
|
||||
};
|
||||
//TODO using the stream directly throws but I think it should be valid, need to debug
|
||||
var arr = _converter.ResizeIfBigger(stream, limit, format).ReadAllBytes();
|
||||
return File(arr, mime);
|
||||
}
|
||||
else
|
||||
return File(comicPage.Data, comicPage.Mime);
|
||||
}
|
||||
[HttpGet("{handle}/thumbnail")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetComicThumbnail(
|
||||
string handle,
|
||||
[FromQuery]
|
||||
[DefaultValue(false)]
|
||||
//if thumbnail doesn't exist, try to find a cover
|
||||
bool fallbackToCover)
|
||||
{
|
||||
RequestError accErrors = new();
|
||||
var validated = ComicsContext.CleanValidateHandle(handle);
|
||||
if (validated is null)
|
||||
return BadRequest(RequestError.InvalidHandle);
|
||||
var comic = _context.Comics.SingleOrDefault(c => c.Handle == validated);
|
||||
if (comic is null)
|
||||
return NotFound(RequestError.ComicNotFound);
|
||||
if (comic.ThumbnailWebp is byte[] img)
|
||||
{
|
||||
return File(img, "application/webp");
|
||||
}
|
||||
if (fallbackToCover)
|
||||
{
|
||||
var cover = _context.Covers.SingleOrDefault(c => c.FileXxhash64 == comic.FileXxhash64);
|
||||
if (cover is not null)
|
||||
{
|
||||
//TODO should this convert to a thumbnail on the fly?
|
||||
return File(cover.CoverFile, IComicAnalyzer.GetImageMime(cover.Filename) ?? "application/octet-stream");
|
||||
}
|
||||
accErrors = accErrors.And(RequestError.CoverNotFound);
|
||||
}
|
||||
return NotFound(RequestError.ThumbnailNotFound.And(accErrors));
|
||||
}
|
||||
[HttpPost("cleandb")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult CleanUnusedTagAuthors()
|
||||
{
|
||||
_context.Authors
|
||||
.Include(a => a.ComicAuthors)
|
||||
.Where(a => a.ComicAuthors.Count == 0)
|
||||
.ExecuteDelete();
|
||||
_context.Tags
|
||||
.Include(a => a.ComicTags)
|
||||
.Where(a => a.ComicTags.Count == 0)
|
||||
.ExecuteDelete();
|
||||
//ExecuteDelete doesn't wait for SaveChanges
|
||||
//_context.SaveChanges();
|
||||
return Ok();
|
||||
}
|
||||
[HttpGet("duplicates")]
|
||||
[ProducesResponseType<Paginated<ComicDuplicateList>>(StatusCodes.Status200OK)]
|
||||
public IActionResult GetDuplicateFiles(
|
||||
[FromQuery]
|
||||
[DefaultValue(0)]
|
||||
int page,
|
||||
[FromQuery]
|
||||
[DefaultValue(20)]
|
||||
int pageSize)
|
||||
{
|
||||
var groups = _context.Comics
|
||||
.Include("ComicAuthors.Author")
|
||||
.Include("ComicTags.Tag")
|
||||
.GroupBy(c => c.FileXxhash64)
|
||||
.Where(g => g.Count() > 1)
|
||||
.OrderBy(g => g.Key);
|
||||
var ret = new Paginated<ComicDuplicateList>(pageSize, page,
|
||||
groups.Select(g =>
|
||||
new ComicDuplicateList(g.Key, g.Select(g => g))
|
||||
));
|
||||
return Ok(ret);
|
||||
}
|
||||
[HttpGet("library")]
|
||||
[ProducesResponseType<LibraryResponse>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public IActionResult GetLibraryStats()
|
||||
{
|
||||
return Ok(new LibraryResponse(
|
||||
_context.Comics.Count(),
|
||||
_context.Comics.Select(c => c.FileXxhash64).Distinct().Count()
|
||||
));
|
||||
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()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,67 +6,66 @@ using ComiServ.Background;
|
||||
using ComiServ.Models;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace ComiServ.Controllers
|
||||
namespace ComiServ.Controllers;
|
||||
|
||||
[Route(ROUTE)]
|
||||
[ApiController]
|
||||
public class MiscController(ComicsContext context, ILogger<MiscController> logger, IConfigService config, IAuthenticationService auth)
|
||||
: ControllerBase
|
||||
{
|
||||
[Route(ROUTE)]
|
||||
[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
|
||||
)
|
||||
{
|
||||
public const string ROUTE = "/api/v1/";
|
||||
ComicsContext _context = context;
|
||||
ILogger<MiscController> _logger = logger;
|
||||
IConfigService _config = config;
|
||||
IAuthenticationService _auth = auth;
|
||||
[HttpGet("authors")]
|
||||
[ProducesResponseType<Paginated<AuthorResponse>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public IActionResult GetAuthors(
|
||||
[FromQuery]
|
||||
[DefaultValue(0)]
|
||||
int page,
|
||||
[FromQuery]
|
||||
[DefaultValue(20)]
|
||||
int pageSize
|
||||
)
|
||||
if (_auth.User is null)
|
||||
{
|
||||
if (_auth.User is null)
|
||||
{
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
}
|
||||
if (_auth.User.UserTypeId != UserTypeEnum.Administrator)
|
||||
return Forbid();
|
||||
var items = context.Authors
|
||||
.OrderBy(a => a.ComicAuthors.Count())
|
||||
.Select(a => new AuthorResponse(a.Name, a.ComicAuthors.Count()));
|
||||
return Ok(new Paginated<AuthorResponse>(pageSize, page, items));
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
}
|
||||
[HttpGet("tags")]
|
||||
[ProducesResponseType<Paginated<TagResponse>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public IActionResult GetTags(
|
||||
[FromQuery]
|
||||
[DefaultValue(0)]
|
||||
int page,
|
||||
[FromQuery]
|
||||
[DefaultValue(20)]
|
||||
int pageSize
|
||||
)
|
||||
if (_auth.User.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)
|
||||
{
|
||||
if (_auth.User is null)
|
||||
{
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
}
|
||||
if (_auth.User.UserTypeId != UserTypeEnum.Administrator)
|
||||
return Forbid();
|
||||
var items = context.Tags
|
||||
.OrderBy(t => t.ComicTags.Count())
|
||||
.Select(t => new TagResponse(t.Name, t.ComicTags.Count()));
|
||||
return Ok(new Paginated<TagResponse>(pageSize, page, items));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,55 +7,54 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using System.ComponentModel;
|
||||
using System.Security.Policy;
|
||||
|
||||
namespace ComiServ.Controllers
|
||||
namespace ComiServ.Controllers;
|
||||
|
||||
[Route(ROUTE)]
|
||||
[ApiController]
|
||||
public class TaskController(
|
||||
ComicsContext context
|
||||
,ITaskManager manager
|
||||
,IComicScanner scanner
|
||||
,ILogger<TaskController> logger
|
||||
) : ControllerBase
|
||||
{
|
||||
[Route(ROUTE)]
|
||||
[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 Task<IActionResult> GetTasks(
|
||||
[FromQuery]
|
||||
[DefaultValue(20)]
|
||||
int limit
|
||||
)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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();
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
}
|
||||
if (auth.User.UserTypeId != Entities.UserTypeEnum.Administrator)
|
||||
return Forbid();
|
||||
manager.CancelAll();
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,146 +7,146 @@ using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
|
||||
namespace ComiServ.Controllers
|
||||
namespace ComiServ.Controllers;
|
||||
|
||||
[Route(ROUTE)]
|
||||
[ApiController]
|
||||
public class UserController
|
||||
: ControllerBase
|
||||
{
|
||||
[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)
|
||||
{
|
||||
public const string ROUTE = "/api/v1/users";
|
||||
[HttpGet]
|
||||
[ProducesResponseType<Paginated<UserDescription>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public IActionResult GetUsers(IAuthenticationService auth,
|
||||
ComicsContext context,
|
||||
[FromQuery]
|
||||
[DefaultValue(null)]
|
||||
string? search,
|
||||
[FromQuery]
|
||||
[DefaultValue(null)]
|
||||
UserTypeEnum? type,
|
||||
[FromQuery]
|
||||
[DefaultValue(0)]
|
||||
int page,
|
||||
[FromQuery]
|
||||
[DefaultValue(20)]
|
||||
int pageSize)
|
||||
if (auth.User is null)
|
||||
{
|
||||
if (auth.User is null)
|
||||
{
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
}
|
||||
if (auth.User.UserTypeId != UserTypeEnum.Administrator)
|
||||
return Forbid();
|
||||
IQueryable<User> users = context.Users;
|
||||
if (type is UserTypeEnum t)
|
||||
users = users.Where(u => u.UserTypeId == t);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
users = users.Where(u => EF.Functions.Like(u.Username, $"%{search}%"));
|
||||
return Ok(new Paginated<UserDescription>(pageSize, page, users
|
||||
.Include(u => u.UserType)
|
||||
.Select(u => new UserDescription(u.Username, u.UserType.Name))));
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
}
|
||||
[HttpPost("create")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public IActionResult AddUser(IAuthenticationService auth,
|
||||
ComicsContext context,
|
||||
[FromBody]
|
||||
UserCreateRequest req)
|
||||
if (auth.User.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)
|
||||
{
|
||||
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();
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
}
|
||||
[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.UserTypeId != UserTypeEnum.Administrator)
|
||||
return Forbid();
|
||||
var salt = Entities.User.MakeSalt();
|
||||
var bPass = Encoding.UTF8.GetBytes(req.Password);
|
||||
var newUser = new Entities.User()
|
||||
{
|
||||
if (auth.User is null)
|
||||
{
|
||||
HttpContext.Response.Headers.WWWAuthenticate = "Basic";
|
||||
return Unauthorized(RequestError.NoAccess);
|
||||
}
|
||||
if (auth.User.UserTypeId != UserTypeEnum.Administrator)
|
||||
return Forbid();
|
||||
username = username.Trim();
|
||||
var user = context.Users.SingleOrDefault(u => EF.Functions.Like(u.Username, $"{username}"));
|
||||
if (user is null)
|
||||
return BadRequest();
|
||||
context.Users.Remove(user);
|
||||
return Ok();
|
||||
}
|
||||
[HttpPost("modify")]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public IActionResult ModifyUser(IAuthenticationService auth,
|
||||
ComicsContext context,
|
||||
[FromBody]
|
||||
UserModifyRequest req)
|
||||
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)
|
||||
{
|
||||
//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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user