From 31bedb9e9a140f1e0ad5fa21a4c020d32a4c773d Mon Sep 17 00:00:00 2001 From: devjerry0 <99199491+devjerry0@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:49:04 -0400 Subject: [PATCH] feat: verify and persist external GIF links Direct HTTPS GIF signature/type/dimension verification and gif_url storage, without emitting a new embed kind yet. Codex-Thread: 01a15b08-d4ad-7e72-8725-b3ea1fde71ce --- app/Actions/UpdateLinkPreview.php | 44 ++++++- app/Dto/LinkPreviewMetadata.php | 4 +- app/Models/LinkPreview.php | 2 + app/Services/GifLinks.php | 85 ++++++++++++ ...000_add_gif_url_to_link_previews_table.php | 17 +++ tests/Fixtures/link-preview-http.php | 42 ++++++ tests/Unit/Actions/UpdateLinkPreviewTest.php | 124 +++++++++++++++++- .../Services/LinkPreviewHttpClientTest.php | 49 +++++++ 8 files changed, 354 insertions(+), 13 deletions(-) create mode 100644 app/Services/GifLinks.php create mode 100644 database/migrations/2026_10_20_120000_add_gif_url_to_link_previews_table.php create mode 100644 tests/Fixtures/link-preview-http.php create mode 100644 tests/Unit/Services/LinkPreviewHttpClientTest.php diff --git a/app/Actions/UpdateLinkPreview.php b/app/Actions/UpdateLinkPreview.php index d47c45932..4947335cc 100644 --- a/app/Actions/UpdateLinkPreview.php +++ b/app/Actions/UpdateLinkPreview.php @@ -7,6 +7,7 @@ use App\Dto\LinkPreviewMetadata; use App\Dto\LinkPreviewRequest; use App\Models\LinkPreview; +use App\Services\GifLinks; use App\Services\LinkPreviewAddress; use App\Services\LinkPreviewBudget; use App\Services\LinkPreviewHttpClient; @@ -24,11 +25,14 @@ private const string HEADER_ACCEPT = 'Accept'; + private const string HEADER_RANGE = 'Range'; + private const string HTML_CONTENT_TYPE = 'text/html'; public function __construct( private LinkPreviewAddress $addresses, private LinkPreviewBudget $budget, + private GifLinks $gifLinks, private LinkPreviewHttpClient $http, private LinkPreviewParser $parser, ) { @@ -45,6 +49,7 @@ public function handle(LinkPreview $linkPreview): void private function fetchMetadata(string $url): LinkPreviewMetadata { $page = Url::parse($url); + $isGif = $this->gifLinks->isGifUrl($page); $deadline = $this->budget->start(); $visited = []; @@ -56,7 +61,7 @@ private function fetchMetadata(string $url): LinkPreviewMetadata } $visited[$source] = true; - $request = $this->prepareRequest($page, $deadline); + $request = $this->prepareRequest($page, $isGif, $deadline); if (! $request instanceof LinkPreviewRequest) { return new LinkPreviewMetadata; @@ -66,7 +71,7 @@ private function fetchMetadata(string $url): LinkPreviewMetadata $response->throwIf($response->serverError() || $response->tooManyRequests()); if (! $response->redirect()) { - return $this->responseMetadata($response, $page); + return $this->responseMetadata($response, $page, $isGif); } $page = $this->redirectPage($response, $page); @@ -75,8 +80,12 @@ private function fetchMetadata(string $url): LinkPreviewMetadata return new LinkPreviewMetadata; } - private function prepareRequest(Url $page, float $deadline): ?LinkPreviewRequest + private function prepareRequest(Url $page, bool $isGif, float $deadline): ?LinkPreviewRequest { + if ($isGif && $page->getScheme() !== 'https') { + return null; + } + $address = $this->addresses->get($page); $remaining = $this->budget->remaining($deadline); @@ -87,8 +96,8 @@ private function prepareRequest(Url $page, float $deadline): ?LinkPreviewRequest return new LinkPreviewRequest( url: $page, address: $address, - headers: [self::HEADER_ACCEPT => self::HTML_CONTENT_TYPE], - maxBodyBytes: self::MAX_BODY_BYTES, + headers: $this->requestHeaders($isGif), + maxBodyBytes: $isGif ? GifLinks::HEADER_BYTES : self::MAX_BODY_BYTES, timeoutSeconds: $remaining, ); } @@ -104,12 +113,28 @@ private function redirectPage(Response $response, Url $page): ?Url return Url::parse($location, $page); } - private function responseMetadata(Response $response, Url $page): LinkPreviewMetadata + /** + * @return array + */ + private function requestHeaders(bool $isGif): array + { + if (! $isGif) { + return [self::HEADER_ACCEPT => self::HTML_CONTENT_TYPE]; + } + + return [self::HEADER_ACCEPT => GifLinks::CONTENT_TYPE, self::HEADER_RANGE => sprintf('bytes=0-%d', GifLinks::HEADER_BYTES - 1)]; + } + + private function responseMetadata(Response $response, Url $page, bool $isGif): LinkPreviewMetadata { if (! $response->successful()) { return new LinkPreviewMetadata; } + if ($isGif) { + return $this->getGifMetadata($response, $page); + } + if (! $this->isHtml($response)) { return new LinkPreviewMetadata; } @@ -117,6 +142,13 @@ private function responseMetadata(Response $response, Url $page): LinkPreviewMet return $this->parser->parse($this->body($response), $page); } + private function getGifMetadata(Response $response, Url $page): LinkPreviewMetadata + { + $url = $this->gifLinks->getVerifiedUrl($page, $response->header('Content-Type'), $this->body($response)); + + return new LinkPreviewMetadata(gifUrl: $url); + } + private function isHtml(Response $response): bool { $mediaType = mb_strtolower(mb_trim(Str::before($response->header('Content-Type'), ';'))); diff --git a/app/Dto/LinkPreviewMetadata.php b/app/Dto/LinkPreviewMetadata.php index 7e0168326..1faa88191 100644 --- a/app/Dto/LinkPreviewMetadata.php +++ b/app/Dto/LinkPreviewMetadata.php @@ -16,12 +16,13 @@ public function __construct( public ?string $description = null, public ?string $imageUrl = null, public ?string $siteName = null, + public ?string $gifUrl = null, ) { // } /** - * @return array{title: string|null, description: string|null, image_url: string|null, site_name: string|null} + * @return array{title: string|null, description: string|null, image_url: string|null, site_name: string|null, gif_url: string|null} */ public function toArray(): array { @@ -30,6 +31,7 @@ public function toArray(): array 'description' => $this->description, 'image_url' => $this->imageUrl, 'site_name' => $this->siteName, + 'gif_url' => $this->gifUrl, ]; } } diff --git a/app/Models/LinkPreview.php b/app/Models/LinkPreview.php index cda016627..ccf22ed44 100644 --- a/app/Models/LinkPreview.php +++ b/app/Models/LinkPreview.php @@ -20,6 +20,7 @@ * @property-read string|null $description * @property-read string|null $image_url * @property-read string|null $site_name + * @property-read string|null $gif_url * @property-read CarbonInterface|null $fetched_at * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at @@ -51,6 +52,7 @@ public function casts(): array 'description' => 'string', 'image_url' => 'string', 'site_name' => 'string', + 'gif_url' => 'string', 'fetched_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', diff --git a/app/Services/GifLinks.php b/app/Services/GifLinks.php new file mode 100644 index 000000000..caff45d9c --- /dev/null +++ b/app/Services/GifLinks.php @@ -0,0 +1,85 @@ +getScheme() !== 'https') { + return false; + } + + return str_ends_with(mb_strtolower($url->getPath()), '.gif'); + } + + public function getVerifiedUrl(Url $url, string $contentType, string $header): ?string + { + $mediaType = mb_strtolower(mb_trim(Str::before($contentType, ';'))); + + if ($mediaType !== self::CONTENT_TYPE) { + return null; + } + + $sourceUrl = $url->toAsciiString(); + + if (mb_strlen($sourceUrl) > Urls::MAX_LENGTH) { + return null; + } + + if (! $this->isValidHeader($header)) { + return null; + } + + return $sourceUrl; + } + + private function isValidHeader(string $header): bool + { + if (mb_strlen($header, self::BINARY_ENCODING) < self::MIN_HEADER_BYTES) { + return false; + } + + return $this->hasValidSignature($header) && $this->hasValidDimensions($header); + } + + private function hasValidSignature(string $header): bool + { + $signature = mb_substr($header, 0, self::SIGNATURE_BYTES, self::BINARY_ENCODING); + + return in_array($signature, self::SIGNATURES, true); + } + + private function hasValidDimensions(string $header): bool + { + $width = mb_substr($header, self::WIDTH_OFFSET, self::DIMENSION_BYTES, self::BINARY_ENCODING); + $height = mb_substr($header, self::HEIGHT_OFFSET, self::DIMENSION_BYTES, self::BINARY_ENCODING); + + return $width !== self::ZERO_DIMENSION && $height !== self::ZERO_DIMENSION; + } +} diff --git a/database/migrations/2026_10_20_120000_add_gif_url_to_link_previews_table.php b/database/migrations/2026_10_20_120000_add_gif_url_to_link_previews_table.php new file mode 100644 index 000000000..d730d21d9 --- /dev/null +++ b/database/migrations/2026_10_20_120000_add_gif_url_to_link_previews_table.php @@ -0,0 +1,17 @@ +string('gif_url', 2048)->nullable(); + }); + } +}; diff --git a/tests/Fixtures/link-preview-http.php b/tests/Fixtures/link-preview-http.php new file mode 100644 index 000000000..641748cfd --- /dev/null +++ b/tests/Fixtures/link-preview-http.php @@ -0,0 +1,42 @@ + 'IMAGE/GIF; charset=binary']); + }); + + $preview = unfurl('https://example.com/cat.GIF?token=keep'); + + expect($preview->gif_url)->toBe('https://example.com/cat.GIF?token=keep') + ->and($preview->image_url)->toBeNull() + ->and($preview->fetched_at)->not->toBeNull() + ->and($options['sink'])->toBeInstanceOf(LimitedResponseBody::class) + ->and($options['sink']->write(str_repeat('x', 33)))->toBe(32) + ->and($options['sink']->getSize())->toBe(32); + + Http::assertSent(fn (Request $request): bool => $request->header('Range') === ['bytes=0-31'] + && $request->header('Accept') === ['image/gif']); +})->with(['GIF87a', 'GIF89a']); + +it('reads GIF dimensions as bytes rather than UTF-8 text', function (): void { + Http::fake(['https://example.com/cat.gif' => Http::response("GIF89a\xc2\x80\x01\x00\x80\x00\x00", 206, ['Content-Type' => 'image/gif'])]); + + expect(unfurl('https://example.com/cat.gif')->gif_url)->toBe('https://example.com/cat.gif'); +}); + +it('does not trust a GIF extension or a content type without a valid header', function (string $body, string $contentType): void { + Http::fake(['https://example.com/*' => Http::response($body, 200, ['Content-Type' => $contentType])]); + + expect(unfurl('https://example.com/cat.gif')->gif_url)->toBeNull(); +})->with([ + 'html' => ['A page', 'text/html'], + 'spoofed type' => ['', 'image/gif'], + 'wrong type' => ["GIF89a\x01\x00\x01\x00\x80\x00\x00", 'image/png'], + 'truncated' => ['GIF89a', 'image/gif'], + 'wrong signature' => ["GIF90a\x01\x00\x01\x00\x80\x00\x00", 'image/gif'], + 'zero width' => ["GIF89a\x00\x00\x01\x00\x80\x00\x00", 'image/gif'], + 'zero height' => ["GIF89a\x01\x00\x00\x00\x80\x00\x00", 'image/gif'], + 'zero height with wide canvas' => ["GIF89a\x00\x01\x00\x00\x80\x00\x00", 'image/gif'], + 'incomplete screen descriptor' => ["GIF89a\x01\x00\x01\x00\x80\x00", 'image/gif'], +]); + +it('keeps only the final public HTTPS GIF URL after redirects', function (): void { + Http::fake([ + 'https://example.com/cat.gif' => Http::response(status: 302, headers: ['Location' => 'https://www.example.com/media?id=cat']), + 'https://www.example.com/media?id=cat' => Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 200, ['Content-Type' => 'image/gif']), + ]); + + $preview = unfurl('https://example.com/cat.gif'); + + expect($preview->url)->toBe('https://example.com/cat.gif') + ->and($preview->gif_url)->toBe('https://www.example.com/media?id=cat'); + Http::assertSentCount(2); +}); + +it('does not follow GIF redirects to unsafe destinations', function (string $destination): void { + Http::fake(['https://example.com/cat.gif' => Http::response(status: 302, headers: ['Location' => $destination])]); + + expect(unfurl('https://example.com/cat.gif')->gif_url)->toBeNull(); + Http::assertSentCount(1); +})->with([ + 'downgrade' => 'http://www.example.com/cat.gif', + 'private address' => 'https://10.0.0.1/cat.gif', + 'private DNS' => 'https://internal.example.com/cat.gif', + 'mixed DNS' => 'https://mixed.example.com/cat.gif', + 'credentials' => 'https://user:secret@www.example.com/cat.gif', + 'port' => 'https://www.example.com:8443/cat.gif', +]); + +it('does not embed HTTP GIFs or treat a query string as a GIF path', function (string $url): void { + Http::fake(['*' => Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 200, ['Content-Type' => 'image/gif'])]); + + expect(unfurl($url)->gif_url)->toBeNull(); +})->with([ + 'HTTP' => 'http://example.com/cat.gif', + 'query' => 'https://example.com/page?image=cat.gif', +]); + +it('does not keep oversized redirected GIF URLs', function (): void { + $destination = 'https://www.example.com/'.str_repeat('x', 2048); + Http::fake([ + 'https://example.com/cat.gif' => Http::response(status: 302, headers: ['Location' => $destination]), + $destination => Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 200, ['Content-Type' => 'image/gif']), + ]); + + expect(unfurl('https://example.com/cat.gif')->gif_url)->toBeNull(); +}); + +it('clears a previously verified GIF when refetching fails permanently', function (): void { + Http::fake(['https://example.com/cat.gif' => Http::response(status: 404)]); + $preview = LinkPreview::factory()->create([ + 'url' => 'https://example.com/cat.gif', + 'gif_url' => 'https://example.com/old.gif', + ]); + + resolve(UpdateLinkPreview::class)->handle($preview); + + expect($preview->refresh()->gif_url)->toBeNull(); +}); + function unfurl(string $url): LinkPreview { $linkPreview = LinkPreview::factory()->create(['url' => $url]); @@ -332,7 +433,7 @@ function expectNoMetadata(LinkPreview $linkPreview): void 'https on http port' => 'https://example.com:80/', 'http on https port' => 'http://example.com:443/', 'credentials' => 'https://user:secret@example.com/', - 'username only' => 'https://user@example.com/', + 'handle only' => 'https://user@example.com/', 'password only' => 'https://:secret@example.com/', 'ftp' => 'ftp://example.com/file', 'file' => 'file:///etc/passwd', @@ -450,7 +551,7 @@ public function resolve(string $host): array it('stops repeated redirect destinations before fetching them again', function (array $responses, int $requests): void { Http::fake($responses); - expect(unfurl('https://example.com/start.gif')->title)->toBeNull(); + expect(unfurl('https://example.com/start.gif')->gif_url)->toBeNull(); Http::assertSentCount($requests); })->with([ 'self loop' => [fn (): array => [ @@ -468,7 +569,7 @@ public function resolve(string $host): array it('does not refetch redirects with a missing or invalid location', function (?string $location): void { Http::fake(['https://example.com/*' => Http::response(status: 302, headers: $location === null ? [] : ['Location' => $location])]); - expect(unfurl('https://example.com/start.gif')->title)->toBeNull(); + expect(unfurl('https://example.com/start.gif')->gif_url)->toBeNull(); Http::assertSentCount(1); })->with([null, '', ' ', 'https://invalid host/cat.gif']); @@ -488,7 +589,7 @@ public function resolve(string $host): array return Http::response(status: 302, headers: ['Location' => '/hop-'.count($timeouts).'.gif']); }); - expect(unfurl('https://example.com/start.gif')->title)->toBeNull() + expect(unfurl('https://example.com/start.gif')->gif_url)->toBeNull() ->and($timeouts)->toBe([5.0, 3.0, 1.0]) ->and($connectTimeouts)->toBe([3, 3.0, 1.0]); Http::assertSentCount(3); @@ -502,7 +603,7 @@ public function resolve(string $host): array })); Http::fake(); - expect(unfurl('https://example.com/start.gif')->title)->toBeNull(); + expect(unfurl('https://example.com/start.gif')->gif_url)->toBeNull(); Http::assertNothingSent(); }); @@ -518,10 +619,21 @@ public function resolve(string $host): array }); Http::fake(['https://example.com/start.gif' => Http::response(status: 302, headers: ['Location' => '/next.gif'])]); - expect(unfurl('https://example.com/start.gif')->title)->toBeNull(); + expect(unfurl('https://example.com/start.gif')->gif_url)->toBeNull(); Http::assertSentCount(1); }); +it('accepts only final GIF URLs within the storage length limit', function (int $length, bool $valid): void { + $base = 'https://www.example.com/'; + $destination = $base.str_repeat('x', $length - mb_strlen($base)); + Http::fake([ + 'https://example.com/cat.gif' => Http::response(status: 302, headers: ['Location' => $destination]), + $destination => Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 200, ['Content-Type' => 'image/gif']), + ]); + + expect(unfurl('https://example.com/cat.gif')->gif_url)->toBe($valid ? $destination : null); +})->with([[2048, true], [2049, false]]); + it('pins the explicit port carried by a prepared request', function (): void { $options = []; Http::fake(function (Request $request, array $sent) use (&$options): PromiseInterface { diff --git a/tests/Unit/Services/LinkPreviewHttpClientTest.php b/tests/Unit/Services/LinkPreviewHttpClientTest.php new file mode 100644 index 000000000..92f459790 --- /dev/null +++ b/tests/Unit/Services/LinkPreviewHttpClientTest.php @@ -0,0 +1,49 @@ +setTimeout(5)->start(); + + try { + $server->waitUntil( + static fn (string $type, string $output): bool => $type === Process::OUT && mb_trim($output) !== '', + ); + $port = (int) mb_trim($server->getOutput()); + + throw_if($port === 0, RuntimeException::class, 'Link preview fixture did not report a port.'); + Http::allowStrayRequests(["http://example.test:{$port}/*"]); + + $response = resolve(LinkPreviewHttpClient::class)->get(new LinkPreviewRequest( + url: Url::parse("http://example.test:{$port}/cat.gif"), + address: '127.0.0.1', + headers: ['Accept' => 'image/gif', 'Range' => 'bytes=0-31'], + maxBodyBytes: 32, + timeoutSeconds: 5, + )); + + expect($response->status())->toBe(200) + ->and($response->header('Content-Type'))->toBe('image/gif') + ->and($response->body())->toHaveLength(32) + ->and($response->body())->toBe("GIF89a\x01\x00\x01\x00\x80\x00\x00".str_repeat('x', 19)) + ->and(file_get_contents($requestLog))->toContain("Range: bytes=0-31\r\n"); + } finally { + $server->stop(); + File::delete($requestLog); + } +});