From ac04556239b73a927f2a70f8a48c3672958b5ed4 Mon Sep 17 00:00:00 2001 From: devjerry0 <99199491+devjerry0@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:10:59 -0400 Subject: [PATCH 1/2] feat: verify external GIF link previews Verify GIF headers through the existing pinned unfurler and retain only the final public HTTPS URL. Bound GIF responses to 32 bytes. Codex-Thread: 01a15b08-d4ad-7e72-8725-b3ea1fde71ce --- app/Actions/UpdateLinkPreview.php | 45 ++++++- app/Models/LinkPreview.php | 2 + .../LinkPreviews/LimitedResponseBody.php | 25 ++++ .../PreviewLinkProviders/GifPreviewLink.php | 86 ++++++++++++ ...000_add_gif_url_to_link_previews_table.php | 17 +++ tests/Fixtures/link-preview-http.php | 42 ++++++ .../Actions/UpdateLinkPreviewHttpTest.php | 44 ++++++ tests/Unit/Actions/UpdateLinkPreviewTest.php | 125 ++++++++++++++++++ .../LinkPreviews/LimitedResponseBodyTest.php | 39 ++++++ 9 files changed, 418 insertions(+), 7 deletions(-) create mode 100644 app/Services/LinkPreviews/LimitedResponseBody.php create mode 100644 app/Services/PreviewLinkProviders/GifPreviewLink.php create mode 100644 database/migrations/2026_10_20_120000_add_gif_url_to_link_previews_table.php create mode 100644 tests/Fixtures/link-preview-http.php create mode 100644 tests/Unit/Actions/UpdateLinkPreviewHttpTest.php create mode 100644 tests/Unit/Services/LinkPreviews/LimitedResponseBodyTest.php diff --git a/app/Actions/UpdateLinkPreview.php b/app/Actions/UpdateLinkPreview.php index 7f2258163..b925b1ed2 100644 --- a/app/Actions/UpdateLinkPreview.php +++ b/app/Actions/UpdateLinkPreview.php @@ -6,9 +6,12 @@ use App\Contracts\Services\DnsResolver; use App\Models\LinkPreview; +use App\Services\LinkPreviews\LimitedResponseBody; +use App\Services\PreviewLinkProviders\GifPreviewLink; use Dom\HTMLDocument; use GuzzleHttp\Exception\ResponseException; use GuzzleHttp\Psr7\Utils; +use GuzzleHttp\RequestOptions; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -17,7 +20,7 @@ use Uri\WhatWg\Url; /** - * @phpstan-type Metadata array{title: string|null, description: string|null, image_url: string|null, site_name: string|null} + * @phpstan-type Metadata array{title: string|null, description: string|null, image_url: string|null, site_name: string|null, gif_url: string|null} */ final readonly class UpdateLinkPreview { @@ -59,9 +62,10 @@ 'description' => null, 'image_url' => null, 'site_name' => null, + 'gif_url' => null, ]; - public function __construct(private DnsResolver $dns) + public function __construct(private DnsResolver $dns, private GifPreviewLink $gifs) { // } @@ -84,21 +88,30 @@ public function handle(LinkPreview $linkPreview): void private function fetchMetadata(string $url): array { $page = Url::parse($url); + $isGif = $this->gifs->isGifUrl($page); for ($hop = 0; $hop <= self::MAX_REDIRECTS && $page instanceof Url; $hop++) { + if ($isGif && ($page->getScheme() !== 'https' || $page->getPort() !== null)) { + return self::NO_METADATA; + } + $address = $this->publicAddress($page); if ($address === null) { return self::NO_METADATA; } - $response = $this->get($page, $address); + $response = $this->get($page, $address, $isGif); if ($response->serverError() || $response->tooManyRequests()) { $response->throw(); } if (! $response->redirect()) { + if ($isGif && $response->successful()) { + return $this->gifMetadata($response, $page); + } + return $response->successful() && $this->isHtml($response) ? $this->parse($this->body($response), $page) : self::NO_METADATA; @@ -141,12 +154,12 @@ private function isPublic(string $address): bool * (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 get(Url $url, string $address, bool $isGif): Response { $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') + ->accept($isGif ? GifPreviewLink::CONTENT_TYPE : 'text/html') ->withHeaders(['Accept-Language' => 'en-US,en;q=0.9']) ->connectTimeout(3) ->timeout(5) @@ -159,6 +172,11 @@ private function get(Url $url, string $address): Response ], ]); + if ($isGif) { + $request->withHeaders(['Range' => sprintf('bytes=0-%d', GifPreviewLink::HEADER_BYTES - 1)]) + ->withOptions([RequestOptions::SINK => new LimitedResponseBody(GifPreviewLink::HEADER_BYTES)]); + } + try { return $request->get($url->toAsciiString()); } catch (ConnectionException $connectionException) { @@ -172,6 +190,18 @@ private function get(Url $url, string $address): Response } } + /** + * @return Metadata + */ + private function gifMetadata(Response $response, Url $page): array + { + return [...self::NO_METADATA, 'gif_url' => $this->gifs->getVerifiedUrl( + $page, + $response->header('Content-Type'), + $this->body($response, GifPreviewLink::HEADER_BYTES), + )]; + } + private function isHtml(Response $response): bool { $mediaType = mb_strtolower(mb_trim(Str::before($response->header('Content-Type'), ';'))); @@ -179,12 +209,12 @@ private function isHtml(Response $response): bool return in_array($mediaType, ['text/html', 'application/xhtml+xml'], true); } - private function body(Response $response): string + private function body(Response $response, int $limit = self::MAX_BODY_BYTES): string { $body = $response->toPsrResponse()->getBody(); $body->rewind(); - return Utils::copyToString($body, self::MAX_BODY_BYTES); + return Utils::copyToString($body, $limit); } /** @@ -205,6 +235,7 @@ private function parse(string $html, Url $page): array '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), + 'gif_url' => null, ]; } diff --git a/app/Models/LinkPreview.php b/app/Models/LinkPreview.php index cda016627..ccf22ed44 100644 --- a/app/Models/LinkPreview.php +++ b/app/Models/LinkPreview.php @@ -20,6 +20,7 @@ * @property-read string|null $description * @property-read string|null $image_url * @property-read string|null $site_name + * @property-read string|null $gif_url * @property-read CarbonInterface|null $fetched_at * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at @@ -51,6 +52,7 @@ public function casts(): array 'description' => 'string', 'image_url' => 'string', 'site_name' => 'string', + 'gif_url' => 'string', 'fetched_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', diff --git a/app/Services/LinkPreviews/LimitedResponseBody.php b/app/Services/LinkPreviews/LimitedResponseBody.php new file mode 100644 index 000000000..dfdd09e3e --- /dev/null +++ b/app/Services/LinkPreviews/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/PreviewLinkProviders/GifPreviewLink.php b/app/Services/PreviewLinkProviders/GifPreviewLink.php new file mode 100644 index 000000000..bd182cbc9 --- /dev/null +++ b/app/Services/PreviewLinkProviders/GifPreviewLink.php @@ -0,0 +1,86 @@ +getScheme() !== 'https') { + return false; + } + + return str_ends_with(mb_strtolower($url->getPath()), '.gif'); + } + + public function getVerifiedUrl(Url $url, string $contentType, string $header): ?string + { + $mediaType = mb_strtolower(mb_trim(Str::before($contentType, ';'))); + + if ($mediaType !== self::CONTENT_TYPE) { + return null; + } + + $sourceUrl = $url->toAsciiString(); + + if (mb_strlen($sourceUrl) > Urls::MAX_LENGTH) { + return null; + } + + if (! $this->isValidHeader($header)) { + return null; + } + + return $sourceUrl; + } + + private function isValidHeader(string $header): bool + { + if (mb_strlen($header, self::BINARY_ENCODING) < self::MIN_HEADER_BYTES) { + return false; + } + + return $this->hasValidSignature($header) && $this->hasValidDimensions($header); + } + + private function hasValidSignature(string $header): bool + { + $signature = mb_substr($header, 0, self::SIGNATURE_BYTES, self::BINARY_ENCODING); + + return in_array($signature, self::SIGNATURES, true); + } + + private function hasValidDimensions(string $header): bool + { + $width = mb_substr($header, self::WIDTH_OFFSET, self::DIMENSION_BYTES, self::BINARY_ENCODING); + $height = mb_substr($header, self::HEIGHT_OFFSET, self::DIMENSION_BYTES, self::BINARY_ENCODING); + + return $width !== self::ZERO_DIMENSION && $height !== self::ZERO_DIMENSION; + } +} diff --git a/database/migrations/2026_10_20_120000_add_gif_url_to_link_previews_table.php b/database/migrations/2026_10_20_120000_add_gif_url_to_link_previews_table.php new file mode 100644 index 000000000..d730d21d9 --- /dev/null +++ b/database/migrations/2026_10_20_120000_add_gif_url_to_link_previews_table.php @@ -0,0 +1,17 @@ +string('gif_url', 2048)->nullable(); + }); + } +}; diff --git a/tests/Fixtures/link-preview-http.php b/tests/Fixtures/link-preview-http.php new file mode 100644 index 000000000..641748cfd --- /dev/null +++ b/tests/Fixtures/link-preview-http.php @@ -0,0 +1,42 @@ +setTimeout(5)->start(); + + try { + $server->waitUntil( + static fn (string $type, string $output): bool => $type === Process::OUT && mb_trim($output) !== '', + ); + $port = (int) mb_trim($server->getOutput()); + throw_if($port === 0, RuntimeException::class, 'GIF fixture did not report a port.'); + Http::allowStrayRequests(["http://example.test:{$port}/*"]); + + $response = new ReflectionMethod(UpdateLinkPreview::class, 'get')->invoke( + resolve(UpdateLinkPreview::class), + Url::parse("http://example.test:{$port}/cat.gif"), + '127.0.0.1', + true, + ); + assert($response instanceof Response); + + expect($response->status())->toBe(200) + ->and($response->header('Content-Type'))->toBe('image/gif') + ->and($response->body())->toHaveLength(32) + ->and($response->body())->toBe("GIF89a\x01\x00\x01\x00\x80\x00\x00".str_repeat('x', 19)) + ->and(file_get_contents($requestLog))->toContain("Range: bytes=0-31\r\n"); + } finally { + $server->stop(); + File::delete($requestLog); + } +}); diff --git a/tests/Unit/Actions/UpdateLinkPreviewTest.php b/tests/Unit/Actions/UpdateLinkPreviewTest.php index 6c1c7537d..b2e36b1ea 100644 --- a/tests/Unit/Actions/UpdateLinkPreviewTest.php +++ b/tests/Unit/Actions/UpdateLinkPreviewTest.php @@ -6,6 +6,7 @@ use App\Contracts\Services\DnsResolver; use App\Models\LinkPreview; use App\Services\DnsResolvers\SystemDnsResolver; +use App\Services\LinkPreviews\LimitedResponseBody; use GuzzleHttp\Exception\ResponseException; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; @@ -32,6 +33,119 @@ 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', + 'HTTP port on HTTPS' => 'https://www.example.com:80/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(); +}); + +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]]); + function unfurl(string $url): LinkPreview { $linkPreview = LinkPreview::factory()->create(['url' => $url]); @@ -430,3 +544,14 @@ 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('only requests GIFs using normalized HTTPS ports', function (string $url, bool $accepted): void { + Http::fake(['*' => Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 200, ['Content-Type' => 'image/gif'])]); + + expect(unfurl($url)->gif_url)->toBe($accepted ? 'https://example.com/cat.gif' : null); + Http::assertSentCount($accepted ? 1 : 0); +})->with([ + 'default HTTPS' => ['https://example.com:443/cat.gif', true], + 'HTTP port' => ['https://example.com:80/cat.gif', false], + 'custom port' => ['https://example.com:8443/cat.gif', false], +]); diff --git a/tests/Unit/Services/LinkPreviews/LimitedResponseBodyTest.php b/tests/Unit/Services/LinkPreviews/LimitedResponseBodyTest.php new file mode 100644 index 000000000..eabdb26f0 --- /dev/null +++ b/tests/Unit/Services/LinkPreviews/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); +}); From 6fc1d6781fa3a8490460e0813f9f75ed33ba7b74 Mon Sep 17 00:00:00 2001 From: devjerry0 <99199491+devjerry0@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:11:08 -0400 Subject: [PATCH 2/2] feat: render Giphy and verified GIFs inline Expose GIF embeds with visible-only playback, reduced-motion controls and bounded manual retries. Codex-Thread: 01a15b08-d4ad-7e72-8725-b3ea1fde71ce --- README.md | 2 + app/Dto/Embed.php | 7 +- app/Enums/EmbedKind.php | 1 + app/Models/LinkPreview.php | 2 +- app/Services/EmbedProviders/Giphy.php | 75 +++++ app/Services/Embeds.php | 12 +- config/embeds.php | 3 + resources/js/components/gif-embed.tsx | 233 +++++++++++++++ resources/js/components/link-embed.tsx | 13 + resources/js/components/link-preview.tsx | 8 + resources/js/hooks/use-gif-playback.ts | 102 +++++++ resources/js/lib/gifs.ts | 158 ++++++++++ resources/js/types/commit.ts | 2 +- .../Controllers/Api/CommitControllerTest.php | 20 ++ tests/Unit/GifEmbed.test.tsx | 102 +++++++ tests/Unit/GifEmbedPlayback.test.tsx | 270 ++++++++++++++++++ tests/Unit/GifPlayback.test.ts | 226 +++++++++++++++ tests/Unit/GifPlaybackHook.test.tsx | 181 ++++++++++++ tests/Unit/Models/LinkPreviewTest.php | 14 + .../Services/EmbedProviders/GiphyTest.php | 44 +++ tests/Unit/Services/EmbedsTest.php | 30 ++ tests/draft-images.config.ts | 3 + tests/tsconfig.json | 3 + 23 files changed, 1505 insertions(+), 6 deletions(-) create mode 100644 app/Services/EmbedProviders/Giphy.php create mode 100644 resources/js/components/gif-embed.tsx create mode 100644 resources/js/hooks/use-gif-playback.ts create mode 100644 resources/js/lib/gifs.ts create mode 100644 tests/Unit/GifEmbed.test.tsx create mode 100644 tests/Unit/GifEmbedPlayback.test.tsx create mode 100644 tests/Unit/GifPlayback.test.ts create mode 100644 tests/Unit/GifPlaybackHook.test.tsx create mode 100644 tests/Unit/Services/EmbedProviders/GiphyTest.php diff --git a/README.md b/README.md index 10c3fb906..43eac3769 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ composer setup composer dev ``` +Giphy share links and verified public HTTPS `.gif` links play inline from their external host. GIFs autoplay while visible; reduced-motion users press Play. + Run `composer test` before opening a pull request. ## License diff --git a/app/Dto/Embed.php b/app/Dto/Embed.php index a5c6ef6a6..b221a40f6 100644 --- a/app/Dto/Embed.php +++ b/app/Dto/Embed.php @@ -24,8 +24,6 @@ private function __construct( } /** - * The poster stands in for the player until it is played, for when the link's own preview has no image. - * * @param array $query */ public static function video(string $provider, string $src, array $query = [], string $aspectRatio = '16 / 9', ?string $poster = null): self @@ -41,6 +39,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, null, 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/Models/LinkPreview.php b/app/Models/LinkPreview.php index ccf22ed44..3a5b564d0 100644 --- a/app/Models/LinkPreview.php +++ b/app/Models/LinkPreview.php @@ -64,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/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/app/Services/Embeds.php b/app/Services/Embeds.php index 9b2847713..bb805b5d5 100644 --- a/app/Services/Embeds.php +++ b/app/Services/Embeds.php @@ -11,12 +11,14 @@ final readonly class Embeds { + private const string GIF_PROVIDER = 'GIF'; + public function __construct(private Container $container) { // } - public function for(string $url): ?Embed + public function for(string $url, ?string $gifUrl = null): ?Embed { $parsed = Url::parse($url); @@ -32,7 +34,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(self::GIF_PROVIDER, $gifUrl); } /** diff --git a/config/embeds.php b/config/embeds.php index c9349bc0c..bbc3b3d9d 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; @@ -17,9 +18,11 @@ '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' => [ + 'giphy' => Giphy::class, 'youtube' => YouTube::class, 'vimeo' => Vimeo::class, 'loom' => Loom::class, diff --git a/resources/js/components/gif-embed.tsx b/resources/js/components/gif-embed.tsx new file mode 100644 index 000000000..552f16022 --- /dev/null +++ b/resources/js/components/gif-embed.tsx @@ -0,0 +1,233 @@ +import { Play, RotateCcw, Square } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { useGifPlayback } from '@/hooks/use-gif-playback'; +import { canLoadGifPoster } from '@/lib/gifs'; +import { hostname } from '@/lib/urls'; +import { cn } from '@/lib/utils'; +import type { Embed, LinkPreview } from '@/types'; + +const GIF_LABEL = 'GIF'; + +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_LABEL} + > +
+ + +
+ +
+ ); +} + +function GifMedia({ preview, embed, playback }: GifMediaProps) { + return ( + <> + {/* Retry posters only for new media or a manual retry. */} + + {playback.isPlaying && embed && ( + + )} + + ); +} + +function GifAnimation({ + preview, + embed, + playback, +}: GifMediaProps & { embed: Embed }) { + const [isLoaded, setIsLoaded] = useState(false); + + return ( + {preview.title setIsLoaded(true)} + onError={playback.onError} + className={cn( + 'absolute inset-0 size-full bg-muted object-contain', + !isLoaded && 'invisible', + )} + data-test="gif-animation" + /> + ); +} + +function GifFallback({ preview, embed, playback }: GifMediaProps) { + const [posterFailed, setPosterFailed] = useState(false); + + if ( + embed?.poster && + canLoadGifPoster(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 ( + + {gifStatusMessage(preview, embed, playback.hasFailed)} + + ); +} + +function gifStatusMessage( + preview: LinkPreview, + embed: Embed | null, + hasFailed: boolean, +) { + if (hasFailed) { + return 'GIF could not load'; + } + + if (embed) { + return GIF_LABEL; + } + + if (preview.is_pending) { + return 'Loading GIF…'; + } + + return 'GIF preview unavailable'; +} + +function GifPlaybackButton({ + embed, + playback, + isStatic, +}: { + embed: Embed | null; + playback: GifPlaybackState; + isStatic: boolean; +}) { + if (isStatic || !embed || (playback.hasFailed && !playback.canRetry)) { + return null; + } + + const { Icon, label, onClick } = gifPlaybackControl(playback); + + return ( + + ); +} + +function gifPlaybackControl(playback: GifPlaybackState) { + if (playback.hasFailed) { + return { Icon: RotateCcw, label: 'Retry', onClick: playback.onRetry }; + } + + return { + Icon: playback.isPlaying ? Square : Play, + label: playback.isPlaying ? 'Stop' : 'Play', + onClick: playback.onToggle, + }; +} + +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_LABEL} + + + ); +} diff --git a/resources/js/components/link-embed.tsx b/resources/js/components/link-embed.tsx index cd19198ba..a8af6c897 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 { useEffect, useRef, useState } from 'react'; +import { GifEmbed } from '@/components/gif-embed'; import { cn } from '@/lib/utils'; import type { Embed, LinkPreview } from '@/types'; @@ -179,6 +180,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..d5d66c2b4 --- /dev/null +++ b/resources/js/hooks/use-gif-playback.ts @@ -0,0 +1,102 @@ +import type { RefObject } from 'react'; +import { useEffect, useState } from 'react'; +import { useMediaQuery } from '@/hooks/use-media-query'; +import { + canRetryGifLoad, + failGifLoad, + GIF_PLAYBACK_MODE, + hasGifLoadFailed, + isGifPlaying, + retryGifLoad, + syncGifLoadRetryState, + toggleGifPlayback, + type GifLoadRetryState, +} 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 source = embed?.src ?? null; + const [retryState, setRetryState] = useState({ + source, + failedSource: null, + retryCount: 0, + playback: GIF_PLAYBACK_MODE.AUTOMATIC, + }); + const currentRetryState = syncGifLoadRetryState(retryState, source); + + if (currentRetryState !== retryState) { + setRetryState(currentRetryState); + } + + const hasFailed = hasGifLoadFailed(currentRetryState, source); + const canRetry = canRetryGifLoad(currentRetryState, source); + const isPlaying = + embed !== null && + isGifPlaying({ + source: embed.src, + playback: currentRetryState.playback, + isVisible, + reducedMotion, + isStatic, + hasFailed, + }); + + return { + isPlaying, + hasFailed, + canRetry, + retryCount: currentRetryState.retryCount, + onError: () => setRetryState((state) => failGifLoad(state, source)), + onRetry: () => setRetryState((state) => retryGifLoad(state, source)), + onToggle: () => + setRetryState((state) => + toggleGifPlayback(state, source, isPlaying), + ), + }; +} diff --git a/resources/js/lib/gifs.ts b/resources/js/lib/gifs.ts new file mode 100644 index 000000000..00138046c --- /dev/null +++ b/resources/js/lib/gifs.ts @@ -0,0 +1,158 @@ +const GIF_POSTER_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 canLoadGifPoster(value: string): boolean { + const url = parseHttpsUrl(value); + + return url !== null && GIF_POSTER_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 const MAX_GIF_RETRIES = 2; + +export type GifLoadRetryState = { + source: string | null; + failedSource: string | null; + retryCount: number; + playback: GifPlayback; +}; + +export function syncGifLoadRetryState( + state: GifLoadRetryState, + source: string | null, +): GifLoadRetryState { + if (state.source === source) { + return state; + } + + return { + source, + failedSource: null, + retryCount: 0, + playback: state.playback, + }; +} + +export function hasGifLoadFailed( + state: GifLoadRetryState, + source: string | null, +): boolean { + return source !== null && state.failedSource === source; +} + +export function canRetryGifLoad( + state: GifLoadRetryState, + source: string | null, +): boolean { + return ( + hasGifLoadFailed(state, source) && state.retryCount < MAX_GIF_RETRIES + ); +} + +export function failGifLoad( + state: GifLoadRetryState, + source: string | null, +): GifLoadRetryState { + if (state.source !== source || source === null) { + return state; + } + + return { ...state, failedSource: source }; +} + +export function retryGifLoad( + state: GifLoadRetryState, + source: string | null, +): GifLoadRetryState { + if (!canRetryGifLoad(state, source)) { + return state; + } + + return { + ...state, + failedSource: null, + retryCount: state.retryCount + 1, + playback: GIF_PLAYBACK_MODE.PLAYING, + }; +} + +export function toggleGifPlayback( + state: GifLoadRetryState, + source: string | null, + isPlaying: boolean, +): GifLoadRetryState { + if (state.source !== source) { + return state; + } + + return { + ...state, + playback: isPlaying + ? GIF_PLAYBACK_MODE.STOPPED + : GIF_PLAYBACK_MODE.PLAYING, + }; +} + +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 || + parseHttpsUrl(source) === null + ) { + return false; + } + + if (playback === GIF_PLAYBACK_MODE.PLAYING) { + return true; + } + + return !reducedMotion; +} diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 8af6a4e68..532390c3a 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/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index 1d9199f10..0ddece0e3 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -128,6 +128,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' => null, + '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/GifEmbed.test.tsx b/tests/Unit/GifEmbed.test.tsx new file mode 100644 index 000000000..19d98aeab --- /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: null, + 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('external GIFs do not load unverified posters before visibility', () => { + const html = renderToStaticMarkup( + , + ); + + assert.ok(html.includes('aria-label="Play GIF"')); + assert.ok(!html.includes('press Play to load')); + assert.ok(!html.includes(' +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ImgHTMLAttributes, ReactElement, ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterAll, afterEach, test, vi } from 'vite-plus/test'; +import type { Embed, LinkPreview } from '../../resources/js/types/commit'; + +const playback = { + isPlaying: true, + hasFailed: false, + canRetry: false, + retryCount: 0, + onError: () => {}, + onToggle: () => {}, + onRetry: vi.fn(), +}; + +let states: unknown[] = []; +let stateIndex = 0; + +vi.doMock('react', () => ({ + ...React, + useRef: (initial: unknown) => ({ current: initial }), + useState: (initial: unknown) => { + const index = stateIndex++; + states[index] ??= typeof initial === 'function' ? initial() : initial; + + return [ + states[index], + (value: unknown) => { + states[index] = + typeof value === 'function' ? value(states[index]) : value; + }, + ]; + }, +})); + +vi.doMock('../../resources/js/hooks/use-gif-playback', () => ({ + useGifPlayback: () => playback, +})); + +const { GifEmbed } = await import('../../resources/js/components/gif-embed'); + +afterAll(() => vi.doUnmock('../../resources/js/hooks/use-gif-playback')); +afterEach(() => { + playback.isPlaying = true; + playback.hasFailed = false; + playback.canRetry = false; + playback.retryCount = 0; + playback.onRetry.mockClear(); + states = []; + stateIndex = 0; +}); + +const preview: LinkPreview = { + url: 'https://giphy.com/gifs/cat', + title: null, + description: null, + image_url: null, + site_name: null, + is_pending: false, +}; + +const embed: Embed = { + provider: 'GIPHY', + kind: 'gif', + src: 'https://media.giphy.com/media/cat/giphy.gif', + poster: 'https://media.giphy.com/media/cat/giphy_s.gif', + aspect_ratio: null, + height: null, +}; + +function render(current: Embed = embed): string { + startRender(); + + return renderToStaticMarkup(); +} + +function startRender() { + stateIndex = 0; +} + +function startPlaybackSession() { + states = []; + startRender(); +} + +function animation( + current: Embed = embed, +): ReactElement> { + const content = mediaContent(current); + const image = React.Children.toArray( + content.props.children, + )[1] as ReactElement<{ + preview: LinkPreview; + embed: Embed; + playback: typeof playback; + }>; + + return ( + image.type as ( + props: typeof image.props, + ) => ReactElement> + )(image.props); +} + +function frame(current: Embed = embed): ReactElement<{ children: ReactNode }> { + startRender(); + const card = GifEmbed({ preview, embed: current }) as ReactElement<{ + children: ReactNode; + }>; + return React.Children.toArray(card.props.children)[0] as ReactElement<{ + children: ReactNode; + }>; +} + +function mediaContent( + current: Embed = embed, +): ReactElement<{ children: ReactNode }> { + const media = React.Children.toArray( + frame(current).props.children, + )[0] as ReactElement<{ + preview: LinkPreview; + embed: Embed; + playback: typeof playback; + }>; + + return ( + media.type as (props: typeof media.props) => ReactElement<{ + children: ReactNode; + }> + )(media.props); +} + +test('keeps the static poster underneath an animation that has not loaded', () => { + const html = render(); + + assert.ok(html.includes('data-test="gif-poster"')); + assert.match( + html, + /]*class="[^"]*invisible[^>]*data-test="gif-animation"/, + ); +}); + +test('keeps the direct GIF placeholder while its animation loads', () => { + const html = render({ ...embed, poster: null }); + + assert.match(html, /]*data-test="gif-animation"/, + ); +}); + +test('shows a loaded animation and starts a fresh playback session hidden', () => { + const loading = animation(); + + assert.match(String(loading.props.className), /invisible/); + loading.props.onLoad?.({} as React.SyntheticEvent); + + const loaded = animation(); + + assert.doesNotMatch(String(loaded.props.className), /invisible/); + + startPlaybackSession(); + + const restarted = animation({ ...embed, src: `${embed.src}?restart=1` }); + + assert.match(String(restarted.props.className), /invisible/); +}); + +test('Stop and offscreen states unload the animation and retain the poster', () => { + playback.isPlaying = false; + const html = render(); + + assert.ok(html.includes('data-test="gif-poster"')); + assert.ok(!html.includes('data-test="gif-animation"')); + assert.ok(html.includes('aria-label="Play GIF"')); +}); + +test('a failed animation retains the card and reports its failure', () => { + playback.isPlaying = false; + playback.hasFailed = true; + const html = render(); + + assert.ok(html.includes('aspect-square')); + assert.ok(html.includes('GIF could not load')); + assert.ok(!html.includes(' { + playback.isPlaying = false; + playback.hasFailed = true; + playback.canRetry = true; + const html = render(); + + assert.ok(html.includes('GIF could not load')); + assert.ok(html.includes('aria-label="Retry GIF"')); + assert.ok(!html.includes('opacity-0')); + assert.ok(!html.includes('data-test="gif-animation"')); +}); + +test('the retry control invokes retry rather than toggling playback', () => { + playback.isPlaying = false; + playback.hasFailed = true; + playback.canRetry = true; + const control = React.Children.toArray( + frame().props.children, + )[1] as ReactElement<{ + embed: Embed; + playback: typeof playback; + isStatic: boolean; + }>; + const button = ( + control.type as (props: typeof control.props) => ReactElement<{ + onClick: () => void; + }> + )(control.props); + button.props.onClick(); + + assert.equal(playback.onRetry.mock.calls.length, 1); +}); + +test('retry keeps the verified animation and poster URLs unchanged', () => { + playback.retryCount = 1; + const html = render(); + + assert.ok(html.includes(`src="${embed.src}"`)); + assert.ok(html.includes(`src="${embed.poster}"`)); +}); + +test('poster failures survive Stop and visibility changes but reset on manual retry or new media', () => { + playback.isPlaying = false; + const fallback = React.Children.toArray( + mediaContent().props.children, + )[0] as ReactElement<{ + preview: LinkPreview; + embed: Embed; + playback: typeof playback; + }>; + const poster = ( + fallback.type as ( + props: typeof fallback.props, + ) => ReactElement> + )(fallback.props); + poster.props.onError?.({} as React.SyntheticEvent); + + assert.ok(!render().includes('data-test="gif-poster"')); + playback.isPlaying = true; + assert.ok(!render().includes('data-test="gif-poster"')); + const unchanged = React.Children.toArray( + mediaContent().props.children, + )[0] as ReactElement; + assert.equal(unchanged.key, fallback.key); + + playback.retryCount = 1; + const retried = React.Children.toArray( + mediaContent().props.children, + )[0] as ReactElement; + assert.notEqual(retried.key, fallback.key); + const changed = React.Children.toArray( + mediaContent({ + ...embed, + poster: 'https://media.giphy.com/media/dog/giphy_s.gif', + }).props.children, + )[0] as ReactElement; + assert.notEqual(changed.key, retried.key); +}); diff --git a/tests/Unit/GifPlayback.test.ts b/tests/Unit/GifPlayback.test.ts new file mode 100644 index 000000000..dba5398cf --- /dev/null +++ b/tests/Unit/GifPlayback.test.ts @@ -0,0 +1,226 @@ +/// +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + canLoadGifPoster, + canRetryGifLoad, + failGifLoad, + GIF_PLAYBACK_MODE, + hasGifLoadFailed, + isGifPlaying, + isGifUrl, + MAX_GIF_RETRIES, + retryGifLoad, + syncGifLoadRetryState, + type GifLoadRetryState, +} 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); + assert.equal(isGifPlaying({ ...playback, source: 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('autoplays a server-verified GIF after an extensionless redirect', () => { + const source = 'https://www.example.com/media?id=cat'; + + assert.equal(isGifUrl(source), false); + assert.equal(isGifPlaying({ ...playback, source }), true); +}); + +for (const source of [ + 'not a URL', + 'http://example.com/media?id=cat', + 'https://user:secret@example.com/media?id=cat', + 'https://example.com:8443/media?id=cat', +]) { + void test(`rejects an unsafe verified GIF source in every playback mode: ${source}`, () => { + for (const mode of Object.values(GIF_PLAYBACK_MODE)) { + assert.equal( + isGifPlaying({ ...playback, source, playback: mode }), + 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 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', + '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(canLoadGifPoster(source), false); + } +}); + +void test('external GIFs autoplay while visible and respect playback preferences', () => { + const external = { ...playback, source: 'https://example.com/cat.gif' }; + 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, + ); +}); + +void test('a failed GIF stays stopped until the user retries it', () => { + const source = 'https://example.com/cat.gif'; + const failed = failGifLoad( + { source, failedSource: null, retryCount: 0, playback: 'auto' }, + source, + ); + + assert.equal(hasGifLoadFailed(failed, source), true); + assert.equal(isGifPlaying({ ...playback, source, hasFailed: true }), false); + assert.equal(canRetryGifLoad(failed, source), true); + + const retried = retryGifLoad(failed, source); + assert.equal(hasGifLoadFailed(retried, source), false); + assert.equal(retried.retryCount, 1); + assert.equal(retried.playback, 'play'); +}); + +void test('a new GIF source gets a fresh retry budget without changing playback preference', () => { + const source = 'https://example.com/cat.gif'; + const replacement = 'https://example.com/dog.gif'; + const failed = failGifLoad( + { + source, + failedSource: null, + retryCount: MAX_GIF_RETRIES, + playback: 'stop', + }, + source, + ); + + const replacementState = syncGifLoadRetryState(failed, replacement); + assert.equal(hasGifLoadFailed(replacementState, replacement), false); + assert.equal(replacementState.retryCount, 0); + assert.equal(replacementState.playback, 'stop'); + assert.equal(canRetryGifLoad(replacementState, replacement), false); + + const failedReplacement = failGifLoad(replacementState, replacement); + assert.equal(canRetryGifLoad(failedReplacement, replacement), true); + + const originalState = syncGifLoadRetryState(failedReplacement, source); + assert.equal(hasGifLoadFailed(originalState, source), false); + assert.equal(originalState.retryCount, 0); + assert.equal(canRetryGifLoad(originalState, source), false); +}); + +void test('GIF retries are bounded and stale failures cannot affect a replacement source', () => { + const source = 'https://example.com/cat.gif'; + const replacement = 'https://example.com/dog.gif'; + let state: GifLoadRetryState = { + source, + failedSource: null, + retryCount: 0, + playback: 'auto' as const, + }; + + for (let retry = 0; retry < MAX_GIF_RETRIES; retry++) { + state = failGifLoad(state, source); + assert.equal(canRetryGifLoad(state, source), true); + state = retryGifLoad(state, source); + } + + state = failGifLoad(state, source); + assert.equal(hasGifLoadFailed(state, source), true); + assert.equal(canRetryGifLoad(state, source), false); + assert.equal(retryGifLoad(state, source), state); + + const active = syncGifLoadRetryState(state, replacement); + assert.equal(failGifLoad(active, source), active); + assert.equal(hasGifLoadFailed(active, replacement), false); +}); diff --git a/tests/Unit/GifPlaybackHook.test.tsx b/tests/Unit/GifPlaybackHook.test.tsx new file mode 100644 index 000000000..1b197cf19 --- /dev/null +++ b/tests/Unit/GifPlaybackHook.test.tsx @@ -0,0 +1,181 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import { afterAll, afterEach, test, vi } from 'vite-plus/test'; +import type { Embed } from '../../resources/js/types/commit'; + +let states: unknown[] = []; +let stateIndex = 0; +let didUpdate = false; +let effects: (() => void | (() => void))[] = []; +let reducedMotion = false; +let setVisible: (visible: boolean) => void; + +vi.doMock('react', () => ({ + ...React, + useEffect: (effect: () => void | (() => void)) => effects.push(effect), + useState: (initial: unknown) => { + const index = stateIndex++; + states[index] ??= typeof initial === 'function' ? initial() : initial; + + return [ + states[index], + (update: unknown) => { + const next = + typeof update === 'function' + ? update(states[index]) + : update; + didUpdate ||= next !== states[index]; + states[index] = next; + }, + ]; + }, +})); + +vi.doMock('../../resources/js/hooks/use-media-query', () => ({ + useMediaQuery: () => reducedMotion, +})); + +vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback: IntersectionObserverCallback) { + setVisible = (visible) => + callback( + [ + { + isIntersecting: visible, + intersectionRatio: visible ? 1 : 0, + } as IntersectionObserverEntry, + ], + this as unknown as IntersectionObserver, + ); + } + + observe() {} + disconnect() {} + }, +); + +const { useGifPlayback } = + await import('../../resources/js/hooks/use-gif-playback'); + +afterAll(() => vi.unstubAllGlobals()); +afterEach(() => { + states = []; + stateIndex = 0; + effects = []; + reducedMotion = false; +}); + +const embed: Embed = { + provider: 'GIF', + kind: 'gif', + src: 'https://example.com/cat.gif', + poster: null, + aspect_ratio: null, + height: null, +}; +const container = { current: {} as HTMLDivElement }; + +function render(current: Embed = embed) { + let result: ReturnType; + let renders = 0; + + do { + assert.ok( + renders++ < 10, + 'source state must settle without a render loop', + ); + didUpdate = false; + stateIndex = 0; + result = useGifPlayback({ embed: current, isStatic: false, container }); + } while (didUpdate); + + return result; +} + +function mount() { + render(); + effects.shift()?.(); + setVisible(true); + + return render(); +} + +test('only manual retry clears a failure and the hook enforces two extra attempts', () => { + mount().onError(); + let playback = render(); + + assert.equal(playback.hasFailed, true); + assert.equal(playback.isPlaying, false); + assert.equal(playback.canRetry, true); + + setVisible(false); + render(); + setVisible(true); + playback = render(); + assert.equal(playback.hasFailed, true); + assert.equal(playback.isPlaying, false); + + for (let attempt = 1; attempt <= 2; attempt++) { + playback.onRetry(); + playback = render(); + assert.equal(playback.hasFailed, false); + assert.equal(playback.isPlaying, true); + assert.equal(playback.retryCount, attempt); + playback.onError(); + playback = render(); + } + + assert.equal(playback.canRetry, false); + playback.onRetry(); + playback = render(); + assert.equal(playback.hasFailed, true); + assert.equal(playback.isPlaying, false); + assert.equal(playback.retryCount, 2); +}); + +test('changing sources clears failure and the retry budget without stale error leakage', () => { + const first = mount(); + first.onError(); + render().onRetry(); + render().onError(); + const failedFirst = render(); + const next = { ...embed, src: 'https://example.com/dog.gif' }; + let playback = render(next); + + assert.equal(playback.hasFailed, false); + assert.equal(playback.isPlaying, true); + assert.equal(playback.retryCount, 0); + + first.onError(); + playback = render(next); + assert.equal(playback.hasFailed, false); + playback.onToggle(); + failedFirst.onRetry(); + playback = render(next); + assert.equal(playback.isPlaying, false); + assert.equal(playback.retryCount, 0); + playback.onError(); + assert.equal(render(next).hasFailed, true); + playback = render(); + assert.equal(playback.hasFailed, false); + assert.equal(playback.retryCount, 0); +}); + +test('retry is explicit playback for reduced motion and source changes retain Stop', () => { + reducedMotion = true; + let playback = mount(); + assert.equal(playback.isPlaying, false); + playback.onToggle(); + playback = render(); + assert.equal(playback.isPlaying, true); + playback.onError(); + render().onRetry(); + playback = render(); + assert.equal(playback.isPlaying, true); + playback.onToggle(); + playback = render({ ...embed, src: 'https://example.com/dog.gif' }); + assert.equal(playback.isPlaying, false); +}); 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/EmbedProviders/GiphyTest.php b/tests/Unit/Services/EmbedProviders/GiphyTest.php new file mode 100644 index 000000000..264b5c3b1 --- /dev/null +++ b/tests/Unit/Services/EmbedProviders/GiphyTest.php @@ -0,0 +1,44 @@ +embed(Url::parse($url))?->toArray())->toBe([ + 'provider' => 'GIPHY', + 'kind' => 'gif', + 'src' => 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif', + 'aspect_ratio' => null, + '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', + 'explicit default HTTPS port' => 'https://giphy.com:443/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', +]); diff --git a/tests/Unit/Services/EmbedsTest.php b/tests/Unit/Services/EmbedsTest.php index 02386da8c..ebaf34f88 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' => null, + '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', diff --git a/tests/draft-images.config.ts b/tests/draft-images.config.ts index 45bb2673d..31d9e490d 100644 --- a/tests/draft-images.config.ts +++ b/tests/draft-images.config.ts @@ -12,6 +12,9 @@ export default defineConfig({ VITE_APP_NAME: '/dev/null', }, include: [ + 'tests/Unit/GifEmbed.test.tsx', + 'tests/Unit/GifEmbedPlayback.test.tsx', + 'tests/Unit/GifPlaybackHook.test.tsx', 'tests/Unit/CommitPhoto.test.tsx', 'tests/Unit/CommitSize.test.tsx', 'tests/Unit/ThreadHistory.test.tsx', diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 2a1a766d9..7d85452c1 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -6,6 +6,9 @@ }, "include": [ "Unit/**/*.test.ts", + "Unit/GifEmbed.test.tsx", + "Unit/GifEmbedPlayback.test.tsx", + "Unit/GifPlaybackHook.test.tsx", "Unit/CommitPhoto.test.tsx", "Unit/DraftStash.test.tsx", "Unit/ComposerDraft.test.tsx",