using System.ComponentModel; using ErsatzTV.Application.Artworks; using ErsatzTV.Core; using ErsatzTV.Core.Api.Artwork; using ErsatzTV.Core.Domain; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] public class ArtworkUploadController(IMediator mediator) : ControllerBase { [HttpPost("/api/v1/artwork/uploads", Name = "UploadArtwork")] [Consumes("multipart/form-data")] [Tags("Artwork")] [EndpointSummary("Upload channel logo or watermark artwork")] [EndpointGroupName("general")] [ProducesResponseType(typeof(ArtworkUploadResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task Upload( IFormFile file, [FromForm][Description("Artwork target: 'logo' (default) or 'watermark'")] string target, CancellationToken cancellationToken) { if (file is null || file.Length == 0) { return BaseError.New("A non-empty image file is required").ToErrorResult(); } long maxBytes = (long)SystemEnvironment.MaximumUploadMb * 1024 * 1024; if (file.Length > maxBytes) { return BaseError.New($"Image exceeds the maximum allowed size of {SystemEnvironment.MaximumUploadMb} MB") .ToErrorResult(); } if (!TryParseTarget(target, out ArtworkKind artworkKind)) { return BaseError.New($"Unknown upload target '{target}'; expected 'logo' or 'watermark'").ToErrorResult(); } await using Stream stream = file.OpenReadStream(); Either result = await mediator.Send( new UploadArtwork(stream, artworkKind), cancellationToken); return result.ToCreatedResult( value => LocationFor(artworkKind, value.Path), value => value); } // "logo" (default) and "watermark" are the two channel-artwork surfaces the API exposes today. private static bool TryParseTarget(string target, out ArtworkKind artworkKind) { switch ((target ?? string.Empty).Trim().ToLowerInvariant()) { case "": case "logo": artworkKind = ArtworkKind.Logo; return true; case "watermark": artworkKind = ArtworkKind.Watermark; return true; default: artworkKind = ArtworkKind.Logo; return false; } } // The serve routes (GetImage / GetWatermark) sniff the content type from the stored file, so the // Location no longer carries a ?contentType= (issue #283 — that reflection was the XSS sink). private static string LocationFor(ArtworkKind artworkKind, string path) => artworkKind switch { // logo paths already carry the servable prefix ("iptv/logos/{file}") ArtworkKind.Logo => $"/{path}", _ => $"/artwork/watermarks/{path}" }; }