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.
This commit is contained in:
Paulo Castellano 2026-07-17 14:32:40 -03:00
parent 450b6fd3de
commit 8648ed9720
17 changed files with 986 additions and 25 deletions

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\Post\LinkPreviewRequest;
use App\Services\Social\LinkCard\LinkCardFetcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class LinkPreviewController extends Controller
{
public function __invoke(LinkPreviewRequest $request, LinkCardFetcher $fetcher): JsonResponse|Response
{
$card = $fetcher->fetch($request->validated('url'));
if ($card === null) {
return response()->noContent();
}
return response()->json($card->toArray());
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Post;
use Illuminate\Foundation\Http\FormRequest;
class LinkPreviewRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'url' => ['required', 'string', 'max:2048'],
];
}
}

View file

@ -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';

View file

@ -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');

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\LinkCard;
use App\Services\Brand\SafeHttpFetcher;
use App\Support\UrlDetector;
use Illuminate\Support\Facades\Cache;
/**
* Builds a link preview card (OpenGraph metadata) for the first URL in a piece
* of text. Shared by BlueskyPublisher (publish-time embed) and the editor
* preview endpoint. Returns null whenever a card cannot or should not be built,
* so callers degrade gracefully.
*/
class LinkCardFetcher
{
/** Cards for a public URL are identical for everyone; cache briefly and globally. */
private const int CACHE_MINUTES = 10;
public function __construct(
private readonly SafeHttpFetcher $http = new SafeHttpFetcher,
private readonly OpenGraphExtractor $extractor = new OpenGraphExtractor,
) {}
public function fetch(string $text): ?LinkCardMetadata
{
$url = UrlDetector::firstUrl($text);
if ($url === null) {
return null;
}
return Cache::remember(
'link_card:'.sha1($url),
now()->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'),
);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\LinkCard;
final readonly class LinkCardMetadata
{
public function __construct(
public string $uri,
public string $title,
public string $description,
public ?string $imageUrl,
) {}
/**
* @return array{uri: string, title: string, description: string, image: ?string}
*/
public function toArray(): array
{
return [
'uri' => $this->uri,
'title' => $this->title,
'description' => $this->description,
'image' => $this->imageUrl,
];
}
}

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\LinkCard;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\UriResolver;
/**
* Deterministic OpenGraph reader for link preview cards. Pulls og:title /
* og:description / og:image with <title> 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;
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Support;
final class UrlDetector
{
/** PCRE matching bare http(s) URLs; shared with Bluesky link-facet parsing. */
public const string URL_PATTERN = '/(https?:\/\/[^\s]+)/u';
public static function firstUrl(string $text): ?string
{
if (preg_match(self::URL_PATTERN, $text, $matches) !== 1) {
return null;
}
return self::trimTrailingPunctuation($matches[0]);
}
/**
* 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.
*/
public static function trimTrailingPunctuation(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;
}
}

View file

@ -1,5 +1,9 @@
<script setup lang="ts">
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<Props>();
const props = defineProps<Props>();
const { card: linkCard, loading: linkCardLoading } = useLinkCard(
toRef(props, 'content'),
toRef(props, 'media'),
);
</script>
<template>
@ -99,6 +108,13 @@ defineProps<Props>();
</div>
</div>
<!-- Link preview card -->
<div
v-if="media.length === 0 && linkCardLoading"
class="mt-3 h-24 animate-pulse rounded-xl border border-neutral-200 bg-neutral-100 dark:border-[#1e3a5f] dark:bg-[#0f2138]"
></div>
<LinkCard v-else-if="media.length === 0 && linkCard" :card="linkCard" />
<!-- Timestamp -->
<div class="mt-3 text-[13px] text-neutral-500 dark:text-[#7b8d9e]">
4:18 PM · Jan 21, 2026

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { LinkCard } from '@/composables/useLinkCard';
const props = defineProps<{ card: LinkCard }>();
const domain = computed(() => {
try {
return new URL(props.card.uri).hostname.replace(/^www\./, '');
} catch {
return props.card.uri;
}
});
</script>
<template>
<div class="mt-3 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<img
v-if="card.image"
:src="card.image"
:alt="card.title"
class="aspect-[1.91/1] w-full object-cover"
/>
<div class="px-3 py-2">
<div class="text-[13px] text-neutral-500 dark:text-neutral-400">{{ domain }}</div>
<div
v-if="card.title"
class="mt-0.5 text-[15px] font-semibold text-neutral-900 dark:text-neutral-100 line-clamp-2"
>
{{ card.title }}
</div>
<div
v-if="card.description"
class="mt-0.5 text-[14px] text-neutral-500 dark:text-neutral-400 line-clamp-2"
>
{{ card.description }}
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,87 @@
import { useHttp } from '@inertiajs/vue3';
import { onBeforeUnmount, ref, watch, type Ref } from 'vue';
import debounce from '@/debounce';
import { linkPreview } from '@/routes/app/posts';
import type { MediaItem } from '@/types/media';
export interface LinkCard {
uri: string;
title: string;
description: string;
image: string | null;
}
const URL_RE = /(https?:\/\/[^\s]+)/;
const firstUrl = (text: string): string | null => {
const match = text.match(URL_RE);
if (!match) {
return null;
}
let url = match[0];
if (/[.,;:!?]$/.test(url)) {
url = url.slice(0, -1);
}
if (url.endsWith(')') && !url.includes('(')) {
url = url.slice(0, -1);
}
return url;
};
export const useLinkCard = (content: Ref<string>, media: Ref<MediaItem[]>) => {
const card = ref<LinkCard | null>(null);
const loading = ref(false);
const lastAttemptedUrl = ref<string | null>(null);
const http = useHttp<{ url: string }, LinkCard | null>({ url: '' });
const fetchCard = async (url: string): Promise<void> => {
lastAttemptedUrl.value = url;
loading.value = true;
try {
http.url = url;
const data = await http.post(linkPreview.url());
card.value = data && data.uri ? data : null;
} catch {
card.value = null;
} finally {
loading.value = false;
}
};
const debounced = debounce((url: string) => {
void fetchCard(url);
}, 400);
watch(
[content, media],
() => {
if (media.value.length > 0) {
card.value = null;
loading.value = false;
lastAttemptedUrl.value = null;
return;
}
const url = firstUrl(content.value);
if (!url) {
card.value = null;
loading.value = false;
lastAttemptedUrl.value = null;
return;
}
if (url === lastAttemptedUrl.value) {
return;
}
debounced(url);
},
{ immediate: true },
);
onBeforeUnmount(() => debounced.cancel());
return { card, loading };
};

View file

@ -9,6 +9,7 @@
use App\Http\Controllers\App\BillingController;
use App\Http\Controllers\App\DiscordController as AppDiscordController;
use App\Http\Controllers\App\GiphyController;
use App\Http\Controllers\App\LinkPreviewController;
use App\Http\Controllers\App\NotificationController;
use App\Http\Controllers\App\OnboardingController;
use App\Http\Controllers\App\PostAiCreateController;
@ -187,6 +188,9 @@
Route::put('posts/{post}', [PostController::class, 'update'])->name('app.posts.update');
Route::delete('posts/{post}', [PostController::class, 'destroy'])->name('app.posts.destroy');
Route::post('posts/{post}/duplicate', [PostController::class, 'duplicate'])->name('app.posts.duplicate');
Route::post('posts/link-preview', LinkPreviewController::class)
->middleware('throttle:30,1')
->name('app.posts.link-preview');
// Post Templates
Route::get('post-templates', [PostTemplateController::class, 'index'])->name('app.post-templates.index');

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
beforeEach(function () {
$this->user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $workspace->id]);
});
test('returns a link card for a valid url', function () {
Http::fake([
'https://example.com' => Http::response(
'<html><head><meta property="og:title" content="Example">'
.'<meta property="og:description" content="Desc">'
.'<meta property="og:image" content="https://example.com/card.png"></head></html>',
200,
),
]);
$this->actingAs($this->user)
->postJson(route('app.posts.link-preview'), ['url' => 'https://example.com'])
->assertOk()
->assertJson([
'uri' => 'https://example.com',
'title' => 'Example',
'description' => 'Desc',
'image' => 'https://example.com/card.png',
]);
});
test('returns no content when there is no card', function () {
$this->actingAs($this->user)
->postJson(route('app.posts.link-preview'), ['url' => 'http://127.0.0.1/private'])
->assertNoContent();
});
test('validates that a url is required', function () {
$this->actingAs($this->user)
->postJson(route('app.posts.link-preview'), [])
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
});
test('requires authentication', function () {
$this->postJson(route('app.posts.link-preview'), ['url' => 'https://example.com'])
->assertUnauthorized();
});

