diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d2a1d9c1..4fe0759e4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -146,5 +146,8 @@ jobs: if: steps.build-cache.outputs.cache-hit != 'true' run: bun run build + - name: GIF Security Mutations + run: composer test:mutations + - name: Tests run: composer test:unit diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 240aa2d73..981bd8c25 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -216,7 +216,7 @@ Sources: [body renderer](resources/js/components/commit-body.tsx), [colors](reso ### Draft previews -Use the composer's eye button or **⌘/Ctrl+Shift+P** to preview a commit, reply, or quote before submitting it. Preview shows the rendered code, emoji, mentions, links, and images, and identifies the link selected for a card. Esc returns from preview to writing. +Use the composer's eye button or **⌘/Ctrl+Shift+P** to preview a commit, reply, or quote. The selected link card also loads while writing; direct GIFs appear after verification. Selected URLs and metadata share the posted-link cache, even if the draft is never published. Uncached links load in the background. Esc returns to writing. Draft text is not scanned for secrets before publishing or saving locally. @@ -242,6 +242,8 @@ URLs and bare domains in commits and replies become clickable links. Long URLs a Supported links can also show click-to-play embedded players: YouTube, Vimeo, Loom, Twitch, Spotify, SoundCloud, Apple Music, Suno, CodePen, and asciinema. Only the link that gets the card can show a player, so choosing no card means no player. Embeds depend on server configuration and the linked URL being supported. Third-party player content loads only after you press play; those providers have their own privacy policies. This is embedding a link, not uploading a video or audio file to /dev/null. +Giphy share links and verified public HTTPS `.gif` links play inline from their external host. Verified GIFs autoplay while visible and unload offscreen; reduced-motion users press **Play**. **Stop** unloads the animation, and failed GIFs offer two manual retries. No uploads or provider keys are required. Enable with `EMBEDS_PROVIDERS=giphy,gif`. Previously cached direct GIF links require refetching. + Sources: [link rendering](resources/js/components/linkified-text.tsx), [link-card selection](app/Http/Requests/CreateCommitRequest.php), [embed configuration](config/embeds.php), [embedded player](resources/js/components/link-embed.tsx). ### Replies and threads diff --git a/README.md b/README.md index 10c3fb906..49e0270bb 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ composer setup composer dev ``` +Link cards preview while writing. Giphy links and verified public HTTPS `.gif` links play inline from their external host, with Play/Stop controls, visible-only autoplay, and manual playback for reduced motion. + Run `composer test` before opening a pull request. ## License diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index 95354559f..ef865acd4 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -11,7 +11,6 @@ use App\Jobs\EmbedCommit; use App\Jobs\ModerateCommit; use App\Jobs\ReplyAsTom; -use App\Jobs\UnfurlLink; use App\Models\Commit; use App\Models\LinkPreview; use App\Models\User; @@ -31,6 +30,7 @@ public function __construct( private CreateMentionNotifications $createMentionNotifications, private CreateCommitImages $createCommitImages, private CreateWorldReply $createWorldReply, + private CreateLinkPreviewJob $createLinkPreviewJob, private UpdateCommitHotScores $updateCommitHotScores, private TheAlgorithm $theAlgorithm, private EmojiShortcodes $emojiShortcodes, @@ -42,8 +42,6 @@ public function __construct( } /** - * The link preview is of the first URL in the body when true, of the given URL when a string, or none when false. - * * @param list $images * @param array $imageDescriptions */ @@ -86,8 +84,10 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $this->createQuoteNotifications->handle($commit); $this->createMentionNotifications->handle($commit); - if ($linkPreview instanceof LinkPreview && $linkPreview->fetched_at === null) { - dispatch(new UnfurlLink($linkPreview)); + if ($linkPreview instanceof LinkPreview && $linkPreview->prepareUnfurlForPublication()) { + DB::afterCommit(function () use ($linkPreview): void { + $this->createLinkPreviewJob->handle($linkPreview); + }); } $this->createWorldReply->handle($commit); @@ -118,9 +118,6 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit }); } - /** - * A poll opens with its commit's approval, so polls of pending commits wait for moderation. - */ private function createPoll(Commit $commit, NewPoll $newPoll): void { $poll = $commit->poll()->create([ @@ -135,9 +132,6 @@ private function createPoll(Commit $commit, NewPoll $newPoll): void )); } - /** - * Commits that link to the same URL share one preview, so a page is fetched once however often it is linked. - */ private function linkPreviewFor(?string $url): ?LinkPreview { if ($url === null) { diff --git a/app/Actions/CreateLinkPreviewJob.php b/app/Actions/CreateLinkPreviewJob.php new file mode 100644 index 000000000..11f8e0803 --- /dev/null +++ b/app/Actions/CreateLinkPreviewJob.php @@ -0,0 +1,31 @@ +fresh(); + + if (! $preview instanceof LinkPreview || $preview->fetched_at !== null) { + return; + } + + $job = new UnfurlLink($preview); + + try { + dispatch($job)->afterCommit(); + } catch (Throwable $throwable) { + $job->failed(); + + throw $throwable; + } + } +} diff --git a/app/Actions/CreateMessage.php b/app/Actions/CreateMessage.php index d45b63fa9..925cde5d2 100644 --- a/app/Actions/CreateMessage.php +++ b/app/Actions/CreateMessage.php @@ -6,7 +6,6 @@ use App\Enums\RemoteStatus; use App\Jobs\SendRemotePush; -use App\Jobs\UnfurlLink; use App\Models\Conversation; use App\Models\ConversationParticipant; use App\Models\LinkPreview; @@ -28,13 +27,11 @@ public function __construct( private CreateMessageVoiceFile $createMessageVoiceFile, private MentionedUsersQuery $mentionedUsers, private Urls $urls, + private CreateLinkPreviewJob $createLinkPreviewJob, ) { // } - /** - * Makes the message the conversation's latest, for every member's inbox. - */ public static function advance(Conversation $conversation, Message $message, bool $unstash): void { Conversation::query() @@ -48,10 +45,6 @@ public static function advance(Conversation $conversation, Message $message, boo } /** - * Sending the same client id again, as a retry does, returns the message it already created, without storing its - * images or voice note twice. A new message brings the conversation back out of the stash for everyone in it. The link card is - * of the first link in the body when true, of the given link when a string, or none when false. - * * @param list $images * @param array $imageDescriptions * @param list $voiceWaveform @@ -108,8 +101,10 @@ public function handle(User $sender, Conversation $conversation, string $body, s ->where('read_through_id', '<', $message->id) ->update(['read_through_id' => $message->id, 'read_at' => now()]); - if ($linkPreview instanceof LinkPreview && $linkPreview->fetched_at === null) { - dispatch(new UnfurlLink($linkPreview))->afterCommit(); + if ($linkPreview instanceof LinkPreview && $linkPreview->prepareUnfurlForPublication()) { + DB::afterCommit(function () use ($linkPreview): void { + $this->createLinkPreviewJob->handle($linkPreview); + }); } return $message; diff --git a/app/Actions/UpdateLinkPreview.php b/app/Actions/UpdateLinkPreview.php index 7f2258163..532c1e6fa 100644 --- a/app/Actions/UpdateLinkPreview.php +++ b/app/Actions/UpdateLinkPreview.php @@ -4,244 +4,163 @@ namespace App\Actions; -use App\Contracts\Services\DnsResolver; +use App\Dto\LinkPreviewMetadata; +use App\Dto\LinkPreviewRequest; use App\Models\LinkPreview; -use Dom\HTMLDocument; -use GuzzleHttp\Exception\ResponseException; +use App\Services\GifLinks; +use App\Services\LinkPreviewAddress; +use App\Services\LinkPreviewBudget; +use App\Services\LinkPreviewHttpClient; +use App\Services\LinkPreviewParser; use GuzzleHttp\Psr7\Utils; -use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Response; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; -use Symfony\Component\HttpFoundation\IpUtils; use Uri\WhatWg\Url; -/** - * @phpstan-type Metadata array{title: string|null, description: string|null, image_url: string|null, site_name: string|null} - */ final readonly class UpdateLinkPreview { private const int MAX_REDIRECTS = 3; private const int MAX_BODY_BYTES = 1_048_576; - private const int MAX_IMAGE_URL_LENGTH = 2048; + private const string HEADER_ACCEPT = 'Accept'; - /** - * Non-public ranges that filter_var's private and reserved range flags let through: carrier-grade NAT, IETF protocol - * assignments, 6to4 relays, benchmarking, documentation and multicast for IPv4; NAT64, discard, Teredo, - * documentation, 6to4, site-local, multicast and IPv4-translated addresses for IPv6. - * - * @var list - */ - private const array BLOCKED_RANGES = [ - '100.64.0.0/10', - '192.0.0.0/24', - '192.0.2.0/24', - '192.88.99.0/24', - '198.18.0.0/15', - '198.51.100.0/24', - '203.0.113.0/24', - '224.0.0.0/4', - '64:ff9b::/96', - '64:ff9b:1::/48', - '100::/64', - '2001::/32', - '2001:db8::/32', - '2002::/16', - 'fec0::/10', - 'ff00::/8', - '::ffff:0:0:0/96', - ]; - - private const array NO_METADATA = [ - 'title' => null, - 'description' => null, - 'image_url' => null, - 'site_name' => null, - ]; - - public function __construct(private DnsResolver $dns) - { + private const string HEADER_RANGE = 'Range'; + + private const string HTML_CONTENT_TYPE = 'text/html'; + + public function __construct( + private LinkPreviewAddress $addresses, + private LinkPreviewBudget $budget, + private GifLinks $gifLinks, + private LinkPreviewHttpClient $http, + private LinkPreviewParser $parser, + ) { // } - /** - * Fetches the page behind the link and stores its Open Graph metadata. A page that can't or mustn't be fetched is - * marked as fetched without metadata, so it is not tried again. Failures that may pass, such as connection errors, - * timeouts, rate limits and server errors, are thrown instead, so the caller can try again later. - */ public function handle(LinkPreview $linkPreview): void { - $linkPreview->update([...$this->fetchMetadata($linkPreview->url), 'fetched_at' => now()]); + $metadata = $this->fetchMetadata($linkPreview->url); + + $linkPreview->completeUnfurl($metadata->toArray()); } - /** - * Follows up to three redirects by hand, so that every hop goes through the same checks as the original URL. - * - * @return Metadata - */ - private function fetchMetadata(string $url): array + private function fetchMetadata(string $url): LinkPreviewMetadata { $page = Url::parse($url); + $isGif = $this->gifLinks->isGifUrl($page); + $deadline = $this->budget->start(); + $visited = []; for ($hop = 0; $hop <= self::MAX_REDIRECTS && $page instanceof Url; $hop++) { - $address = $this->publicAddress($page); + $source = $page->withFragment(null)->toAsciiString(); - if ($address === null) { - return self::NO_METADATA; + if (isset($visited[$source])) { + return new LinkPreviewMetadata; } - $response = $this->get($page, $address); + $visited[$source] = true; + $request = $this->prepareRequest($page, $isGif, $deadline); - if ($response->serverError() || $response->tooManyRequests()) { - $response->throw(); + if (! $request instanceof LinkPreviewRequest) { + return new LinkPreviewMetadata; } + $response = $this->http->get($request); + $response->throwIf($response->serverError() || $response->tooManyRequests()); + if (! $response->redirect()) { - return $response->successful() && $this->isHtml($response) - ? $this->parse($this->body($response), $page) - : self::NO_METADATA; + return $this->responseMetadata($response, $page, $isGif); } - $page = Url::parse($response->header('Location'), $page); + $page = $this->redirectPage($response, $page); } - return self::NO_METADATA; + return new LinkPreviewMetadata; } - /** - * Returns the address to connect to, or null when the URL must not be fetched: anything but plain http(s) on a - * default port without credentials, or a host that resolves to nothing or to any non-public address. - */ - private function publicAddress(Url $url): ?string + private function prepareRequest(Url $page, bool $isGif, float $deadline): ?LinkPreviewRequest { - if (! in_array($url->getScheme(), ['http', 'https'], true) - || ($url->getUsername() ?? '') !== '' - || ($url->getPassword() ?? '') !== '' - || ! in_array($url->getPort(), [null, 80, 443], true)) { + if ($isGif && $page->getScheme() !== 'https') { return null; } - $addresses = $this->dns->resolve(mb_trim((string) $url->getAsciiHost(), '[]')); + $address = $this->addresses->get($page); + $remaining = $this->budget->remaining($deadline); - return $addresses !== [] && array_all($addresses, $this->isPublic(...)) ? $addresses[0] : null; + if ($address === null || $remaining <= 0) { + return null; + } + + return new LinkPreviewRequest( + url: $page, + address: $address, + headers: $this->requestHeaders($isGif), + maxBodyBytes: $isGif ? GifLinks::HEADER_BYTES : self::MAX_BODY_BYTES, + timeoutSeconds: $remaining, + ); } - private function isPublic(string $address): bool + private function redirectPage(Response $response, Url $page): ?Url { - return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false - && ! IpUtils::checkIp($address, self::BLOCKED_RANGES); + $location = mb_trim($response->header('Location')); + + if ($location === '') { + return null; + } + + return Url::parse($location, $page); } /** - * Connects to the address that was checked rather than resolving the host again, so DNS rebinding can't swap in a - * private address. The empty host and port in CURLOPT_CONNECT_TO match every request, so the pin holds even if - * cURL were to read the URL differently than we do. Bodies are cut off after 1 MB, and a body that was cut off - * (by that limit or by the timeout) is still used, as metadata lives at the top of the page. English is asked for - * explicitly, as sites such as YouTube otherwise pick the language from the server's location. + * @return array */ - private function get(Url $url, string $address): Response + private function requestHeaders(bool $isGif): array { - $port = $url->getPort() ?? ($url->getScheme() === 'https' ? 443 : 80); - - $request = Http::withUserAgent(sprintf('Mozilla/5.0 (compatible; devnull-linkpreview/1.0; +%s)', config()->string('app.url'))) - ->accept('text/html') - ->withHeaders(['Accept-Language' => 'en-US,en;q=0.9']) - ->connectTimeout(3) - ->timeout(5) - ->withoutRedirecting() - ->withOptions([ - 'decode_content' => false, - 'progress' => fn (int $expected, int $downloaded): bool => $downloaded > self::MAX_BODY_BYTES, - 'curl' => [ - CURLOPT_CONNECT_TO => [sprintf('::%s:%d', str_contains($address, ':') ? "[{$address}]" : $address, $port)], - ], - ]); - - try { - return $request->get($url->toAsciiString()); - } catch (ConnectionException $connectionException) { - $cutOff = $connectionException->getPrevious(); - - if ($cutOff instanceof ResponseException) { - return new Response($cutOff->getResponse()); - } - - throw $connectionException; + if (! $isGif) { + return [self::HEADER_ACCEPT => self::HTML_CONTENT_TYPE]; } + + return [self::HEADER_ACCEPT => GifLinks::CONTENT_TYPE, self::HEADER_RANGE => sprintf('bytes=0-%d', GifLinks::HEADER_BYTES - 1)]; } - private function isHtml(Response $response): bool + private function responseMetadata(Response $response, Url $page, bool $isGif): LinkPreviewMetadata { - $mediaType = mb_strtolower(mb_trim(Str::before($response->header('Content-Type'), ';'))); + if (! $response->successful()) { + return new LinkPreviewMetadata; + } - return in_array($mediaType, ['text/html', 'application/xhtml+xml'], true); - } + if ($isGif) { + return $this->getGifMetadata($response, $page); + } - private function body(Response $response): string - { - $body = $response->toPsrResponse()->getBody(); - $body->rewind(); + if (! $this->isHtml($response)) { + return new LinkPreviewMetadata; + } - return Utils::copyToString($body, self::MAX_BODY_BYTES); + return $this->parser->parse($this->body($response), $page); } - /** - * @return Metadata - */ - private function parse(string $html, Url $page): array + private function getGifMetadata(Response $response, Url $page): LinkPreviewMetadata { - $document = HTMLDocument::createFromString($html, LIBXML_NOERROR); - $meta = []; - - foreach ($document->querySelectorAll('meta[content]') as $element) { - $key = mb_strtolower(mb_trim($element->getAttribute('property') ?? $element->getAttribute('name') ?? '')); - $meta[$key] ??= (string) $element->getAttribute('content'); - } + $url = $this->gifLinks->getVerifiedUrl($page, $response->header('Content-Type'), $this->body($response)); - return [ - 'title' => $this->text([$meta['og:title'] ?? '', $meta['twitter:title'] ?? '', $document->title], 300), - 'description' => $this->text([$meta['og:description'] ?? '', $meta['twitter:description'] ?? '', $meta['description'] ?? ''], 500), - 'image_url' => $this->imageUrl([$meta['og:image:secure_url'] ?? '', $meta['og:image'] ?? '', $meta['twitter:image'] ?? ''], $page), - 'site_name' => $this->text([$meta['og:site_name'] ?? ''], 100), - ]; + return new LinkPreviewMetadata(gifUrl: $url); } - /** - * Returns the first candidate that still has text once entities are decoded and whitespace is collapsed, - * truncated to the column's length. - * - * @param list $candidates - */ - private function text(array $candidates, int $limit): ?string + private function isHtml(Response $response): bool { - foreach ($candidates as $candidate) { - $text = Str::squish(html_entity_decode($candidate, ENT_QUOTES | ENT_HTML5, 'UTF-8')); - - if ($text !== '') { - return mb_strlen($text) > $limit ? mb_rtrim(mb_substr($text, 0, $limit - 1)).'…' : $text; - } - } + $mediaType = mb_strtolower(mb_trim(Str::before($response->header('Content-Type'), ';'))); - return null; + return in_array($mediaType, [self::HTML_CONTENT_TYPE, 'application/xhtml+xml'], true); } - /** - * Returns the first candidate that, resolved against the page URL, is an https URL. - * - * @param list $candidates - */ - private function imageUrl(array $candidates, Url $page): ?string + private function body(Response $response): string { - foreach ($candidates as $candidate) { - $image = mb_trim($candidate) === '' ? null : Url::parse($candidate, $page); - - if ($image?->getScheme() === 'https' && mb_strlen($image->toAsciiString()) <= self::MAX_IMAGE_URL_LENGTH) { - return $image->toAsciiString(); - } - } + $body = $response->toPsrResponse()->getBody(); + $body->rewind(); - return null; + return Utils::copyToString($body, self::MAX_BODY_BYTES); } } 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/Dto/LinkPreviewMetadata.php b/app/Dto/LinkPreviewMetadata.php new file mode 100644 index 000000000..1faa88191 --- /dev/null +++ b/app/Dto/LinkPreviewMetadata.php @@ -0,0 +1,37 @@ + + */ +final readonly class LinkPreviewMetadata implements Arrayable +{ + public function __construct( + public ?string $title = null, + public ?string $description = null, + public ?string $imageUrl = null, + public ?string $siteName = null, + public ?string $gifUrl = null, + ) { + // + } + + /** + * @return array{title: string|null, description: string|null, image_url: string|null, site_name: string|null, gif_url: string|null} + */ + public function toArray(): array + { + return [ + 'title' => $this->title, + 'description' => $this->description, + 'image_url' => $this->imageUrl, + 'site_name' => $this->siteName, + 'gif_url' => $this->gifUrl, + ]; + } +} diff --git a/app/Dto/LinkPreviewRequest.php b/app/Dto/LinkPreviewRequest.php new file mode 100644 index 000000000..23f9f6f86 --- /dev/null +++ b/app/Dto/LinkPreviewRequest.php @@ -0,0 +1,23 @@ + $headers + */ + public function __construct( + public Url $url, + public string $address, + public array $headers, + public int $maxBodyBytes, + public float $timeoutSeconds, + ) { + // + } +} diff --git a/app/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/Http/Controllers/DraftLinkPreviewController.php b/app/Http/Controllers/DraftLinkPreviewController.php new file mode 100644 index 000000000..70c07a9cb --- /dev/null +++ b/app/Http/Controllers/DraftLinkPreviewController.php @@ -0,0 +1,104 @@ +user(); + assert($user instanceof User); + + $url = $request->string('url')->value(); + + Log::info('Draft link preview requested', [ + 'user_id' => $user->id, + 'host' => Url::parse($url)?->getAsciiHost(), + ]); + + if ($embeds->for($url)?->kind === EmbedKind::Gif) { + return $this->response($this->preview($url, new LinkPreviewMetadata)); + } + + $key = "draft-link-unfurls:{$user->id}"; + + return $this->response($this->cachedPreview($url, $key)); + } + + private function cachedPreview(string $url, string $key): LinkPreview + { + $cached = LinkPreview::query()->where('url_hash', hash('sha256', $url))->first(); + + if ($cached instanceof LinkPreview) { + return $cached; + } + + if (RateLimiter::tooManyAttempts($key, self::UNFURLS_PER_MINUTE)) { + throw new TooManyRequestsHttpException(RateLimiter::availableIn($key)); + } + + RateLimiter::increment($key, CarbonInterface::SECONDS_PER_MINUTE); + + return $this->createPreview($url); + } + + private function createPreview(string $url): LinkPreview + { + return DB::transaction(function () use ($url): LinkPreview { + $preview = LinkPreview::query()->firstOrCreate( + ['url_hash' => hash('sha256', $url)], + ['url' => $url, ...(new LinkPreviewMetadata)->toArray()], + ); + + if ($preview->wasRecentlyCreated) { + DB::afterCommit(function () use ($preview): void { + $this->createLinkPreviewJob->handle($preview); + }); + } + + return $preview; + }); + } + + private function response(LinkPreview $preview): JsonResponse + { + return response()->json([ + 'link_preview' => $preview, + ])->header('Cache-Control', 'private, no-store'); + } + + private function preview(string $url, LinkPreviewMetadata $metadata): LinkPreview + { + return new LinkPreview([ + 'url' => $url, + ...$metadata->toArray(), + 'fetched_at' => now(), + ]); + } +} diff --git a/app/Http/Requests/ShowDraftLinkPreviewRequest.php b/app/Http/Requests/ShowDraftLinkPreviewRequest.php new file mode 100644 index 000000000..82026b282 --- /dev/null +++ b/app/Http/Requests/ShowDraftLinkPreviewRequest.php @@ -0,0 +1,26 @@ +> + */ + public function rules(): array + { + return [ + 'url' => ['required', 'string', 'url:http,https', 'max:'.Urls::MAX_LENGTH], + ]; + } +} diff --git a/app/Jobs/UnfurlLink.php b/app/Jobs/UnfurlLink.php index 7ce7f47b2..3bddaa1f5 100644 --- a/app/Jobs/UnfurlLink.php +++ b/app/Jobs/UnfurlLink.php @@ -6,41 +6,59 @@ use App\Actions\UpdateLinkPreview; use App\Models\LinkPreview; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Queue\Attributes\Backoff; use Illuminate\Queue\Attributes\DeleteWhenMissingModels; use Illuminate\Queue\Attributes\Tries; +use Illuminate\Support\Facades\DB; #[Backoff([60, 600])] #[Tries(3)] #[DeleteWhenMissingModels] -final class UnfurlLink implements ShouldQueue +final class UnfurlLink implements ShouldBeUnique, ShouldQueue { use Queueable; + // Allow foreign-key references while serializing workers. + private const string METADATA_LOCK = 'for no key update'; + + // Default supports older queued payloads. + public int $publicationUnfurlRetries = 0; + public function __construct(public readonly LinkPreview $linkPreview) { - // + if ($linkPreview->publication_unfurl_retries === 0) { + return; + } + + $this->publicationUnfurlRetries = $linkPreview->publication_unfurl_retries; + } + + public function uniqueId(): string + { + return $this->linkPreview->id; } - /** - * Commits that share a link may each queue this job before the first one runs, so only the first one fetches. - */ public function handle(UpdateLinkPreview $action): void { - if ($this->linkPreview->fetched_at !== null) { - return; - } + DB::transaction(function () use ($action): void { + $linkPreview = LinkPreview::query()->lock(self::METADATA_LOCK)->find($this->linkPreview->id); + + if (! $linkPreview instanceof LinkPreview + || $linkPreview->fetched_at !== null + || $linkPreview->publication_unfurl_retries !== $this->publicationUnfurlRetries + ) { + return; + } - $action->handle($this->linkPreview); + $action->handle($linkPreview); + }); } - /** - * Once every attempt has failed, the link preview is marked as fetched without metadata, so it is not tried again. - */ public function failed(): void { - $this->linkPreview->update(['fetched_at' => now()]); + $this->linkPreview->markUnfurlAsTransientFailure($this->publicationUnfurlRetries); } } diff --git a/app/Models/LinkPreview.php b/app/Models/LinkPreview.php index cda016627..cdb2e9a7b 100644 --- a/app/Models/LinkPreview.php +++ b/app/Models/LinkPreview.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; /** * @property-read string $id @@ -20,7 +21,10 @@ * @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|null $unfurl_failed_at + * @property-read int $publication_unfurl_retries * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at */ @@ -38,6 +42,13 @@ final class LinkPreview extends Model use HasUuids; + public const int MAX_PUBLICATION_UNFURL_RETRIES = 1; + + /** @var array */ + protected $attributes = [ + 'publication_unfurl_retries' => 0, + ]; + /** * @return array */ @@ -51,17 +62,63 @@ public function casts(): array 'description' => 'string', 'image_url' => 'string', 'site_name' => 'string', + 'gif_url' => 'string', 'fetched_at' => 'datetime', + 'unfurl_failed_at' => 'datetime', + 'publication_unfurl_retries' => 'integer', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; } + public function prepareUnfurlForPublication(): bool + { + if ($this->fetched_at === null) { + return true; + } + + return $this->retryTransientFailureForPublication(); + } + + public function retryTransientFailureForPublication(): bool + { + return self::query() + ->whereKey($this->id) + ->whereNotNull('unfurl_failed_at') + ->where('publication_unfurl_retries', '<', self::MAX_PUBLICATION_UNFURL_RETRIES) + ->update([ + 'fetched_at' => null, + 'unfurl_failed_at' => null, + 'publication_unfurl_retries' => DB::raw('publication_unfurl_retries + 1'), + ]) === 1; + } + + /** + * @param array $metadata + */ + public function completeUnfurl(array $metadata): bool + { + return self::query() + ->whereKey($this->id) + ->where('publication_unfurl_retries', $this->publication_unfurl_retries) + ->whereNull('fetched_at') + ->update([...$metadata, 'fetched_at' => now(), 'unfurl_failed_at' => null]) === 1; + } + + public function markUnfurlAsTransientFailure(int $publicationUnfurlRetries): void + { + self::query() + ->whereKey($this->id) + ->where('publication_unfurl_retries', $publicationUnfurlRetries) + ->whereNull('fetched_at') + ->update(['fetched_at' => now(), 'unfurl_failed_at' => now()]); + } + /** * @return 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/app/Services/GifLinks.php b/app/Services/GifLinks.php new file mode 100644 index 000000000..caff45d9c --- /dev/null +++ b/app/Services/GifLinks.php @@ -0,0 +1,85 @@ +getScheme() !== 'https') { + return false; + } + + return str_ends_with(mb_strtolower($url->getPath()), '.gif'); + } + + public function getVerifiedUrl(Url $url, string $contentType, string $header): ?string + { + $mediaType = mb_strtolower(mb_trim(Str::before($contentType, ';'))); + + if ($mediaType !== self::CONTENT_TYPE) { + return null; + } + + $sourceUrl = $url->toAsciiString(); + + if (mb_strlen($sourceUrl) > Urls::MAX_LENGTH) { + return null; + } + + if (! $this->isValidHeader($header)) { + return null; + } + + return $sourceUrl; + } + + private function isValidHeader(string $header): bool + { + if (mb_strlen($header, self::BINARY_ENCODING) < self::MIN_HEADER_BYTES) { + return false; + } + + return $this->hasValidSignature($header) && $this->hasValidDimensions($header); + } + + private function hasValidSignature(string $header): bool + { + $signature = mb_substr($header, 0, self::SIGNATURE_BYTES, self::BINARY_ENCODING); + + return in_array($signature, self::SIGNATURES, true); + } + + private function hasValidDimensions(string $header): bool + { + $width = mb_substr($header, self::WIDTH_OFFSET, self::DIMENSION_BYTES, self::BINARY_ENCODING); + $height = mb_substr($header, self::HEIGHT_OFFSET, self::DIMENSION_BYTES, self::BINARY_ENCODING); + + return $width !== self::ZERO_DIMENSION && $height !== self::ZERO_DIMENSION; + } +} diff --git a/app/Services/LimitedResponseBody.php b/app/Services/LimitedResponseBody.php new file mode 100644 index 000000000..85453c0be --- /dev/null +++ b/app/Services/LimitedResponseBody.php @@ -0,0 +1,25 @@ +maxBytes - $this->tell()); + + return parent::write(mb_substr($string, 0, $remaining, self::BINARY_ENCODING)); + } +} diff --git a/app/Services/LinkPreviewAddress.php b/app/Services/LinkPreviewAddress.php new file mode 100644 index 000000000..bf9db2e93 --- /dev/null +++ b/app/Services/LinkPreviewAddress.php @@ -0,0 +1,59 @@ + + */ + private const array BLOCKED_RANGES = [ + ...IpUtils::PRIVATE_SUBNETS, + '64:ff9b::/32', // NAT64; FILTER_FLAG_GLOBAL_RANGE permits RFC 6052. + '192.88.99.0/24', + '224.0.0.0/4', + 'fec0::/10', + 'ff00::/8', + '::ffff:0:0:0/96', + ]; + + public function __construct(private DnsResolver $dns) + { + // + } + + public function get(Url $url): ?string + { + if (! in_array($url->getScheme(), ['http', 'https'], true)) { + return null; + } + + if (($url->getUsername() ?? '') !== '') { + return null; + } + + if (($url->getPassword() ?? '') !== '') { + return null; + } + + if ($url->getPort() !== null) { + return null; + } + + $addresses = $this->dns->resolve(mb_trim((string) $url->getAsciiHost(), '[]')); + + return $addresses !== [] && array_all($addresses, $this->isPublic(...)) ? $addresses[0] : null; + } + + private function isPublic(string $address): bool + { + return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) !== false + && ! IpUtils::checkIp($address, self::BLOCKED_RANGES); + } +} diff --git a/app/Services/LinkPreviewBudget.php b/app/Services/LinkPreviewBudget.php new file mode 100644 index 000000000..dc05fcbe6 --- /dev/null +++ b/app/Services/LinkPreviewBudget.php @@ -0,0 +1,42 @@ +seconds() + self::TIMEOUT_SECONDS; + } + + public function remaining(float $deadline): float + { + $milliseconds = floor(($deadline - $this->seconds()) * CarbonInterface::MILLISECONDS_PER_SECOND); + + return max(0, $milliseconds) / CarbonInterface::MILLISECONDS_PER_SECOND; + } + + private function seconds(): float + { + $nanoseconds = $this->clock instanceof Closure ? ($this->clock)() : hrtime(true); + + return $nanoseconds / self::NANOSECONDS_PER_SECOND; + } +} diff --git a/app/Services/LinkPreviewHttpClient.php b/app/Services/LinkPreviewHttpClient.php new file mode 100644 index 000000000..fdac09890 --- /dev/null +++ b/app/Services/LinkPreviewHttpClient.php @@ -0,0 +1,51 @@ + 'en-US,en;q=0.9']; + + public function get(LinkPreviewRequest $request): Response + { + $port = $request->url->getPort() ?? ($request->url->getScheme() === 'https' ? self::HTTPS_PORT : self::HTTP_PORT); + $connection = sprintf('::%s:%d', str_contains($request->address, ':') ? "[{$request->address}]" : $request->address, $port); + + try { + return Http::withUserAgent(sprintf(self::USER_AGENT_FORMAT, config()->string('app.url'))) + ->withHeaders([...self::DEFAULT_HEADERS, ...$request->headers]) + ->connectTimeout(min(self::CONNECT_TIMEOUT_SECONDS, $request->timeoutSeconds)) + ->timeout($request->timeoutSeconds) + ->withoutRedirecting() + ->withOptions([ + RequestOptions::DECODE_CONTENT => false, + RequestOptions::SINK => new LimitedResponseBody($request->maxBodyBytes), + RequestOptions::CURL => [CURLOPT_CONNECT_TO => [$connection]], + ]) + ->get($request->url->toAsciiString()); + } catch (ConnectionException $connectionException) { + $previous = $connectionException->getPrevious(); + + throw_unless($previous instanceof ResponseException, $connectionException); + + return new Response($previous->getResponse()); + } + } +} diff --git a/app/Services/LinkPreviewParser.php b/app/Services/LinkPreviewParser.php new file mode 100644 index 000000000..c67033983 --- /dev/null +++ b/app/Services/LinkPreviewParser.php @@ -0,0 +1,83 @@ +querySelectorAll('meta[content]') as $element) { + $key = mb_strtolower(mb_trim($element->getAttribute('property') ?? $element->getAttribute('name') ?? '')); + $meta[$key] ??= (string) $element->getAttribute('content'); + } + + return new LinkPreviewMetadata( + title: $this->text([$meta['og:title'] ?? '', $meta['twitter:title'] ?? '', $document->title], self::MAX_TITLE_LENGTH), + description: $this->text([$meta['og:description'] ?? '', $meta['twitter:description'] ?? '', $meta['description'] ?? ''], self::MAX_DESCRIPTION_LENGTH), + imageUrl: $this->imageUrl([$meta['og:image:secure_url'] ?? '', $meta['og:image'] ?? '', $meta['twitter:image'] ?? ''], $page), + siteName: $this->text([$meta['og:site_name'] ?? ''], self::MAX_SITE_NAME_LENGTH), + ); + } + + /** + * @param list $candidates + */ + private function text(array $candidates, int $limit): ?string + { + foreach ($candidates as $candidate) { + $text = Str::squish(html_entity_decode($candidate, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + + if ($text === '') { + continue; + } + + if (mb_strlen($text) <= $limit) { + return $text; + } + + return mb_rtrim(mb_substr($text, 0, $limit - 1)).'…'; + } + + return null; + } + + /** + * @param list $candidates + */ + private function imageUrl(array $candidates, Url $page): ?string + { + foreach ($candidates as $candidate) { + $image = mb_trim($candidate) === '' ? null : Url::parse($candidate, $page); + + if ($image?->getScheme() !== 'https') { + continue; + } + + if (mb_strlen($image->toAsciiString()) > self::MAX_IMAGE_URL_LENGTH) { + continue; + } + + return $image->toAsciiString(); + } + + return null; + } +} diff --git a/composer.json b/composer.json index a1b2c2a36..39ed54b18 100644 --- a/composer.json +++ b/composer.json @@ -116,8 +116,10 @@ "@test:lint", "@test:types", "@test:type-coverage", - "@test:unit" - ] + "@test:unit", + "@test:mutations" + ], + "test:mutations": "bun run test:mutations" }, "extra": { "laravel": { diff --git a/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/database/factories/LinkPreviewFactory.php b/database/factories/LinkPreviewFactory.php index 49d5ccdb2..a61d83a3d 100644 --- a/database/factories/LinkPreviewFactory.php +++ b/database/factories/LinkPreviewFactory.php @@ -50,6 +50,7 @@ public function failed(): self { return $this->state(fn (array $attributes): array => [ 'fetched_at' => now(), + 'unfurl_failed_at' => now(), ]); } } 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/database/migrations/2026_10_20_130000_add_unfurl_state_to_link_previews_table.php b/database/migrations/2026_10_20_130000_add_unfurl_state_to_link_previews_table.php new file mode 100644 index 000000000..a540093d8 --- /dev/null +++ b/database/migrations/2026_10_20_130000_add_unfurl_state_to_link_previews_table.php @@ -0,0 +1,18 @@ +timestamp('unfurl_failed_at')->nullable()->after('fetched_at'); + $table->unsignedTinyInteger('publication_unfurl_retries')->default(0)->after('unfurl_failed_at'); + }); + } +}; diff --git a/package.json b/package.json index 0fed34885..d0cfff9b0 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint --fix", "test:lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt --check resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint", "test:types": "tsc --noEmit && tsc --noEmit --project tests/tsconfig.json", - "test:unit": "php artisan wayfinder:generate --with-form && node --experimental-strip-types --test tests/Unit/*.test.ts" + "test:unit": "php artisan wayfinder:generate --with-form && node --experimental-strip-types --test tests/Unit/*.test.ts", + "test:mutations": "node --experimental-strip-types --test tests/Mutation/*.test.ts" }, "dependencies": { "@daypicker/react": "^10.0.2", diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index a8898760d..2f1ee1aa6 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -19,6 +19,7 @@ import { PreviewToggle, useComposerPreview, } from '@/components/composer-preview'; +import { DraftLinkPreview } from '@/components/draft-link-preview'; import { GraphRail, RailAvatar } from '@/components/graph-rail'; import InputError from '@/components/input-error'; import type { UseIssueCommandReturn } from '@/components/issue-command'; @@ -286,7 +287,6 @@ function CommitComposerBody({ {preview.isPreviewing && ( + + {issue.hint && ( + + + {!preview.isPreviewing && ( export type ComposerPreview = ReturnType; -/** - * Switches a composer between writing and previewing its draft as the commit it becomes. The draft is rendered right - * away, and its code blocks are highlighted and its mentions linked once the server, the only place code is - * highlighted and handles are looked up, sends them back. - */ export function useComposerPreview({ body, textareaRef, @@ -106,9 +101,7 @@ export function useComposerPreview({ previewId, toggle: (moveFocus = false) => show(!isPreviewing, moveFocus), close: () => setIsPreviewing(false), - /** - * Handles ⌘/Ctrl + Shift + P in the textarea, and tells whether it did. - */ + handleShortcut: (event: KeyboardEvent) => { if (!isPreviewShortcut(event)) { return false; @@ -172,13 +165,11 @@ export function ComposerPreviewBody({ preview, images, descriptions = [], - url, className, }: { preview: ComposerPreview; images: File[]; descriptions?: string[]; - url: string | null; className?: string; }) { return ( @@ -211,34 +202,10 @@ export function ComposerPreviewBody({ description: descriptions[index]?.trim() || null, }))} /> - - {url && ( -
- -
-

- {url.replace(/^https?:\/\//i, '')} -

-

- Unfurls into a link card after you commit. -

-
-
- )} ); } -/** - * Shows a poll draft as it reads once committed: open right away for admins, whose commits skip moderation, and - * waiting for approval for everyone else. - */ export function ComposerPollPreview({ poll, className, diff --git a/resources/js/components/draft-link-preview.tsx b/resources/js/components/draft-link-preview.tsx new file mode 100644 index 000000000..b4b5b6b30 --- /dev/null +++ b/resources/js/components/draft-link-preview.tsx @@ -0,0 +1,98 @@ +import { Link2 } from 'lucide-react'; +import DraftLinkPreviewController from '@/actions/App/Http/Controllers/DraftLinkPreviewController'; +import { GifEmbed } from '@/components/gif-embed'; +import { LinkPreview } from '@/components/link-preview'; +import { useDraftLinkPreview } from '@/hooks/use-draft-link-preview'; +import { isGifUrl } from '@/lib/gifs'; +import { hostname } from '@/lib/urls'; +import type { LinkPreview as LinkPreviewData } from '@/types'; + +function DraftLinkPreviewFallback({ + url, + isLoading, +}: { + url: string; + isLoading: boolean; +}) { + return ( +
+ +
+

+ {url.replace(/^https?:\/\//i, '')} +

+

+ {isLoading + ? 'Loading link preview…' + : 'Link preview unavailable'} +

+
+
+ ); +} + +export function DraftLinkPreview({ url }: { url: string | null }) { + const { preview, isLoading } = useDraftLinkPreview(url); + + if (url === null) { + return null; + } + + return ( +
+ +
+ ); +} + +function DraftLinkPreviewContent({ + url, + preview, + isLoading, +}: { + url: string; + preview: LinkPreviewData | null; + isLoading: boolean; +}) { + if (preview) { + return ( + + } + /> + ); + } + + if (isGifUrl(url) || hostname(url) === 'giphy.com') { + return ( + + ); + } + + return ; +} 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..979c38abb 100644 --- a/resources/js/components/link-preview.tsx +++ b/resources/js/components/link-preview.tsx @@ -1,83 +1,12 @@ -import { useEffect, useState } from 'react'; +import { useState, type ReactNode } from 'react'; +import { GifEmbed } from '@/components/gif-embed'; import { LinkEmbed } from '@/components/link-embed'; import { Skeleton } from '@/components/ui/skeleton'; +import { useFetchedLinkPreview } from '@/hooks/use-fetched-link-preview'; +import { isGifUrl } from '@/lib/gifs'; import { hostname } from '@/lib/urls'; import type { LinkPreview as LinkPreviewData } from '@/types'; -const POLL_DELAYS = [1000, 2000, 3000, 5000, 8000, 13000]; - -/** - * A link is unfurled in the background after its commit or message is posted, so a pending preview is fetched again - * from the refresh URL until the page behind it has been read, instead of only showing up after a reload. - */ -function useFetchedLinkPreview( - refreshUrl: string, - preview: LinkPreviewData, -): { preview: LinkPreviewData; isWaiting: boolean } { - const [fetched, setFetched] = useState(null); - const [hasGivenUp, setHasGivenUp] = useState(false); - - useEffect(() => { - if (!preview.is_pending) { - return; - } - - const controller = new AbortController(); - let timeout = 0; - - const poll = async (attempt: number) => { - try { - const response = await fetch(refreshUrl, { - headers: { Accept: 'application/json' }, - cache: 'no-store', - signal: controller.signal, - }); - - if (!response.ok) { - setHasGivenUp(true); - - return; - } - - const { link_preview } = (await response.json()) as { - link_preview: LinkPreviewData | null; - }; - - if (link_preview && !link_preview.is_pending) { - setFetched(link_preview); - - return; - } - } catch { - if (controller.signal.aborted) { - return; - } - } - - if (attempt + 1 < POLL_DELAYS.length) { - timeout = window.setTimeout( - () => void poll(attempt + 1), - POLL_DELAYS[attempt + 1], - ); - } else { - setHasGivenUp(true); - } - }; - - timeout = window.setTimeout(() => void poll(0), POLL_DELAYS[0]); - - return () => { - window.clearTimeout(timeout); - controller.abort(); - }; - }, [refreshUrl, preview.is_pending]); - - const current = - preview.is_pending && fetched?.url === preview.url ? fetched : preview; - - return { preview: current, isWaiting: current.is_pending && !hasGivenUp }; -} - function LinkPreviewSkeleton() { return (
+ ); + } + if (isWaiting) { return ; } if (!preview.title) { - return null; + return emptyState; } const content = ( diff --git a/resources/js/hooks/use-draft-link-preview.ts b/resources/js/hooks/use-draft-link-preview.ts new file mode 100644 index 000000000..3e8e7ddb7 --- /dev/null +++ b/resources/js/hooks/use-draft-link-preview.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; +import DraftLinkPreviewController from '@/actions/App/Http/Controllers/DraftLinkPreviewController'; +import type { LinkPreview } from '@/types'; + +const PREVIEW_DELAY_MS = 400; + +type PreviewResult = { url: string; preview: LinkPreview | null }; +type PreviewResponse = { link_preview: LinkPreview | null }; + +async function fetchDraftLinkPreview( + url: string, + signal: AbortSignal, +): Promise { + try { + const response = await fetch( + DraftLinkPreviewController.show.url({ query: { url } }), + { headers: { Accept: 'application/json' }, signal }, + ); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as PreviewResponse; + + return data.link_preview; + } catch { + return null; + } +} + +export function useDraftLinkPreview(url: string | null) { + const [result, setResult] = useState(null); + + useEffect(() => { + if (url === null) { + return; + } + + const controller = new AbortController(); + const load = async () => { + const preview = await fetchDraftLinkPreview(url, controller.signal); + + if (controller.signal.aborted) { + return; + } + + setResult({ url, preview }); + }; + + const timeout = window.setTimeout(() => void load(), PREVIEW_DELAY_MS); + + return () => { + window.clearTimeout(timeout); + controller.abort(); + }; + }, [url]); + + return { + preview: result?.url === url ? result.preview : null, + isLoading: url !== null && result?.url !== url, + }; +} diff --git a/resources/js/hooks/use-fetched-link-preview.ts b/resources/js/hooks/use-fetched-link-preview.ts new file mode 100644 index 000000000..f2c1d3824 --- /dev/null +++ b/resources/js/hooks/use-fetched-link-preview.ts @@ -0,0 +1,206 @@ +import { useEffect, useState } from 'react'; +import type { LinkPreview } from '@/types'; + +const POLL_DELAYS = [1000, 2000, 3000, 5000, 8000, 13000]; +const MAX_RETRY_AFTER_MS = 60_000; +const MILLISECONDS_PER_SECOND = 1000; +const HTTP_TOO_MANY_REQUESTS = 429; + +type PollCycle = { refreshUrl: string; url: string; isPending: boolean }; +type PollState = PollCycle & { + fetched: LinkPreview | null; + hasGivenUp: boolean; +}; +type PreviewResponse = { link_preview: LinkPreview | null }; +type PollResponse = { response: Response; preview: LinkPreview | null }; + +async function requestLinkPreview( + refreshUrl: string, + signal: AbortSignal, +): Promise { + try { + const response = await fetch(refreshUrl, { + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal, + }); + + if (signal.aborted) { + return null; + } + + if (!response.ok) { + return { response, preview: null }; + } + + const data = (await response.json()) as PreviewResponse; + + if (signal.aborted) { + return null; + } + + return { response, preview: data.link_preview }; + } catch { + return null; + } +} + +function nextPollDelay( + response: Response | null, + fallback: number | null, +): number | null { + if (response === null || response.ok) { + return fallback; + } + + if (response.status !== HTTP_TOO_MANY_REQUESTS || fallback === null) { + return null; + } + + return retryAfterDelay(response, fallback); +} + +function retryAfterDelay(response: Response, fallback: number): number | null { + const retryAfter = response.headers.get('Retry-After'); + + if (retryAfter === null) { + return fallback; + } + + const seconds = Number(retryAfter); + + if (Number.isFinite(seconds)) { + return retryAfterMilliseconds( + seconds * MILLISECONDS_PER_SECOND, + fallback, + ); + } + + const date = Date.parse(retryAfter); + + if (Number.isNaN(date)) { + return fallback; + } + + return retryAfterMilliseconds(Math.max(date - Date.now(), 0), fallback); +} + +function retryAfterMilliseconds( + delay: number, + fallback: number, +): number | null { + if (delay > MAX_RETRY_AFTER_MS) { + return null; + } + + return Math.max(fallback, delay); +} + +function isCurrentPollCycle(state: PollState, cycle: PollCycle): boolean { + return ( + state.refreshUrl === cycle.refreshUrl && + state.url === cycle.url && + state.isPending === cycle.isPending + ); +} + +function syncPollState(state: PollState, cycle: PollCycle): PollState { + if (isCurrentPollCycle(state, cycle)) { + return state; + } + + return { ...cycle, fetched: null, hasGivenUp: false }; +} + +export function useFetchedLinkPreview( + refreshUrl: string, + preview: LinkPreview, +): { preview: LinkPreview; isWaiting: boolean } { + const { url, is_pending: isPending } = preview; + const cycle = { refreshUrl, url, isPending }; + const [pollState, setPollState] = useState({ + ...cycle, + fetched: null, + hasGivenUp: false, + }); + const currentPollState = syncPollState(pollState, cycle); + + if (currentPollState !== pollState) { + setPollState(currentPollState); + } + + useEffect(() => { + if (!isPending) { + return; + } + + const activeCycle = { refreshUrl, url, isPending }; + const controller = new AbortController(); + let timeout = 0; + + const giveUp = () => { + setPollState((state) => { + if (!isCurrentPollCycle(state, activeCycle)) { + return state; + } + + return { ...state, hasGivenUp: true }; + }); + }; + + const poll = async (attempt: number) => { + const result = await requestLinkPreview( + refreshUrl, + controller.signal, + ); + + if (controller.signal.aborted) { + return; + } + + const fetched = result?.preview; + + if (fetched && !fetched.is_pending) { + setPollState((state) => { + if (!isCurrentPollCycle(state, activeCycle)) { + return state; + } + + return { ...state, fetched }; + }); + + return; + } + + const delay = nextPollDelay( + result?.response ?? null, + POLL_DELAYS[attempt + 1] ?? null, + ); + + if (delay === null) { + giveUp(); + + return; + } + + timeout = window.setTimeout(() => void poll(attempt + 1), delay); + }; + + timeout = window.setTimeout(() => void poll(0), POLL_DELAYS[0]); + + return () => { + window.clearTimeout(timeout); + controller.abort(); + }; + }, [isPending, refreshUrl, url]); + + const current = + isPending && currentPollState.fetched?.url === url + ? currentPollState.fetched + : preview; + + return { + preview: current, + isWaiting: current.is_pending && !currentPollState.hasGivenUp, + }; +} 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/routes/web.php b/routes/web.php index 19685915d..532006b7b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -43,6 +43,7 @@ use App\Http\Controllers\ConversationReadController; use App\Http\Controllers\ConversationTypingController; use App\Http\Controllers\DeviceController; +use App\Http\Controllers\DraftLinkPreviewController; use App\Http\Controllers\EmailController; use App\Http\Controllers\EmailDraftController; use App\Http\Controllers\EmailPreviewController; @@ -303,6 +304,9 @@ ->name('commit.store'); Route::post('commits/preview', [CommitPreviewController::class, 'store']) ->name('commit-preview.store'); + Route::get('draft-link-preview', [DraftLinkPreviewController::class, 'show']) + ->middleware('throttle:'.DraftLinkPreviewController::READS_PER_MINUTE.',1,draft-link-preview-reads:') + ->name('draft-link-preview.show'); Route::post('commits/{commit}/translation', [CommitTranslationController::class, 'store']) ->middleware('throttle:10,1,translations:') ->name('commit-translation.store'); diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index 1eb4c87d5..846a2ecdc 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -129,6 +129,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/Feature/Controllers/ConversationMessageControllerTest.php b/tests/Feature/Controllers/ConversationMessageControllerTest.php index 19327838a..2b0b12b93 100644 --- a/tests/Feature/Controllers/ConversationMessageControllerTest.php +++ b/tests/Feature/Controllers/ConversationMessageControllerTest.php @@ -2,8 +2,10 @@ declare(strict_types=1); +use App\Actions\UpdateLinkPreview; use App\Jobs\UnfurlLink; use App\Models\Conversation; +use App\Models\LinkPreview; use App\Models\Message; use App\Models\MessageVoice; use App\Models\User; @@ -13,6 +15,7 @@ use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -226,6 +229,50 @@ 'no card' => [['link_preview_url' => ''], null], ]); +it('retries a failed draft preview when it is sent in a message', function (): void { + Queue::fake(); + $url = 'https://93.184.216.34/retry'; + Http::fake([$url => Http::response('Back online', 200, ['Content-Type' => 'text/html'])]); + $preview = LinkPreview::factory()->failed()->create(['url' => $url]); + $sender = User::factory()->withRemotes()->create(); + $recipient = User::factory()->withRemotes()->create(); + + $this->actingAs($sender)->postJson(route('conversation-message.store', $recipient), [ + 'body' => 'Look at '.$url, + 'client_id' => Str::uuid()->toString(), + ])->assertCreated() + ->assertJsonPath('message.link_preview.is_pending', true); + + $messagePreview = LinkPreview::query()->findOrFail($preview->id); + + expect($messagePreview) + ->id->toBe($preview->id) + ->fetched_at->toBeNull(); + Queue::assertPushed(UnfurlLink::class, fn (UnfurlLink $job): bool => $job->linkPreview->is($preview)); + + new UnfurlLink($messagePreview)->handle(resolve(UpdateLinkPreview::class)); + + expect($preview->refresh()) + ->title->toBe('Back online') + ->fetched_at->not->toBeNull(); +}); + +it('enqueues a draft preview that is already pending when it is sent in a message', function (): void { + Queue::fake(); + $url = 'https://example.com/pending'; + LinkPreview::factory()->create(['url' => $url]); + $sender = User::factory()->withRemotes()->create(); + $recipient = User::factory()->withRemotes()->create(); + + $this->actingAs($sender)->postJson(route('conversation-message.store', $recipient), [ + 'body' => 'Look at '.$url, + 'client_id' => Str::uuid()->toString(), + ])->assertCreated() + ->assertJsonPath('message.link_preview.is_pending', true); + + Queue::assertPushed(UnfurlLink::class); +}); + it('refuses a link card for a link that is not in the message', function (): void { $mark = User::factory()->withRemotes()->create(); $nuno = User::factory()->withRemotes()->create(); diff --git a/tests/Feature/Controllers/DraftLinkPreviewControllerTest.php b/tests/Feature/Controllers/DraftLinkPreviewControllerTest.php new file mode 100644 index 000000000..bb236150b --- /dev/null +++ b/tests/Feature/Controllers/DraftLinkPreviewControllerTest.php @@ -0,0 +1,421 @@ +app->instance(DnsResolver::class, new readonly class implements DnsResolver + { + private const array HOSTS = [ + 'example.com' => ['93.184.216.34'], + 'www.example.com' => ['93.184.216.35'], + 'giphy.com' => ['104.16.105.123'], + 'mixed.example.com' => ['93.184.216.34', '64:ff9b:2::7f00:1'], + 'nat64.example.com' => ['64:ff9b:2::7f00:1'], + ]; + + public function resolve(string $host): array + { + return self::HOSTS[$host] ?? []; + } + }); +}); + +function draftPreviewUrl(string $url): string +{ + return '/draft-link-preview?'.http_build_query(['url' => $url]); +} + +function fetchQueuedDraftPreview(): LinkPreview +{ + $preview = LinkPreview::query()->sole(); + Queue::assertPushed(UnfurlLink::class, fn (UnfurlLink $job): bool => $job->linkPreview->is($preview)); + new UnfurlLink($preview)->handle(resolve(UpdateLinkPreview::class)); + + return $preview->refresh(); +} + +it('requires authentication', function (): void { + Log::spy(); + $this->getJson(draftPreviewUrl('https://example.com/cat.gif'))->assertUnauthorized(); + + Http::assertNothingSent(); + Queue::assertNothingPushed(); + Log::shouldNotHaveReceived('info'); + expect(LinkPreview::query()->count())->toBe(0); +}); + +it('validates draft link preview URLs before creating cache entries', function (mixed $url): void { + Log::spy(); + $this->actingAs(User::factory()->create()) + ->getJson('/draft-link-preview', ['url' => $url]) + ->assertUnprocessable() + ->assertJsonValidationErrors('url'); + + Http::assertNothingSent(); + Queue::assertNothingPushed(); + Log::shouldNotHaveReceived('info'); + expect(LinkPreview::query()->count())->toBe(0); +})->with([ + 'missing' => null, + 'empty' => '', + 'unsupported protocol' => 'ftp://example.com/cat.gif', + 'too long' => 'https://example.com/'.str_repeat('x', 2048), +]); + +it('returns a Giphy embed without fetching or caching a draft', function (string $host): void { + $url = 'https://'.$host.'/gifs/JIX9t2j0ZTN9S'; + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($url)) + ->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertExactJson(['link_preview' => [ + 'url' => $url, + 'title' => null, + 'description' => null, + 'image_url' => null, + 'site_name' => null, + 'is_pending' => false, + 'embed' => [ + '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', + ], + ]]); + + Http::assertNothingSent(); + Queue::assertNothingPushed(); + expect(LinkPreview::query()->count())->toBe(0); +})->with(['giphy.com', 'www.giphy.com']); + +it('queues a plain Giphy card when GIF embeds are unavailable', function (array $config): void { + config($config); + $url = 'https://giphy.com/gifs/JIX9t2j0ZTN9S'; + Http::fake([$url => Http::response('Giphy card', 200, ['Content-Type' => 'text/html'])]); + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.is_pending', true); + + Http::assertNothingSent(); + fetchQueuedDraftPreview(); + $this->getJson(draftPreviewUrl($url)) + ->assertOk() + ->assertJsonPath('link_preview.title', 'Giphy card') + ->assertJsonPath('link_preview.embed', null); + + Http::assertSentCount(1); +})->with([ + 'embeds disabled' => [['embeds.enabled' => false]], + 'Giphy excluded' => [['embeds.only' => ['youtube']]], +]); + +it('queues uncached previews without resolving DNS or publishing content', function (): void { + $this->app->instance(DnsResolver::class, new readonly class implements DnsResolver + { + public function resolve(string $host): array + { + throw new RuntimeException('DNS must run on the queue worker.'); + } + }); + $url = 'https://example.com/cat.gif'; + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($url).'&gif_url=https%3A%2F%2Fevil.example%2Fcat.gif') + ->assertOk() + ->assertExactJson(['link_preview' => [ + 'url' => $url, + 'title' => null, + 'description' => null, + 'image_url' => null, + 'site_name' => null, + 'is_pending' => true, + 'embed' => null, + ]]); + + Http::assertNothingSent(); + $preview = LinkPreview::query()->sole(); + expect($preview->url_hash)->toBe(hash('sha256', $url)) + ->and($preview->gif_url)->toBeNull() + ->and(Commit::query()->count())->toBe(0); + Queue::assertPushed(UnfurlLink::class, fn (UnfurlLink $job): bool => $job->linkPreview->is($preview)); +}); + +it('returns verified GIFs from the shared cache after the queued fetch', function (bool $redirect): void { + $source = 'https://example.com/cat.gif'; + $final = $redirect ? 'https://www.example.com/media?id=cat' : $source; + Http::fake([ + $source => $redirect + ? Http::response(status: 302, headers: ['Location' => $final]) + : Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 206, ['Content-Type' => 'image/gif']), + ...($redirect ? [$final => Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 206, ['Content-Type' => 'image/gif'])] : []), + ]); + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($source))->assertOk()->assertJsonPath('link_preview.is_pending', true); + Http::assertNothingSent(); + fetchQueuedDraftPreview(); + + $this->getJson(draftPreviewUrl($source)) + ->assertOk() + ->assertJsonPath('link_preview.url', $source) + ->assertJsonPath('link_preview.is_pending', false) + ->assertJsonPath('link_preview.embed.provider', 'GIF') + ->assertJsonPath('link_preview.embed.kind', 'gif') + ->assertJsonPath('link_preview.embed.src', $final) + ->assertJsonPath('link_preview.embed.aspect_ratio', null); + + Http::assertSentCount($redirect ? 2 : 1); + Queue::assertPushed(UnfurlLink::class, 1); + expect(LinkPreview::query()->count())->toBe(1); +})->with(['direct' => false, 'extensionless redirect' => true]); + +it('does not create an embed when the queued GIF check fails', function (): void { + $url = 'https://example.com/cat.gif'; + Http::fake([$url => Http::response('Not a GIF', 200, ['Content-Type' => 'image/gif'])]); + + $this->actingAs(User::factory()->create())->getJson(draftPreviewUrl($url))->assertOk(); + fetchQueuedDraftPreview(); + $this->getJson(draftPreviewUrl($url)) + ->assertOk()->assertJsonPath('link_preview.is_pending', false)->assertJsonPath('link_preview.embed', null); +}); + +it('keeps the shared address guard on queued draft fetches', function (string $host): void { + $url = 'https://'.$host.'/cat.gif'; + $this->actingAs(User::factory()->create())->getJson(draftPreviewUrl($url))->assertOk(); + fetchQueuedDraftPreview(); + + $this->getJson(draftPreviewUrl($url)) + ->assertOk()->assertJsonPath('link_preview.is_pending', false)->assertJsonPath('link_preview.embed', null); + Http::assertNothingSent(); +})->with(['mixed.example.com', 'nat64.example.com']); + +it('does not follow queued redirects to an unsafe DNS destination', function (): void { + $url = 'https://example.com/cat.gif'; + Http::fake([$url => Http::response(status: 302, headers: ['Location' => 'https://nat64.example.com/cat.gif'])]); + + $this->actingAs(User::factory()->create())->getJson(draftPreviewUrl($url))->assertOk(); + fetchQueuedDraftPreview(); + $this->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.embed', null); + Http::assertSentCount(1); +}); + +it('leaves a transient failure pending for the existing job retry policy', function (): void { + $url = 'https://example.com/cat.gif'; + Http::fake([$url => Http::failedConnection()]); + + $this->actingAs(User::factory()->create())->getJson(draftPreviewUrl($url))->assertOk(); + expect(fn (): LinkPreview => fetchQueuedDraftPreview())->toThrow(ConnectionException::class); + $this->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.is_pending', true); + Queue::assertPushed(UnfurlLink::class, 1); + Http::assertSentCount(1); +}); + +it('reuses completed metadata without an independent expiry policy', function (int $ageInDays): void { + $preview = LinkPreview::factory()->fetched()->create([ + 'url' => 'https://example.com/cat.gif', + 'gif_url' => 'https://www.example.com/media?id=cat', + 'fetched_at' => now()->subDays($ageInDays), + ]); + $original = $preview->refresh()->getAttributes(); + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($preview->url)) + ->assertOk() + ->assertJsonPath('link_preview.title', $preview->title) + ->assertJsonPath('link_preview.embed.src', $preview->gif_url) + ->assertJsonPath('link_preview.is_pending', false); + + Http::assertNothingSent(); + Queue::assertNothingPushed(); + expect($preview->fresh()->getAttributes())->toBe($original) + ->and(LinkPreview::query()->count())->toBe(1); +})->with([0, 30]); + +it('does not retry a terminal empty cache row just because a draft polls it', function (): void { + $preview = LinkPreview::factory()->failed()->create(['url' => 'https://example.com/unavailable']); + $this->actingAs(User::factory()->create()); + + foreach (range(1, 7) as $attempt) { + $this->getJson(draftPreviewUrl($preview->url)) + ->assertOk() + ->assertJsonPath('link_preview.is_pending', false) + ->assertJsonPath('link_preview.title', null) + ->assertJsonPath('link_preview.embed', null); + } + + Queue::assertNothingPushed(); + Http::assertNothingSent(); +}); + +it('reuses pending rows and polling never enqueues duplicate work', function (): void { + $url = 'https://example.com/article'; + $this->actingAs(User::factory()->create()); + foreach (range(1, 7) as $attempt) { + $this->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.is_pending', true); + } + + Queue::assertPushed(UnfurlLink::class, 1); + Http::assertNothingSent(); + expect(LinkPreview::query()->count())->toBe(1); +}); + +it('dispatches only after its cache transaction commits', function (): void { + $transactionLevel = DB::transactionLevel(); + Bus::shouldReceive('dispatch')->once() + ->withArgs(fn (UnfurlLink $job): bool => DB::transactionLevel() === $transactionLevel) + ->andReturnNull(); + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl('https://example.com/article'))->assertOk(); +}); + +it('ends the pending state when the real queue driver cannot enqueue the job', function (): void { + config(['queue.default' => 'database']); + Queue::swap(Queue::fake()->queue); + DB::connection()->beforeExecuting(function (string $query): void { + throw_if(str_starts_with($query, 'insert into "jobs"'), RuntimeException::class, 'Queue unavailable'); + }); + $this->withoutExceptionHandling(); + $user = User::factory()->create(); + $this->actingAs($user); + + expect(fn () => $this->getJson(draftPreviewUrl('https://example.com/article'))) + ->toThrow(RuntimeException::class, 'Queue unavailable'); + + expect(LinkPreview::query()->sole()->fetched_at)->not->toBeNull() + ->and(DB::table('jobs')->count())->toBe(0) + ->and(RateLimiter::attempts('draft-link-unfurls:'.$user->id))->toBe(1); + Http::assertNothingSent(); +}); + +it('runs an uncached preview on the database queue and returns it on polling', function (): void { + config(['queue.default' => 'database']); + Queue::swap(Queue::fake()->queue); + $url = 'https://example.com/article'; + Http::fake([$url => Http::response('Queued article', 200, ['Content-Type' => 'text/html'])]); + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.is_pending', true); + Http::assertNothingSent(); + + expect(DB::table('jobs')->count())->toBe(1); + $this->artisan('queue:work', ['connection' => 'database', '--once' => true])->assertSuccessful(); + expect(DB::table('failed_jobs')->count())->toBe(0); + Http::assertSentCount(1); + $this->getJson(draftPreviewUrl($url)) + ->assertOk()->assertJsonPath('link_preview.title', 'Queued article')->assertJsonPath('link_preview.is_pending', false); + Http::assertSentCount(1); +}); + +it('looks up only the requested URL hash', function (): void { + LinkPreview::factory()->fetched()->create(['url' => 'https://example.com/other']); + $url = 'https://example.com/article'; + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.is_pending', true); + expect(LinkPreview::query()->count())->toBe(2); + Queue::assertPushed(UnfurlLink::class, fn (UnfurlLink $job): bool => $job->linkPreview->url === $url); + Http::assertNothingSent(); +}); + +it('records the authenticated user and host without logging draft URL secrets', function (): void { + Log::spy(); + $user = User::factory()->create(); + $this->actingAs($user) + ->getJson(draftPreviewUrl('https://example.com/cat.gif?token=private-draft-token'))->assertOk(); + Log::shouldHaveReceived('info')->once()->with('Draft link preview requested', [ + 'user_id' => $user->id, + 'host' => 'example.com', + ]); + Http::assertNothingSent(); +}); + +it('reuses the fetched draft metadata when the URL is published', function (): void { + $url = 'https://example.com/cat.gif'; + Http::fake([$url => Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 206, ['Content-Type' => 'image/gif'])]); + $user = User::factory()->create(); + $this->actingAs($user)->getJson(draftPreviewUrl($url))->assertOk(); + $preview = fetchQueuedDraftPreview(); + Queue::fake(); + + $commit = resolve(CreateCommit::class)->handle($user, 'Look at '.$url); + expect($commit->link_preview_id)->toBe($preview->id) + ->and(LinkPreview::query()->count())->toBe(1); + Queue::assertNotPushed(UnfurlLink::class); + Http::assertSentCount(1); +}); + +it('limits new unfurls without charging cached polls to that budget', function (): void { + $this->actingAs(User::factory()->create()); + foreach (range(1, 10) as $attempt) { + $this->getJson(draftPreviewUrl('https://example.com/article/'.$attempt))->assertOk(); + } + + $blocked = 'https://example.com/another'; + $this->getJson(draftPreviewUrl($blocked))->assertTooManyRequests()->assertHeader('Retry-After'); + expect(LinkPreview::query()->count())->toBe(10); + + foreach (range(1, 7) as $attempt) { + $this->getJson(draftPreviewUrl('https://example.com/article/1'))->assertOk(); + } + + Queue::assertPushed(UnfurlLink::class, 10); + $this->actingAs(User::factory()->create())->getJson(draftPreviewUrl($blocked))->assertOk(); + Queue::assertPushed(UnfurlLink::class, 11); + Http::assertNothingSent(); +}); + +it('allows two pending cards to complete their polling cycles in one minute', function (): void { + $this->actingAs(User::factory()->create()); + foreach (['https://example.com/first', 'https://example.com/second'] as $url) { + foreach (range(1, 7) as $attempt) { + $this->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.is_pending', true); + } + } + + Queue::assertPushed(UnfurlLink::class, 2); + Http::assertNothingSent(); +}); + +it('allows new unfurls again after the work budget expires', function (): void { + $this->actingAs(User::factory()->create()); + foreach (range(1, 10) as $attempt) { + $this->getJson(draftPreviewUrl('https://example.com/article/'.$attempt))->assertOk(); + } + + $url = 'https://example.com/after-limit'; + $this->getJson(draftPreviewUrl($url))->assertTooManyRequests(); + $this->travel(61)->seconds(); + $this->getJson(draftPreviewUrl($url))->assertOk(); + Queue::assertPushed(UnfurlLink::class, 11); +}); + +it('limits preview reads separately from new work', function (): void { + $this->actingAs(User::factory()->create()); + $url = draftPreviewUrl('https://giphy.com/gifs/JIX9t2j0ZTN9S'); + foreach (range(1, 120) as $attempt) { + $this->getJson($url)->assertOk(); + } + + $this->getJson($url)->assertTooManyRequests(); + Queue::assertNothingPushed(); + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Controllers/UserPreviewControllerTest.php b/tests/Feature/Controllers/UserPreviewControllerTest.php index b11bc9052..781b41abf 100644 --- a/tests/Feature/Controllers/UserPreviewControllerTest.php +++ b/tests/Feature/Controllers/UserPreviewControllerTest.php @@ -175,7 +175,7 @@ }); it('includes the shared local time in the user preview', function (): void { - User::factory()->create(['username' => 'octocat', 'timezone' => 'Europe/Berlin', 'show_local_time' => true]); + User::factory()->create(['handle' => 'octocat', 'timezone' => 'Europe/Berlin', 'show_local_time' => true]); $this->actingAs(User::factory()->create()) ->getJson('/@octocat/preview') 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 @@ +make(Kernel::class)->bootstrap(); + +$preview = LinkPreview::query()->findOrFail($argv[1]); +$directory = $argv[2]; +$isFirst = $argv[3] === 'first'; +$job = new UnfurlLink($preview); +$pid = DB::selectOne('select pg_backend_pid() as pid')->pid; +fwrite(STDOUT, "PID:{$pid}\n"); + +Http::preventStrayRequests(); +Http::fake(function () use ($directory, $isFirst) { + file_put_contents($directory.'/requests', "request\n", FILE_APPEND | LOCK_EX); + + if ($isFirst) { + fwrite(STDOUT, "FETCHING\n"); + $deadline = microtime(true) + RELEASE_TIMEOUT_SECONDS; + + while (! is_file($directory.'/release')) { + throw_if(microtime(true) > $deadline, RuntimeException::class, 'Concurrent unfurl fixture was not released.'); + + Sleep::usleep(POLL_INTERVAL_MICROSECONDS); + } + } + + return Http::response('Concurrent preview', 200, ['Content-Type' => 'text/html']); +}); + +$job->handle($app->make(UpdateLinkPreview::class)); diff --git a/tests/Mutation/GifSecurity.test.ts b/tests/Mutation/GifSecurity.test.ts new file mode 100644 index 000000000..464bae595 --- /dev/null +++ b/tests/Mutation/GifSecurity.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import type { SpawnSyncReturns } from 'node:child_process'; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { phpGifMutations } from './php-gif-mutations.ts'; +import type { GifMutation } from './php-gif-mutations.ts'; +import { playbackMutations } from './playback-mutations.ts'; + +const root = fileURLToPath(new URL('../..', import.meta.url)); +const temporary = mkdtempSync(join(tmpdir(), 'devnull-gif-mutations-')); +const mutationTarget = 'PEST_MUTATION_TESTING'; +const mutationSource = 'PEST_MUTATION_FILE'; +const timeoutMilliseconds = 60_000; +const controls = new Set(); + +function runPhp( + mutation: GifMutation, + source: string, +): SpawnSyncReturns { + const modifiedFile = join(temporary, 'mutated.php'); + writeFileSync(modifiedFile, source); + + return spawnSync( + 'php', + [ + 'vendor/bin/pest', + '--ci', + '--compact', + '--colors=never', + mutation.test, + ], + { + cwd: root, + encoding: 'utf8', + timeout: timeoutMilliseconds, + env: { + ...process.env, + [mutationTarget]: join(root, mutation.source), + [mutationSource]: modifiedFile, + }, + }, + ); +} + +function runPlayback( + mutation: GifMutation, + source: string, +): SpawnSyncReturns { + const modifiedFile = join(temporary, mutation.source); + const testFile = join(temporary, mutation.test); + mkdirSync(dirname(modifiedFile), { recursive: true }); + mkdirSync(dirname(testFile), { recursive: true }); + writeFileSync(join(temporary, 'package.json'), '{"type":"module"}'); + writeFileSync(modifiedFile, source); + writeFileSync(testFile, readFileSync(join(root, mutation.test))); + const environment = { ...process.env }; + delete environment.NODE_TEST_CONTEXT; + + return spawnSync( + process.execPath, + ['--experimental-strip-types', '--test', testFile], + { + encoding: 'utf8', + timeout: timeoutMilliseconds, + env: environment, + }, + ); +} + +function output(result: SpawnSyncReturns): string { + assert.ifError(result.error); + assert.notEqual(result.status, null, 'Mutation process did not finish'); + + return result.stdout + result.stderr; +} + +type Runner = ( + mutation: GifMutation, + source: string, +) => SpawnSyncReturns; + +function verifyControl( + mutation: GifMutation, + source: string, + run: Runner, +): void { + if (controls.has(mutation.source)) { + return; + } + + const control = run(mutation, source); + const report = output(control); + assert.equal(control.status, 0, report); + assert.match( + report, + run === runPhp ? /Tests:\s+.*\bpassed\b/ : /\bpass [1-9]\d*/, + report, + ); + controls.add(mutation.source); +} + +function checkMutation(mutation: GifMutation): void { + const original = readFileSync(join(root, mutation.source), 'utf8'); + const run = mutation.source.endsWith('.php') ? runPhp : runPlayback; + verifyControl(mutation, original, run); + assert.ok( + original.includes(mutation.before), + `Mutation no longer matches ${mutation.source}`, + ); + const result = run( + mutation, + original.replace(mutation.before, mutation.after), + ); + const report = output(result); + assert.notEqual( + result.status, + 0, + `Mutation survived: ${mutation.name}\n${report}`, + ); + assert.match( + report, + run === runPhp ? /Tests:\s+.*\bfailed\b/ : /ERR_ASSERTION/, + report, + ); +} + +await test('GIF security mutation gate', async (context) => { + context.after(() => rmSync(temporary, { recursive: true, force: true })); + + for (const mutation of [...phpGifMutations, ...playbackMutations]) { + await context.test(`detects mutation: ${mutation.name}`, () => + checkMutation(mutation), + ); + } +}); diff --git a/tests/Mutation/php-gif-mutations.ts b/tests/Mutation/php-gif-mutations.ts new file mode 100644 index 000000000..a254509f7 --- /dev/null +++ b/tests/Mutation/php-gif-mutations.ts @@ -0,0 +1,199 @@ +export type GifMutation = { + name: string; + source: string; + test: string; + before: string; + after: string; +}; + +export const phpGifMutations: GifMutation[] = [ + { + name: 'concurrent workers must serialize an unfurl', + source: 'app/Jobs/UnfurlLink.php', + test: 'tests/Unit/Jobs/UnfurlLinkConcurrencyTest.php', + before: '->lock(self::METADATA_LOCK)', + after: '', + }, + { + name: 'unfurl workers must allow foreign-key references', + source: 'app/Jobs/UnfurlLink.php', + test: 'tests/Unit/Jobs/UnfurlLinkConcurrencyTest.php', + before: '->lock(self::METADATA_LOCK)', + after: '->lockForUpdate()', + }, + { + name: 'publication retries stay bounded', + source: 'app/Models/LinkPreview.php', + test: 'tests/Unit/Actions/CreateCommitTest.php', + before: " ->where('publication_unfurl_retries', '<', self::MAX_PUBLICATION_UNFURL_RETRIES)\n", + after: '', + }, + { + name: 'definitive unfurl outcomes remain cached', + source: 'app/Models/LinkPreview.php', + test: 'tests/Unit/Actions/CreateCommitTest.php', + before: " ->whereNotNull('unfurl_failed_at')\n", + after: '', + }, + { + name: 'redirect limit cannot increase', + source: 'app/Actions/UpdateLinkPreview.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'MAX_REDIRECTS = 3', + after: 'MAX_REDIRECTS = 4', + }, + { + name: 'redirect limit cannot decrease', + source: 'app/Actions/UpdateLinkPreview.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'MAX_REDIRECTS = 3', + after: 'MAX_REDIRECTS = 2', + }, + { + name: 'redirect cycles stop before refetching', + source: 'app/Actions/UpdateLinkPreview.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'if (isset($visited[$source]))', + after: 'if (false)', + }, + { + name: 'non-default ports are rejected', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'if ($url->getPort() !== null)', + after: 'if (false)', + }, + { + name: 'non-global addresses are rejected', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) !== false', + after: 'return true', + }, + { + name: 'Symfony special-use subnets stay blocked', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Services/LinkPreviewAddressTest.php', + before: '...IpUtils::PRIVATE_SUBNETS,', + after: '', + }, + { + name: 'additional relay ranges stay blocked', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: "'192.88.99.0/24',", + after: '', + }, + { + name: 'all DNS answers must be public', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'array_all($addresses, $this->isPublic(...))', + after: 'true', + }, + { + name: 'NAT64 translations stay blocked', + source: 'app/Services/LinkPreviewAddress.php', + test: 'tests/Unit/Services/LinkPreviewAddressTest.php', + before: "'64:ff9b::/32',", + after: '', + }, + { + name: 'response body is capped', + source: 'app/Services/LimitedResponseBody.php', + test: 'tests/Unit/Services/LimitedResponseBodyTest.php', + before: '$remaining = max(0, $this->maxBytes - $this->tell());', + after: '$remaining = PHP_INT_MAX;', + }, + { + name: 'seeking cannot bypass the byte cap', + source: 'app/Services/LimitedResponseBody.php', + test: 'tests/Unit/Services/LimitedResponseBodyTest.php', + before: 'max(0, $this->maxBytes - $this->tell())', + after: 'max(-1, $this->maxBytes - $this->tell())', + }, + { + name: 'overall deadline stays at five seconds', + source: 'app/Services/LinkPreviewBudget.php', + test: 'tests/Unit/Services/LinkPreviewBudgetTest.php', + before: 'TIMEOUT_SECONDS = 5', + after: 'TIMEOUT_SECONDS = 6', + }, + { + name: 'remaining timeout never rounds upward', + source: 'app/Services/LinkPreviewBudget.php', + test: 'tests/Unit/Services/LinkPreviewBudgetTest.php', + before: 'floor(($deadline - $this->seconds())', + after: 'round(($deadline - $this->seconds())', + }, + { + name: 'automatic HTTP redirects stay disabled', + source: 'app/Services/LinkPreviewHttpClient.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: '->withoutRedirecting()', + after: '->withOptions([RequestOptions::ALLOW_REDIRECTS => true])', + }, + { + name: 'automatic decompression stays disabled', + source: 'app/Services/LinkPreviewHttpClient.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'RequestOptions::DECODE_CONTENT => false', + after: 'RequestOptions::DECODE_CONTENT => true', + }, + { + name: 'requests pin the checked address', + source: 'app/Services/LinkPreviewHttpClient.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'CURLOPT_CONNECT_TO => [$connection]', + after: 'CURLOPT_CONNECT_TO => []', + }, + { + name: 'content type is checked', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'if ($mediaType !== self::CONTENT_TYPE)', + after: 'if (false)', + }, + { + name: 'both supported GIF signatures remain valid', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: "SIGNATURES = ['GIF87a', 'GIF89a']", + after: "SIGNATURES = ['GIF89a']", + }, + { + name: 'truncated GIF descriptors are rejected', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'MIN_HEADER_BYTES = 13', + after: 'MIN_HEADER_BYTES = 12', + }, + { + name: 'GIF dimensions must be nonzero', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'return $width !== self::ZERO_DIMENSION && $height !== self::ZERO_DIMENSION;', + after: 'return true;', + }, + { + name: 'oversized URLs stay rejected', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'mb_strlen($sourceUrl) > Urls::MAX_LENGTH', + after: 'mb_strlen($sourceUrl) > Urls::MAX_LENGTH + 1', + }, + { + name: 'maximum-length URLs remain supported', + source: 'app/Services/GifLinks.php', + test: 'tests/Unit/Actions/UpdateLinkPreviewTest.php', + before: 'mb_strlen($sourceUrl) > Urls::MAX_LENGTH', + after: 'mb_strlen($sourceUrl) > Urls::MAX_LENGTH - 1', + }, + { + name: 'Giphy hosts cannot be spoofed', + source: 'app/Services/EmbedProviders/Giphy.php', + test: 'tests/Unit/Services/EmbedProviders/GiphyTest.php', + before: 'if (! in_array($url->getAsciiHost(), self::SUPPORTED_HOSTS, true))', + after: 'if (false)', + }, +]; diff --git a/tests/Mutation/playback-mutations.ts b/tests/Mutation/playback-mutations.ts new file mode 100644 index 000000000..6cf818f17 --- /dev/null +++ b/tests/Mutation/playback-mutations.ts @@ -0,0 +1,95 @@ +import type { GifMutation } from './php-gif-mutations.ts'; + +export const playbackMutations: GifMutation[] = [ + { + name: 'play unsafe verified GIF sources', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: 'parseHttpsUrl(source) === null', + after: 'false', + }, + { + name: 'ignore reduced motion', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: 'return !reducedMotion;', + after: 'return true;', + }, + { + name: 'ignore visibility', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: ' !isVisible ||\n', + after: '', + }, + { + name: 'ignore static cards', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: ' isStatic ||\n', + after: '', + }, + { + name: 'ignore failed loads', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: ' hasFailed ||\n', + after: '', + }, + { + name: 'ignore Stop', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: ' playback === GIF_PLAYBACK_MODE.STOPPED', + after: ' false', + }, + { + name: 'deny explicit Play', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: 'playback === GIF_PLAYBACK_MODE.PLAYING', + after: 'false', + }, + { + name: 'keep GIF failures after a manual retry', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: 'failedSource: null,\n retryCount: state.retryCount + 1', + after: 'failedSource: state.failedSource,\n retryCount: state.retryCount + 1', + }, + { + name: 'allow unlimited GIF retries', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: 'state.retryCount < MAX_GIF_RETRIES', + after: 'true', + }, + { + name: 'allow HTTP', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: " url.protocol !== 'https:' ||\n", + after: '', + }, + { + name: 'allow username', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: " url.username !== '' ||\n", + after: '', + }, + { + name: 'allow password', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: " url.password !== '' ||\n", + after: '', + }, + { + name: 'allow non-default port', + source: 'resources/js/lib/gifs.ts', + test: 'tests/Unit/GifPlayback.test.ts', + before: " url.password !== '' ||\n url.port !== ''", + after: " url.password !== ''", + }, +]; diff --git a/tests/Unit/Actions/CreateCommitTest.php b/tests/Unit/Actions/CreateCommitTest.php index 7ed3b12bf..733a62494 100644 --- a/tests/Unit/Actions/CreateCommitTest.php +++ b/tests/Unit/Actions/CreateCommitTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Actions\CreateCommit; +use App\Actions\UpdateLinkPreview; use App\Dto\NewPoll; use App\Dto\ThreadEngagement; use App\Enums\CommitStatus; @@ -19,6 +20,7 @@ use App\Services\TheAlgorithm; use Illuminate\Database\QueryException; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; @@ -344,6 +346,85 @@ Queue::assertNotPushed(UnfurlLink::class); }); +it('keeps a definitive empty draft preview cached when it is published', function (): void { + Queue::fake(); + $url = 'https://93.184.216.34/retry'; + $preview = LinkPreview::factory()->create(['url' => $url]); + Http::fakeSequence('https://93.184.216.34/*') + ->push('', 404); + new UnfurlLink($preview)->handle(resolve(UpdateLinkPreview::class)); + expect($preview->refresh()) + ->title->toBeNull() + ->fetched_at->not->toBeNull() + ->unfurl_failed_at->toBeNull(); + Http::assertSentCount(1); + + $commit = resolve(CreateCommit::class)->handle(User::factory()->create(), 'Look at '.$url); + + expect($commit->linkPreview) + ->id->toBe($preview->id) + ->fetched_at->not->toBeNull() + ->toArray()->is_pending->toBeFalse(); + Queue::assertNotPushed(UnfurlLink::class); + Http::assertSentCount(1); +}); + +it('retries a transient draft preview only once when it is published', function (): void { + Queue::fake(); + $preview = LinkPreview::factory()->failed()->create(['url' => 'https://example.com/retry']); + $action = resolve(CreateCommit::class); + + $action->handle(User::factory()->create(), 'Look at '.$preview->url); + + expect($preview->refresh()) + ->fetched_at->toBeNull() + ->publication_unfurl_retries->toBe(1); + Queue::assertPushed(UnfurlLink::class, 1); + + $preview->markUnfurlAsTransientFailure(1); + $action->handle(User::factory()->create(), 'Look at '.$preview->url); + + expect($preview->refresh()) + ->fetched_at->not->toBeNull() + ->publication_unfurl_retries->toBe(1); + Queue::assertPushed(UnfurlLink::class, 1); +}); + +it('queues a new link preview after the commit transaction commits', function (): void { + Queue::fake(); + + resolve(CreateCommit::class)->handle(User::factory()->create(), 'Look at https://example.com/new'); + + Queue::assertPushed(UnfurlLink::class, 1); + Queue::assertPushed(UnfurlLink::class, fn (UnfurlLink $job): bool => $job->afterCommit === true); +}); + +it('enqueues a draft preview that is already pending when it is published', function (): void { + Queue::fake(); + LinkPreview::factory()->create(['url' => 'https://example.com/pending']); + + resolve(CreateCommit::class)->handle(User::factory()->create(), 'Look at https://example.com/pending'); + + Queue::assertPushed(UnfurlLink::class); +}); + +it('keeps completed GIF preview metadata when it is published', function (): void { + Queue::fake(); + $preview = LinkPreview::factory()->create([ + 'url' => 'https://example.com/cat.gif', + 'gif_url' => 'https://cdn.example.com/cat.gif', + 'fetched_at' => now(), + ]); + + $commit = resolve(CreateCommit::class)->handle(User::factory()->create(), 'Look at https://example.com/cat.gif'); + + expect($commit->linkPreview) + ->id->toBe($preview->id) + ->gif_url->toBe('https://cdn.example.com/cat.gif') + ->fetched_at->not->toBeNull(); + Queue::assertNotPushed(UnfurlLink::class); +}); + it('queues the link preview for the saved commit', function (): void { Queue::fake(); diff --git a/tests/Unit/Actions/CreateLinkPreviewJobTest.php b/tests/Unit/Actions/CreateLinkPreviewJobTest.php new file mode 100644 index 000000000..a5732000b --- /dev/null +++ b/tests/Unit/Actions/CreateLinkPreviewJobTest.php @@ -0,0 +1,44 @@ +fetched()->create(); + + resolve(CreateLinkPreviewJob::class)->handle($preview); + + Queue::assertNothingPushed(); +}); + +it('does not queue a preview deleted before its dispatch callback runs', function (): void { + Queue::fake(); + $preview = LinkPreview::factory()->create(); + $preview->delete(); + + resolve(CreateLinkPreviewJob::class)->handle($preview); + + Queue::assertNothingPushed(); +}); + +it('records a transient failure when the queue driver rejects a preview', function (): void { + config(['queue.default' => 'database']); + Queue::swap(Queue::fake()->queue); + DB::connection()->beforeExecuting(function (string $query): void { + throw_if(str_starts_with($query, 'insert into "jobs"'), RuntimeException::class, 'Queue unavailable'); + }); + $preview = LinkPreview::factory()->create(); + + expect(fn () => resolve(CreateLinkPreviewJob::class)->handle($preview)) + ->toThrow(RuntimeException::class, 'Queue unavailable'); + + expect($preview->refresh()) + ->fetched_at->not->toBeNull() + ->unfurl_failed_at->not->toBeNull() + ->publication_unfurl_retries->toBe(0); +}); diff --git a/tests/Unit/Actions/UpdateLinkPreviewTest.php b/tests/Unit/Actions/UpdateLinkPreviewTest.php index 6c1c7537d..081b50a70 100644 --- a/tests/Unit/Actions/UpdateLinkPreviewTest.php +++ b/tests/Unit/Actions/UpdateLinkPreviewTest.php @@ -4,15 +4,21 @@ use App\Actions\UpdateLinkPreview; use App\Contracts\Services\DnsResolver; +use App\Dto\LinkPreviewRequest; use App\Models\LinkPreview; use App\Services\DnsResolvers\SystemDnsResolver; +use App\Services\LimitedResponseBody; +use App\Services\LinkPreviewBudget; +use App\Services\LinkPreviewHttpClient; use GuzzleHttp\Exception\ResponseException; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\Response as PsrResponse; +use GuzzleHttp\RequestOptions; use Illuminate\Http\Client\HttpClientException; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +use Uri\WhatWg\Url; beforeEach(function (): void { $this->app->instance(DnsResolver::class, new readonly class implements DnsResolver @@ -32,6 +38,107 @@ public function resolve(string $host): array }); }); +it('verifies external GIFs from a small header without storing their bytes', function (string $signature): void { + $options = []; + Http::fake(function (Request $request, array $sent) use (&$options, $signature): PromiseInterface { + $options = $sent; + + return Http::response($signature."\x01\x00\x01\x00\x80\x00\x00", 206, ['Content-Type' => 'IMAGE/GIF; charset=binary']); + }); + + $preview = unfurl('https://example.com/cat.GIF?token=keep'); + + expect($preview->gif_url)->toBe('https://example.com/cat.GIF?token=keep') + ->and($preview->image_url)->toBeNull() + ->and($preview->fetched_at)->not->toBeNull() + ->and($options['sink'])->toBeInstanceOf(LimitedResponseBody::class) + ->and($options['sink']->write(str_repeat('x', 33)))->toBe(32) + ->and($options['sink']->getSize())->toBe(32); + + Http::assertSent(fn (Request $request): bool => $request->header('Range') === ['bytes=0-31'] + && $request->header('Accept') === ['image/gif']); +})->with(['GIF87a', 'GIF89a']); + +it('reads GIF dimensions as bytes rather than UTF-8 text', function (): void { + Http::fake(['https://example.com/cat.gif' => Http::response("GIF89a\xc2\x80\x01\x00\x80\x00\x00", 206, ['Content-Type' => 'image/gif'])]); + + expect(unfurl('https://example.com/cat.gif')->gif_url)->toBe('https://example.com/cat.gif'); +}); + +it('does not trust a GIF extension or a content type without a valid header', function (string $body, string $contentType): void { + Http::fake(['https://example.com/*' => Http::response($body, 200, ['Content-Type' => $contentType])]); + + expect(unfurl('https://example.com/cat.gif')->gif_url)->toBeNull(); +})->with([ + 'html' => ['A page', 'text/html'], + 'spoofed type' => ['', 'image/gif'], + 'wrong type' => ["GIF89a\x01\x00\x01\x00\x80\x00\x00", 'image/png'], + 'truncated' => ['GIF89a', 'image/gif'], + 'wrong signature' => ["GIF90a\x01\x00\x01\x00\x80\x00\x00", 'image/gif'], + 'zero width' => ["GIF89a\x00\x00\x01\x00\x80\x00\x00", 'image/gif'], + 'zero height' => ["GIF89a\x01\x00\x00\x00\x80\x00\x00", 'image/gif'], + 'zero height with wide canvas' => ["GIF89a\x00\x01\x00\x00\x80\x00\x00", 'image/gif'], + 'incomplete screen descriptor' => ["GIF89a\x01\x00\x01\x00\x80\x00", 'image/gif'], +]); + +it('keeps only the final public HTTPS GIF URL after redirects', function (): void { + Http::fake([ + 'https://example.com/cat.gif' => Http::response(status: 302, headers: ['Location' => 'https://www.example.com/media?id=cat']), + 'https://www.example.com/media?id=cat' => Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 200, ['Content-Type' => 'image/gif']), + ]); + + $preview = unfurl('https://example.com/cat.gif'); + + expect($preview->url)->toBe('https://example.com/cat.gif') + ->and($preview->gif_url)->toBe('https://www.example.com/media?id=cat'); + Http::assertSentCount(2); +}); + +it('does not follow GIF redirects to unsafe destinations', function (string $destination): void { + Http::fake(['https://example.com/cat.gif' => Http::response(status: 302, headers: ['Location' => $destination])]); + + expect(unfurl('https://example.com/cat.gif')->gif_url)->toBeNull(); + Http::assertSentCount(1); +})->with([ + 'downgrade' => 'http://www.example.com/cat.gif', + 'private address' => 'https://10.0.0.1/cat.gif', + 'private DNS' => 'https://internal.example.com/cat.gif', + 'mixed DNS' => 'https://mixed.example.com/cat.gif', + 'credentials' => 'https://user:secret@www.example.com/cat.gif', + 'port' => 'https://www.example.com:8443/cat.gif', +]); + +it('does not embed HTTP GIFs or treat a query string as a GIF path', function (string $url): void { + Http::fake(['*' => Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 200, ['Content-Type' => 'image/gif'])]); + + expect(unfurl($url)->gif_url)->toBeNull(); +})->with([ + 'HTTP' => 'http://example.com/cat.gif', + 'query' => 'https://example.com/page?image=cat.gif', +]); + +it('does not keep oversized redirected GIF URLs', function (): void { + $destination = 'https://www.example.com/'.str_repeat('x', 2048); + Http::fake([ + 'https://example.com/cat.gif' => Http::response(status: 302, headers: ['Location' => $destination]), + $destination => Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 200, ['Content-Type' => 'image/gif']), + ]); + + expect(unfurl('https://example.com/cat.gif')->gif_url)->toBeNull(); +}); + +it('clears a previously verified GIF when refetching fails permanently', function (): void { + Http::fake(['https://example.com/cat.gif' => Http::response(status: 404)]); + $preview = LinkPreview::factory()->create([ + 'url' => 'https://example.com/cat.gif', + 'gif_url' => 'https://example.com/old.gif', + ]); + + resolve(UpdateLinkPreview::class)->handle($preview); + + expect($preview->refresh()->gif_url)->toBeNull(); +}); + function unfurl(string $url): LinkPreview { $linkPreview = LinkPreview::factory()->create(['url' => $url]); @@ -103,11 +210,12 @@ function expectNoMetadata(LinkPreview $linkPreview): void expect($options) ->allow_redirects->toBeFalse() ->connect_timeout->toBe(3) - ->timeout->toBe(5) + ->timeout->toBeGreaterThan(0)->toBeLessThanOrEqual(5) ->decode_content->toBeFalse() ->and($options['curl'][CURLOPT_CONNECT_TO])->toBe(['::93.184.216.34:443']) - ->and($options['progress'](0, 1_048_576, 0, 0))->toBeFalse() - ->and($options['progress'](2_000_000, 1_048_577, 0, 0))->toBeTrue(); + ->and($options['sink'])->toBeInstanceOf(LimitedResponseBody::class) + ->and($options['sink']->write(str_repeat('x', 1_048_577)))->toBe(1_048_576) + ->and($options['sink']->getSize())->toBe(1_048_576); }); it('pins the connection to the port and address that were checked', function (string $url, string $connectTo): void { @@ -301,6 +409,9 @@ function expectNoMetadata(LinkPreview $linkPreview): void 'home network' => 'http://192.168.1.1/', 'cloud metadata' => 'http://169.254.169.254/latest/meta-data/', 'carrier-grade nat' => 'http://100.64.0.1/', + 'protocol assignment' => 'http://192.0.0.1/', + 'deprecated relay' => 'http://192.88.99.1/', + 'documentation' => 'http://192.0.2.1/', 'benchmarking' => 'http://198.18.0.1/', 'unspecified' => 'http://0.0.0.0/', 'multicast' => 'http://224.0.0.1/', @@ -309,12 +420,18 @@ function expectNoMetadata(LinkPreview $linkPreview): void 'ipv6 link-local' => 'http://[fe80::1]/', 'ipv4-mapped ipv6' => 'http://[::ffff:127.0.0.1]/', 'nat64' => 'http://[64:ff9b::a00:1]/', + 'nat64 local use' => 'http://[64:ff9b:1::a00:1]/', + 'ipv6 discard' => 'http://[100::1]/', + 'ipv6 site-local' => 'http://[fec0::1]/', + 'ipv4-translated ipv6' => 'http://[::ffff:0:a00:1]/', 'ipv6 multicast' => 'http://[ff02::1]/', 'localhost' => 'http://localhost/', 'host resolving to a private address' => 'https://internal.example.com/', 'host resolving partly to a private address' => 'https://mixed.example.com/', 'host resolving to nothing' => 'https://nowhere.example.com/', 'non-default port' => 'https://example.com:8080/', + 'https on http port' => 'https://example.com:80/', + 'http on https port' => 'http://example.com:443/', 'credentials' => 'https://user:secret@example.com/', 'handle only' => 'https://user@example.com/', 'password only' => 'https://:secret@example.com/', @@ -430,3 +547,108 @@ public function resolve(string $host): array expect(fn () => resolve(UpdateLinkPreview::class)->handle($linkPreview))->toThrow(RuntimeException::class, 'The resolver broke.') ->and($linkPreview->refresh()->fetched_at)->toBeNull(); }); + +it('stops repeated redirect destinations before fetching them again', function (array $responses, int $requests): void { + Http::fake($responses); + + expect(unfurl('https://example.com/start.gif')->gif_url)->toBeNull(); + Http::assertSentCount($requests); +})->with([ + 'self loop' => [fn (): array => [ + 'https://example.com/start.gif' => Http::response(status: 302, headers: ['Location' => '/start.gif']), + ], 1], + 'fragment-only loop' => [fn (): array => [ + 'https://example.com/start.gif' => Http::response(status: 302, headers: ['Location' => '#other']), + ], 1], + 'two host loop' => [fn (): array => [ + 'https://example.com/start.gif' => Http::response(status: 302, headers: ['Location' => 'https://www.example.com/next.gif']), + 'https://www.example.com/next.gif' => Http::response(status: 302, headers: ['Location' => 'https://example.com/start.gif']), + ], 2], +]); + +it('does not refetch redirects with a missing or invalid location', function (?string $location): void { + Http::fake(['https://example.com/*' => Http::response(status: 302, headers: $location === null ? [] : ['Location' => $location])]); + + expect(unfurl('https://example.com/start.gif')->gif_url)->toBeNull(); + Http::assertSentCount(1); +})->with([null, '', ' ', 'https://invalid host/cat.gif']); + +it('reduces each hop timeout to the remaining overall budget', function (): void { + $nanoseconds = 0; + $clock = function () use (&$nanoseconds): int { + return $nanoseconds; + }; + $this->app->instance(LinkPreviewBudget::class, new LinkPreviewBudget($clock)); + $timeouts = []; + $connectTimeouts = []; + Http::fake(function (Request $request, array $options) use (&$nanoseconds, &$timeouts, &$connectTimeouts): PromiseInterface { + $timeouts[] = $options['timeout']; + $connectTimeouts[] = $options['connect_timeout']; + $nanoseconds += 2_000_000_000; + + return Http::response(status: 302, headers: ['Location' => '/hop-'.count($timeouts).'.gif']); + }); + + expect(unfurl('https://example.com/start.gif')->gif_url)->toBeNull() + ->and($timeouts)->toBe([5.0, 3.0, 1.0]) + ->and($connectTimeouts)->toBe([3, 3.0, 1.0]); + Http::assertSentCount(3); +}); + +it('does not connect if resolving an address exhausted the budget', function (): void { + $this->app->instance(LinkPreviewBudget::class, new LinkPreviewBudget(function (): int { + static $calls = 0; + + return $calls++ === 0 ? 0 : 6_000_000_000; + })); + Http::fake(); + + expect(unfurl('https://example.com/start.gif')->gif_url)->toBeNull(); + Http::assertNothingSent(); +}); + +it('rechecks changed DNS answers before following a redirect', function (): void { + $this->app->instance(DnsResolver::class, new class implements DnsResolver + { + private int $calls = 0; + + public function resolve(string $host): array + { + return $this->calls++ === 0 ? ['93.184.216.34'] : ['10.0.0.1']; + } + }); + Http::fake(['https://example.com/start.gif' => Http::response(status: 302, headers: ['Location' => '/next.gif'])]); + + expect(unfurl('https://example.com/start.gif')->gif_url)->toBeNull(); + Http::assertSentCount(1); +}); + +it('accepts only final GIF URLs within the storage length limit', function (int $length, bool $valid): void { + $base = 'https://www.example.com/'; + $destination = $base.str_repeat('x', $length - mb_strlen($base)); + Http::fake([ + 'https://example.com/cat.gif' => Http::response(status: 302, headers: ['Location' => $destination]), + $destination => Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 200, ['Content-Type' => 'image/gif']), + ]); + + expect(unfurl('https://example.com/cat.gif')->gif_url)->toBe($valid ? $destination : null); +})->with([[2048, true], [2049, false]]); + +it('pins the explicit port carried by a prepared request', function (): void { + $options = []; + Http::fake(function (Request $request, array $sent) use (&$options): PromiseInterface { + $options = $sent; + + return htmlResponse('Prepared request'); + }); + $request = new LinkPreviewRequest( + url: Url::parse('https://example.com:8443/'), + address: '93.184.216.34', + headers: ['Accept' => 'text/html'], + maxBodyBytes: 32, + timeoutSeconds: 5, + ); + + expect(resolve(LinkPreviewHttpClient::class)->get($request)->successful())->toBeTrue() + ->and($options[RequestOptions::CURL][CURLOPT_CONNECT_TO])->toBe(['::93.184.216.34:8443']); +}); diff --git a/tests/Unit/ComposerPreview.test.tsx b/tests/Unit/ComposerPreview.test.tsx index 218a02af3..d3babea76 100644 --- a/tests/Unit/ComposerPreview.test.tsx +++ b/tests/Unit/ComposerPreview.test.tsx @@ -14,6 +14,7 @@ import { useComposerPreview, type ComposerPreview, } from '../../resources/js/components/composer-preview'; +import { DraftLinkPreview } from '../../resources/js/components/draft-link-preview'; import { hasMention } from '../../resources/js/components/linkified-text'; import { TooltipProvider } from '../../resources/js/components/ui/tooltip'; import { bodySegments } from '../../resources/js/lib/code'; @@ -205,16 +206,8 @@ test('keeps the composer avatar unlinked inside the dialog', () => { }); test('previews the chosen link card instead of the first link in the body', () => { - const preview = previewFor({ - body: 'https://first.example.com https://second.example.com', - isSubmittable: true, - }); const markup = renderPage( - , + , ); const card = markup.match(/data-test="link-card-preview"[\s\S]*/)?.[0]; @@ -229,7 +222,10 @@ test('omits the preview card when the author chooses no card', () => { isSubmittable: true, }); const markup = renderPage( - , + <> + + + , ); assert.ok(markup.includes('href="https://first.example.com"')); @@ -253,6 +249,15 @@ test('retains the link card picker and scheduling beside preview', () => { assert.ok(!/\sdisabled(?:=|\s|>)/.test(schedule)); }); +test('shows a pending GIF card while writing before Preview or Commit', () => { + const markup = renderComposer('https://example.com/cat.gif'); + + assert.ok(markup.includes('data-test="gif-embed"')); + assert.ok(markup.includes('Loading GIF')); + assert.ok(!markup.includes('data-test="gif-animation"')); + assert.ok(!markup.includes('data-test="composer-preview"')); +}); + const secret = 'ghp_' + 'Ab3dEf6hIj9kLm2nOp5qRs8tUv1wXy4zABCD'; test('keeps token-shaped text editable with preview and no secret warning', () => { @@ -325,7 +330,6 @@ test('links the mentions the server found in the preview', () => { mentions: { alice: '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b' }, }} images={[]} - url={null} />, ); @@ -340,7 +344,7 @@ test('shows mentions as text until the server has found them', () => { isSubmittable: true, }); const markup = renderPage( - , + , ); assert.equal(preview.mentions, null); @@ -360,8 +364,7 @@ test('previews attached image descriptions without a published photo link', () = preview={previewFor({ isSubmittable: true })} images={images} descriptions={[' Build output in the terminal ', '']} - url={null} - />, + />, ); assert.ok(markup.includes('alt="Build output in the terminal"')); @@ -390,7 +393,6 @@ for (const pasteisMode of [false, true]) { ), pasteisMode, diff --git a/tests/Unit/DraftLinkPreview.test.tsx b/tests/Unit/DraftLinkPreview.test.tsx new file mode 100644 index 000000000..db9873cb9 --- /dev/null +++ b/tests/Unit/DraftLinkPreview.test.tsx @@ -0,0 +1,162 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, test, vi } from 'vite-plus/test'; +import type { LinkPreview } from '../../resources/js/types/commit'; + +let result: { + preview: LinkPreview | null; + isLoading: boolean; +} = { preview: null, isLoading: true }; + +vi.doMock('../../resources/js/hooks/use-draft-link-preview', () => ({ + useDraftLinkPreview: () => result, +})); + +const { DraftLinkPreview } = + await import('../../resources/js/components/draft-link-preview'); + +const directGif = 'https://images.example.test/cat.gif'; + +function render(url: string | null): string { + return renderToStaticMarkup(); +} + +afterEach(() => { + result = { preview: null, isLoading: true }; +}); + +test('renders nothing without a selected link card', () => { + assert.equal(render(null), ''); +}); + +test('keeps an unverified GIF pending without requesting media or exposing Play', () => { + const markup = render(directGif); + + assert.ok(markup.includes('data-test="gif-embed"')); + assert.ok(markup.includes('aspect-square')); + assert.ok(markup.includes('Loading GIF')); + assert.ok(!markup.includes(' { + const markup = render('https://www.giphy.com/gifs/JIX9t2j0ZTN9S'); + + assert.ok(markup.includes('data-test="gif-embed"')); + assert.ok(markup.includes('aspect-square')); + assert.ok(markup.includes('Loading GIF')); + assert.ok(!markup.includes(' { + let refreshUrl: string | null = null; + + vi.resetModules(); + vi.doMock('../../resources/js/hooks/use-draft-link-preview', () => ({ + useDraftLinkPreview: () => ({ + preview: pendingPreview('https://example.test/article'), + isLoading: false, + }), + })); + vi.doMock('../../resources/js/components/link-preview', () => ({ + LinkPreview: ({ refreshUrl: url }: { refreshUrl: string }) => { + refreshUrl = url; + + return
; + }, + })); + + const { DraftLinkPreview: PendingDraftLinkPreview } = + await import('../../resources/js/components/draft-link-preview'); + const markup = renderToStaticMarkup( + , + ); + + assert.ok(markup.includes('data-test="shared-link-preview"')); + assert.equal( + refreshUrl, + '/draft-link-preview?url=https%3A%2F%2Fexample.test%2Farticle', + ); + + vi.doUnmock('../../resources/js/components/link-preview'); + vi.resetModules(); +}); + +test('renders verified Giphy and direct GIFs through the shared GIF component', () => { + for (const preview of [ + verifiedGif('https://giphy.com/gifs/JIX9t2j0ZTN9S', { + provider: 'GIPHY', + src: 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif', + poster: 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy_s.gif', + }), + verifiedGif(directGif, { + provider: 'GIF', + src: directGif, + poster: null, + }), + ]) { + result = { preview, isLoading: false }; + const markup = render(preview.url); + + assert.ok(markup.includes('data-test="gif-embed"')); + assert.ok(markup.includes(`href="${preview.url}"`)); + } +}); + +test('shows loading and unavailable states for non-GIF links', () => { + const url = 'https://example.test/article'; + + assert.ok(render(url).includes('Loading link preview…')); + + result = { preview: null, isLoading: false }; + + assert.ok(render(url).includes('Link preview unavailable')); +}); + +test('keeps an unavailable card when a completed draft has no metadata', () => { + const url = 'https://example.test/unavailable'; + result = { + preview: { + ...pendingPreview(url), + is_pending: false, + }, + isLoading: false, + }; + + const markup = render(url); + + assert.ok(markup.includes('data-test="link-card-preview"')); + assert.ok(markup.includes('Link preview unavailable')); +}); + +function verifiedGif( + url: string, + embed: Omit< + NonNullable, + 'kind' | 'aspect_ratio' | 'height' + >, +): LinkPreview { + return { + url, + title: null, + description: null, + image_url: null, + site_name: null, + is_pending: false, + embed: { kind: 'gif', aspect_ratio: null, height: null, ...embed }, + }; +} + +function pendingPreview(url: string): LinkPreview { + return { + url, + title: null, + description: null, + image_url: null, + site_name: null, + is_pending: true, + embed: null, + }; +} diff --git a/tests/Unit/DraftLinkPreviewRequest.test.tsx b/tests/Unit/DraftLinkPreviewRequest.test.tsx new file mode 100644 index 000000000..586d3be58 --- /dev/null +++ b/tests/Unit/DraftLinkPreviewRequest.test.tsx @@ -0,0 +1,183 @@ +/// +import assert from 'node:assert/strict'; +import { setImmediate } from 'node:timers/promises'; +import * as React from 'react'; +import { afterAll, afterEach, test, vi } from 'vite-plus/test'; +import type { LinkPreview } from '../../resources/js/types/commit'; + +type Effect = () => void | (() => void); +type StateUpdate = { url: string; preview: LinkPreview | null }; + +let effects: Effect[] = []; +let states: unknown[] = []; +let stateIndex = 0; +const updates: StateUpdate[] = []; + +vi.doMock('react', () => ({ + ...React, + useEffect: (effect: Effect) => effects.push(effect), + useState: (initial: unknown) => { + const index = stateIndex++; + states[index] ??= typeof initial === 'function' ? initial() : initial; + + return [states[index], (update: StateUpdate) => updates.push(update)]; + }, +})); + +const { useDraftLinkPreview } = + await import('../../resources/js/hooks/use-draft-link-preview'); + +let nextTimer = 0; +const timers = new Map void>(); + +vi.stubGlobal('window', { + setTimeout: (callback: () => void) => { + nextTimer++; + timers.set(nextTimer, callback); + + return nextTimer; + }, + clearTimeout: (timer: number) => timers.delete(timer), +}); + +afterAll(() => vi.unstubAllGlobals()); +afterEach(() => { + effects = []; + states = []; + stateIndex = 0; + updates.length = 0; + timers.clear(); + nextTimer = 0; +}); + +test('debounces one request for the chosen URL', () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + const url = 'https://example.test/a?tag=gif'; + + const cleanup = mount(url); + + assert.equal(fetch.mock.calls.length, 0); + runTimers(); + + assert.equal(fetch.mock.calls.length, 1); + assert.equal( + fetch.mock.calls[0]?.[0], + '/draft-link-preview?url=https%3A%2F%2Fexample.test%2Fa%3Ftag%3Dgif', + ); + + cleanup(); +}); + +test('aborts an in-flight request when the selected URL changes without applying its late result', async () => { + let resolveResponse: (response: Response) => void; + let signal: AbortSignal | undefined; + const fetch = vi.fn( + (_url: string, options: { signal: AbortSignal }) => + new Promise((resolve) => { + signal = options.signal; + resolveResponse = resolve; + }), + ); + vi.stubGlobal('fetch', fetch); + + const firstCleanup = mount('https://example.test/first'); + runTimers(); + + firstCleanup(); + mount('https://example.test/second'); + resolveResponse!({ + ok: true, + json: async () => ({ link_preview: null }), + } as Response); + await setImmediate(); + + assert.equal(signal?.aborted, true); + assert.deepEqual(updates, []); +}); + +test('aborts an in-flight request when the component unmounts', () => { + let signal: AbortSignal | undefined; + const fetch = vi.fn((_url: string, options: { signal: AbortSignal }) => { + signal = options.signal; + + return new Promise(() => {}); + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount('https://example.test/first'); + runTimers(); + + cleanup(); + + assert.equal(signal?.aborted, true); +}); + +test('applies the returned metadata to the selected preview', async () => { + const url = 'https://example.test/preview'; + const preview: LinkPreview = { + url, + title: 'Example', + description: null, + image_url: null, + site_name: null, + is_pending: false, + }; + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => ({ link_preview: preview }), + })), + ); + + mount(url); + runTimers(); + await setImmediate(); + + assert.deepEqual(updates, [{ url, preview }]); +}); + +test.each([ + [ + 'the request fails', + async () => { + throw new Error('Offline'); + }, + ], + ['the server rejects the request', async () => ({ ok: false })], + [ + 'the response is malformed', + async () => ({ + ok: true, + json: async () => { + throw new SyntaxError('Invalid JSON'); + }, + }), + ], +])('leaves the preview unavailable when %s', async (_reason, fetch) => { + const url = 'https://example.test/preview'; + vi.stubGlobal('fetch', vi.fn(fetch)); + + mount(url); + runTimers(); + await setImmediate(); + + assert.deepEqual(updates, [{ url, preview: null }]); +}); + +function mount(url: string | null): () => void { + stateIndex = 0; + useDraftLinkPreview(url); + const effect = effects.at(-1); + + return effect?.() ?? (() => {}); +} + +function runTimers(): void { + for (const callback of timers.values()) { + callback(); + } + + timers.clear(); +} 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/Jobs/UnfurlLinkConcurrencyTest.php b/tests/Unit/Jobs/UnfurlLinkConcurrencyTest.php new file mode 100644 index 000000000..1c037b412 --- /dev/null +++ b/tests/Unit/Jobs/UnfurlLinkConcurrencyTest.php @@ -0,0 +1,89 @@ +array('database.connections.'.$connection); + config()->set('database.connections.unfurl_concurrency', $settings); + $fixtures = DB::connection('unfurl_concurrency'); + $id = (string) Str::uuid(); + $url = 'https://93.184.216.34/concurrent/'.$id; + $directory = sys_get_temp_dir().'/unfurl-concurrency-'.$id; + File::makeDirectory($directory); + + // A separate connection commits this fixture so both worker processes can see it. + $fixtures->table('link_previews')->insert([ + 'id' => $id, + 'url' => $url, + 'url_hash' => hash('sha256', $url), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $environment = [ + 'APP_ENV' => 'testing', + 'DB_CONNECTION' => $connection, + 'DB_URL' => '', + 'DB_HOST' => config()->string('database.connections.'.$connection.'.host'), + 'DB_PORT' => (string) config('database.connections.'.$connection.'.port'), + 'DB_DATABASE' => config()->string('database.connections.'.$connection.'.database'), + 'DB_USERNAME' => config()->string('database.connections.'.$connection.'.username'), + 'DB_PASSWORD' => config()->string('database.connections.'.$connection.'.password'), + 'CACHE_STORE' => 'array', + ]; + $fixture = base_path('tests/Fixtures/unfurl-link.php'); + $first = new Process([PHP_BINARY, $fixture, $id, $directory, 'first'], base_path(), $environment); + $second = new Process([PHP_BINARY, $fixture, $id, $directory, 'second'], base_path(), $environment); + + try { + Sleep::fake(false); + $first->setTimeout(UNFURL_CONCURRENCY_TIMEOUT_SECONDS)->start(); + $first->waitUntil(static fn (string $type, string $output): bool => $type === Process::OUT && str_contains($output, 'FETCHING')); + expect($fixtures->selectOne('select id from link_previews where id = ? for key share nowait', [$id])?->id)->toBe($id); + $second->setTimeout(UNFURL_CONCURRENCY_TIMEOUT_SECONDS)->start(); + $second->waitUntil(static fn (string $type, string $output): bool => $type === Process::OUT && str_contains($output, 'PID:')); + throw_unless(preg_match('/PID:(\d+)/', $second->getOutput(), $match) === 1, RuntimeException::class, 'Second worker did not report its database session.'); + + $deadline = microtime(true) + UNFURL_CONCURRENCY_TIMEOUT_SECONDS; + $isWaiting = false; + + while ($second->isRunning() && microtime(true) < $deadline) { + $isWaiting = $fixtures->selectOne('select wait_event_type from pg_stat_activity where pid = ?', [(int) $match[1]])?->wait_event_type === 'Lock'; + + if ($isWaiting) { + break; + } + + Sleep::usleep(UNFURL_LOCK_POLL_MICROSECONDS); + } + + touch($directory.'/release'); + $first->wait(); + $second->wait(); + + expect($first->isSuccessful())->toBeTrue($first->getErrorOutput()) + ->and($second->isSuccessful())->toBeTrue($second->getErrorOutput()) + ->and(file_get_contents($directory.'/requests'))->toBe("request\n") + ->and($isWaiting)->toBeTrue() + ->and(LinkPreview::query()->findOrFail($id)->title)->toBe('Concurrent preview'); + } finally { + Sleep::fake(); + touch($directory.'/release'); + $first->stop(); + $second->stop(); + $fixtures->table('link_previews')->where('id', $id)->delete(); + DB::purge('unfurl_concurrency'); + File::deleteDirectory($directory); + } +}); diff --git a/tests/Unit/Jobs/UnfurlLinkTest.php b/tests/Unit/Jobs/UnfurlLinkTest.php index c859be632..7673d0a04 100644 --- a/tests/Unit/Jobs/UnfurlLinkTest.php +++ b/tests/Unit/Jobs/UnfurlLinkTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use App\Actions\UpdateLinkPreview; +use App\Contracts\Services\DnsResolver; use App\Jobs\UnfurlLink; use App\Models\LinkPreview; use Illuminate\Support\Facades\DB; @@ -38,6 +40,26 @@ function runNextQueuedJob(): void expect($linkPreview->refresh()->title)->toBe('Fetched before'); }); +it('keeps a blocked address as a definitive empty preview', function (): void { + $this->app->instance(DnsResolver::class, new readonly class implements DnsResolver + { + /** @return list */ + public function resolve(string $host): array + { + return ['10.0.0.1']; + } + }); + $linkPreview = LinkPreview::factory()->create(['url' => 'https://blocked.example.com/']); + + new UnfurlLink($linkPreview)->handle(resolve(UpdateLinkPreview::class)); + + expect($linkPreview->refresh()) + ->fetched_at->not->toBeNull() + ->unfurl_failed_at->toBeNull() + ->and($linkPreview->prepareUnfurlForPublication())->toBeFalse(); + Http::assertNothingSent(); +}); + it('retries a page that may be reachable later', function (): void { Exceptions::fake(); @@ -87,11 +109,51 @@ function runNextQueuedJob(): void expect($linkPreview->refresh()) ->title->toBeNull() ->fetched_at->not->toBeNull() + ->unfurl_failed_at->not->toBeNull() ->and(DB::table('jobs')->count())->toBe(0); Http::assertSentCount(3); }); +it('queues one job for a preview while its retries are pending', function (): void { + Queue::fake(); + $linkPreview = LinkPreview::factory()->create(); + + dispatch(new UnfurlLink($linkPreview)); + dispatch(new UnfurlLink($linkPreview)); + + Queue::assertPushed(UnfurlLink::class, 1); +}); + +it('does not let a stale failed callback overwrite a completed publication retry', function (): void { + Http::fake(['https://93.184.216.34/*' => Http::response('Recovered', 200, ['Content-Type' => 'text/html'])]); + $linkPreview = LinkPreview::factory()->create(['url' => 'https://93.184.216.34/']); + $staleJob = new UnfurlLink($linkPreview); + + $linkPreview->markUnfurlAsTransientFailure(0); + $retry = LinkPreview::query()->findOrFail($linkPreview->id); + $retry->retryTransientFailureForPublication(); + + new UnfurlLink(LinkPreview::query()->findOrFail($linkPreview->id))->handle(resolve(UpdateLinkPreview::class)); + $staleJob->failed(); + + expect($linkPreview->refresh()) + ->title->toBe('Recovered') + ->fetched_at->not->toBeNull() + ->unfurl_failed_at->toBeNull() + ->publication_unfurl_retries->toBe(1); +}); + +it('keeps old queued job payloads compatible with the first unfurl cycle', function (): void { + $job = new UnfurlLink(LinkPreview::factory()->create()); + $payload = $job->__serialize(); + unset($payload['publicationUnfurlRetries']); + $legacyJob = new ReflectionClass(UnfurlLink::class)->newInstanceWithoutConstructor(); + $legacyJob->__unserialize($payload); + + expect($legacyJob->publicationUnfurlRetries)->toBe(0); +}); + it('is dropped when its link preview is gone', function (): void { Queue::connection('database')->push(new UnfurlLink(LinkPreview::factory()->create())); diff --git a/tests/Unit/LinkPreviewRequest.test.tsx b/tests/Unit/LinkPreviewRequest.test.tsx new file mode 100644 index 000000000..2ef44c8c1 --- /dev/null +++ b/tests/Unit/LinkPreviewRequest.test.tsx @@ -0,0 +1,431 @@ +/// +import assert from 'node:assert/strict'; +import { setImmediate } from 'node:timers/promises'; +import * as React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterAll, afterEach, test, vi } from 'vite-plus/test'; +import type { LinkPreview as LinkPreviewData } from '../../resources/js/types/commit'; + +type Effect = () => void | (() => void); +type EffectState = { + dependencies: readonly unknown[] | undefined; + effect: Effect; + cleanup?: () => void; + shouldRun: boolean; +}; + +let effectStates: EffectState[] = []; +let states: unknown[] = []; +let stateIndex = 0; +let effectIndex = 0; + +vi.doMock('react', () => ({ + ...React, + useEffect: (effect: Effect, dependencies?: readonly unknown[]) => { + const index = effectIndex++; + const previous = effectStates[index]; + const shouldRun = + previous === undefined || + !haveSameDependencies(previous.dependencies, dependencies); + + effectStates[index] = { + cleanup: previous?.cleanup, + dependencies, + effect, + shouldRun, + }; + }, + 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; + + if (Object.is(states[index], next)) { + return; + } + + states[index] = next; + }, + ]; + }, +})); + +const { LinkPreview } = + await import('../../resources/js/components/link-preview'); + +type Timer = { callback: () => void; delay: number }; +let nextTimer = 0; +const timers = new Map(); + +vi.stubGlobal('window', { + setTimeout: (callback: () => void, delay: number) => { + nextTimer++; + timers.set(nextTimer, { callback, delay }); + + return nextTimer; + }, + clearTimeout: (timer: number) => timers.delete(timer), +}); + +afterAll(() => vi.unstubAllGlobals()); +afterEach(() => { + cleanupEffects(); + effectStates = []; + states = []; + stateIndex = 0; + effectIndex = 0; + timers.clear(); + nextTimer = 0; +}); + +test('retries a rate-limited draft poll instead of hiding its pending card', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 429, + headers: new Headers({ 'Retry-After': '60' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + link_preview: { + ...pendingPreview, + is_pending: false, + title: 'Recovered preview', + }, + }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + await runNextTimer(); + + assert.ok(renderPreview().includes('data-test="link-preview-skeleton"')); + assert.equal(timers.size, 1); + assert.equal([...timers.values()][0]?.delay, 60000); + + await runNextTimer(); + + const markup = renderPreview(); + + assert.ok(markup.includes('Recovered preview')); + cleanup(); +}); + +test('gives up only after the bounded poll cycle remains pending', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ link_preview: pendingPreview }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + + for (let attempt = 0; attempt < 6; attempt++) { + await runNextTimer(); + } + + assert.equal(fetch.mock.calls.length, 6); + assert.equal(timers.size, 0); + const markup = renderPreview( + pendingPreview, + '/draft-link-preview?url=https%3A%2F%2Fexample.test%2Farticle', +
+ Link preview unavailable +
, + ); + + assert.ok(markup.includes('data-test="draft-preview-unavailable"')); + runPendingEffects(); + assert.equal(timers.size, 0); + cleanup(); +}); + +test('starts a fresh poll cycle when a pending preview changes URL', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ link_preview: pendingPreview }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + + for (let attempt = 0; attempt < 6; attempt++) { + await runNextTimer(); + } + + const nextPreview = { + ...pendingPreview, + url: 'https://example.test/another-article', + }; + const markup = renderPreview(nextPreview); + + assert.ok(markup.includes('data-test="link-preview-skeleton"')); + const nextCleanup = runPendingEffects(); + assert.equal(timers.size, 1); + cleanup(); + nextCleanup(); +}); + +test('starts a fresh poll cycle when its refresh URL changes', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ link_preview: pendingPreview }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + + for (let attempt = 0; attempt < 6; attempt++) { + await runNextTimer(); + } + + const markup = renderPreview( + pendingPreview, + '/draft-link-preview?url=https%3A%2F%2Fexample.test%2Farticle&refresh=1', + ); + + assert.ok(markup.includes('data-test="link-preview-skeleton"')); + const nextCleanup = runPendingEffects(); + assert.equal(timers.size, 1); + cleanup(); + nextCleanup(); +}); + +test('starts a fresh poll cycle when a preview becomes pending again', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ link_preview: pendingPreview }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + + for (let attempt = 0; attempt < 6; attempt++) { + await runNextTimer(); + } + + mount({ ...pendingPreview, is_pending: false }); + + const markup = renderPreview(); + + assert.ok(markup.includes('data-test="link-preview-skeleton"')); + const nextCleanup = runPendingEffects(); + assert.equal(timers.size, 1); + cleanup(); + nextCleanup(); +}); + +test('ignores a previous cycle response that completes after a new render', async () => { + let resolvePreview: (value: unknown) => void; + const deferredPreview = new Promise((resolve) => { + resolvePreview = resolve; + }); + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => deferredPreview, + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + await runNextTimer(); + const nextPreview = { + ...pendingPreview, + url: 'https://example.test/another-article', + }; + + assert.ok( + renderPreview(nextPreview).includes( + 'data-test="link-preview-skeleton"', + ), + ); + + resolvePreview!({ + link_preview: { + ...pendingPreview, + is_pending: false, + title: 'Stale preview', + }, + }); + await setImmediate(); + + const markup = renderPreview(nextPreview); + + assert.ok(markup.includes('data-test="link-preview-skeleton"')); + assert.equal(markup.includes('Stale preview'), false); + cleanup(); +}); + +test('gives up when a rate limit outlasts the visible retry window', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + headers: new Headers({ 'Retry-After': '61' }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + await runNextTimer(); + + assert.equal(timers.size, 0); + const markup = renderPreview( + pendingPreview, + undefined, +
+ Link preview unavailable +
, + ); + + assert.ok(markup.includes('data-test="draft-preview-unavailable"')); + cleanup(); +}); + +test('does not retry a rejected poll response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 403 }), + ); + + const cleanup = mount(); + await runNextTimer(); + + assert.equal(timers.size, 0); + assert.ok( + renderPreview( + pendingPreview, + undefined, +
Unavailable
, + ).includes('Unavailable'), + ); + cleanup(); +}); + +test.each([ + [ + 'a network failure', + async () => { + throw new Error('Offline'); + }, + ], + [ + 'invalid JSON', + async () => ({ + ok: true, + json: async () => { + throw new SyntaxError('Invalid JSON'); + }, + }), + ], +])('retries a pending preview after %s', async (_reason, request) => { + const fetch = vi + .fn() + .mockImplementationOnce(request) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + link_preview: { + ...pendingPreview, + is_pending: false, + title: 'Recovered preview', + }, + }), + }); + vi.stubGlobal('fetch', fetch); + + const cleanup = mount(); + await runNextTimer(); + + assert.equal(timers.size, 1); + assert.ok(renderPreview().includes('data-test="link-preview-skeleton"')); + await runNextTimer(); + + assert.ok(renderPreview().includes('Recovered preview')); + cleanup(); +}); + +const pendingPreview: LinkPreviewData = { + url: 'https://example.test/article', + title: null, + description: null, + image_url: null, + site_name: null, + is_pending: true, +}; + +function mount( + preview = pendingPreview, + refreshUrl = '/draft-link-preview?url=https%3A%2F%2Fexample.test%2Farticle', +): () => void { + renderPreview(preview, refreshUrl); + + return runPendingEffects(); +} + +function renderPreview( + preview = pendingPreview, + refreshUrl = '/draft-link-preview?url=https%3A%2F%2Fexample.test%2Farticle', + emptyState?: React.ReactNode, +): string { + stateIndex = 0; + effectIndex = 0; + + return renderToStaticMarkup( + LinkPreview({ + refreshUrl, + preview, + emptyState, + }), + ); +} + +function runPendingEffects(): () => void { + for (const effectState of effectStates) { + if (!effectState.shouldRun) { + continue; + } + + effectState.cleanup?.(); + effectState.cleanup = effectState.effect() ?? undefined; + effectState.shouldRun = false; + } + + return cleanupEffects; +} + +function cleanupEffects(): void { + for (const effectState of effectStates) { + effectState.cleanup?.(); + effectState.cleanup = undefined; + } +} + +function haveSameDependencies( + previous: readonly unknown[] | undefined, + next: readonly unknown[] | undefined, +): boolean { + if (previous === undefined || next === undefined) { + return previous === next; + } + + return ( + previous.length === next.length && + previous.every((dependency, index) => + Object.is(dependency, next[index]), + ) + ); +} + +async function runNextTimer(): Promise { + const [timer, value] = timers.entries().next().value as [number, Timer]; + timers.delete(timer); + value.callback(); + await setImmediate(); +} diff --git a/tests/Unit/Models/LinkPreviewTest.php b/tests/Unit/Models/LinkPreviewTest.php index 83447671b..2cf3d12d6 100644 --- a/tests/Unit/Models/LinkPreviewTest.php +++ b/tests/Unit/Models/LinkPreviewTest.php @@ -27,6 +27,41 @@ ->and(LinkPreview::factory()->failed()->create()->toArray()['is_pending'])->toBeFalse(); }); +it('lets only one stale instance start the publication retry', function (): void { + $preview = LinkPreview::factory()->failed()->create(); + $first = LinkPreview::query()->findOrFail($preview->id); + $second = LinkPreview::query()->findOrFail($preview->id); + + expect($first->prepareUnfurlForPublication())->toBeTrue() + ->and($second->prepareUnfurlForPublication())->toBeFalse() + ->and($preview->refresh()) + ->fetched_at->toBeNull() + ->publication_unfurl_retries->toBe(1); +}); + +it('distinguishes definitive completion from a recorded transient failure', function (): void { + $completed = LinkPreview::factory()->create(); + $completed->completeUnfurl(['title' => null]); + + $transientFailure = LinkPreview::factory()->failed()->create(); + + expect($completed->fresh()->prepareUnfurlForPublication())->toBeFalse() + ->and($transientFailure->prepareUnfurlForPublication())->toBeTrue(); +}); + +it('does not let an old completion overwrite a newer publication retry', function (): void { + $preview = LinkPreview::factory()->create(); + $oldCycle = LinkPreview::query()->findOrFail($preview->id); + $oldCycle->markUnfurlAsTransientFailure(0); + + $retry = LinkPreview::query()->findOrFail($preview->id); + $retry->prepareUnfurlForPublication(); + + expect($oldCycle->completeUnfurl(['title' => 'Old']))->toBeFalse() + ->and(LinkPreview::query()->findOrFail($preview->id)->completeUnfurl(['title' => 'New']))->toBeTrue() + ->and($preview->refresh()->title)->toBe('New'); +}); + it('has an embed when its link can be played in place', function (): void { expect(LinkPreview::factory()->create(['url' => 'https://youtu.be/dQw4w9WgXcQ'])->toArray()['embed'])->toMatchArray([ 'provider' => 'YouTube', @@ -41,6 +76,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/QuoteDraft.test.tsx b/tests/Unit/QuoteDraft.test.tsx index 125df1f92..e96448546 100644 --- a/tests/Unit/QuoteDraft.test.tsx +++ b/tests/Unit/QuoteDraft.test.tsx @@ -2,13 +2,14 @@ import assert from 'node:assert/strict'; import * as React from 'react'; import type { ReactElement, ReactNode } from 'react'; -import { test, vi } from 'vite-plus/test'; +import { afterEach, test, vi } from 'vite-plus/test'; import type { Commit } from '../../resources/js/types'; const image = new File(['restored'], 'draft.png', { type: 'image/png' }); const updateImages = vi.fn(); const drop = vi.fn(); const user = { id: 'viewer', handle: 'viewer' }; +let isPreviewing = false; const draft = { key: 'stash:viewer:quote', body: 'A restored quote', @@ -49,7 +50,7 @@ vi.doMock('../../resources/js/components/composer-preview', async () => ({ '../../resources/js/components/composer-preview', )), useComposerPreview: () => ({ - isPreviewing: false, + isPreviewing, handleShortcut: () => false, }), })); @@ -63,6 +64,8 @@ const { StashLabel } = await import('../../resources/js/components/stash-label'); const { LinkCardPicker } = await import('../../resources/js/components/link-card-picker'); +const { DraftLinkPreview } = + await import('../../resources/js/components/draft-link-preview'); const { default: InputError } = await import('../../resources/js/components/input-error'); @@ -81,6 +84,55 @@ function elements(node: ReactNode): ReactElement>[] { ]; } +afterEach(() => { + isPreviewing = false; +}); + +for (const previewing of [false, true]) { + for (const url of [ + 'https://giphy.com/gifs/JIX9t2j0ZTN9S', + 'https://images.example.test/cat.gif', + null, + ]) { + test(`quotes keep the selected draft preview for ${url ?? 'no URL'} while ${previewing ? 'previewing' : 'writing'}`, () => { + draft.body = url ?? 'A quote without a link'; + isPreviewing = previewing; + const commit = { + id: 'quoted', + user_id: 'author', + user: { handle: 'author' }, + status: 'approved', + } as Commit; + const button = CommitQuoteButton({ commit })!; + const dialog = React.Children.toArray( + button.props.children, + )[1] as ReactElement<{ + children: ReactElement<{ commit: Commit; onClose: () => void }>; + }>; + const composer = dialog.props.children; + const renderComposer = composer.type as ( + props: typeof composer.props, + ) => ReactElement; + const form = elements(renderComposer(composer.props)).find( + (element) => element.type === Form, + )!; + const renderFields = form.props.children as (props: { + processing: boolean; + errors: Record; + }) => ReactNode; + const fields = elements( + renderFields({ processing: false, errors: {} }), + ); + const preview = fields.find( + (element) => element.type === DraftLinkPreview, + ); + + assert.ok(preview); + assert.equal(preview.props.url, url); + }); + } +} + test('quotes submit restored images and clear the complete draft only on success or discard', async () => { draft.body = 'ghp_' + 'Ab3dEf6hIj9kLm2nOp5qRs8tUv1wXy4zABCD'; const commit = { 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/Unit/Services/LimitedResponseBodyTest.php b/tests/Unit/Services/LimitedResponseBodyTest.php new file mode 100644 index 000000000..d040fd93b --- /dev/null +++ b/tests/Unit/Services/LimitedResponseBodyTest.php @@ -0,0 +1,39 @@ +write($header))->toBe(13) + ->and((string) $body)->toBe($header); +}); + +it('returns a short write so the transport aborts an oversized response', function (): void { + $body = new LimitedResponseBody(32); + + expect($body->write('GIF89a'.str_repeat('x', 1_048_576)))->toBe(32) + ->and($body->getSize())->toBe(32) + ->and((string) $body)->toBe('GIF89a'.str_repeat('x', 26)); +}); + +it('enforces the same cap across multiple writes', function (): void { + $body = new LimitedResponseBody(32); + + expect($body->write(str_repeat('a', 20)))->toBe(20) + ->and($body->write(str_repeat('b', 20)))->toBe(12) + ->and($body->write('c'))->toBe(0) + ->and($body->getSize())->toBe(32) + ->and((string) $body)->toBe(str_repeat('a', 20).str_repeat('b', 12)); +}); + +it('does not let a seek move writes past the byte cap', function (): void { + $body = new LimitedResponseBody(32); + $body->seek(64); + + expect($body->write('overflow'))->toBe(0) + ->and($body->getSize())->toBe(0); +}); diff --git a/tests/Unit/Services/LinkPreviewAddressTest.php b/tests/Unit/Services/LinkPreviewAddressTest.php new file mode 100644 index 000000000..95e4ffc48 --- /dev/null +++ b/tests/Unit/Services/LinkPreviewAddressTest.php @@ -0,0 +1,94 @@ +address]; + } + }); + + expect($validator->get(Url::parse('https://example.com/cat.gif')))->toBeNull(); +})->with([ + 'carrier-grade NAT' => '100.64.0.1', + 'IETF protocol assignment' => '192.0.0.1', + 'IPv4 documentation one' => '192.0.2.1', + '6to4 relay' => '192.88.99.1', + 'benchmarking' => '198.18.0.1', + 'IPv4 documentation two' => '198.51.100.1', + 'IPv4 documentation three' => '203.0.113.1', + 'IPv4 multicast' => '224.0.0.1', + 'RFC 6052 loopback' => '64:ff9b::7f00:1', + 'local-use NAT64 loopback' => '64:ff9b:1::7f00:1', + 'broader NAT64 prefix one' => '64:ff9b:7f00:1::', + 'broader NAT64 prefix two' => '64:ff9b::1:7f00:1', + 'RFC 6052 private address' => '64:ff9b::a00:1', + 'discard-only' => '100::1', + 'Teredo' => '2001::1', + 'IPv6 documentation' => '2001:db8::1', + 'IPv6 6to4' => '2002::1', + 'site-local' => 'fec0::1', + 'IPv6 multicast' => 'ff00::1', + 'IPv4-compatible address' => '::2', + 'IPv4-mapped address' => '::ffff:127.0.0.1', + 'IPv4-translated address' => '::ffff:0:7f00:1', + 'IPv4 loopback' => '127.0.0.1', + 'IPv6 loopback' => '::1', +]); + +it('rejects a host when any resolved address is not public', function (): void { + $validator = new LinkPreviewAddress(new readonly class implements DnsResolver + { + public function resolve(string $host): array + { + return ['93.184.216.34', '64:ff9b::7f00:1']; + } + }); + + expect($validator->get(Url::parse('https://example.com/cat.gif')))->toBeNull(); +}); + +it('accepts public addresses on default HTTP ports', function (string $url, string $address): void { + $validator = new LinkPreviewAddress(new readonly class($address) implements DnsResolver + { + public function __construct(private string $address) + { + // + } + + public function resolve(string $host): array + { + return [$this->address]; + } + }); + + expect($validator->get(Url::parse($url)))->toBe($address); +})->with([ + 'IPv4 HTTPS default port' => ['https://example.com:443/cat.gif', '93.184.216.34'], + 'IPv6 HTTPS default port' => ['https://example.com:443/cat.gif', '2606:2800:220:1:248:1893:25c8:1946'], + 'IPv4 HTTP default port' => ['http://example.com:80/cat.gif', '93.184.216.34'], +]); + +it('rejects non-default ports', function (): void { + $validator = new LinkPreviewAddress(new readonly class implements DnsResolver + { + public function resolve(string $host): array + { + return ['93.184.216.34']; + } + }); + + expect($validator->get(Url::parse('https://example.com:8443/cat.gif')))->toBeNull(); +}); diff --git a/tests/Unit/Services/LinkPreviewBudgetTest.php b/tests/Unit/Services/LinkPreviewBudgetTest.php new file mode 100644 index 000000000..765cf81f6 --- /dev/null +++ b/tests/Unit/Services/LinkPreviewBudgetTest.php @@ -0,0 +1,28 @@ +start(); + $nanoseconds = 2_000_000_000; + + expect($deadline)->toBe(5.0) + ->and($budget->remaining($deadline))->toBe(3.0); + + $nanoseconds = 6_000_000_000; + + expect($budget->remaining($deadline))->toBe(0.0); +}); + +it('does not pass a sub-millisecond timeout to the HTTP client', function (int $nanoseconds): void { + $budget = new LinkPreviewBudget(fn (): int => $nanoseconds); + + expect($budget->remaining(5.0))->toBe(0.0); +})->with([4_999_999_999, 4_999_100_000]); diff --git a/tests/Unit/Services/LinkPreviewHttpClientTest.php b/tests/Unit/Services/LinkPreviewHttpClientTest.php new file mode 100644 index 000000000..92f459790 --- /dev/null +++ b/tests/Unit/Services/LinkPreviewHttpClientTest.php @@ -0,0 +1,49 @@ +setTimeout(5)->start(); + + try { + $server->waitUntil( + static fn (string $type, string $output): bool => $type === Process::OUT && mb_trim($output) !== '', + ); + $port = (int) mb_trim($server->getOutput()); + + throw_if($port === 0, RuntimeException::class, 'Link preview fixture did not report a port.'); + Http::allowStrayRequests(["http://example.test:{$port}/*"]); + + $response = resolve(LinkPreviewHttpClient::class)->get(new LinkPreviewRequest( + url: Url::parse("http://example.test:{$port}/cat.gif"), + address: '127.0.0.1', + headers: ['Accept' => 'image/gif', 'Range' => 'bytes=0-31'], + maxBodyBytes: 32, + timeoutSeconds: 5, + )); + + expect($response->status())->toBe(200) + ->and($response->header('Content-Type'))->toBe('image/gif') + ->and($response->body())->toHaveLength(32) + ->and($response->body())->toBe("GIF89a\x01\x00\x01\x00\x80\x00\x00".str_repeat('x', 19)) + ->and(file_get_contents($requestLog))->toContain("Range: bytes=0-31\r\n"); + } finally { + $server->stop(); + File::delete($requestLog); + } +}); diff --git a/tests/draft-images.config.ts b/tests/draft-images.config.ts index 45bb2673d..3167fdf1e 100644 --- a/tests/draft-images.config.ts +++ b/tests/draft-images.config.ts @@ -12,6 +12,12 @@ 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/DraftLinkPreview.test.tsx', + 'tests/Unit/DraftLinkPreviewRequest.test.tsx', + 'tests/Unit/LinkPreviewRequest.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..4b6e53a1e 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,11 +1,20 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "types": ["node"], + "types": [ + "node" + ], "allowImportingTsExtensions": true }, "include": [ "Unit/**/*.test.ts", + "Unit/GifEmbed.test.tsx", + "Unit/GifEmbedPlayback.test.tsx", + "Unit/GifPlaybackHook.test.tsx", + "Unit/ComposerPreview.test.tsx", + "Unit/DraftLinkPreview.test.tsx", + "Unit/DraftLinkPreviewRequest.test.tsx", + "Unit/LinkPreviewRequest.test.tsx", "Unit/CommitPhoto.test.tsx", "Unit/DraftStash.test.tsx", "Unit/ComposerDraft.test.tsx", @@ -32,6 +41,7 @@ "Unit/RemotePreferences.test.tsx", "Unit/ChatNavigation.test.tsx", "../resources/js/types/*.d.ts", - "draft-images.config.ts" + "draft-images.config.ts", + "Mutation/**/*.ts" ] }