82 lines
3.4 KiB
C#
82 lines
3.4 KiB
C#
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Api.Artwork;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Images;
|
|
using ErsatzTV.Core.Interfaces.Images;
|
|
|
|
namespace ErsatzTV.Application.Artworks;
|
|
|
|
public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseError, ArtworkUploadResponseModel>>
|
|
{
|
|
private readonly IImageCache _imageCache;
|
|
private readonly IRemoteImageValidator _validator;
|
|
|
|
public UploadArtworkHandler(IImageCache imageCache, IRemoteImageValidator validator)
|
|
{
|
|
_imageCache = imageCache;
|
|
_validator = validator;
|
|
}
|
|
|
|
public async Task<Either<BaseError, ArtworkUploadResponseModel>> Handle(
|
|
UploadArtwork request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Buffer the upload so we can sniff its true format before storing it. The request body is
|
|
// already bounded by the Kestrel MaxRequestBodySize / the controller's size check, so this
|
|
// is a bounded read.
|
|
byte[] bytes;
|
|
await using (var buffer = new MemoryStream())
|
|
{
|
|
await request.Stream.CopyToAsync(buffer, cancellationToken);
|
|
bytes = buffer.ToArray();
|
|
}
|
|
|
|
// Derive the content type from the actual bytes, never from the client-declared value
|
|
// (issue #283 — a spoofed image/png header let a <script> payload be stored and later served
|
|
// as HTML). A payload that isn't a supported raster image is rejected here.
|
|
Option<string> maybeContentType = ImageContentTypes.DetectContentType(bytes);
|
|
if (maybeContentType.IsNone)
|
|
{
|
|
return BaseError.New(
|
|
$"Uploaded file is not a supported image; supported types are: {string.Join(", ", ImageContentTypes.Accepted)}");
|
|
}
|
|
|
|
string contentType = maybeContentType.IfNone(string.Empty);
|
|
|
|
// One rule: anything entering the logo cache is decode-budget-checked. A supported format is
|
|
// not enough — a small header can declare a multi-gigabyte canvas (a decompression bomb), so
|
|
// reject it here before it lands in the cache. The synthetic upload:// Uri is only for the
|
|
// exception message text. (ersatztv#525)
|
|
using (var probe = new MemoryStream(bytes, writable: false))
|
|
{
|
|
try
|
|
{
|
|
await _validator.Validate(probe, new Uri("upload://artwork"), cancellationToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return BaseError.New($"Image cannot be used: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
using var toCache = new MemoryStream(bytes, writable: false);
|
|
Either<BaseError, string> maybeFileName = await _imageCache.SaveArtworkToCache(
|
|
toCache,
|
|
request.ArtworkKind);
|
|
|
|
return maybeFileName.Map(fileName => new ArtworkUploadResponseModel(
|
|
BuildPath(request.ArtworkKind, fileName),
|
|
contentType));
|
|
}
|
|
|
|
// Mirror the on-disk conventions the Blazor editors use so the returned path is a drop-in
|
|
// for ArtworkContentTypeModel.Path: channel logos are addressed as "iptv/logos/{file}"
|
|
// (see ChannelEditor.UploadLogo), watermarks by the bare cache file name (see WatermarkEditor).
|
|
private static string BuildPath(ArtworkKind artworkKind, string fileName) =>
|
|
artworkKind switch
|
|
{
|
|
ArtworkKind.Logo => $"iptv/logos/{fileName}",
|
|
_ => fileName
|
|
};
|
|
}
|