From 8648ed97209f3828bc87555a87a31b063a1b651a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 17 Jul 2026 14:32:40 -0300 Subject: [PATCH 01/10] feat(bluesky): add link preview cards for posts Bluesky does not hydrate link cards server-side, so build the app.bsky.embed.external embed at publish time: detect the first URL, scrape its OpenGraph metadata, and re-upload the og:image as the card thumb. Works for web, API and MCP. Adds a posts/link-preview endpoint so the editor renders the card live. The thumb download is SSRF-guarded and does not follow redirects. --- .../Controllers/App/LinkPreviewController.php | 25 ++ .../Requests/App/Post/LinkPreviewRequest.php | 25 ++ app/Services/Social/BlueskyLexicon.php | 2 + app/Services/Social/BlueskyPublisher.php | 112 +++++-- .../Social/LinkCard/LinkCardFetcher.php | 65 ++++ .../Social/LinkCard/LinkCardMetadata.php | 28 ++ .../Social/LinkCard/OpenGraphExtractor.php | 75 +++++ app/Support/UrlDetector.php | 38 +++ .../posts/previews/BlueskyPreview.vue | 18 +- .../js/components/posts/previews/LinkCard.vue | 41 +++ resources/js/composables/useLinkCard.ts | 87 +++++ routes/app.php | 4 + .../App/Post/LinkPreviewControllerTest.php | 52 +++ .../Services/Social/BlueskyPublisherTest.php | 316 ++++++++++++++++++ .../Social/LinkCard/LinkCardFetcherTest.php | 57 ++++ .../LinkCard/OpenGraphExtractorTest.php | 38 +++ tests/Unit/Support/UrlDetectorTest.php | 28 ++ 17 files changed, 986 insertions(+), 25 deletions(-) create mode 100644 app/Http/Controllers/App/LinkPreviewController.php create mode 100644 app/Http/Requests/App/Post/LinkPreviewRequest.php create mode 100644 app/Services/Social/LinkCard/LinkCardFetcher.php create mode 100644 app/Services/Social/LinkCard/LinkCardMetadata.php create mode 100644 app/Services/Social/LinkCard/OpenGraphExtractor.php create mode 100644 app/Support/UrlDetector.php create mode 100644 resources/js/components/posts/previews/LinkCard.vue create mode 100644 resources/js/composables/useLinkCard.ts create mode 100644 tests/Feature/App/Post/LinkPreviewControllerTest.php create mode 100644 tests/Feature/Services/Social/LinkCard/LinkCardFetcherTest.php create mode 100644 tests/Unit/Services/Social/LinkCard/OpenGraphExtractorTest.php create mode 100644 tests/Unit/Support/UrlDetectorTest.php diff --git a/app/Http/Controllers/App/LinkPreviewController.php b/app/Http/Controllers/App/LinkPreviewController.php new file mode 100644 index 00000000..8e2209e1 --- /dev/null +++ b/app/Http/Controllers/App/LinkPreviewController.php @@ -0,0 +1,25 @@ +fetch($request->validated('url')); + + if ($card === null) { + return response()->noContent(); + } + + return response()->json($card->toArray()); + } +} diff --git a/app/Http/Requests/App/Post/LinkPreviewRequest.php b/app/Http/Requests/App/Post/LinkPreviewRequest.php new file mode 100644 index 00000000..c932f33f --- /dev/null +++ b/app/Http/Requests/App/Post/LinkPreviewRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'url' => ['required', 'string', 'max:2048'], + ]; + } +} diff --git a/app/Services/Social/BlueskyLexicon.php b/app/Services/Social/BlueskyLexicon.php index 0fbc70be..ae56ad84 100644 --- a/app/Services/Social/BlueskyLexicon.php +++ b/app/Services/Social/BlueskyLexicon.php @@ -37,6 +37,8 @@ final class BlueskyLexicon public const EMBED_VIDEO = 'app.bsky.embed.video'; + public const EMBED_EXTERNAL = 'app.bsky.embed.external'; + public const FACET_LINK = 'app.bsky.richtext.facet#link'; public const FACET_MENTION = 'app.bsky.richtext.facet#mention'; diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index f45f541c..7c6a128a 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -9,13 +9,18 @@ use App\Exceptions\Social\BlueskyPublishException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Brand\SafeHttpFetcher; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; +use App\Services\Social\LinkCard\LinkCardFetcher; +use App\Services\Social\LinkCard\LinkCardMetadata; +use App\Support\UrlDetector; use Carbon\CarbonInterface; use Exception; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use RuntimeException; use Throwable; class BlueskyPublisher @@ -25,6 +30,9 @@ class BlueskyPublisher /** Seconds allowed for a remote media download (large videos need time). */ private const DOWNLOAD_TIMEOUT = 600; + /** Short timeout for the card thumbnail download (attacker-influenceable og:image, not a large media upload). */ + private const THUMB_DOWNLOAD_TIMEOUT = 15; + /** Re-upload a transiently-failing transcode this many times before giving up. */ private const VIDEO_UPLOAD_ATTEMPTS = 3; @@ -95,6 +103,13 @@ public function publish(PostPlatform $postPlatform): array } } + // No image or video embed, so a bare link can carry a preview card. + // Bluesky does not hydrate cards server-side: the client must attach an + // app.bsky.embed.external built from the page's OpenGraph metadata. + if ($embed === null && $medias->isEmpty() && $content !== null) { + $embed = $this->buildExternalEmbed($postPlatform->socialAccount, $service, $content); + } + // Parse facets (links, mentions, hashtags) from text $text = $content ?? ''; $facets = $this->parseFacets($text); @@ -142,9 +157,63 @@ public function publish(PostPlatform $postPlatform): array ]; } - private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType): ?array + /** + * Build an app.bsky.embed.external card for the first link in the text, or + * null when there is no link, the scrape fails, or the page has no metadata. + * The og:image is re-uploaded as the card thumb because Bluesky's `thumb` + * only accepts a blob, never an external URL. Any failure degrades to null + * so the post still publishes with just the link facet. + */ + private function buildExternalEmbed(SocialAccount $account, string $service, string $content): ?array { - $tempFile = $this->downloadToTempFile($url, 'bsky_blob_'); + $card = app(LinkCardFetcher::class)->fetch($content); + + if ($card === null) { + return null; + } + + $external = [ + 'uri' => $card->uri, + 'title' => $card->title, + 'description' => $card->description, + ]; + + $thumb = $this->uploadCardThumb($account, $service, $card); + + if ($thumb !== null) { + $external['thumb'] = $thumb; + } + + return [ + '$type' => BlueskyLexicon::EMBED_EXTERNAL, + 'external' => $external, + ]; + } + + /** + * Download the card's og:image and upload it as a blob for the thumb. + * Returns null (card renders without a thumbnail) when there is no image or + * the upload fails. A JPEG hint routes it through the image optimizer, which + * re-encodes any static image and enforces Bluesky's 1MB blob limit. + */ + private function uploadCardThumb(SocialAccount $account, string $service, LinkCardMetadata $card): ?array + { + if ($card->imageUrl === null) { + return null; + } + + try { + app(SafeHttpFetcher::class)->guardAgainstSsrf($card->imageUrl); + } catch (RuntimeException) { + return null; + } + + return $this->uploadBlob($account, $service, $card->imageUrl, 'image/jpeg', self::THUMB_DOWNLOAD_TIMEOUT, followRedirects: false); + } + + private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType, int $downloadTimeout = self::DOWNLOAD_TIMEOUT, bool $followRedirects = true): ?array + { + $tempFile = $this->downloadToTempFile($url, 'bsky_blob_', $downloadTimeout, $followRedirects); if ($tempFile === null) { return null; @@ -202,8 +271,15 @@ private function uploadBlob(SocialAccount $account, string $service, string $url * Download a remote media file to a temp file. Returns the temp path, or * null (after cleaning up) if the temp file can't be created, the download * fails, or the downloaded file is empty. + * + * $followRedirects defaults to true for the media/video paths, which + * download from our own storage/CDN URLs. The card thumb path passes + * false because the source is an attacker-influenceable og:image that + * was only guarded against SSRF on its original URL — a redirect on that + * hop must not be followed without re-guarding, so it is simply not + * followed at all (the thumb degrades to null instead). */ - private function downloadToTempFile(string $url, string $prefix): ?string + private function downloadToTempFile(string $url, string $prefix, int $timeoutSeconds = self::DOWNLOAD_TIMEOUT, bool $followRedirects = true): ?string { $tempFile = tempnam(sys_get_temp_dir(), $prefix); @@ -214,7 +290,13 @@ private function downloadToTempFile(string $url, string $prefix): ?string } try { - $response = Http::withOptions(['sink' => $tempFile])->timeout(self::DOWNLOAD_TIMEOUT)->get($url); + $options = ['sink' => $tempFile]; + + if (! $followRedirects) { + $options['allow_redirects'] = false; + } + + $response = Http::withOptions($options)->timeout($timeoutSeconds)->get($url); if ($response->failed()) { throw new Exception('HTTP '.$response->status()); @@ -555,14 +637,14 @@ private function parseFacets(string $text): array // Parse URLs preg_match_all( - '/(https?:\/\/[^\s]+)/u', + UrlDetector::URL_PATTERN, $text, $urlMatches, PREG_OFFSET_CAPTURE ); foreach ($urlMatches[0] as $match) { - $url = $this->trimTrailingUrlPunctuation($match[0]); + $url = UrlDetector::trimTrailingPunctuation($match[0]); $start = (int) $match[1]; $end = $start + strlen($url); @@ -676,24 +758,6 @@ private function resolveHandleToDid(string $handle): ?string } } - /** - * Trailing sentence punctuation and an unmatched closing paren are almost - * never part of a URL (e.g. "see https://x.com)."). Mirrors the official - * atproto link tokenizer so the link facet doesn't over-extend past the URL. - */ - private function trimTrailingUrlPunctuation(string $url): string - { - if (preg_match('/[.,;:!?]$/', $url)) { - $url = substr($url, 0, -1); - } - - if (str_ends_with($url, ')') && ! str_contains($url, '(')) { - $url = substr($url, 0, -1); - } - - return $url; - } - private function buildPostUrl(string $handle, string $postId): string { $webApp = (string) config('trypost.platforms.bluesky.web_app'); diff --git a/app/Services/Social/LinkCard/LinkCardFetcher.php b/app/Services/Social/LinkCard/LinkCardFetcher.php new file mode 100644 index 00000000..b4985799 --- /dev/null +++ b/app/Services/Social/LinkCard/LinkCardFetcher.php @@ -0,0 +1,65 @@ +addMinutes(self::CACHE_MINUTES), + fn (): ?LinkCardMetadata => $this->build($url), + ); + } + + private function build(string $url): ?LinkCardMetadata + { + $response = $this->http->tryGet($url); + + if ($response === null) { + return null; + } + + $meta = $this->extractor->extract($response->body(), $url); + $title = data_get($meta, 'title'); + $description = data_get($meta, 'description'); + + if ($title === null && $description === null) { + return null; + } + + return new LinkCardMetadata( + uri: $url, + title: $title ?? '', + description: $description ?? '', + imageUrl: data_get($meta, 'image'), + ); + } +} diff --git a/app/Services/Social/LinkCard/LinkCardMetadata.php b/app/Services/Social/LinkCard/LinkCardMetadata.php new file mode 100644 index 00000000..2e1f47a7 --- /dev/null +++ b/app/Services/Social/LinkCard/LinkCardMetadata.php @@ -0,0 +1,28 @@ + $this->uri, + 'title' => $this->title, + 'description' => $this->description, + 'image' => $this->imageUrl, + ]; + } +} diff --git a/app/Services/Social/LinkCard/OpenGraphExtractor.php b/app/Services/Social/LinkCard/OpenGraphExtractor.php new file mode 100644 index 00000000..dcf62f38 --- /dev/null +++ b/app/Services/Social/LinkCard/OpenGraphExtractor.php @@ -0,0 +1,75 @@ + and meta-description fallbacks. + * Deliberately separate from HomepageMetaExtractor, which is brand-tuned and + * intentionally excludes og:image. + */ +final class OpenGraphExtractor +{ + /** + * @return array{title: ?string, description: ?string, image: ?string} + */ + public function extract(string $html, string $baseUrl): array + { + $crawler = new Crawler($html, $baseUrl); + + return [ + 'title' => $this->title($crawler), + 'description' => $this->metaContent($crawler, 'property', 'og:description') + ?? $this->metaContent($crawler, 'name', 'description'), + 'image' => $this->image($crawler, $baseUrl), + ]; + } + + private function title(Crawler $crawler): ?string + { + $ogTitle = $this->metaContent($crawler, 'property', 'og:title'); + + if ($ogTitle !== null) { + return $ogTitle; + } + + $title = $crawler->filter('title')->first(); + + if ($title->count() === 0) { + return null; + } + + $text = trim($title->text('')); + + return $text === '' ? null : $text; + } + + private function image(Crawler $crawler, string $baseUrl): ?string + { + $image = $this->metaContent($crawler, 'property', 'og:image'); + + if ($image === null) { + return null; + } + + return UriResolver::resolve($image, $baseUrl); + } + + private function metaContent(Crawler $crawler, string $attr, string $value): ?string + { + $node = $crawler->filter(sprintf('meta[%s="%s"]', $attr, $value))->first(); + + if ($node->count() === 0) { + return null; + } + + $content = trim((string) $node->attr('content', '')); + + return $content === '' ? null : $content; + } +} diff --git a/app/Support/UrlDetector.php b/app/Support/UrlDetector.php new file mode 100644 index 00000000..1ac84586 --- /dev/null +++ b/app/Support/UrlDetector.php @@ -0,0 +1,38 @@ + +import { toRef } from 'vue'; + +import LinkCard from "@/components/posts/previews/LinkCard.vue"; import VideoPreview from "@/components/posts/previews/VideoPreview.vue"; +import { useLinkCard } from '@/composables/useLinkCard'; import { isVideoMedia } from '@/composables/useMedia'; import type { MediaItem } from '@/types/media'; @@ -17,7 +21,12 @@ interface Props { media: MediaItem[]; } -defineProps(); +const props = defineProps(); + +const { card: linkCard, loading: linkCardLoading } = useLinkCard( + toRef(props, 'content'), + toRef(props, 'media'), +);