using System; using System.Threading.Tasks; using API.Constants; using API.Data; using API.DTOs.Uploads; using API.Extensions; using API.Services; using API.SignalR; using Flurl.Http; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace API.Controllers; #nullable enable /// /// /// public class UploadController : BaseApiController { private readonly IUnitOfWork _unitOfWork; private readonly IImageService _imageService; private readonly ILogger _logger; private readonly ITaskScheduler _taskScheduler; private readonly IDirectoryService _directoryService; private readonly IEventHub _eventHub; private readonly IReadingListService _readingListService; private readonly ILocalizationService _localizationService; /// public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub, IReadingListService readingListService, ILocalizationService localizationService) { _unitOfWork = unitOfWork; _imageService = imageService; _logger = logger; _taskScheduler = taskScheduler; _directoryService = directoryService; _eventHub = eventHub; _readingListService = readingListService; _localizationService = localizationService; } /// /// This stores a file (image) in temp directory for use in a cover image replacement flow. /// This is automatically cleaned up. /// /// Escaped url to download from /// filename [Authorize(Policy = "RequireAdminRole")] [HttpPost("upload-by-url")] public async Task> GetImageFromFile(UploadUrlDto dto) { var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow.ToLongTimeString()}".Replace('/', '_').Replace(':', '_'); var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", string.Empty); try { var path = await dto.Url .DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}"); if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-not-valid")); if (!await _imageService.IsImage(path)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-not-valid")); return $"coverupload_{dateString}.{format}"; } catch (FlurlHttpException ex) { // Unauthorized if (ex.StatusCode == 401) return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-not-valid")); } return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-not-valid")); } /// /// Replaces series cover image and locks it with a base64 encoded image /// /// /// [Authorize(Policy = "RequireAdminRole")] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("series")] public async Task UploadSeriesCoverImageFromUrl(UploadFileDto uploadFileDto) { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system if (string.IsNullOrEmpty(uploadFileDto.Url)) { return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); } try { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id); if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist")); var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}"); if (!string.IsNullOrEmpty(filePath)) { series.CoverImage = filePath; series.CoverImageLocked = true; _unitOfWork.SeriesRepository.Update(series); } if (_unitOfWork.HasChanges()) { await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false); await _unitOfWork.CommitAsync(); return Ok(); } } catch (Exception e) { _logger.LogError(e, "There was an issue uploading cover image for Series {Id}", uploadFileDto.Id); await _unitOfWork.RollbackAsync(); } return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-series-save")); } /// /// Replaces collection tag cover image and locks it with a base64 encoded image /// /// /// [Authorize(Policy = "RequireAdminRole")] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("collection")] public async Task UploadCollectionCoverImageFromUrl(UploadFileDto uploadFileDto) { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system if (string.IsNullOrEmpty(uploadFileDto.Url)) { return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); } try { var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id); if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}"); if (!string.IsNullOrEmpty(filePath)) { tag.CoverImage = filePath; tag.CoverImageLocked = true; _unitOfWork.CollectionTagRepository.Update(tag); } if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(tag.Id, MessageFactoryEntityTypes.CollectionTag), false); return Ok(); } } catch (Exception e) { _logger.LogError(e, "There was an issue uploading cover image for Collection Tag {Id}", uploadFileDto.Id); await _unitOfWork.RollbackAsync(); } return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-collection-save")); } /// /// Replaces reading list cover image and locks it with a base64 encoded image /// /// This is the only API that can be called by non-admins, but the authenticated user must have a readinglist permission /// /// [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("reading-list")] public async Task UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto) { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system if (string.IsNullOrEmpty(uploadFileDto.Url)) { return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); } if (_readingListService.UserHasReadingListAccess(uploadFileDto.Id, User.GetUsername()) == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "access-denied")); try { var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id); if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}"); if (!string.IsNullOrEmpty(filePath)) { readingList.CoverImage = filePath; readingList.CoverImageLocked = true; _unitOfWork.ReadingListRepository.Update(readingList); } if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(readingList.Id, MessageFactoryEntityTypes.ReadingList), false); return Ok(); } } catch (Exception e) { _logger.LogError(e, "There was an issue uploading cover image for Reading List {Id}", uploadFileDto.Id); await _unitOfWork.RollbackAsync(); } return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-reading-list-save")); } private async Task CreateThumbnail(UploadFileDto uploadFileDto, string filename, int thumbnailSize = 0) { var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs; if (thumbnailSize > 0) { return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, filename, encodeFormat, thumbnailSize); } return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, filename, encodeFormat); } /// /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. /// /// /// [Authorize(Policy = "RequireAdminRole")] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("chapter")] public async Task UploadChapterCoverImageFromUrl(UploadFileDto uploadFileDto) { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system if (string.IsNullOrEmpty(uploadFileDto.Url)) { return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); } try { var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}"); if (!string.IsNullOrEmpty(filePath)) { chapter.CoverImage = filePath; chapter.CoverImageLocked = true; _unitOfWork.ChapterRepository.Update(chapter); var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId); if (volume != null) { volume.CoverImage = chapter.CoverImage; _unitOfWork.VolumeRepository.Update(volume); } } if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(chapter.VolumeId, MessageFactoryEntityTypes.Volume), false); await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter), false); return Ok(); } } catch (Exception e) { _logger.LogError(e, "There was an issue uploading cover image for Chapter {Id}", uploadFileDto.Id); await _unitOfWork.RollbackAsync(); } return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-chapter-save")); } /// /// Replaces library cover image with a base64 encoded image. If empty string passed, will reset to null. /// /// /// [Authorize(Policy = "RequireAdminRole")] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("library")] public async Task UploadLibraryCoverImageFromUrl(UploadFileDto uploadFileDto) { var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(uploadFileDto.Id); if (library == null) return BadRequest("This library does not exist"); // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system if (string.IsNullOrEmpty(uploadFileDto.Url)) { library.CoverImage = null; _unitOfWork.LibraryRepository.Update(library); if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(library.Id, MessageFactoryEntityTypes.Library), false); } return Ok(); } try { var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}", ImageService.LibraryThumbnailWidth); if (!string.IsNullOrEmpty(filePath)) { library.CoverImage = filePath; _unitOfWork.LibraryRepository.Update(library); } if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(library.Id, MessageFactoryEntityTypes.Library), false); return Ok(); } } catch (Exception e) { _logger.LogError(e, "There was an issue uploading cover image for Library {Id}", uploadFileDto.Id); await _unitOfWork.RollbackAsync(); } return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-library-save")); } /// /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. /// /// Does not use Url property /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("reset-chapter-lock")] public async Task ResetChapterLock(UploadFileDto uploadFileDto) { try { var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var originalFile = chapter.CoverImage; chapter.CoverImage = string.Empty; chapter.CoverImageLocked = false; _unitOfWork.ChapterRepository.Update(chapter); var volume = (await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId))!; volume.CoverImage = chapter.CoverImage; _unitOfWork.VolumeRepository.Update(volume); var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!; if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); if (originalFile != null) System.IO.File.Delete(originalFile); _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); return Ok(); } } catch (Exception e) { _logger.LogError(e, "There was an issue resetting cover lock for Chapter {Id}", uploadFileDto.Id); await _unitOfWork.RollbackAsync(); } return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock")); } }