From 985a15db343b9f059beec588e63747853e42e0cd Mon Sep 17 00:00:00 2001 From: Jerry <99199491+devjerry0@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:54:52 -0400 Subject: [PATCH 01/25] refactor: return typed link preview metadata --- app/Actions/UpdateLinkPreview.php | 87 ++++------------------------- app/Dto/LinkPreviewMetadata.php | 35 ++++++++++++ app/Services/LinkPreviewParser.php | 88 ++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 76 deletions(-) create mode 100644 app/Dto/LinkPreviewMetadata.php create mode 100644 app/Services/LinkPreviewParser.php diff --git a/app/Actions/UpdateLinkPreview.php b/app/Actions/UpdateLinkPreview.php index 941badb57..583f32f85 100644 --- a/app/Actions/UpdateLinkPreview.php +++ b/app/Actions/UpdateLinkPreview.php @@ -5,8 +5,9 @@ namespace App\Actions; use App\Contracts\Services\DnsResolver; +use App\Dto\LinkPreviewMetadata; use App\Models\LinkPreview; -use Dom\HTMLDocument; +use App\Services\LinkPreviewParser; use GuzzleHttp\Exception\ResponseException; use GuzzleHttp\Psr7\Utils; use Illuminate\Http\Client\ConnectionException; @@ -22,8 +23,6 @@ private const int MAX_BODY_BYTES = 1_048_576; - private const int MAX_IMAGE_URL_LENGTH = 2048; - /** * Non-public ranges that filter_var's private and reserved range flags let through: carrier-grade NAT, IETF protocol * assignments, 6to4 relays, benchmarking, documentation and multicast for IPv4; NAT64, discard, Teredo, @@ -51,14 +50,7 @@ '::ffff:0:0:0/96', ]; - private const array NO_METADATA = [ - 'title' => null, - 'description' => null, - 'image_url' => null, - 'site_name' => null, - ]; - - public function __construct(private DnsResolver $dns) + public function __construct(private DnsResolver $dns, private LinkPreviewParser $parser) { // } @@ -70,15 +62,15 @@ public function __construct(private DnsResolver $dns) */ public function handle(LinkPreview $linkPreview): void { - $linkPreview->update([...$this->fetchMetadata($linkPreview->url), 'fetched_at' => now()]); + $metadata = $this->fetchMetadata($linkPreview->url); + + $linkPreview->update([...$metadata->toArray(), 'fetched_at' => now()]); } /** * Follows up to three redirects by hand, so that every hop goes through the same checks as the original URL. - * - * @return array{title: string|null, description: string|null, image_url: string|null, site_name: string|null} */ - private function fetchMetadata(string $url): array + private function fetchMetadata(string $url): LinkPreviewMetadata { $page = Url::parse($url); @@ -86,7 +78,7 @@ private function fetchMetadata(string $url): array $address = $this->publicAddress($page); if ($address === null) { - return self::NO_METADATA; + return new LinkPreviewMetadata; } $response = $this->get($page, $address); @@ -97,14 +89,14 @@ private function fetchMetadata(string $url): array if (! $response->redirect()) { return $response->successful() && $this->isHtml($response) - ? $this->parse($this->body($response), $page) - : self::NO_METADATA; + ? $this->parser->parse($this->body($response), $page) + : new LinkPreviewMetadata; } $page = Url::parse($response->header('Location'), $page); } - return self::NO_METADATA; + return new LinkPreviewMetadata; } /** @@ -184,61 +176,4 @@ private function body(Response $response): string return Utils::copyToString($body, self::MAX_BODY_BYTES); } - /** - * @return array{title: string|null, description: string|null, image_url: string|null, site_name: string|null} - */ - private function parse(string $html, Url $page): array - { - $document = HTMLDocument::createFromString($html, LIBXML_NOERROR); - $meta = []; - - foreach ($document->querySelectorAll('meta[content]') as $element) { - $key = mb_strtolower(mb_trim($element->getAttribute('property') ?? $element->getAttribute('name') ?? '')); - $meta[$key] ??= (string) $element->getAttribute('content'); - } - - return [ - 'title' => $this->text([$meta['og:title'] ?? '', $meta['twitter:title'] ?? '', $document->title], 300), - 'description' => $this->text([$meta['og:description'] ?? '', $meta['twitter:description'] ?? '', $meta['description'] ?? ''], 500), - 'image_url' => $this->imageUrl([$meta['og:image:secure_url'] ?? '', $meta['og:image'] ?? '', $meta['twitter:image'] ?? ''], $page), - 'site_name' => $this->text([$meta['og:site_name'] ?? ''], 100), - ]; - } - - /** - * Returns the first candidate that still has text once entities are decoded and whitespace is collapsed, - * truncated to the column's length. - * - * @param list $candidates - */ - private function text(array $candidates, int $limit): ?string - { - foreach ($candidates as $candidate) { - $text = Str::squish(html_entity_decode($candidate, ENT_QUOTES | ENT_HTML5, 'UTF-8')); - - if ($text !== '') { - return mb_strlen($text) > $limit ? mb_rtrim(mb_substr($text, 0, $limit - 1)).'…' : $text; - } - } - - return null; - } - - /** - * Returns the first candidate that, resolved against the page URL, is an https URL. - * - * @param list $candidates - */ - private function imageUrl(array $candidates, Url $page): ?string - { - foreach ($candidates as $candidate) { - $image = mb_trim($candidate) === '' ? null : Url::parse($candidate, $page); - - if ($image?->getScheme() === 'https' && mb_strlen($image->toAsciiString()) <= self::MAX_IMAGE_URL_LENGTH) { - return $image->toAsciiString(); - } - } - - return null; - } } diff --git a/app/Dto/LinkPreviewMetadata.php b/app/Dto/LinkPreviewMetadata.php new file mode 100644 index 000000000..7e0168326 --- /dev/null +++ b/app/Dto/LinkPreviewMetadata.php @@ -0,0 +1,35 @@ + + */ +final readonly class LinkPreviewMetadata implements Arrayable +{ + public function __construct( + public ?string $title = null, + public ?string $description = null, + public ?string $imageUrl = null, + public ?string $siteName = null, + ) { + // + } + + /** + * @return array{title: string|null, description: string|null, image_url: string|null, site_name: string|null} + */ + public function toArray(): array + { + return [ + 'title' => $this->title, + 'description' => $this->description, + 'image_url' => $this->imageUrl, + 'site_name' => $this->siteName, + ]; + } +} diff --git a/app/Services/LinkPreviewParser.php b/app/Services/LinkPreviewParser.php new file mode 100644 index 000000000..79ca52577 --- /dev/null +++ b/app/Services/LinkPreviewParser.php @@ -0,0 +1,88 @@ +querySelectorAll('meta[content]') as $element) { + $key = mb_strtolower(mb_trim($element->getAttribute('property') ?? $element->getAttribute('name') ?? '')); + $meta[$key] ??= (string) $element->getAttribute('content'); + } + + return new LinkPreviewMetadata( + title: $this->text([$meta['og:title'] ?? '', $meta['twitter:title'] ?? '', $document->title], self::MAX_TITLE_LENGTH), + description: $this->text([$meta['og:description'] ?? '', $meta['twitter:description'] ?? '', $meta['description'] ?? ''], self::MAX_DESCRIPTION_LENGTH), + imageUrl: $this->imageUrl([$meta['og:image:secure_url'] ?? '', $meta['og:image'] ?? '', $meta['twitter:image'] ?? ''], $page), + siteName: $this->text([$meta['og:site_name'] ?? ''], self::MAX_SITE_NAME_LENGTH), + ); + } + + /** + * Returns the first candidate that still has text once entities are decoded and whitespace is collapsed, + * truncated to the column's length. + * + * @param list $candidates + */ + private function text(array $candidates, int $limit): ?string + { + foreach ($candidates as $candidate) { + $text = Str::squish(html_entity_decode($candidate, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + + if ($text === '') { + continue; + } + + if (mb_strlen($text) <= $limit) { + return $text; + } + + return mb_rtrim(mb_substr($text, 0, $limit - 1)).'…'; + } + + return null; + } + + /** + * Returns the first candidate that, resolved against the page URL, is an https URL. + * + * @param list $candidates + */ + private function imageUrl(array $candidates, Url $page): ?string + { + foreach ($candidates as $candidate) { + $image = mb_trim($candidate) === '' ? null : Url::parse($candidate, $page); + + if ($image?->getScheme() !== 'https') { + continue; + } + + if (mb_strlen($image->toAsciiString()) > self::MAX_IMAGE_URL_LENGTH) { + continue; + } + + return $image->toAsciiString(); + } + + return null; + } +} From a66fa5e2857bc7f604a15d1be13a728b57250943 Mon Sep 17 00:00:00 2001 From: Jerry <99199491+devjerry0@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:55:26 -0400 Subject: [PATCH 02/25] refactor: reuse Symfony ranges for preview address checks --- app/Actions/UpdateLinkPreview.php | 58 +---------------- app/Services/LinkPreviewAddress.php | 65 ++++++++++++++++++++ tests/Unit/Actions/UpdateLinkPreviewTest.php | 9 +++ 3 files changed, 77 insertions(+), 55 deletions(-) create mode 100644 app/Services/LinkPreviewAddress.php diff --git a/app/Actions/UpdateLinkPreview.php b/app/Actions/UpdateLinkPreview.php index 583f32f85..5fbd072cb 100644 --- a/app/Actions/UpdateLinkPreview.php +++ b/app/Actions/UpdateLinkPreview.php @@ -4,7 +4,7 @@ namespace App\Actions; -use App\Contracts\Services\DnsResolver; +use App\Services\LinkPreviewAddress; use App\Dto\LinkPreviewMetadata; use App\Models\LinkPreview; use App\Services\LinkPreviewParser; @@ -14,7 +14,6 @@ use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; -use Symfony\Component\HttpFoundation\IpUtils; use Uri\WhatWg\Url; final readonly class UpdateLinkPreview @@ -23,34 +22,7 @@ private const int MAX_BODY_BYTES = 1_048_576; - /** - * Non-public ranges that filter_var's private and reserved range flags let through: carrier-grade NAT, IETF protocol - * assignments, 6to4 relays, benchmarking, documentation and multicast for IPv4; NAT64, discard, Teredo, - * documentation, 6to4, site-local, multicast and IPv4-translated addresses for IPv6. - * - * @var list - */ - private const array BLOCKED_RANGES = [ - '100.64.0.0/10', - '192.0.0.0/24', - '192.0.2.0/24', - '192.88.99.0/24', - '198.18.0.0/15', - '198.51.100.0/24', - '203.0.113.0/24', - '224.0.0.0/4', - '64:ff9b::/96', - '64:ff9b:1::/48', - '100::/64', - '2001::/32', - '2001:db8::/32', - '2002::/16', - 'fec0::/10', - 'ff00::/8', - '::ffff:0:0:0/96', - ]; - - public function __construct(private DnsResolver $dns, private LinkPreviewParser $parser) + public function __construct(private LinkPreviewAddress $addresses, private LinkPreviewParser $parser) { // } @@ -75,7 +47,7 @@ private function fetchMetadata(string $url): LinkPreviewMetadata $page = Url::parse($url); for ($hop = 0; $hop <= self::MAX_REDIRECTS && $page instanceof Url; $hop++) { - $address = $this->publicAddress($page); + $address = $this->addresses->get($page); if ($address === null) { return new LinkPreviewMetadata; @@ -99,30 +71,6 @@ private function fetchMetadata(string $url): LinkPreviewMetadata return new LinkPreviewMetadata; } - /** - * Returns the address to connect to, or null when the URL must not be fetched: anything but plain http(s) on a - * default port without credentials, or a host that resolves to nothing or to any non-public address. - */ - private function publicAddress(Url $url): ?string - { - if (! in_array($url->getScheme(), ['http', 'https'], true) - || ($url->getUsername() ?? '') !== '' - || ($url->getPassword() ?? '') !== '' - || ! in_array($url->getPort(), [null, 80, 443], true)) { - return null; - } - - $addresses = $this->dns->resolve(mb_trim((string) $url->getAsciiHost(), '[]')); - - return $addresses !== [] && array_all($addresses, $this->isPublic(...)) ? $addresses[0] : null; - } - - private function isPublic(string $address): bool - { - return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false - && ! IpUtils::checkIp($address, self::BLOCKED_RANGES); - } - /** * Connects to the address that was checked rather than resolving the host again, so DNS rebinding can't swap in a * private address. The empty host and port in CURLOPT_CONNECT_TO match every request, so the pin holds even if diff --git a/app/Services/LinkPreviewAddress.php b/app/Services/LinkPreviewAddress.php new file mode 100644 index 000000000..b4a6a7d04 --- /dev/null +++ b/app/Services/LinkPreviewAddress.php @@ -0,0 +1,65 @@ + + */ + private const array BLOCKED_RANGES = [ + ...IpUtils::PRIVATE_SUBNETS, + '192.88.99.0/24', + '224.0.0.0/4', + 'fec0::/10', + 'ff00::/8', + '::ffff:0:0:0/96', + ]; + + public function __construct(private DnsResolver $dns) + { + // + } + + /** + * Returns the address to connect to, or null when the URL must not be fetched: anything but plain http(s) on a + * default port without credentials, or a host that resolves to nothing or to any non-public address. + */ + public function get(Url $url): ?string + { + if (! in_array($url->getScheme(), ['http', 'https'], true)) { + return null; + } + + if (($url->getUsername() ?? '') !== '') { + return null; + } + + if (($url->getPassword() ?? '') !== '') { + return null; + } + + if ($url->getPort() !== null) { + return null; + } + + $addresses = $this->dns->resolve(mb_trim((string) $url->getAsciiHost(), '[]')); + + return $addresses !== [] && array_all($addresses, $this->isPublic(...)) ? $addresses[0] : null; + } + + private function isPublic(string $address): bool + { + return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) !== false + && ! IpUtils::checkIp($address, self::BLOCKED_RANGES); + } +} diff --git a/tests/Unit/Actions/UpdateLinkPreviewTest.php b/tests/Unit/Actions/UpdateLinkPreviewTest.php index bd4d7f77a..58d70f171 100644 --- a/tests/Unit/Actions/UpdateLinkPreviewTest.php +++ b/tests/Unit/Actions/UpdateLinkPreviewTest.php @@ -301,6 +301,9 @@ function expectNoMetadata(LinkPreview $linkPreview): void 'home network' => 'http://192.168.1.1/', 'cloud metadata' => 'http://169.254.169.254/latest/meta-data/', 'carrier-grade nat' => 'http://100.64.0.1/', + 'protocol assignment' => 'http://192.0.0.1/', + 'deprecated relay' => 'http://192.88.99.1/', + 'documentation' => 'http://192.0.2.1/', 'benchmarking' => 'http://198.18.0.1/', 'unspecified' => 'http://0.0.0.0/', 'multicast' => 'http://224.0.0.1/', @@ -309,12 +312,18 @@ function expectNoMetadata(LinkPreview $linkPreview): void 'ipv6 link-local' => 'http://[fe80::1]/', 'ipv4-mapped ipv6' => 'http://[::ffff:127.0.0.1]/', 'nat64' => 'http://[64:ff9b::a00:1]/', + 'nat64 local use' => 'http://[64:ff9b:1::a00:1]/', + 'ipv6 discard' => 'http://[100::1]/', + 'ipv6 site-local' => 'http://[fec0::1]/', + 'ipv4-translated ipv6' => 'http://[::ffff:0:a00:1]/', 'ipv6 multicast' => 'http://[ff02::1]/', 'localhost' => 'http://localhost/', 'host resolving to a private address' => 'https://internal.example.com/', 'host resolving partly to a private address' => 'https://mixed.example.com/', 'host resolving to nothing' => 'https://nowhere.example.com/', 'non-default port' => 'https://example.com:8080/', + '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/', 'password only' => 'https://:secret@example.com/', From aabddceb69dd99342f1f00e43faac4f2ff6cddc4 Mon Sep 17 00:00:00 2001 From: Jerry <99199491+devjerry0@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:55:29 -0400 Subject: [PATCH 03/25] fix: bound preview bodies and redirect request budgets --- app/Actions/UpdateLinkPreview.php | 115 ++++++++++-------- app/Dto/LinkPreviewRequest.php | 23 ++++ app/Services/LimitedResponseBody.php | 25 ++++ app/Services/LinkPreviewBudget.php | 42 +++++++ app/Services/LinkPreviewHttpClient.php | 51 ++++++++ tests/Unit/Actions/UpdateLinkPreviewTest.php | 107 +++++++++++++++- .../Unit/Services/LimitedResponseBodyTest.php | 39 ++++++ tests/Unit/Services/LinkPreviewBudgetTest.php | 28 +++++ 8 files changed, 377 insertions(+), 53 deletions(-) create mode 100644 app/Dto/LinkPreviewRequest.php create mode 100644 app/Services/LimitedResponseBody.php create mode 100644 app/Services/LinkPreviewBudget.php create mode 100644 app/Services/LinkPreviewHttpClient.php create mode 100644 tests/Unit/Services/LimitedResponseBodyTest.php create mode 100644 tests/Unit/Services/LinkPreviewBudgetTest.php diff --git a/app/Actions/UpdateLinkPreview.php b/app/Actions/UpdateLinkPreview.php index 5fbd072cb..b1ebadbd8 100644 --- a/app/Actions/UpdateLinkPreview.php +++ b/app/Actions/UpdateLinkPreview.php @@ -4,15 +4,15 @@ namespace App\Actions; -use App\Services\LinkPreviewAddress; use App\Dto\LinkPreviewMetadata; +use App\Dto\LinkPreviewRequest; use App\Models\LinkPreview; +use App\Services\LinkPreviewAddress; +use App\Services\LinkPreviewBudget; +use App\Services\LinkPreviewHttpClient; use App\Services\LinkPreviewParser; -use GuzzleHttp\Exception\ResponseException; use GuzzleHttp\Psr7\Utils; -use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Response; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Uri\WhatWg\Url; @@ -22,8 +22,16 @@ private const int MAX_BODY_BYTES = 1_048_576; - public function __construct(private LinkPreviewAddress $addresses, private LinkPreviewParser $parser) - { + private const string HEADER_ACCEPT = 'Accept'; + + private const string HTML_CONTENT_TYPE = 'text/html'; + + public function __construct( + private LinkPreviewAddress $addresses, + private LinkPreviewBudget $budget, + private LinkPreviewHttpClient $http, + private LinkPreviewParser $parser, + ) { // } @@ -45,75 +53,83 @@ public function handle(LinkPreview $linkPreview): void private function fetchMetadata(string $url): LinkPreviewMetadata { $page = Url::parse($url); + $deadline = $this->budget->start(); + $visited = []; for ($hop = 0; $hop <= self::MAX_REDIRECTS && $page instanceof Url; $hop++) { - $address = $this->addresses->get($page); + $source = $page->withFragment(null)->toAsciiString(); - if ($address === null) { + if (isset($visited[$source])) { return new LinkPreviewMetadata; } - $response = $this->get($page, $address); + $visited[$source] = true; + $request = $this->prepareRequest($page, $deadline); - if ($response->serverError() || $response->tooManyRequests()) { - $response->throw(); + if (! $request instanceof LinkPreviewRequest) { + return new LinkPreviewMetadata; } + $response = $this->http->get($request); + $response->throwIf($response->serverError() || $response->tooManyRequests()); + if (! $response->redirect()) { - return $response->successful() && $this->isHtml($response) - ? $this->parser->parse($this->body($response), $page) - : new LinkPreviewMetadata; + return $this->responseMetadata($response, $page); } - $page = Url::parse($response->header('Location'), $page); + $page = $this->redirectPage($response, $page); } return new LinkPreviewMetadata; } - /** - * Connects to the address that was checked rather than resolving the host again, so DNS rebinding can't swap in a - * private address. The empty host and port in CURLOPT_CONNECT_TO match every request, so the pin holds even if - * cURL were to read the URL differently than we do. Bodies are cut off after 1 MB, and a body that was cut off - * (by that limit or by the timeout) is still used, as metadata lives at the top of the page. English is asked for - * explicitly, as sites such as YouTube otherwise pick the language from the server's location. - */ - private function get(Url $url, string $address): Response + private function prepareRequest(Url $page, float $deadline): ?LinkPreviewRequest { - $port = $url->getPort() ?? ($url->getScheme() === 'https' ? 443 : 80); - - $request = Http::withUserAgent(sprintf('Mozilla/5.0 (compatible; devnull-linkpreview/1.0; +%s)', config()->string('app.url'))) - ->accept('text/html') - ->withHeaders(['Accept-Language' => 'en-US,en;q=0.9']) - ->connectTimeout(3) - ->timeout(5) - ->withoutRedirecting() - ->withOptions([ - 'decode_content' => false, - 'progress' => fn (int $expected, int $downloaded): bool => $downloaded > self::MAX_BODY_BYTES, - 'curl' => [ - CURLOPT_CONNECT_TO => [sprintf('::%s:%d', str_contains($address, ':') ? "[{$address}]" : $address, $port)], - ], - ]); - - try { - return $request->get($url->toAsciiString()); - } catch (ConnectionException $connectionException) { - $cutOff = $connectionException->getPrevious(); - - if ($cutOff instanceof ResponseException) { - return new Response($cutOff->getResponse()); - } + $address = $this->addresses->get($page); + $remaining = $this->budget->remaining($deadline); - throw $connectionException; + if ($address === null || $remaining <= 0) { + return null; } + + return new LinkPreviewRequest( + url: $page, + address: $address, + headers: [self::HEADER_ACCEPT => self::HTML_CONTENT_TYPE], + maxBodyBytes: self::MAX_BODY_BYTES, + timeoutSeconds: $remaining, + ); + } + + private function redirectPage(Response $response, Url $page): ?Url + { + $location = mb_trim($response->header('Location')); + + if ($location === '') { + return null; + } + + return Url::parse($location, $page); + } + + private function responseMetadata(Response $response, Url $page): LinkPreviewMetadata + { + if (! $response->successful()) { + return new LinkPreviewMetadata; + } + + if (! $this->isHtml($response)) { + return new LinkPreviewMetadata; + } + + return $this->parser->parse($this->body($response), $page); } private function isHtml(Response $response): bool { $mediaType = mb_strtolower(mb_trim(Str::before($response->header('Content-Type'), ';'))); - return in_array($mediaType, ['text/html', 'application/xhtml+xml'], true); + return in_array($mediaType, [self::HTML_CONTENT_TYPE, 'application/xhtml+xml'], true); } private function body(Response $response): string @@ -123,5 +139,4 @@ private function body(Response $response): string return Utils::copyToString($body, self::MAX_BODY_BYTES); } - } diff --git a/app/Dto/LinkPreviewRequest.php b/app/Dto/LinkPreviewRequest.php new file mode 100644 index 000000000..23f9f6f86 --- /dev/null +++ b/app/Dto/LinkPreviewRequest.php @@ -0,0 +1,23 @@ + $headers + */ + public function __construct( + public Url $url, + public string $address, + public array $headers, + public int $maxBodyBytes, + public float $timeoutSeconds, + ) { + // + } +} diff --git a/app/Services/LimitedResponseBody.php b/app/Services/LimitedResponseBody.php new file mode 100644 index 000000000..85453c0be --- /dev/null +++ b/app/Services/LimitedResponseBody.php @@ -0,0 +1,25 @@ +maxBytes - $this->tell()); + + return parent::write(mb_substr($string, 0, $remaining, self::BINARY_ENCODING)); + } +} diff --git a/app/Services/LinkPreviewBudget.php b/app/Services/LinkPreviewBudget.php new file mode 100644 index 000000000..dc05fcbe6 --- /dev/null +++ b/app/Services/LinkPreviewBudget.php @@ -0,0 +1,42 @@ +seconds() + self::TIMEOUT_SECONDS; + } + + public function remaining(float $deadline): float + { + $milliseconds = floor(($deadline - $this->seconds()) * CarbonInterface::MILLISECONDS_PER_SECOND); + + return max(0, $milliseconds) / CarbonInterface::MILLISECONDS_PER_SECOND; + } + + private function seconds(): float + { + $nanoseconds = $this->clock instanceof Closure ? ($this->clock)() : hrtime(true); + + return $nanoseconds / self::NANOSECONDS_PER_SECOND; + } +} diff --git a/app/Services/LinkPreviewHttpClient.php b/app/Services/LinkPreviewHttpClient.php new file mode 100644 index 000000000..fdac09890 --- /dev/null +++ b/app/Services/LinkPreviewHttpClient.php @@ -0,0 +1,51 @@ + 'en-US,en;q=0.9']; + + public function get(LinkPreviewRequest $request): Response + { + $port = $request->url->getPort() ?? ($request->url->getScheme() === 'https' ? self::HTTPS_PORT : self::HTTP_PORT); + $connection = sprintf('::%s:%d', str_contains($request->address, ':') ? "[{$request->address}]" : $request->address, $port); + + try { + return Http::withUserAgent(sprintf(self::USER_AGENT_FORMAT, config()->string('app.url'))) + ->withHeaders([...self::DEFAULT_HEADERS, ...$request->headers]) + ->connectTimeout(min(self::CONNECT_TIMEOUT_SECONDS, $request->timeoutSeconds)) + ->timeout($request->timeoutSeconds) + ->withoutRedirecting() + ->withOptions([ + RequestOptions::DECODE_CONTENT => false, + RequestOptions::SINK => new LimitedResponseBody($request->maxBodyBytes), + RequestOptions::CURL => [CURLOPT_CONNECT_TO => [$connection]], + ]) + ->get($request->url->toAsciiString()); + } catch (ConnectionException $connectionException) { + $previous = $connectionException->getPrevious(); + + throw_unless($previous instanceof ResponseException, $connectionException); + + return new Response($previous->getResponse()); + } + } +} diff --git a/tests/Unit/Actions/UpdateLinkPreviewTest.php b/tests/Unit/Actions/UpdateLinkPreviewTest.php index 58d70f171..21b9f2a90 100644 --- a/tests/Unit/Actions/UpdateLinkPreviewTest.php +++ b/tests/Unit/Actions/UpdateLinkPreviewTest.php @@ -4,15 +4,21 @@ use App\Actions\UpdateLinkPreview; use App\Contracts\Services\DnsResolver; +use App\Dto\LinkPreviewRequest; use App\Models\LinkPreview; use App\Services\DnsResolvers\SystemDnsResolver; +use App\Services\LimitedResponseBody; +use App\Services\LinkPreviewBudget; +use App\Services\LinkPreviewHttpClient; use GuzzleHttp\Exception\ResponseException; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\Response as PsrResponse; +use GuzzleHttp\RequestOptions; use Illuminate\Http\Client\HttpClientException; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +use Uri\WhatWg\Url; beforeEach(function (): void { $this->app->instance(DnsResolver::class, new readonly class implements DnsResolver @@ -103,11 +109,12 @@ function expectNoMetadata(LinkPreview $linkPreview): void expect($options) ->allow_redirects->toBeFalse() ->connect_timeout->toBe(3) - ->timeout->toBe(5) + ->timeout->toBeGreaterThan(0)->toBeLessThanOrEqual(5) ->decode_content->toBeFalse() ->and($options['curl'][CURLOPT_CONNECT_TO])->toBe(['::93.184.216.34:443']) - ->and($options['progress'](0, 1_048_576, 0, 0))->toBeFalse() - ->and($options['progress'](2_000_000, 1_048_577, 0, 0))->toBeTrue(); + ->and($options['sink'])->toBeInstanceOf(LimitedResponseBody::class) + ->and($options['sink']->write(str_repeat('x', 1_048_577)))->toBe(1_048_576) + ->and($options['sink']->getSize())->toBe(1_048_576); }); it('pins the connection to the port and address that were checked', function (string $url, string $connectTo): void { @@ -439,3 +446,97 @@ public function resolve(string $host): array expect(fn () => resolve(UpdateLinkPreview::class)->handle($linkPreview))->toThrow(RuntimeException::class, 'The resolver broke.') ->and($linkPreview->refresh()->fetched_at)->toBeNull(); }); + +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(); + Http::assertSentCount($requests); +})->with([ + 'self loop' => [fn (): array => [ + 'https://example.com/start.gif' => Http::response(status: 302, headers: ['Location' => '/start.gif']), + ], 1], + 'fragment-only loop' => [fn (): array => [ + 'https://example.com/start.gif' => Http::response(status: 302, headers: ['Location' => '#other']), + ], 1], + 'two host loop' => [fn (): array => [ + 'https://example.com/start.gif' => Http::response(status: 302, headers: ['Location' => 'https://www.example.com/next.gif']), + 'https://www.example.com/next.gif' => Http::response(status: 302, headers: ['Location' => 'https://example.com/start.gif']), + ], 2], +]); + +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(); + Http::assertSentCount(1); +})->with([null, '', ' ', 'https://invalid host/cat.gif']); + +it('reduces each hop timeout to the remaining overall budget', function (): void { + $nanoseconds = 0; + $clock = function () use (&$nanoseconds): int { + return $nanoseconds; + }; + $this->app->instance(LinkPreviewBudget::class, new LinkPreviewBudget($clock)); + $timeouts = []; + $connectTimeouts = []; + Http::fake(function (Request $request, array $options) use (&$nanoseconds, &$timeouts, &$connectTimeouts): PromiseInterface { + $timeouts[] = $options['timeout']; + $connectTimeouts[] = $options['connect_timeout']; + $nanoseconds += 2_000_000_000; + + return Http::response(status: 302, headers: ['Location' => '/hop-'.count($timeouts).'.gif']); + }); + + expect(unfurl('https://example.com/start.gif')->title)->toBeNull() + ->and($timeouts)->toBe([5.0, 3.0, 1.0]) + ->and($connectTimeouts)->toBe([3, 3.0, 1.0]); + Http::assertSentCount(3); +}); + +it('does not connect if resolving an address exhausted the budget', function (): void { + $this->app->instance(LinkPreviewBudget::class, new LinkPreviewBudget(function (): int { + static $calls = 0; + + return $calls++ === 0 ? 0 : 6_000_000_000; + })); + Http::fake(); + + expect(unfurl('https://example.com/start.gif')->title)->toBeNull(); + Http::assertNothingSent(); +}); + +it('rechecks changed DNS answers before following a redirect', function (): void { + $this->app->instance(DnsResolver::class, new class implements DnsResolver + { + private int $calls = 0; + + public function resolve(string $host): array + { + return $this->calls++ === 0 ? ['93.184.216.34'] : ['10.0.0.1']; + } + }); + Http::fake(['https://example.com/start.gif' => Http::response(status: 302, headers: ['Location' => '/next.gif'])]); + + expect(unfurl('https://example.com/start.gif')->title)->toBeNull(); + Http::assertSentCount(1); +}); + +it('pins the explicit port carried by a prepared request', function (): void { + $options = []; + Http::fake(function (Request $request, array $sent) use (&$options): PromiseInterface { + $options = $sent; + + return htmlResponse('Prepared request'); + }); + $request = new LinkPreviewRequest( + url: Url::parse('https://example.com:8443/'), + address: '93.184.216.34', + headers: ['Accept' => 'text/html'], + maxBodyBytes: 32, + timeoutSeconds: 5, + ); + + expect(resolve(LinkPreviewHttpClient::class)->get($request)->successful())->toBeTrue() + ->and($options[RequestOptions::CURL][CURLOPT_CONNECT_TO])->toBe(['::93.184.216.34:8443']); +}); diff --git a/tests/Unit/Services/LimitedResponseBodyTest.php b/tests/Unit/Services/LimitedResponseBodyTest.php new file mode 100644 index 000000000..d040fd93b --- /dev/null +++ b/tests/Unit/Services/LimitedResponseBodyTest.php @@ -0,0 +1,39 @@ +write($header))->toBe(13) + ->and((string) $body)->toBe($header); +}); + +it('returns a short write so the transport aborts an oversized response', function (): void { + $body = new LimitedResponseBody(32); + + expect($body->write('GIF89a'.str_repeat('x', 1_048_576)))->toBe(32) + ->and($body->getSize())->toBe(32) + ->and((string) $body)->toBe('GIF89a'.str_repeat('x', 26)); +}); + +it('enforces the same cap across multiple writes', function (): void { + $body = new LimitedResponseBody(32); + + expect($body->write(str_repeat('a', 20)))->toBe(20) + ->and($body->write(str_repeat('b', 20)))->toBe(12) + ->and($body->write('c'))->toBe(0) + ->and($body->getSize())->toBe(32) + ->and((string) $body)->toBe(str_repeat('a', 20).str_repeat('b', 12)); +}); + +it('does not let a seek move writes past the byte cap', function (): void { + $body = new LimitedResponseBody(32); + $body->seek(64); + + expect($body->write('overflow'))->toBe(0) + ->and($body->getSize())->toBe(0); +}); diff --git a/tests/Unit/Services/LinkPreviewBudgetTest.php b/tests/Unit/Services/LinkPreviewBudgetTest.php new file mode 100644 index 000000000..765cf81f6 --- /dev/null +++ b/tests/Unit/Services/LinkPreviewBudgetTest.php @@ -0,0 +1,28 @@ +start(); + $nanoseconds = 2_000_000_000; + + expect($deadline)->toBe(5.0) + ->and($budget->remaining($deadline))->toBe(3.0); + + $nanoseconds = 6_000_000_000; + + expect($budget->remaining($deadline))->toBe(0.0); +}); + +it('does not pass a sub-millisecond timeout to the HTTP client', function (int $nanoseconds): void { + $budget = new LinkPreviewBudget(fn (): int => $nanoseconds); + + expect($budget->remaining(5.0))->toBe(0.0); +})->with([4_999_999_999, 4_999_100_000]); From 426d98f08b17a7c581822a3c7e981280c9f897dd Mon Sep 17 00:00:00 2001 From: Jerry <99199491+devjerry0@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:55:31 -0400 Subject: [PATCH 04/25] feat: resolve Giphy share links without API keys --- app/Dto/Embed.php | 5 ++ app/Enums/EmbedKind.php | 1 + app/Services/EmbedProviders/Giphy.php | 75 +++++++++++++++++++ config/embeds.php | 2 + .../Services/EmbedProviders/GiphyTest.php | 43 +++++++++++ 5 files changed, 126 insertions(+) create mode 100644 app/Services/EmbedProviders/Giphy.php create mode 100644 tests/Unit/Services/EmbedProviders/GiphyTest.php diff --git a/app/Dto/Embed.php b/app/Dto/Embed.php index a5c6ef6a6..7d1f161eb 100644 --- a/app/Dto/Embed.php +++ b/app/Dto/Embed.php @@ -41,6 +41,11 @@ public static function fixed(string $provider, EmbedKind $kind, string $src, int return new self($provider, $kind, self::withQuery($src, $query), null, $height); } + public static function gif(string $provider, string $src, ?string $poster = null): self + { + return new self($provider, EmbedKind::Gif, $src, '1 / 1', null, $poster); + } + /** * @return array{provider: string, kind: string, src: string, aspect_ratio: string|null, height: int|null, poster: string|null} */ diff --git a/app/Enums/EmbedKind.php b/app/Enums/EmbedKind.php index 09d39fa51..ca66dd258 100644 --- a/app/Enums/EmbedKind.php +++ b/app/Enums/EmbedKind.php @@ -9,4 +9,5 @@ enum EmbedKind: string case Video = 'video'; case Audio = 'audio'; case Code = 'code'; + case Gif = 'gif'; } diff --git a/app/Services/EmbedProviders/Giphy.php b/app/Services/EmbedProviders/Giphy.php new file mode 100644 index 000000000..2856adffa --- /dev/null +++ b/app/Services/EmbedProviders/Giphy.php @@ -0,0 +1,75 @@ +supportsUrl($url)) { + return null; + } + + $identifier = $this->getGifId($url->getPath()); + + if ($identifier === null) { + return null; + } + + $mediaUrl = "https://media.giphy.com/media/{$identifier}"; + + return Embed::gif('GIPHY', "{$mediaUrl}/giphy.gif", "{$mediaUrl}/giphy_s.gif"); + } + + private function supportsUrl(Url $url): bool + { + if (! in_array($url->getScheme(), self::SUPPORTED_SCHEMES, true)) { + return false; + } + + if (! in_array($url->getAsciiHost(), self::SUPPORTED_HOSTS, true)) { + return false; + } + + if (($url->getUsername() ?? '') !== '') { + return false; + } + + if (($url->getPassword() ?? '') !== '') { + return false; + } + + return $url->getPort() === null; + } + + private function getGifId(string $path): ?string + { + $segments = explode('/', mb_trim($path, '/')); + + if (count($segments) !== self::PATH_SEGMENT_COUNT) { + return null; + } + + [$section, $slug] = $segments; + $identifier = match (Str::lower($section)) { + 'gifs' => Str::afterLast($slug, '-'), + 'embed' => $slug, + default => '', + }; + + return ctype_alnum($identifier) ? $identifier : null; + } +} diff --git a/config/embeds.php b/config/embeds.php index c9349bc0c..ee6564a40 100644 --- a/config/embeds.php +++ b/config/embeds.php @@ -5,6 +5,7 @@ use App\Services\EmbedProviders\AppleMusic; use App\Services\EmbedProviders\Asciinema; use App\Services\EmbedProviders\CodePen; +use App\Services\EmbedProviders\Giphy; use App\Services\EmbedProviders\Loom; use App\Services\EmbedProviders\SoundCloud; use App\Services\EmbedProviders\Spotify; @@ -20,6 +21,7 @@ 'only' => array_values(array_filter(array_map(trim(...), explode(',', (string) env('EMBEDS_PROVIDERS', ''))))), 'providers' => [ + 'giphy' => Giphy::class, 'youtube' => YouTube::class, 'vimeo' => Vimeo::class, 'loom' => Loom::class, diff --git a/tests/Unit/Services/EmbedProviders/GiphyTest.php b/tests/Unit/Services/EmbedProviders/GiphyTest.php new file mode 100644 index 000000000..c098e9ba8 --- /dev/null +++ b/tests/Unit/Services/EmbedProviders/GiphyTest.php @@ -0,0 +1,43 @@ +embed(Url::parse($url))?->toArray())->toBe([ + 'provider' => 'GIPHY', + 'kind' => 'gif', + 'src' => 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif', + 'aspect_ratio' => '1 / 1', + 'height' => null, + 'poster' => 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy_s.gif', + ]); +})->with([ + 'share' => 'https://giphy.com/gifs/cat-typing-JIX9t2j0ZTN9S', + 'id' => 'https://www.giphy.com/gifs/JIX9t2j0ZTN9S/', + 'embed' => 'https://giphy.com/embed/JIX9t2j0ZTN9S', + 'uppercase section' => 'https://giphy.com/GIFS/cat-JIX9t2j0ZTN9S', + 'query and fragment' => 'https://giphy.com/gifs/cat-JIX9t2j0ZTN9S?tracking=ignored#ignored', + 'http link' => 'http://giphy.com/gifs/JIX9t2j0ZTN9S', +]); + +it('does not embed malformed links or untrusted hosts', function (string $url): void { + expect(resolve(Giphy::class)->embed(Url::parse($url)))->toBeNull(); +})->with([ + 'home' => 'https://giphy.com/', + 'search' => 'https://giphy.com/search/cats', + 'missing id' => 'https://giphy.com/gifs/', + 'empty slug identifier' => 'https://giphy.com/gifs/cat-', + 'embed with slug' => 'https://giphy.com/embed/cat-JIX9t2j0ZTN9S', + 'escaped id' => 'https://giphy.com/gifs/cat-%3Cscript%3E', + 'extra segment' => 'https://giphy.com/embed/JIX9t2j0ZTN9S/other', + 'lookalike' => 'https://giphy.com.example.com/gifs/JIX9t2j0ZTN9S', + 'other host' => 'https://example.com/gifs/JIX9t2j0ZTN9S', + 'credentials' => 'https://user:secret@giphy.com/gifs/JIX9t2j0ZTN9S', + 'username' => 'https://user@giphy.com/gifs/JIX9t2j0ZTN9S', + 'password' => 'https://:secret@giphy.com/gifs/JIX9t2j0ZTN9S', + 'port' => 'https://giphy.com:8443/gifs/JIX9t2j0ZTN9S', + 'scheme' => 'ftp://giphy.com/gifs/JIX9t2j0ZTN9S', +]); From 40fb0fae4870abeb574f93ae9fc61937fce1c946 Mon Sep 17 00:00:00 2001 From: Jerry <99199491+devjerry0@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:55:34 -0400 Subject: [PATCH 05/25] feat: verify and persist external GIF preview URLs --- app/Actions/UpdateLinkPreview.php | 44 ++++++- app/Dto/LinkPreviewMetadata.php | 4 +- app/Models/LinkPreview.php | 4 +- app/Services/Embeds.php | 13 +- app/Services/GifLinks.php | 87 +++++++++++++ config/embeds.php | 1 + ...000_add_gif_url_to_link_previews_table.php | 17 +++ .../Controllers/Api/CommitControllerTest.php | 20 +++ tests/Unit/Actions/UpdateLinkPreviewTest.php | 122 +++++++++++++++++- tests/Unit/Models/LinkPreviewTest.php | 14 ++ tests/Unit/Services/EmbedsTest.php | 30 +++++ 11 files changed, 341 insertions(+), 15 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 diff --git a/app/Actions/UpdateLinkPreview.php b/app/Actions/UpdateLinkPreview.php index b1ebadbd8..f520103fb 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, ) { @@ -53,6 +57,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 = []; @@ -64,7 +69,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; @@ -74,7 +79,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); @@ -83,8 +88,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); @@ -95,8 +104,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, ); } @@ -112,12 +121,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; } @@ -125,6 +150,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..3a5b564d0 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', @@ -62,6 +64,6 @@ public function casts(): array */ public function toArray(): array { - return [...parent::toArray(), 'is_pending' => $this->fetched_at === null, 'embed' => resolve(Embeds::class)->for($this->url)?->toArray()]; + return [...parent::toArray(), 'is_pending' => $this->fetched_at === null, 'embed' => resolve(Embeds::class)->for($this->url, $this->gif_url)?->toArray()]; } } diff --git a/app/Services/Embeds.php b/app/Services/Embeds.php index 9b2847713..5946695cf 100644 --- a/app/Services/Embeds.php +++ b/app/Services/Embeds.php @@ -16,7 +16,10 @@ public function __construct(private Container $container) // } - public function for(string $url): ?Embed + /** + * The GIF URL is metadata verified by the link unfurler, never a client-supplied attachment. + */ + public function for(string $url, ?string $gifUrl = null): ?Embed { $parsed = Url::parse($url); @@ -32,7 +35,13 @@ public function for(string $url): ?Embed } } - return null; + $only = config()->array('embeds.only'); + + if ($gifUrl === null || ($only !== [] && ! in_array('gif', $only, true))) { + return null; + } + + return Embed::gif('GIF', $gifUrl); } /** diff --git a/app/Services/GifLinks.php b/app/Services/GifLinks.php new file mode 100644 index 000000000..c4e8bca86 --- /dev/null +++ b/app/Services/GifLinks.php @@ -0,0 +1,87 @@ +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) > self::MAX_URL_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/config/embeds.php b/config/embeds.php index ee6564a40..bbc3b3d9d 100644 --- a/config/embeds.php +++ b/config/embeds.php @@ -18,6 +18,7 @@ 'enabled' => (bool) env('EMBEDS_ENABLED', true), + // "gif" selects verified external GIFs; the other keys select providers below. 'only' => array_values(array_filter(array_map(trim(...), explode(',', (string) env('EMBEDS_PROVIDERS', ''))))), 'providers' => [ 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/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index 406f88ccb..125ba48b9 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -125,6 +125,26 @@ ]); }); +it('includes verified external GIF metadata in the public API', function (): void { + $preview = LinkPreview::factory()->fetched()->create([ + 'url' => 'https://example.com/cat.gif', + 'gif_url' => 'https://cdn.example.com/cat.gif', + ]); + $commit = Commit::factory()->create(['link_preview_id' => $preview->id]); + + $this->getJson(route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonMissingPath('data.link_preview.gif_url') + ->assertJsonPath('data.link_preview.embed', [ + 'provider' => 'GIF', + 'kind' => 'gif', + 'src' => 'https://cdn.example.com/cat.gif', + 'aspect_ratio' => '1 / 1', + 'height' => null, + 'poster' => null, + ]); +}); + it('includes the images of the commit in order', function (): void { $commit = Commit::factory()->create(); $second = CommitImage::factory()->for($commit)->create(['position' => 1, 'width' => 640, 'height' => 480]); diff --git a/tests/Unit/Actions/UpdateLinkPreviewTest.php b/tests/Unit/Actions/UpdateLinkPreviewTest.php index 21b9f2a90..00fb4efba 100644 --- a/tests/Unit/Actions/UpdateLinkPreviewTest.php +++ b/tests/Unit/Actions/UpdateLinkPreviewTest.php @@ -38,6 +38,107 @@ public function resolve(string $host): array }); }); +it('verifies external GIFs from a small header without storing their bytes', function (string $signature): void { + $options = []; + Http::fake(function (Request $request, array $sent) use (&$options, $signature): PromiseInterface { + $options = $sent; + + return Http::response($signature."\x01\x00\x01\x00\x80\x00\x00", 206, ['Content-Type' => '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]); @@ -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/Models/LinkPreviewTest.php b/tests/Unit/Models/LinkPreviewTest.php index 83447671b..fc5b061e2 100644 --- a/tests/Unit/Models/LinkPreviewTest.php +++ b/tests/Unit/Models/LinkPreviewTest.php @@ -41,6 +41,20 @@ expect($linkPreview->url_hash)->toBe(hash('sha256', 'https://example.com')); }); +it('serializes a verified external GIF without exposing its storage field', function (): void { + $preview = LinkPreview::factory()->fetched()->create([ + 'url' => 'https://example.com/cat.gif', + 'gif_url' => 'https://cdn.example.com/cat.gif', + ])->refresh(); + + expect($preview->gif_url)->toBe('https://cdn.example.com/cat.gif') + ->and($preview->toArray())->not->toHaveKey('gif_url') + ->and($preview->toArray()['embed'])->toMatchArray([ + 'kind' => 'gif', + 'src' => 'https://cdn.example.com/cat.gif', + ]); +}); + it('casts the fetched at timestamp', function (): void { $linkPreview = LinkPreview::factory()->fetched()->create()->refresh(); diff --git a/tests/Unit/Services/EmbedsTest.php b/tests/Unit/Services/EmbedsTest.php index 02386da8c..c428faa66 100644 --- a/tests/Unit/Services/EmbedsTest.php +++ b/tests/Unit/Services/EmbedsTest.php @@ -9,6 +9,36 @@ use App\Services\Embeds; use Uri\WhatWg\Url; +it('only embeds direct GIF URLs after the unfurler has verified them', function (): void { + expect(resolve(Embeds::class)->for('https://example.com/cat.gif'))->toBeNull() + ->and(resolve(Embeds::class)->for('https://example.com/cat.gif', 'https://cdn.example.com/cat.gif')?->toArray())->toBe([ + 'provider' => 'GIF', + 'kind' => 'gif', + 'src' => 'https://cdn.example.com/cat.gif', + 'aspect_ratio' => '1 / 1', + 'height' => null, + 'poster' => null, + ]); +}); + +it('honors the GIF selectors independently', function (): void { + $embeds = resolve(Embeds::class); + config(['embeds.only' => ['gif']]); + + expect($embeds->for('https://giphy.com/gifs/cat-JIX9t2j0ZTN9S'))->toBeNull() + ->and($embeds->for('https://example.com/cat.gif', 'https://example.com/cat.gif')?->provider)->toBe('GIF'); + + config(['embeds.only' => ['giphy']]); + + expect($embeds->for('https://giphy.com/gifs/cat-JIX9t2j0ZTN9S')?->provider)->toBe('GIPHY') + ->and($embeds->for('https://example.com/cat.gif', 'https://example.com/cat.gif'))->toBeNull(); + + config(['embeds.enabled' => false]); + + expect($embeds->for('https://giphy.com/gifs/cat-JIX9t2j0ZTN9S'))->toBeNull() + ->and($embeds->for('https://example.com/cat.gif', 'https://example.com/cat.gif'))->toBeNull(); +}); + it('embeds links to a provider', function (): void { expect(resolve(Embeds::class)->for('https://www.youtube.com/watch?v=dQw4w9WgXcQ')?->toArray())->toBe([ 'provider' => 'YouTube', From 5e4b2f8d13709a75bf1bba903604b54abd5e0eaa Mon Sep 17 00:00:00 2001 From: Jerry <99199491+devjerry0@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:55:36 -0400 Subject: [PATCH 06/25] feat: define safe GIF playback and autoplay policy --- resources/js/lib/gifs.ts | 72 ++++++++++++++++++++++ tests/Unit/GifPlayback.test.ts | 107 +++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 resources/js/lib/gifs.ts create mode 100644 tests/Unit/GifPlayback.test.ts diff --git a/resources/js/lib/gifs.ts b/resources/js/lib/gifs.ts new file mode 100644 index 000000000..bb4fca371 --- /dev/null +++ b/resources/js/lib/gifs.ts @@ -0,0 +1,72 @@ +const GIF_AUTOPLAY_HOSTS = new Set(['media.giphy.com']); + +function parseHttpsUrl(value: string): URL | null { + try { + const url = new URL(value); + + if ( + url.protocol !== 'https:' || + url.username !== '' || + url.password !== '' || + url.port !== '' + ) { + return null; + } + + return url; + } catch { + return null; + } +} + +export function isGifUrl(value: string): boolean { + return ( + parseHttpsUrl(value)?.pathname.toLowerCase().endsWith('.gif') ?? false + ); +} + +export function canAutoplayGif(value: string): boolean { + const url = parseHttpsUrl(value); + + return url !== null && GIF_AUTOPLAY_HOSTS.has(url.hostname); +} + +export const GIF_PLAYBACK_MODE = { + AUTOMATIC: 'auto', + PLAYING: 'play', + STOPPED: 'stop', +} as const; + +export type GifPlayback = + (typeof GIF_PLAYBACK_MODE)[keyof typeof GIF_PLAYBACK_MODE]; + +export function isGifPlaying({ + source, + playback, + isVisible, + reducedMotion, + isStatic, + hasFailed, +}: { + source: string; + playback: GifPlayback; + isVisible: boolean; + reducedMotion: boolean; + isStatic: boolean; + hasFailed: boolean; +}): boolean { + if ( + isStatic || + hasFailed || + !isVisible || + playback === GIF_PLAYBACK_MODE.STOPPED + ) { + return false; + } + + if (playback === GIF_PLAYBACK_MODE.PLAYING) { + return true; + } + + return !reducedMotion && canAutoplayGif(source); +} diff --git a/tests/Unit/GifPlayback.test.ts b/tests/Unit/GifPlayback.test.ts new file mode 100644 index 000000000..f29f1d439 --- /dev/null +++ b/tests/Unit/GifPlayback.test.ts @@ -0,0 +1,107 @@ +/// +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { canAutoplayGif, isGifPlaying, isGifUrl } from '../../resources/js/lib/gifs.ts'; + +const validGifUrls = [ + 'https://example.com/animation.gif', + 'https://example.com/animation.GIF?token=abc#frame', + 'https://media2.giphy.com/media/cat/giphy.gif', + 'https://example.com:443/cat.gif', +]; + +for (const url of validGifUrls) { + void test(`recognizes a direct HTTPS GIF link: ${url}`, () => { + assert.equal(isGifUrl(url), true); + }); +} + +const invalidGifUrls = [ + 'not a URL', + 'http://example.com/cat.gif', + 'data:image/gif;base64,R0lGODlh', + 'javascript:alert(1)', + 'https://example.com/page?image=cat.gif', + 'https://example.com/cat.gif/page', + 'https://example.com/cat.gif.html', + 'https://user:secret@example.com/cat.gif', + 'https://user@example.com/cat.gif', + 'https://:secret@example.com/cat.gif', + 'https://example.com:8443/cat.gif', +]; + +for (const url of invalidGifUrls) { + void test(`does not treat an unsupported link as a GIF: ${url}`, () => { + assert.equal(isGifUrl(url), false); + }); +} + +const playback = { + source: 'https://media.giphy.com/media/cat/giphy.gif', + playback: 'auto' as const, + isVisible: true, + reducedMotion: false, + isStatic: false, + hasFailed: false, +}; + +void test('autoplays visible GIFs and suspends them outside the viewport', () => { + assert.equal(isGifPlaying(playback), true); + assert.equal(isGifPlaying({ ...playback, isVisible: false }), false); +}); + +void test('reduced motion requires an explicit Play action', () => { + assert.equal(isGifPlaying({ ...playback, reducedMotion: true }), false); + assert.equal( + isGifPlaying({ ...playback, reducedMotion: true, playback: 'play' }), + true, + ); +}); + +void test('Stop survives leaving and reentering the viewport', () => { + assert.equal(isGifPlaying({ ...playback, playback: 'stop' }), false); + assert.equal( + isGifPlaying({ ...playback, playback: 'stop', isVisible: false }), + false, + ); + assert.equal(isGifPlaying({ ...playback, playback: 'stop' }), false); + assert.equal(isGifPlaying({ ...playback, playback: 'play' }), true); +}); + +void test('static cards and failed GIFs never animate, even after Play', () => { + assert.equal(isGifPlaying({ ...playback, isStatic: true }), false); + assert.equal( + isGifPlaying({ ...playback, playback: 'play', isStatic: true }), + false, + ); + assert.equal(isGifPlaying({ ...playback, hasFailed: true }), false); + assert.equal( + isGifPlaying({ ...playback, playback: 'play', hasFailed: true }), + false, + ); +}); + +void test('only canonical Giphy media can autoplay', () => { + assert.equal(canAutoplayGif('https://media.giphy.com/media/cat/giphy.gif'), true); + for (const source of [ + 'https://example.com/cat.gif', + 'https://media.giphy.com.attacker.example/cat.gif', + 'https://attacker.giphy.com/cat.gif', + 'http://media.giphy.com/media/cat/giphy.gif', + 'https://user:secret@media.giphy.com/media/cat/giphy.gif', + 'https://user@media.giphy.com/media/cat/giphy.gif', + 'https://:secret@media.giphy.com/media/cat/giphy.gif', + 'https://media.giphy.com:8443/media/cat/giphy.gif', + 'not a URL', + ]) { + assert.equal(canAutoplayGif(source), false); + } +}); + +void test('arbitrary GIF hosts require Play and Stop still takes precedence', () => { + const external = { ...playback, source: 'https://example.com/cat.gif' }; + assert.equal(isGifPlaying(external), false); + assert.equal(isGifPlaying({ ...external, playback: 'play' }), true); + assert.equal(isGifPlaying({ ...external, playback: 'stop' }), false); + assert.equal(isGifPlaying({ ...external, playback: 'play', isVisible: false }), false); +}); From 2ba852c3108888e6831d374dbd5a4053b0a9f1ec Mon Sep 17 00:00:00 2001 From: Jerry <99199491+devjerry0@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:55:39 -0400 Subject: [PATCH 07/25] feat: render external GIFs with accessible playback controls --- resources/js/components/gif-embed.tsx | 176 +++++++++++++++++++++++ resources/js/components/link-embed.tsx | 13 ++ resources/js/components/link-preview.tsx | 8 ++ resources/js/hooks/use-gif-playback.ts | 80 +++++++++++ resources/js/types/commit.ts | 2 +- tests/Unit/GifEmbed.test.tsx | 102 +++++++++++++ tests/draft-images.config.ts | 1 + tests/tsconfig.json | 5 +- 8 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 resources/js/components/gif-embed.tsx create mode 100644 resources/js/hooks/use-gif-playback.ts create mode 100644 tests/Unit/GifEmbed.test.tsx diff --git a/resources/js/components/gif-embed.tsx b/resources/js/components/gif-embed.tsx new file mode 100644 index 000000000..41184bba2 --- /dev/null +++ b/resources/js/components/gif-embed.tsx @@ -0,0 +1,176 @@ +import { Play, Square } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { useGifPlayback } from '@/hooks/use-gif-playback'; +import { canAutoplayGif } from '@/lib/gifs'; +import { hostname } from '@/lib/urls'; +import type { Embed, LinkPreview } from '@/types'; + +type GifEmbedProps = { + preview: LinkPreview; + embed?: Embed | null; + isStatic?: boolean; +}; + +type GifPlaybackState = ReturnType; + +type GifMediaProps = { + preview: LinkPreview; + embed: Embed | null; + playback: GifPlaybackState; +}; + +export function GifEmbed({ + preview, + embed = null, + isStatic = false, +}: GifEmbedProps) { + const container = useRef(null); + const playback = useGifPlayback({ embed, isStatic, container }); + + return ( +
event.stopPropagation()} + className="mt-3 overflow-hidden rounded-lg border" + data-test="gif-embed" + data-provider={embed?.provider ?? 'GIF'} + > +
+ + +
+ +
+ ); +} + +function GifMedia({ preview, embed, playback }: GifMediaProps) { + const [posterFailed, setPosterFailed] = useState(false); + + if (playback.isPlaying && embed) { + return ( + {preview.title + ); + } + + if ( + embed?.poster && + canAutoplayGif(embed.poster) && + !posterFailed && + !playback.hasFailed + ) { + return ( + setPosterFailed(true)} + className="absolute inset-0 size-full object-contain" + data-test="gif-poster" + /> + ); + } + + return ; +} + +function GifStatus({ preview, embed, playback }: GifMediaProps) { + return ( + + {playback.hasFailed && 'GIF could not load'} + {!playback.hasFailed && + embed && + (canAutoplayGif(embed.src) + ? 'GIF' + : 'External GIF — press Play to load')} + {!playback.hasFailed && + !embed && + preview.is_pending && + 'Loading GIF…'} + {!playback.hasFailed && + !embed && + !preview.is_pending && + 'GIF preview unavailable'} + + ); +} + +function GifPlaybackButton({ + embed, + playback, + isStatic, +}: { + embed: Embed | null; + playback: GifPlaybackState; + isStatic: boolean; +}) { + if (isStatic || !embed || playback.hasFailed) { + return null; + } + + const Icon = playback.isPlaying ? Square : Play; + const label = playback.isPlaying ? 'Stop' : 'Play'; + + return ( + + ); +} + +function GifSource({ preview, embed, isStatic }: GifEmbedProps) { + const details = ; + + if (isStatic) { + return
{details}
; + } + + return ( + + {details} + + ); +} + +function GifDetails({ preview, embed }: GifEmbedProps) { + return ( + <> + + {embed?.provider ?? hostname(preview.url)} + + + {preview.title ?? 'GIF'} + + + ); +} diff --git a/resources/js/components/link-embed.tsx b/resources/js/components/link-embed.tsx index 38b34eba3..942e34bfb 100644 --- a/resources/js/components/link-embed.tsx +++ b/resources/js/components/link-embed.tsx @@ -1,6 +1,7 @@ import { AudioLines, Code2, Play } from 'lucide-react'; import type { CSSProperties, ReactNode } from 'react'; import { useState } from 'react'; +import { GifEmbed } from '@/components/gif-embed'; import { cn } from '@/lib/utils'; import type { Embed, LinkPreview } from '@/types'; @@ -110,6 +111,18 @@ export function LinkEmbed({ isStatic?: boolean; }) { const [isPlaying, setIsPlaying] = useState(false); + + if (embed.kind === 'gif') { + return ( + + ); + } + const Icon = icons[embed.kind]; const label = `Play ${preview.title ?? preview.url} from ${embed.provider}`; const isVideo = embed.aspect_ratio !== null; diff --git a/resources/js/components/link-preview.tsx b/resources/js/components/link-preview.tsx index 513987a24..700848f6d 100644 --- a/resources/js/components/link-preview.tsx +++ b/resources/js/components/link-preview.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react'; +import { GifEmbed } from '@/components/gif-embed'; import { LinkEmbed } from '@/components/link-embed'; import { Skeleton } from '@/components/ui/skeleton'; +import { isGifUrl } from '@/lib/gifs'; import { hostname } from '@/lib/urls'; import type { LinkPreview as LinkPreviewData } from '@/types'; @@ -129,6 +131,12 @@ export function LinkPreview({ ); } + if (isGifUrl(preview.url)) { + return ( + + ); + } + if (isWaiting) { return ; } diff --git a/resources/js/hooks/use-gif-playback.ts b/resources/js/hooks/use-gif-playback.ts new file mode 100644 index 000000000..4be38359f --- /dev/null +++ b/resources/js/hooks/use-gif-playback.ts @@ -0,0 +1,80 @@ +import type { RefObject } from 'react'; +import { useEffect, useState } from 'react'; +import { useMediaQuery } from '@/hooks/use-media-query'; +import { GIF_PLAYBACK_MODE, isGifPlaying, type GifPlayback } from '@/lib/gifs'; +import type { Embed } from '@/types'; + +const MIN_VISIBLE_RATIO = 0.1; + +function useGifVisibility( + container: RefObject, +): boolean { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const element = container.current; + + if (!element) { + return; + } + + if (typeof IntersectionObserver === 'undefined') { + const frame = requestAnimationFrame(() => setIsVisible(true)); + + return () => cancelAnimationFrame(frame); + } + + const observer = new IntersectionObserver( + ([entry]) => + setIsVisible( + entry.isIntersecting && + entry.intersectionRatio >= MIN_VISIBLE_RATIO, + ), + { threshold: MIN_VISIBLE_RATIO }, + ); + observer.observe(element); + + return () => observer.disconnect(); + }, [container]); + + return isVisible; +} + +export function useGifPlayback({ + embed, + isStatic, + container, +}: { + embed: Embed | null; + isStatic: boolean; + container: RefObject; +}) { + const isVisible = useGifVisibility(container); + const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + const [playback, setPlayback] = useState( + GIF_PLAYBACK_MODE.AUTOMATIC, + ); + const [hasFailed, setHasFailed] = useState(false); + const isPlaying = + embed !== null && + isGifPlaying({ + source: embed.src, + playback, + isVisible, + reducedMotion, + isStatic, + hasFailed, + }); + + return { + isPlaying, + hasFailed, + onError: () => setHasFailed(true), + onToggle: () => + setPlayback( + isPlaying + ? GIF_PLAYBACK_MODE.STOPPED + : GIF_PLAYBACK_MODE.PLAYING, + ), + }; +} diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 82d06dae1..e83515c41 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -2,7 +2,7 @@ import type { User } from '@/types/auth'; export type CommitStatus = 'pending' | 'approved' | 'rejected'; -export type EmbedKind = 'video' | 'audio' | 'code'; +export type EmbedKind = 'video' | 'audio' | 'code' | 'gif'; export type Embed = { provider: string; diff --git a/tests/Unit/GifEmbed.test.tsx b/tests/Unit/GifEmbed.test.tsx new file mode 100644 index 000000000..a5c900d5d --- /dev/null +++ b/tests/Unit/GifEmbed.test.tsx @@ -0,0 +1,102 @@ +/// +import assert from 'node:assert/strict'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { test } from 'vite-plus/test'; +import { GifEmbed } from '../../resources/js/components/gif-embed'; +import { LinkEmbed } from '../../resources/js/components/link-embed'; +import type { Embed, LinkPreview } from '../../resources/js/types/commit'; + +const preview: LinkPreview = { + url: 'https://example.com/cat.gif', + title: null, + description: null, + image_url: 'https://example.com/untrusted-thumbnail.gif', + site_name: null, + is_pending: false, +}; + +const embed: Embed = { + provider: 'GIF', + kind: 'gif', + src: 'https://example.com/cat.gif', + aspect_ratio: '1 / 1', + height: null, + poster: null, +}; + +test('waits until hydration and visibility before requesting animated content', () => { + const html = renderToStaticMarkup( + , + ); + + assert.ok(html.includes('data-test="gif-embed"')); + assert.ok(html.includes('aspect-square')); + assert.ok(html.includes('aria-label="Play GIF"')); + assert.ok(html.includes('href="https://example.com/cat.gif"')); + assert.ok(!html.includes(' { + const pending = renderToStaticMarkup( + , + ); + const verified = renderToStaticMarkup( + , + ); + const box = /class="[^"]*aspect-square[^"]*"/; + + assert.equal(pending.match(box)?.[0], verified.match(box)?.[0]); + assert.ok(pending.includes('Loading GIF')); + assert.ok(!pending.includes(' { + const html = renderToStaticMarkup(); + + assert.ok(html.includes('GIF preview unavailable')); + assert.ok(html.includes('href="https://example.com/cat.gif"')); + assert.ok(!html.includes(' { + const html = renderToStaticMarkup( + , + ); + + assert.ok(html.includes('aspect-square')); + assert.ok(!html.includes(' { + const html = renderToStaticMarkup( + , + ); + + assert.ok(html.includes('data-test="gif-poster"')); + assert.ok(html.includes('referrerPolicy="no-referrer"')); + assert.ok(!html.includes('data-test="gif-animation"')); + assert.ok(!html.includes('untrusted-thumbnail')); +}); + +test('arbitrary GIF hosts have no automatic thumbnail or image request', () => { + const html = renderToStaticMarkup( + , + ); + + assert.ok(html.includes('aria-label="Play GIF"')); + assert.ok(html.includes('External GIF')); + assert.ok(!html.includes(' Date: Tue, 20 Oct 2026 13:55:41 -0400 Subject: [PATCH 08/25] test: automate GIF security mutation checks in CI --- .github/workflows/tests.yml | 3 + composer.json | 6 +- package.json | 3 +- tests/Mutation/GifSecurity.test.ts | 145 +++++++++++++++++++++++ tests/Mutation/php-gif-mutations.ts | 164 +++++++++++++++++++++++++++ tests/Mutation/playback-mutations.ts | 81 +++++++++++++ tests/tsconfig.json | 3 +- 7 files changed, 401 insertions(+), 4 deletions(-) create mode 100644 tests/Mutation/GifSecurity.test.ts create mode 100644 tests/Mutation/php-gif-mutations.ts create mode 100644 tests/Mutation/playback-mutations.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d2a1d9c1..4fe0759e4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -146,5 +146,8 @@ jobs: if: steps.build-cache.outputs.cache-hit != 'true' run: bun run build + - name: GIF Security Mutations + run: composer test:mutations + - name: Tests run: composer test:unit diff --git a/composer.json b/composer.json index c53117727..313cbce01 100644 --- a/composer.json +++ b/composer.json @@ -113,8 +113,10 @@ "@test:lint", "@test:types", "@test:type-coverage", - "@test:unit" - ] + "@test:unit", + "@test:mutations" + ], + "test:mutations": "bun run test:mutations" }, "extra": { "laravel": { diff --git a/package.json b/package.json index f45b0f962..93b8581de 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint --fix", "test:lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt --check resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint", "test:types": "tsc --noEmit && tsc --noEmit --project tests/tsconfig.json", - "test:unit": "node --experimental-strip-types --test tests/Unit/*.test.ts" + "test:unit": "node --experimental-strip-types --test tests/Unit/*.test.ts", + "test:mutations": "node --experimental-strip-types --test tests/Mutation/*.test.ts" }, "dependencies": { "@daypicker/react": "^10.0.2", diff --git a/tests/Mutation/GifSecurity.test.ts b/tests/Mutation/GifSecurity.test.ts new file mode 100644 index 000000000..464bae595 --- /dev/null +++ b/tests/Mutation/GifSecurity.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import type { SpawnSyncReturns } from 'node:child_process'; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { phpGifMutations } from './php-gif-mutations.ts'; +import type { GifMutation } from './php-gif-mutations.ts'; +import { playbackMutations } from './playback-mutations.ts'; + +const root = fileURLToPath(new URL('../..', import.meta.url)); +const temporary = mkdtempSync(join(tmpdir(), 'devnull-gif-mutations-')); +const mutationTarget = 'PEST_MUTATION_TESTING'; +const mutationSource = 'PEST_MUTATION_FILE'; +const timeoutMilliseconds = 60_000; +const controls = new Set(); + +function runPhp( + mutation: GifMutation, + source: string, +): SpawnSyncReturns { + const modifiedFile = join(temporary, 'mutated.php'); + writeFileSync(modifiedFile, source); + + return spawnSync( + 'php', + [ + 'vendor/bin/pest', + '--ci', + '--compact', + '--colors=never', + mutation.test, + ], + { + cwd: root, + encoding: 'utf8', + timeout: timeoutMilliseconds, + env: { + ...process.env, + [mutationTarget]: join(root, mutation.source), + [mutationSource]: modifiedFile, + }, + }, + ); +} + +function runPlayback( + mutation: GifMutation, + source: string, +): SpawnSyncReturns { + const modifiedFile = join(temporary, mutation.source); + const testFile = join(temporary, mutation.test); + mkdirSync(dirname(modifiedFile), { recursive: true }); + mkdirSync(dirname(testFile), { recursive: true }); + writeFileSync(join(temporary, 'package.json'), '{"type":"module"}'); + writeFileSync(modifiedFile, source); + writeFileSync(testFile, readFileSync(join(root, mutation.test))); + const environment = { ...process.env }; + delete environment.NODE_TEST_CONTEXT; + + return spawnSync( + process.execPath, + ['--experimental-strip-types', '--test', testFile], + { + encoding: 'utf8', + timeout: timeoutMilliseconds, + env: environment, + }, + ); +} + +function output(result: SpawnSyncReturns): string { + assert.ifError(result.error); + assert.notEqual(result.status, null, 'Mutation process did not finish'); + + return result.stdout + result.stderr; +} + +type Runner = ( + mutation: GifMutation, + source: string, +) => SpawnSyncReturns; + +function verifyControl( + mutation: GifMutation, + source: string, + run: Runner, +): void { + if (controls.has(mutation.source)) { + return; + } + + const control = run(mutation, source); + const report = output(control); + assert.equal(control.status, 0, report); + assert.match( + report, + run === runPhp ? /Tests:\s+.*\bpassed\b/ : /\bpass [1-9]\d*/, + report, + ); + controls.add(mutation.source); +} + +function checkMutation(mutation: GifMutation): void { + const original = readFileSync(join(root, mutation.source), 'utf8'); + const run = mutation.source.endsWith('.php') ? runPhp : runPlayback; + verifyControl(mutation, original, run); + assert.ok( + original.includes(mutation.before), + `Mutation no longer matches ${mutation.source}`, + ); + const result = run( + mutation, + original.replace(mutation.before, mutation.after), + ); + const report = output(result); + assert.notEqual( + result.status, + 0, + `Mutation survived: ${mutation.name}\n${report}`, + ); + assert.match( + report, + run === runPhp ? /Tests:\s+.*\bfailed\b/ : /ERR_ASSERTION/, + report, + ); +} + +await test('GIF security mutation gate', async (context) => { + context.after(() => rmSync(temporary, { recursive: true, force: true })); + + for (const mutation of [...phpGifMutations, ...playbackMutations]) { + await context.test(`detects mutation: ${mutation.name}`, () => + checkMutation(mutation), + ); + } +}); diff --git a/tests/Mutation/php-gif-mutations.ts b/tests/Mutation/php-gif-mutations.ts new file mode 100644 index 000000000..b962485cb --- /dev/null +++ b/tests/Mutation/php-gif-mutations.ts @@ -0,0 +1,164 @@ +export type GifMutation = { + name: string; + source: string; + test: string; + before: string; + after: string; +}; + +export const phpGifMutations: GifMutation[] = [ + { + name: 'redirect limit cannot increase', + source: 'app/Actions/UpdateLinkPreview.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'MAX_REDIRECTS = 3', + after: 'MAX_REDIRECTS = 4', + }, + { + name: 'redirect limit cannot decrease', + source: 'app/Actions/UpdateLinkPreview.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'MAX_REDIRECTS = 3', + after: 'MAX_REDIRECTS = 2', + }, + { + name: 'redirect cycles stop before refetching', + source: 'app/Actions/UpdateLinkPreview.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'if (isset($visited[$source]))', + after: 'if (false)', + }, + { + name: 'non-default ports are rejected', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'if ($url->getPort() !== null)', + after: 'if (false)', + }, + { + name: 'non-global addresses are rejected', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) !== false', + after: 'return true', + }, + { + name: 'Symfony special-use subnets stay blocked', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: '...IpUtils::PRIVATE_SUBNETS,', + after: '', + }, + { + name: 'additional relay ranges stay blocked', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: "'192.88.99.0/24',", + after: '', + }, + { + name: 'all DNS answers must be public', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'array_all($addresses, $this->isPublic(...))', + after: 'true', + }, + { + name: 'response body is capped', + source: 'app/Services/LimitedResponseBody.php', + test: 'tests/Unit/Services/LimitedResponseBodyTest.php', + before: '$remaining = max(0, $this->maxBytes - $this->tell());', + after: '$remaining = PHP_INT_MAX;', + }, + { + name: 'seeking cannot bypass the byte cap', + source: 'app/Services/LimitedResponseBody.php', + test: 'tests/Unit/Services/LimitedResponseBodyTest.php', + before: 'max(0, $this->maxBytes - $this->tell())', + after: 'max(-1, $this->maxBytes - $this->tell())', + }, + { + name: 'overall deadline stays at five seconds', + source: 'app/Services/LinkPreviewBudget.php', + test: 'tests/Unit/Services/LinkPreviewBudgetTest.php', + before: 'TIMEOUT_SECONDS = 5', + after: 'TIMEOUT_SECONDS = 6', + }, + { + name: 'remaining timeout never rounds upward', + source: 'app/Services/LinkPreviewBudget.php', + test: 'tests/Unit/Services/LinkPreviewBudgetTest.php', + before: 'floor(($deadline - $this->seconds())', + after: 'round(($deadline - $this->seconds())', + }, + { + name: 'automatic HTTP redirects stay disabled', + source: 'app/Services/LinkPreviewHttpClient.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: '->withoutRedirecting()', + after: '->withOptions([RequestOptions::ALLOW_REDIRECTS => true])', + }, + { + name: 'automatic decompression stays disabled', + source: 'app/Services/LinkPreviewHttpClient.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'RequestOptions::DECODE_CONTENT => false', + after: 'RequestOptions::DECODE_CONTENT => true', + }, + { + name: 'requests pin the checked address', + source: 'app/Services/LinkPreviewHttpClient.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'CURLOPT_CONNECT_TO => [$connection]', + after: 'CURLOPT_CONNECT_TO => []', + }, + { + name: 'content type is checked', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'if ($mediaType !== self::CONTENT_TYPE)', + after: 'if (false)', + }, + { + name: 'both supported GIF signatures remain valid', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: "SIGNATURES = ['GIF87a', 'GIF89a']", + after: "SIGNATURES = ['GIF89a']", + }, + { + name: 'truncated GIF descriptors are rejected', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'MIN_HEADER_BYTES = 13', + after: 'MIN_HEADER_BYTES = 12', + }, + { + name: 'GIF dimensions must be nonzero', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'return $width !== self::ZERO_DIMENSION && $height !== self::ZERO_DIMENSION;', + after: 'return true;', + }, + { + name: 'oversized URLs stay rejected', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'MAX_URL_LENGTH = 2048', + after: 'MAX_URL_LENGTH = 2049', + }, + { + name: 'maximum-length URLs remain supported', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'MAX_URL_LENGTH = 2048', + after: 'MAX_URL_LENGTH = 2047', + }, + { + name: 'Giphy hosts cannot be spoofed', + source: 'app/Services/EmbedProviders/Giphy.php', + test: 'tests/Unit/Services/EmbedProviders/GiphyTest.php', + before: 'if (! in_array($url->getAsciiHost(), self::SUPPORTED_HOSTS, true))', + after: 'if (false)', + }, +]; diff --git a/tests/Mutation/playback-mutations.ts b/tests/Mutation/playback-mutations.ts new file mode 100644 index 000000000..48ed82a62 --- /dev/null +++ b/tests/Mutation/playback-mutations.ts @@ -0,0 +1,81 @@ +import type { GifMutation } from './php-gif-mutations.ts'; + +export const playbackMutations: GifMutation[] = [ + { + name: 'autoplay arbitrary hosts', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: 'GIF_AUTOPLAY_HOSTS.has(url.hostname)', + after: 'true', + }, + { + name: 'ignore reduced motion', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: '!reducedMotion && canAutoplayGif(source)', + after: 'canAutoplayGif(source)', + }, + { + name: 'ignore visibility', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: ' !isVisible ||\n', + after: '', + }, + { + name: 'ignore static cards', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: ' isStatic ||\n', + after: '', + }, + { + name: 'ignore failed loads', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: ' hasFailed ||\n', + after: '', + }, + { + name: 'ignore Stop', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: ' playback === GIF_PLAYBACK_MODE.STOPPED', + after: ' false', + }, + { + name: 'deny explicit Play', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: 'playback === GIF_PLAYBACK_MODE.PLAYING', + after: 'false', + }, + { + name: 'allow HTTP', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: " url.protocol !== 'https:' ||\n", + after: '', + }, + { + name: 'allow username', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: " url.username !== '' ||\n", + after: '', + }, + { + name: 'allow password', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: " url.password !== '' ||\n", + after: '', + }, + { + name: 'allow non-default port', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: " url.password !== '' ||\n url.port !== ''", + after: " url.password !== ''", + }, +]; diff --git a/tests/tsconfig.json b/tests/tsconfig.json index ab7c8f13b..d4fa9eb6e 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -18,6 +18,7 @@ "Unit/UseNow.test.tsx", "Unit/GifEmbed.test.tsx", "../resources/js/types/*.d.ts", - "draft-images.config.ts" + "draft-images.config.ts", + "Mutation/**/*.ts" ] } From 162ef7c96b9f40b4b4f88012975cfac48be16f68 Mon Sep 17 00:00:00 2001 From: Jerry <99199491+devjerry0@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:55:44 -0400 Subject: [PATCH 09/25] docs: explain external GIF support and security limits --- KNOWLEDGE_BASE.md | 2 ++ README.md | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index af8ddb6a3..302f3047c 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -225,6 +225,8 @@ URLs and bare domains in commits and replies become clickable links. Long URLs a Supported links can also show click-to-play embedded players: YouTube, Vimeo, Loom, Twitch, Spotify, SoundCloud, Apple Music, Suno, CodePen, and asciinema. Only the link that gets the card can show a player, so choosing no card means no player. Embeds depend on server configuration and the linked URL being supported. Third-party player content loads only after you press play; those providers have their own privacy policies. This is embedding a link, not uploading a video or audio file to /dev/null. +Giphy GIF share links and public HTTPS links ending in `.gif` can show animated GIFs in the selected link card, including in replies and remotes. They load from the external host and have **Play** and **Stop** controls. Only the canonical Giphy media host autoplays while visible; other hosts wait for **Play** before the browser requests the image. With reduced motion enabled they wait for **Play**; static preview cards do not animate. Direct GIFs must pass the link unfurler's public-address, redirect, content-type, and GIF-header checks first. It retains at most 32 response-body bytes for this check, stops redirect cycles and missing locations immediately, and shares one five-second HTTP budget across redirects. Synchronous DNS lookups cannot be interrupted by that budget. The original host still controls the browser's later response and file size. Failed previews keep a link to the source. There is no GIF search, API key, file upload, conversion, or hosted GIF storage. Administrators can enable only these embeds with `EMBEDS_PROVIDERS=giphy,gif`, or disable them with the existing embed controls. Previously fetched direct GIF links need refetching before they can animate; Giphy share links work without a backfill. + Sources: [link rendering](resources/js/components/linkified-text.tsx), [link-card selection](app/Http/Requests/CreateCommitRequest.php), [embed configuration](config/embeds.php), [embedded player](resources/js/components/link-embed.tsx). ### Replies and threads diff --git a/README.md b/README.md index fcc948e5b..88315d9c6 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,14 @@ A link card for a video, song or pen becomes a player that loads when clicked. T | `apple_music` | albums, playlists and songs | | `codepen` | pens | | `asciinema` | recordings | +| `giphy` | `giphy.com/gifs/{id-or-slug-id}` and `giphy.com/embed/{id}` | +| `gif` | Public HTTPS links whose path ends in `.gif` (case-insensitive) | + +GIFs load from their external host, with **Play** and **Stop** controls. Only the canonical Giphy media host autoplays while visible; other hosts require **Play** before the browser requests the image. Reduced-motion users press **Play** to start them. There is no GIF search, provider API key, upload, conversion, or hosted copy. Giphy share links use its public media URLs; direct GIF links are verified by the existing protected link unfurler before playback. It requests the first 32 bytes, caps the response body retained for verification at 32 bytes even if the host ignores the range, and stores only the final URL. Network chunks and response headers can exceed that body cap. Redirect cycles and missing locations stop immediately; the redirect chain shares a five-second HTTP budget, including time spent resolving addresses, although the synchronous DNS lookup itself cannot be interrupted by that budget. External hosts can change their content or redirect later browser requests; this verification does not impose a browser download-size limit. Previously fetched direct GIF links need refetching to gain this metadata; this change does not backfill them. + +GIF embeds use the existing embed JSON fields with `kind: "gif"`; their `src` is an image URL, not an iframe URL. Clients that do not support this kind can keep showing the source link. + +Run `composer test:mutations` to check selected GIF security mutations across the PHP unfurler and browser playback policy. The suite first verifies unchanged-source controls, then requires each weakened protection to cause assertion failures. It runs automatically in the tests workflow; it is a focused regression gate, not an exhaustive mutation score. | Variable | Default | | |---|---|---| From a7f1fa65eeffd201caca9c3c198024f569abd1cc Mon Sep 17 00:00:00 2001 From: Jerry <99199491+devjerry0@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:32:20 -0400 Subject: [PATCH 10/25] chore: separate GIF tests from upstream test additions --- tests/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tsconfig.json b/tests/tsconfig.json index d4fa9eb6e..a0de95d84 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -8,6 +8,7 @@ }, "include": [ "Unit/**/*.test.ts", + "Unit/GifEmbed.test.tsx", "Unit/CommitPhoto.test.tsx", "Unit/DraftStash.test.tsx", "Unit/ComposerDraft.test.tsx", @@ -16,7 +17,6 @@ "Unit/ImageAttachments.test.tsx", "Unit/StagedRail.test.tsx", "Unit/UseNow.test.tsx", - "Unit/GifEmbed.test.tsx", "../resources/js/types/*.d.ts", "draft-images.config.ts", "Mutation/**/*.ts" From 37cb6d3b581b011037c1f25e8ef0e130c6b54634 Mon Sep 17 00:00:00 2001 From: Jerry <99199491+devjerry0@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:01:30 -0400 Subject: [PATCH 11/25] docs: keep GIF embed instructions concise --- README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 88315d9c6..ef88cc757 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,9 @@ A link card for a video, song or pen becomes a player that loads when clicked. T | `codepen` | pens | | `asciinema` | recordings | | `giphy` | `giphy.com/gifs/{id-or-slug-id}` and `giphy.com/embed/{id}` | -| `gif` | Public HTTPS links whose path ends in `.gif` (case-insensitive) | +| `gif` | Public HTTPS `.gif` links | -GIFs load from their external host, with **Play** and **Stop** controls. Only the canonical Giphy media host autoplays while visible; other hosts require **Play** before the browser requests the image. Reduced-motion users press **Play** to start them. There is no GIF search, provider API key, upload, conversion, or hosted copy. Giphy share links use its public media URLs; direct GIF links are verified by the existing protected link unfurler before playback. It requests the first 32 bytes, caps the response body retained for verification at 32 bytes even if the host ignores the range, and stores only the final URL. Network chunks and response headers can exceed that body cap. Redirect cycles and missing locations stop immediately; the redirect chain shares a five-second HTTP budget, including time spent resolving addresses, although the synchronous DNS lookup itself cannot be interrupted by that budget. External hosts can change their content or redirect later browser requests; this verification does not impose a browser download-size limit. Previously fetched direct GIF links need refetching to gain this metadata; this change does not backfill them. - -GIF embeds use the existing embed JSON fields with `kind: "gif"`; their `src` is an image URL, not an iframe URL. Clients that do not support this kind can keep showing the source link. - -Run `composer test:mutations` to check selected GIF security mutations across the PHP unfurler and browser playback policy. The suite first verifies unchanged-source controls, then requires each weakened protection to cause assertion failures. It runs automatically in the tests workflow; it is a focused regression gate, not an exhaustive mutation score. +GIFs load from their external host with Play and Stop controls. Giphy autoplays while visible; other hosts and reduced-motion users press Play to start playback. | Variable | Default | | |---|---|---| From 1c8925497ca0c850afb97ef003d8d4dcf23e5a28 Mon Sep 17 00:00:00 2001 From: Jerry <99199491+devjerry0@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:06:33 -0400 Subject: [PATCH 12/25] style: keep GIF playback controls small and unobtrusive --- resources/js/components/gif-embed.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/resources/js/components/gif-embed.tsx b/resources/js/components/gif-embed.tsx index 41184bba2..0bcf29254 100644 --- a/resources/js/components/gif-embed.tsx +++ b/resources/js/components/gif-embed.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'; import { useGifPlayback } from '@/hooks/use-gif-playback'; import { canAutoplayGif } from '@/lib/gifs'; import { hostname } from '@/lib/urls'; +import { cn } from '@/lib/utils'; import type { Embed, LinkPreview } from '@/types'; type GifEmbedProps = { @@ -36,7 +37,7 @@ export function GifEmbed({ data-test="gif-embed" data-provider={embed?.provider ?? 'GIF'} > -
+
- - {label} + ); } From 40e510e63e5253a807503e1a727f10fd7c5a85e6 Mon Sep 17 00:00:00 2001 From: Jerry <99199491+devjerry0@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:19:52 -0400 Subject: [PATCH 13/25] fix: autoplay verified external GIFs while visible --- KNOWLEDGE_BASE.md | 2 +- README.md | 2 +- resources/js/components/gif-embed.tsx | 10 +++------- resources/js/lib/gifs.ts | 8 ++++---- tests/Mutation/playback-mutations.ts | 10 +++++----- tests/Unit/GifEmbed.test.tsx | 4 ++-- tests/Unit/GifPlayback.test.ts | 17 +++++++++++------ 7 files changed, 27 insertions(+), 26 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 9f257e9eb..d7a0fefd8 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -231,7 +231,7 @@ URLs and bare domains in commits and replies become clickable links. Long URLs a Supported links can also show click-to-play embedded players: YouTube, Vimeo, Loom, Twitch, Spotify, SoundCloud, Apple Music, Suno, CodePen, and asciinema. Only the link that gets the card can show a player, so choosing no card means no player. Embeds depend on server configuration and the linked URL being supported. Third-party player content loads only after you press play; those providers have their own privacy policies. This is embedding a link, not uploading a video or audio file to /dev/null. -Giphy GIF share links and public HTTPS links ending in `.gif` can show animated GIFs in the selected link card, including in replies and remotes. They load from the external host and have **Play** and **Stop** controls. Only the canonical Giphy media host autoplays while visible; other hosts wait for **Play** before the browser requests the image. With reduced motion enabled they wait for **Play**; static preview cards do not animate. Direct GIFs must pass the link unfurler's public-address, redirect, content-type, and GIF-header checks first. It retains at most 32 response-body bytes for this check, stops redirect cycles and missing locations immediately, and shares one five-second HTTP budget across redirects. Synchronous DNS lookups cannot be interrupted by that budget. The original host still controls the browser's later response and file size. Failed previews keep a link to the source. There is no GIF search, API key, file upload, conversion, or hosted GIF storage. Administrators can enable only these embeds with `EMBEDS_PROVIDERS=giphy,gif`, or disable them with the existing embed controls. Previously fetched direct GIF links need refetching before they can animate; Giphy share links work without a backfill. +Giphy GIF share links and public HTTPS links ending in `.gif` can show animated GIFs in the selected link card, including in replies and remotes. They load from the external host and have **Play** and **Stop** controls. Verified GIFs autoplay while visible and unload outside the viewport. Only the canonical Giphy media host can supply a static poster before playback. With reduced motion enabled they wait for **Play**; static preview cards do not animate. Direct GIFs must pass the link unfurler's public-address, redirect, content-type, and GIF-header checks first. It retains at most 32 response-body bytes for this check, stops redirect cycles and missing locations immediately, and shares one five-second HTTP budget across redirects. Synchronous DNS lookups cannot be interrupted by that budget. The original host still controls the browser's later response and file size. Failed previews keep a link to the source. There is no GIF search, API key, file upload, conversion, or hosted GIF storage. Administrators can enable only these embeds with `EMBEDS_PROVIDERS=giphy,gif`, or disable them with the existing embed controls. Previously fetched direct GIF links need refetching before they can animate; Giphy share links work without a backfill. Sources: [link rendering](resources/js/components/linkified-text.tsx), [link-card selection](app/Http/Requests/CreateCommitRequest.php), [embed configuration](config/embeds.php), [embedded player](resources/js/components/link-embed.tsx). diff --git a/README.md b/README.md index ef88cc757..e2edb6e57 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ A link card for a video, song or pen becomes a player that loads when clicked. T | `giphy` | `giphy.com/gifs/{id-or-slug-id}` and `giphy.com/embed/{id}` | | `gif` | Public HTTPS `.gif` links | -GIFs load from their external host with Play and Stop controls. Giphy autoplays while visible; other hosts and reduced-motion users press Play to start playback. +GIFs load from their external host with Play and Stop controls. Verified GIFs autoplay while visible; reduced-motion users press Play to start playback. | Variable | Default | | |---|---|---| diff --git a/resources/js/components/gif-embed.tsx b/resources/js/components/gif-embed.tsx index 0bcf29254..de79ab3a2 100644 --- a/resources/js/components/gif-embed.tsx +++ b/resources/js/components/gif-embed.tsx @@ -2,7 +2,7 @@ import { Play, Square } from 'lucide-react'; import { useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { useGifPlayback } from '@/hooks/use-gif-playback'; -import { canAutoplayGif } from '@/lib/gifs'; +import { canLoadGifPoster } from '@/lib/gifs'; import { hostname } from '@/lib/urls'; import { cn } from '@/lib/utils'; import type { Embed, LinkPreview } from '@/types'; @@ -68,7 +68,7 @@ function GifMedia({ preview, embed, playback }: GifMediaProps) { if ( embed?.poster && - canAutoplayGif(embed.poster) && + canLoadGifPoster(embed.poster) && !posterFailed && !playback.hasFailed ) { @@ -95,11 +95,7 @@ function GifStatus({ preview, embed, playback }: GifMediaProps) { role={playback.hasFailed ? 'status' : undefined} > {playback.hasFailed && 'GIF could not load'} - {!playback.hasFailed && - embed && - (canAutoplayGif(embed.src) - ? 'GIF' - : 'External GIF — press Play to load')} + {!playback.hasFailed && embed && 'GIF'} {!playback.hasFailed && !embed && preview.is_pending && diff --git a/resources/js/lib/gifs.ts b/resources/js/lib/gifs.ts index bb4fca371..46611fe8e 100644 --- a/resources/js/lib/gifs.ts +++ b/resources/js/lib/gifs.ts @@ -1,4 +1,4 @@ -const GIF_AUTOPLAY_HOSTS = new Set(['media.giphy.com']); +const GIF_POSTER_HOSTS = new Set(['media.giphy.com']); function parseHttpsUrl(value: string): URL | null { try { @@ -25,10 +25,10 @@ export function isGifUrl(value: string): boolean { ); } -export function canAutoplayGif(value: string): boolean { +export function canLoadGifPoster(value: string): boolean { const url = parseHttpsUrl(value); - return url !== null && GIF_AUTOPLAY_HOSTS.has(url.hostname); + return url !== null && GIF_POSTER_HOSTS.has(url.hostname); } export const GIF_PLAYBACK_MODE = { @@ -68,5 +68,5 @@ export function isGifPlaying({ return true; } - return !reducedMotion && canAutoplayGif(source); + return !reducedMotion && isGifUrl(source); } diff --git a/tests/Mutation/playback-mutations.ts b/tests/Mutation/playback-mutations.ts index 48ed82a62..92cea825c 100644 --- a/tests/Mutation/playback-mutations.ts +++ b/tests/Mutation/playback-mutations.ts @@ -2,18 +2,18 @@ import type { GifMutation } from './php-gif-mutations.ts'; export const playbackMutations: GifMutation[] = [ { - name: 'autoplay arbitrary hosts', + name: 'autoplay unsupported URLs', source: 'resources/js/lib/gifs.ts', test: 'tests/Unit/GifPlayback.test.ts', - before: 'GIF_AUTOPLAY_HOSTS.has(url.hostname)', - after: 'true', + before: '!reducedMotion && isGifUrl(source)', + after: '!reducedMotion', }, { name: 'ignore reduced motion', source: 'resources/js/lib/gifs.ts', test: 'tests/Unit/GifPlayback.test.ts', - before: '!reducedMotion && canAutoplayGif(source)', - after: 'canAutoplayGif(source)', + before: '!reducedMotion && isGifUrl(source)', + after: 'isGifUrl(source)', }, { name: 'ignore visibility', diff --git a/tests/Unit/GifEmbed.test.tsx b/tests/Unit/GifEmbed.test.tsx index a5c900d5d..ed7e9e36e 100644 --- a/tests/Unit/GifEmbed.test.tsx +++ b/tests/Unit/GifEmbed.test.tsx @@ -90,13 +90,13 @@ test('only the explicit static poster can load before playback', () => { assert.ok(!html.includes('untrusted-thumbnail')); }); -test('arbitrary GIF hosts have no automatic thumbnail or image request', () => { +test('external GIFs do not load unverified posters before visibility', () => { const html = renderToStaticMarkup( , ); assert.ok(html.includes('aria-label="Play GIF"')); - assert.ok(html.includes('External GIF')); + assert.ok(!html.includes('press Play to load')); assert.ok(!html.includes(' import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { canAutoplayGif, isGifPlaying, isGifUrl } from '../../resources/js/lib/gifs.ts'; +import { canLoadGifPoster, isGifPlaying, isGifUrl } from '../../resources/js/lib/gifs.ts'; const validGifUrls = [ 'https://example.com/animation.gif', @@ -13,6 +13,7 @@ const validGifUrls = [ for (const url of validGifUrls) { void test(`recognizes a direct HTTPS GIF link: ${url}`, () => { assert.equal(isGifUrl(url), true); + assert.equal(isGifPlaying({ ...playback, source: url }), true); }); } @@ -33,6 +34,7 @@ const invalidGifUrls = [ for (const url of invalidGifUrls) { void test(`does not treat an unsupported link as a GIF: ${url}`, () => { assert.equal(isGifUrl(url), false); + assert.equal(isGifPlaying({ ...playback, source: url }), false); }); } @@ -81,8 +83,8 @@ void test('static cards and failed GIFs never animate, even after Play', () => { ); }); -void test('only canonical Giphy media can autoplay', () => { - assert.equal(canAutoplayGif('https://media.giphy.com/media/cat/giphy.gif'), true); +void test('only canonical Giphy media can supply a static poster', () => { + assert.equal(canLoadGifPoster('https://media.giphy.com/media/cat/giphy.gif'), true); for (const source of [ 'https://example.com/cat.gif', 'https://media.giphy.com.attacker.example/cat.gif', @@ -94,13 +96,16 @@ void test('only canonical Giphy media can autoplay', () => { 'https://media.giphy.com:8443/media/cat/giphy.gif', 'not a URL', ]) { - assert.equal(canAutoplayGif(source), false); + assert.equal(canLoadGifPoster(source), false); } }); -void test('arbitrary GIF hosts require Play and Stop still takes precedence', () => { +void test('external GIFs autoplay while visible and respect playback preferences', () => { const external = { ...playback, source: 'https://example.com/cat.gif' }; - assert.equal(isGifPlaying(external), false); + assert.equal(isGifPlaying(external), true); + assert.equal(isGifPlaying({ ...external, isVisible: false }), false); + assert.equal(isGifPlaying({ ...external, reducedMotion: true }), false); + assert.equal(isGifPlaying({ ...external, reducedMotion: true, playback: 'play' }), true); assert.equal(isGifPlaying({ ...external, playback: 'play' }), true); assert.equal(isGifPlaying({ ...external, playback: 'stop' }), false); assert.equal(isGifPlaying({ ...external, playback: 'play', isVisible: false }), false); From a2f9d82377b44388014f1540370c1bd152907033 Mon Sep 17 00:00:00 2001 From: Jerry <99199491+devjerry0@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:24:02 -0400 Subject: [PATCH 14/25] fix: retain GIF rendering alongside upstream embed resizing --- resources/js/components/link-embed.tsx | 85 ++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 11 deletions(-) diff --git a/resources/js/components/link-embed.tsx b/resources/js/components/link-embed.tsx index 942e34bfb..9c62d44f5 100644 --- a/resources/js/components/link-embed.tsx +++ b/resources/js/components/link-embed.tsx @@ -1,9 +1,9 @@ import { AudioLines, Code2, Play } from 'lucide-react'; import type { CSSProperties, ReactNode } from 'react'; -import { useState } from 'react'; -import { GifEmbed } from '@/components/gif-embed'; +import { useEffect, useRef, useState } from 'react'; import { cn } from '@/lib/utils'; import type { Embed, LinkPreview } from '@/types'; +import { GifEmbed } from './gif-embed'; const icons = { video: Play, audio: AudioLines, code: Code2 }; @@ -21,6 +21,75 @@ function frameStyle(embed: Embed): CSSProperties { : { aspectRatio: embed.aspect_ratio }; } +function isAsciinemaRecording(embed: Embed): boolean { + return ( + embed.provider === 'asciinema' && + /^https:\/\/asciinema\.org\/a\/\w+\/iframe(?:[?#].*)?$/.test(embed.src) + ); +} + +function isAsciinemaBodySize( + data: unknown, +): data is { type: 'bodySize'; payload: { height: number } } { + return ( + typeof data === 'object' && + data !== null && + 'type' in data && + data.type === 'bodySize' && + 'payload' in data && + typeof data.payload === 'object' && + data.payload !== null && + 'height' in data.payload && + typeof data.payload.height === 'number' && + Number.isFinite(data.payload.height) && + data.payload.height > 0 + ); +} + +export function EmbedFrame({ embed, title }: { embed: Embed; title: string }) { + const iframeRef = useRef(null); + const recording = isAsciinemaRecording(embed); + const [height, setHeight] = useState(embed.height ?? 420); + + useEffect(() => { + if (!recording) { + return; + } + + const iframe = iframeRef.current; + const resize = (event: MessageEvent) => { + if ( + event.origin !== 'https://asciinema.org' || + event.source !== iframe?.contentWindow || + !isAsciinemaBodySize(event.data) + ) { + return; + } + + setHeight(event.data.payload.height); + }; + + window.addEventListener('message', resize); + + return () => window.removeEventListener('message', resize); + }, [recording]); + + return ( +