using System.Buffers; using ErsatzTV.Core.Interfaces.Streaming; using Microsoft.Extensions.Logging; using Microsoft.IO; namespace ErsatzTV.Infrastructure.Streaming; /// /// Fetches a remote graphics-engine image over HTTP with a bounded timeout and a bounded size. /// /// /// This runs during graphics-engine element initialization, which happens inside stream startup /// while ffmpeg waits on the pipe — so an unbounded fetch stalls the tune. Every failure here is /// surfaced as an exception and the calling element disables itself. (ersatztv#511) /// public class HttpRemoteImageFetcher : IRemoteImageFetcher { /// Named configured with the redirect cap in Startup. public const string HttpClientName = "RemoteImage"; /// /// Covers the whole exchange — connect, headers AND body — because the body read happens /// outside under , /// so a slow-drip host would otherwise hang forever. (docs/decisions.md, #289) /// internal static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(10); /// /// Wire-size cap. A channel logo or overlay is orders of magnitude smaller than this. /// This bounds transfer and buffering only — it is NOT decode-bomb protection, since a bomb /// is by definition small on the wire. That check lives in /// ImageElementBase.DecodeRemoteImage, which reads the declared dimensions and frame /// count from the header before the decoder allocates. /// internal const long MaxImageBytes = 10 * 1024 * 1024; private readonly TimeSpan _fetchTimeout; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly RecyclableMemoryStreamManager _memoryStreamManager; public HttpRemoteImageFetcher( IHttpClientFactory httpClientFactory, RecyclableMemoryStreamManager memoryStreamManager, ILogger logger) : this(httpClientFactory, memoryStreamManager, logger, DefaultFetchTimeout) { } // the timeout is only parameterized so a test can prove the deadline fires without waiting the // real ten seconds; production always goes through the constructor above. internal HttpRemoteImageFetcher( IHttpClientFactory httpClientFactory, RecyclableMemoryStreamManager memoryStreamManager, ILogger logger, TimeSpan fetchTimeout) { _httpClientFactory = httpClientFactory; _memoryStreamManager = memoryStreamManager; _logger = logger; _fetchTimeout = fetchTimeout; } public async Task Fetch(Uri uri, CancellationToken cancellationToken) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(_fetchTimeout); try { using HttpClient client = _httpClientFactory.CreateClient(HttpClientName); // set here, not only in the DI registration, so the deadline cannot silently widen to // HttpClient's 100s default if that registration is ever dropped or reordered. the // factory hands back a fresh wrapper each call, so mutating it is safe. client.Timeout = Timeout.InfiniteTimeSpan; using HttpResponseMessage response = await client.GetAsync( uri, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token); response.EnsureSuccessStatusCode(); string mediaType = response.Content.Headers.ContentType?.MediaType; if (!IsAcceptableMediaType(mediaType)) { throw new InvalidOperationException( $"Remote image {uri} returned content type '{mediaType}', which is not an image"); } // the advertised length is a cheap early reject; it is not trusted, because it can be // absent or a lie. the copy below is what actually enforces the cap. long? contentLength = response.Content.Headers.ContentLength; if (contentLength > MaxImageBytes) { throw new InvalidOperationException( $"Remote image {uri} advertises {contentLength} bytes, over the {MaxImageBytes} byte limit"); } await using Stream source = await response.Content.ReadAsStreamAsync(timeoutCts.Token); return await CopyCapped(source, uri, timeoutCts.Token); } catch (OperationCanceledException canceled) when (!cancellationToken.IsCancellationRequested) { // OUR deadline fired, not the caller's. re-thrown as a TimeoutException so the element's // warning names the real cause; caller cancellation (shutdown / client disconnect) still // propagates as OperationCanceledException. _logger.LogWarning( "Timed out after {Seconds}s fetching remote image {Uri}", _fetchTimeout.TotalSeconds, uri); throw new TimeoutException($"Timed out fetching remote image {uri}", canceled); } } // a missing content type is allowed (some hosts omit it) and octet-stream is allowed (a common // default for statically served files). anything else that is positively NOT an image -- an html // error page, say -- is rejected before it reaches the decoder. private static bool IsAcceptableMediaType(string mediaType) => string.IsNullOrWhiteSpace(mediaType) || mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) || mediaType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase); private async Task CopyCapped(Stream source, Uri uri, CancellationToken cancellationToken) { MemoryStream buffer = _memoryStreamManager.GetStream(nameof(HttpRemoteImageFetcher)); byte[] chunk = ArrayPool.Shared.Rent(81920); try { int read; while ((read = await source.ReadAsync(chunk.AsMemory(), cancellationToken)) > 0) { if (buffer.Length + read > MaxImageBytes) { throw new InvalidOperationException( $"Remote image {uri} exceeds the {MaxImageBytes} byte limit"); } await buffer.WriteAsync(chunk.AsMemory(0, read), cancellationToken); } } catch { await buffer.DisposeAsync(); throw; } finally { ArrayPool.Shared.Return(chunk); } buffer.Position = 0; return buffer; } }