`ImageElementBase.LoadImage` fetched http(s) images with a throwaway `new HttpClient()` + `GetStreamAsync`: no timeout override (the 100s default), no size cap, unbounded redirects, no pooling — all inside stream startup, while ffmpeg waits on the pipe. #502 routed ordinary channel-logo watermarks onto that path, widening a pre-existing weakness. Introduce `IRemoteImageFetcher` / `HttpRemoteImageFetcher`, modelled on the neighbouring `IRemoteStreamProber`: - deadline covers headers AND body (linked CTS + `CancelAfter`, client `Timeout = InfiniteTimeSpan`) — under `ResponseHeadersRead` the body read falls outside `HttpClient.Timeout` (the #289 lesson) - 10 MiB cap enforced during the copy; `Content-Length` is only a cheap early reject, since it can be absent or a lie - permissive content-type check (rejects an HTML error page, allows a missing type and octet-stream) - pooled via `IHttpClientFactory`; redirects capped at 3, not 50 A byte cap does NOT bound decoding, so `DecodeRemoteImage` additionally reads declared dimensions + frame count from the header and rejects before `Image.LoadAsync` allocates (50 MP / 600 frames). A 4 KB PNG declaring 30000x30000 costs ~3.6 GB to decode and passes every wire-size check — caught by adversarial review of the first version of this change, which capped bytes and wrongly claimed that was decode-bomb protection. Not cached and SSRF not mitigated — both deliberate, with the reasoning recorded in docs/decisions.md. fixes #511
88 lines
3.0 KiB
C#
88 lines
3.0 KiB
C#
using ErsatzTV.Core.Graphics;
|
|
using ErsatzTV.Core.Interfaces.Streaming;
|
|
using Microsoft.Extensions.Logging;
|
|
using NCalc;
|
|
using SkiaSharp;
|
|
|
|
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
|
|
|
|
public class ImageElement(
|
|
ImageGraphicsElement imageGraphicsElement,
|
|
IRemoteImageFetcher remoteImageFetcher,
|
|
ILogger logger) : ImageElementBase(remoteImageFetcher)
|
|
{
|
|
private Option<Expression> _maybeOpacityExpression;
|
|
private float _opacity;
|
|
|
|
public override int ZIndex { get; } = imageGraphicsElement.ZIndex ?? 0;
|
|
|
|
public override string DebugKey { get; } = $"Image {imageGraphicsElement.DebugName()}";
|
|
|
|
public override async Task InitializeAsync(GraphicsEngineContext context, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(imageGraphicsElement.OpacityExpression))
|
|
{
|
|
var expression = new Expression(imageGraphicsElement.OpacityExpression);
|
|
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction;
|
|
_maybeOpacityExpression = expression;
|
|
}
|
|
else
|
|
{
|
|
_opacity = (imageGraphicsElement.OpacityPercent ?? 100) / 100.0f;
|
|
}
|
|
|
|
foreach (Expression expression in _maybeOpacityExpression)
|
|
{
|
|
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction;
|
|
}
|
|
|
|
await LoadImage(
|
|
context.SquarePixelFrameSize,
|
|
context.FrameSize,
|
|
imageGraphicsElement.Image,
|
|
imageGraphicsElement.Location,
|
|
imageGraphicsElement.Scale,
|
|
imageGraphicsElement.ScaleWidthPercent,
|
|
imageGraphicsElement.HorizontalMarginPercent,
|
|
imageGraphicsElement.VerticalMarginPercent,
|
|
imageGraphicsElement.PlaceWithinSourceContent,
|
|
cancellationToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
IsFinished = true;
|
|
logger.LogWarning(ex, "Failed to initialize image element; will disable for this content");
|
|
}
|
|
}
|
|
|
|
public override ValueTask<Option<PreparedElementImage>> PrepareImage(
|
|
TimeSpan timeOfDay,
|
|
TimeSpan contentTime,
|
|
TimeSpan contentTotalTime,
|
|
TimeSpan channelTime,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
float opacity = _opacity;
|
|
foreach (Expression expression in _maybeOpacityExpression)
|
|
{
|
|
opacity = OpacityExpressionHelper.GetOpacity(
|
|
expression,
|
|
timeOfDay,
|
|
contentTime,
|
|
contentTotalTime,
|
|
channelTime);
|
|
}
|
|
|
|
if (opacity == 0)
|
|
{
|
|
return ValueTask.FromResult(Option<PreparedElementImage>.None);
|
|
}
|
|
|
|
SKBitmap frameForTimestamp = GetFrameForTimestamp(contentTime);
|
|
return ValueTask.FromResult(
|
|
Optional(new PreparedElementImage(frameForTimestamp, Location, opacity, ZIndex, false)));
|
|
}
|
|
}
|