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;
}
}