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 (
+
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('