View file

@ -12,6 +12,8 @@
use App\Models\Workspace;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\BlueskyPublisher;
use App\Services\Social\LinkCard\LinkCardFetcher;
use App\Services\Social\LinkCard\LinkCardMetadata;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
@ -39,6 +41,12 @@
]);
$this->publisher = new BlueskyPublisher;
// Default: no link card. Card-specific tests override this mock.
$this->mock(LinkCardFetcher::class)
->shouldReceive('fetch')
->andReturn(null)
->byDefault();
});
test('bluesky publisher can publish text-only post', function () {
@ -1449,3 +1457,311 @@ function fakeBlueskyVideoPipeline(string $jobState = 'JOB_STATE_COMPLETED', bool
Http::assertSent(fn ($request) => str_contains($request->url(), 'uploadBlob')
&& $request->hasHeader('Content-Type', 'image/gif'));
});
test('bluesky publisher attaches an external card with a thumb for a bare link', function () {
$this->post->update(['content' => 'read this https://example.com/article']);
$this->mock(LinkCardFetcher::class)
->shouldReceive('fetch')
->once()
->andReturn(new LinkCardMetadata(
uri: 'https://example.com/article',
title: 'The Article',
description: 'A great read',
// A public IP literal lets SafeHttpFetcher's SSRF guard pass without a real DNS lookup.
imageUrl: 'https://93.184.216.34/card.jpg',
));
$this->mock(MediaOptimizer::class)
->shouldReceive('optimizeImage')
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'bsky_thumb_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
Http::fake(function ($request) {
if (str_contains($request->url(), 'uploadBlob')) {
return Http::response(['blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafthumb'], 'mimeType' => 'image/jpeg', 'size' => 1024]], 200);
}
if (str_contains($request->url(), 'createRecord')) {
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz', 'cid' => 'bafyreiabc123'], 200);
}
return Http::response(str_repeat('x', 1024), 200); // og:image download
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'createRecord')) {
return false;
}
$embed = $request['record']['embed'] ?? null;
return $embed
&& $embed['$type'] === 'app.bsky.embed.external'
&& $embed['external']['uri'] === 'https://example.com/article'
&& $embed['external']['title'] === 'The Article'
&& $embed['external']['description'] === 'A great read'
&& data_get($embed, 'external.thumb.ref.$link') === 'bafthumb';
});
});
test('bluesky publisher builds an external card without a thumb when there is no image', function () {
$this->post->update(['content' => 'read this https://example.com/article']);
$this->mock(LinkCardFetcher::class)
->shouldReceive('fetch')
->once()
->andReturn(new LinkCardMetadata(
uri: 'https://example.com/article',
title: 'The Article',
description: 'A great read',
imageUrl: null,
));
Http::fake([
config('trypost.platforms.bluesky.default_service').'/xrpc/com.atproto.repo.createRecord' => Http::response([
'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz',
'cid' => 'bafyreiabc123',
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'createRecord')) {
return false;
}
$embed = $request['record']['embed'] ?? null;
return $embed
&& $embed['$type'] === 'app.bsky.embed.external'
&& ! isset($embed['external']['thumb']);
});
});
test('bluesky publisher keeps the card when the thumb upload fails', function () {
$this->post->update(['content' => 'read this https://example.com/article']);
$this->mock(LinkCardFetcher::class)
->shouldReceive('fetch')
->once()
->andReturn(new LinkCardMetadata(
uri: 'https://example.com/article',
title: 'The Article',
description: 'A great read',
// A public IP literal lets SafeHttpFetcher's SSRF guard pass without a real DNS lookup.
imageUrl: 'https://93.184.216.34/card.jpg',
));
$this->mock(MediaOptimizer::class)
->shouldReceive('optimizeImage')
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'bsky_thumb_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
Http::fake(function ($request) {
if (str_contains($request->url(), 'uploadBlob')) {
return Http::response(['error' => 'InternalServerError'], 500);
}
if (str_contains($request->url(), 'createRecord')) {
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz', 'cid' => 'bafyreiabc123'], 200);
}
return Http::response(str_repeat('x', 1024), 200);
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'createRecord')) {
return false;
}
$embed = $request['record']['embed'] ?? null;
return $embed
&& $embed['$type'] === 'app.bsky.embed.external'
&& ! isset($embed['external']['thumb']);
});
});
test('bluesky publisher does not consult the link card fetcher when media is present', function () {
$this->post->update([
'content' => 'has media and a link https://example.com/article',
'media' => [[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
]],
]);
$this->mock(LinkCardFetcher::class)->shouldReceive('fetch')->never();
$this->mock(MediaOptimizer::class)
->shouldReceive('optimizeImage')
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'bsky_img_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
Http::fake(function ($request) {
if (str_contains($request->url(), 'uploadBlob')) {
return Http::response(['blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafimg'], 'mimeType' => 'image/jpeg', 'size' => 1024]], 200);
}
if (str_contains($request->url(), 'createRecord')) {
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz', 'cid' => 'bafyreiabc123'], 200);
}
return Http::response(str_repeat('x', 1024), 200);
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord')
&& ($request['record']['embed']['$type'] ?? null) === 'app.bsky.embed.images');
});
test('bluesky publisher blocks the thumb download when the card image points at a private address', function () {
$this->post->update(['content' => 'read this https://example.com/article']);
$this->mock(LinkCardFetcher::class)
->shouldReceive('fetch')
->once()
->andReturn(new LinkCardMetadata(
uri: 'https://example.com/article',
title: 'The Article',
description: 'A great read',
imageUrl: 'http://127.0.0.1/evil.jpg',
));
Http::fake([
config('trypost.platforms.bluesky.default_service').'/xrpc/com.atproto.repo.createRecord' => Http::response([
'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz',
'cid' => 'bafyreiabc123',
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'createRecord')) {
return false;
}
$embed = $request['record']['embed'] ?? null;
return $embed
&& $embed['$type'] === 'app.bsky.embed.external'
&& ! isset($embed['external']['thumb']);
});
// The SSRF guard must block the download before any request to the private address fires.
Http::assertNotSent(fn ($request) => str_contains($request->url(), '127.0.0.1'));
});
test('bluesky publisher does not follow a redirect on the card thumb download', function () {
$this->post->update(['content' => 'read this https://example.com/article']);
$this->mock(LinkCardFetcher::class)
->shouldReceive('fetch')
->once()
->andReturn(new LinkCardMetadata(
uri: 'https://example.com/article',
title: 'The Article',
description: 'A great read',
// The host guard passes (public IP literal); the redirect target does not.
imageUrl: 'https://93.184.216.34/card.jpg',
));
Http::fake([
'https://93.184.216.34/card.jpg' => Http::response('', 302, ['Location' => 'http://127.0.0.1/internal.jpg']),
config('trypost.platforms.bluesky.default_service').'/xrpc/com.atproto.repo.createRecord' => Http::response([
'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz',
'cid' => 'bafyreiabc123',
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'createRecord')) {
return false;
}
$embed = $request['record']['embed'] ?? null;
return $embed
&& $embed['$type'] === 'app.bsky.embed.external'
&& ! isset($embed['external']['thumb']);
});
// The redirect to the internal address must never be followed.
Http::assertNotSent(fn ($request) => str_contains($request->url(), '127.0.0.1'));
});
test('bluesky publisher does not attach a card when a non-embeddable media item is attached', function () {
$this->post->update([
'content' => 'read this https://example.com/article',
'media' => [[
'id' => 'doc',
'path' => 'media/2026-01/f.pdf',
'url' => 'https://example.com/f.pdf',
'mime_type' => 'application/pdf',
'original_filename' => 'f.pdf',
]],
]);
// A PDF is neither image nor video, so no embed is built for it; the card
// gate must still short-circuit because media is attached to the post.
$this->mock(LinkCardFetcher::class)->shouldReceive('fetch')->never();
Http::fake([
config('trypost.platforms.bluesky.default_service').'/xrpc/com.atproto.repo.createRecord' => Http::response([
'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz',
'cid' => 'bafyreiabc123',
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'createRecord')) {
return false;
}
$record = $request['record'];
return ! isset($record['embed'])
&& collect($record['facets'] ?? [])->contains(
fn ($facet) => $facet['features'][0]['$type'] === 'app.bsky.richtext.facet#link'
);
});
});
test('bluesky publisher publishes with only a facet when no card is available', function () {
$this->post->update(['content' => 'read this https://example.com/article']);
// beforeEach default mock returns null (no card).
Http::fake([
config('trypost.platforms.bluesky.default_service').'/xrpc/com.atproto.repo.createRecord' => Http::response([
'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz',
'cid' => 'bafyreiabc123',
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'createRecord')) {
return false;
}
$record = $request['record'];
return ! isset($record['embed'])
&& collect($record['facets'] ?? [])->contains(
fn ($facet) => $facet['features'][0]['$type'] === 'app.bsky.richtext.facet#link'
);
});
});

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Services\Social\LinkCard\LinkCardFetcher;
use Illuminate\Support\Facades\Http;
// example.com resolves to a public IP so the SafeHttpFetcher SSRF guard passes;
// Http::fake then intercepts the actual request.
test('fetches a card from the first url in the text', function () {
Http::fake([
'https://example.com' => Http::response(
'<html><head><meta property="og:title" content="Example Title">'
.'<meta property="og:description" content="Example description">'
.'<meta property="og:image" content="https://example.com/card.png">'
.'</head></html>',
200,
),
]);
$card = app(LinkCardFetcher::class)->fetch('look at https://example.com today');
expect($card)->not->toBeNull()
->and($card->uri)->toBe('https://example.com')
->and($card->title)->toBe('Example Title')
->and($card->description)->toBe('Example description')
->and($card->imageUrl)->toBe('https://example.com/card.png');
});
test('returns null when the text has no url', function () {
expect(app(LinkCardFetcher::class)->fetch('no links here'))->toBeNull();
});
test('returns null for a private-network url (ssrf guard)', function () {
expect(app(LinkCardFetcher::class)->fetch('internal http://127.0.0.1/admin'))->toBeNull();
});
test('returns null when the page has no title or description', function () {
Http::fake(['https://example.com' => Http::response('<html><body>nothing</body></html>', 200)]);
expect(app(LinkCardFetcher::class)->fetch('see https://example.com'))->toBeNull();
});
test('caches the result so a repeated url is fetched once', function () {
Http::fake([
'https://example.com' => Http::response(
'<html><head><meta property="og:title" content="Cached"></head></html>',
200,
),
]);
$fetcher = app(LinkCardFetcher::class);
$fetcher->fetch('https://example.com');
$fetcher->fetch('https://example.com');
Http::assertSentCount(1);
});

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use App\Services\Social\LinkCard\OpenGraphExtractor;
test('extracts open graph title, description and absolute image', function () {
$html = <<<'HTML'
<html><head>
<meta property="og:title" content="Hello OG">
<meta property="og:description" content="A description">
<meta property="og:image" content="/img/card.png">
</head><body></body></html>
HTML;
$meta = (new OpenGraphExtractor)->extract($html, 'https://example.com/post');
expect($meta['title'])->toBe('Hello OG')
->and($meta['description'])->toBe('A description')
->and($meta['image'])->toBe('https://example.com/img/card.png');
});
test('falls back to title tag and meta description', function () {
$html = '<html><head><title>Plain Title</title>'
.'<meta name="description" content="Meta desc"></head><body></body></html>';
$meta = (new OpenGraphExtractor)->extract($html, 'https://example.com');
expect($meta['title'])->toBe('Plain Title')
->and($meta['description'])->toBe('Meta desc')
->and($meta['image'])->toBeNull();
});
test('returns nulls when nothing is present', function () {
$meta = (new OpenGraphExtractor)->extract('<html><body>no meta</body></html>', 'https://example.com');
expect($meta)->toBe(['title' => null, 'description' => null, 'image' => null]);
});

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Support\UrlDetector;
test('finds the first url in text', function () {
expect(UrlDetector::firstUrl('read https://example.com/article now'))
->toBe('https://example.com/article');
});
test('returns null when there is no url', function () {
expect(UrlDetector::firstUrl('just some #text and @handle'))->toBeNull();
});
test('trims trailing sentence punctuation', function () {
expect(UrlDetector::firstUrl('see https://example.com.'))->toBe('https://example.com');
});
test('drops an unmatched closing paren but keeps a matched one', function () {
expect(UrlDetector::firstUrl('see https://example.com)'))->toBe('https://example.com');
expect(UrlDetector::firstUrl('see https://en.wikipedia.org/wiki/Foo_(bar)'))
->toBe('https://en.wikipedia.org/wiki/Foo_(bar)');
});
test('returns the first of several urls', function () {
expect(UrlDetector::firstUrl('https://a.com and https://b.com'))->toBe('https://a.com');
});