using CliWrap; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Images; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Repositories; using Winista.Mime; namespace ErsatzTV.Application.Images; public class GetCachedImagePathHandler : IRequestHandler> { private static readonly MimeTypes MimeTypes = new(); private readonly IConfigElementRepository _configElementRepository; private readonly IFFmpegProcessService _ffmpegProcessService; private readonly IImageCache _imageCache; public GetCachedImagePathHandler( IImageCache imageCache, IFFmpegProcessService ffmpegProcessService, IConfigElementRepository configElementRepository) { _imageCache = imageCache; _ffmpegProcessService = ffmpegProcessService; _configElementRepository = configElementRepository; } public async Task> Handle( GetCachedImagePath request, CancellationToken cancellationToken) { Validation validation = await Validate(cancellationToken); return await validation.Match( ffmpegPath => Handle(ffmpegPath, request, cancellationToken), error => Task.FromResult>(error.Join())); } private async Task> Handle( string ffmpegPath, GetCachedImagePath request, CancellationToken cancellationToken) { try { string mimeType; string cachePath = _imageCache.GetPathForImage( request.FileName, request.ArtworkKind, Optional(request.MaxHeight)); if (cachePath == null) { return BaseError.New("Failed to generate cache path for image"); } if (!File.Exists(cachePath)) { if (request.MaxHeight.HasValue) { string baseFolder = Path.GetDirectoryName(cachePath); if (baseFolder != null && !Directory.Exists(baseFolder)) { Directory.CreateDirectory(baseFolder); } // ffmpeg needs the extension to determine the output codec string withExtension = cachePath + ".jpg"; string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None); Command process = await _ffmpegProcessService.ResizeImage( ffmpegPath, originalPath, withExtension, request.MaxHeight.Value, cancellationToken); CommandResult resize = await process.ExecuteAsync(cancellationToken); if (resize.ExitCode != 0) { return BaseError.New($"Failed to resize image; exit code {resize.ExitCode}"); } File.Move(withExtension, cachePath); mimeType = "image/jpeg"; } else { return BaseError.New($"Artwork does not exist on disk at {cachePath}"); } } else { // Always derive the type from the stored file — never from a client-supplied value // (issue #283 — the old ?contentType= reflection was the stored-XSS sink). Clamp the // sniffed type to the image allow-list so a file whose bytes are not an accepted image // (a legacy cache entry poisoned before the upload sniff landed, or a hypothetical // polyglot) is served as a non-renderable download, never as HTML/script. string sniffed = MimeTypes.GetMimeTypeFromFile(cachePath)?.Name; mimeType = ImageContentTypes.IsAccepted(sniffed) ? sniffed : "application/octet-stream"; } return new CachedImagePathViewModel(cachePath, mimeType); } catch (Exception ex) { return BaseError.New(ex.Message); } } private async Task> Validate(CancellationToken cancellationToken) => await ValidateFFmpegPath(cancellationToken); private Task> ValidateFFmpegPath(CancellationToken cancellationToken) => _configElementRepository.GetValue(ConfigElementKey.FFmpegPath, cancellationToken) .FilterT(File.Exists) .Map(ffmpegPath => ffmpegPath.ToValidation("FFmpeg path does not exist on the file system")); }