Merge pull request #185 from trypostit/fix/bluesky-link-preview-card

Link preview cards for Bluesky, X and Threads
This commit is contained in:
Paulo Castellano 2026-07-17 16:37:16 -03:00 committed by GitHub
commit 5f0346951d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1519 additions and 84 deletions

View file

@ -13,7 +13,6 @@
use App\Services\Automation\FeedParser;
use App\Services\Brand\SafeHttpFetcher;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
@ -51,21 +50,19 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
return NodeRunResult::failed(__('automations.errors.fetch_rss_missing_url'));
}
// SafeHttpFetcher::get() re-validates every redirect hop against the SSRF
// guard (not just the initial URL), so a public feed that 302s to an
// internal host is never followed. It throws on a blocked hop, connection
// failure, non-2xx status, or an excessive redirect chain — all of which
// are legitimate "this feed couldn't be fetched" failures for this node.
try {
$this->safeHttp->guardAgainstSsrf($feedUrl);
} catch (RuntimeException) {
return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [
'reason' => 'url_not_allowed',
'url' => $feedUrl,
$response = $this->safeHttp->get($feedUrl);
} catch (RuntimeException $e) {
return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), [
'message' => $e->getMessage(),
]);
}
$response = Http::timeout(10)->get($feedUrl);
if (! $response->successful()) {
return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), ['status' => $response->status()]);
}
$items = $this->parser->parse($response->body());
if ($items === null) {

View file

@ -364,7 +364,9 @@ private function buildRequest(array $config, array $context): PendingRequest
$request = $request->withHeaders($headers);
}
return $request->withUserAgent(config('trypost.user_agent'));
return $request
->withUserAgent(config('trypost.user_agent'))
->withOptions($this->safeHttp->redirectGuardOptions());
}
/**

View file

@ -75,6 +75,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
try {
$response = Http::withHeaders($headers)
->withUserAgent(config('trypost.user_agent'))
->withOptions(['allow_redirects' => false])
->send($method, $url, ['json' => $payload]);
} catch (Throwable $e) {
return NodeRunResult::failed(__('automations.errors.webhook_request_failed'), [

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
use Illuminate\Support\Facades\Http;
use App\Services\Brand\SafeHttpFetcher;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@ -22,7 +22,7 @@ function uploadFromUrl(?string $url, string $directory = 'social-accounts'): ?st
}
try {
$response = Http::timeout(10)->get($url);
$response = app(SafeHttpFetcher::class)->guardedRequest($url)->timeout(10)->get($url);
if (! $response->successful()) {
Log::warning('uploadFromUrl: Failed to download', [

View file

@ -9,16 +9,17 @@
use App\Http\Requests\App\Asset\StoreChunkedAssetRequest;
use App\Http\Resources\App\MediaResource;
use App\Models\Media;
use App\Services\Brand\SafeHttpFetcher;
use App\Services\UnsplashService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class AssetController extends Controller
@ -115,7 +116,7 @@ public function storeChunked(StoreChunkedAssetRequest $request): JsonResponse
]);
}
public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $unsplash): MediaResource
public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $unsplash, SafeHttpFetcher $safeHttp): MediaResource
{
$workspace = $request->user()->currentWorkspace;
@ -128,7 +129,13 @@ public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService
$unsplash->trackDownload($downloadLocation);
}
$response = Http::timeout(30)->get(data_get($validated, 'url'));
$url = (string) data_get($validated, 'url');
try {
$response = $safeHttp->guardedRequest($url)->timeout(30)->get($url);
} catch (RuntimeException) {
abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL');
}
if ($response->failed()) {
abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL');

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

@ -5,9 +5,11 @@
namespace App\Services\Brand;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Symfony\Component\DomCrawler\UriResolver;
/**
* HTTP fetcher with SSRF protection, timeout, redirect cap and a branded user-agent.
@ -36,13 +38,38 @@ public function get(string $url): Response
{
$this->guardAgainstSsrf($url);
try {
$response = Http::timeout(self::TIMEOUT_SECONDS)
->withUserAgent(self::USER_AGENT)
->withOptions(['allow_redirects' => ['max' => self::MAX_REDIRECTS]])
->get($url);
} catch (ConnectionException $e) {
throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => $e->getMessage()]));
$currentUrl = $url;
// Redirects are followed manually (allow_redirects disabled) so that every
// hop's Location target is re-validated against the SSRF guard before it is
// ever requested. A public page could otherwise 302 to an internal host and
// Guzzle's built-in redirect following would fetch it without re-checking.
for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) {
try {
$response = Http::timeout(self::TIMEOUT_SECONDS)
->withUserAgent(self::USER_AGENT)
->withOptions(['allow_redirects' => false])
->get($currentUrl);
} catch (ConnectionException $e) {
throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => $e->getMessage()]));
}
if (! $response->redirect() || $hop >= self::MAX_REDIRECTS) {
break;
}
$location = $response->header('Location');
if ($location === '') {
break;
}
$currentUrl = (string) UriResolver::resolve($location, $currentUrl);
$this->guardAgainstSsrf($currentUrl);
}
if ($response->redirect()) {
throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => 'too many redirects']));
}
if ($response->failed()) {
@ -65,6 +92,43 @@ public function tryGet(string $url): ?Response
}
}
/**
* Guzzle allow_redirects options that re-run the SSRF guard on every hop.
* For callers that follow redirects on user-supplied URLs with methods/bodies
* that SafeHttpFetcher::get() cannot express.
*
* @return array<string, mixed>
*/
public function redirectGuardOptions(int $max = self::MAX_REDIRECTS): array
{
return [
'allow_redirects' => [
'max' => $max,
'strict' => true,
'protocols' => ['http', 'https'],
'on_redirect' => function ($request, $response, $uri): void {
$this->guardAgainstSsrf((string) $uri);
},
],
];
}
/**
* A PendingRequest with the SSRF guard applied to $url and redirect handling
* pre-configured (per-hop re-guard when following, or no redirects). Callers
* add their own timeout / sink / headers and dispatch to the SAME $url, so a
* user-supplied URL can never be fetched without the guard and redirect
* protection.
*/
public function guardedRequest(string $url, bool $followRedirects = true): PendingRequest
{
$this->guardAgainstSsrf($url);
return Http::withUserAgent(self::USER_AGENT)->withOptions(
$followRedirects ? $this->redirectGuardOptions() : ['allow_redirects' => false],
);
}
public function guardAgainstSsrf(string $url): void
{
$parts = parse_url($url);
@ -80,6 +144,13 @@ public function guardAgainstSsrf(string $url): void
throw new RuntimeException(__('workspaces.create.autofill_errors.missing_host'));
}
// Self-hosted operators can opt into fetching their own internal
// network. Only the private/reserved-IP rejection below is skipped;
// the scheme and host checks above still always apply.
if ((bool) config('trypost.security.allow_private_network')) {
return;
}
$ip = gethostbyname($host);
if ($ip === $host && filter_var($host, FILTER_VALIDATE_IP) === false) {

View file

@ -8,7 +8,7 @@
use App\Models\Media;
use App\Models\Post;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use App\Services\Brand\SafeHttpFetcher;
use RuntimeException;
use Throwable;
@ -24,6 +24,8 @@
*/
class MediaAttacher
{
public function __construct(private readonly SafeHttpFetcher $safeHttp) {}
/**
* @param array<int, array{url: string, alt?: ?string}> $urls
* @return array{attached: array<int, array<string, mixed>>, failed: array<int, string>}
@ -159,10 +161,10 @@ private function download(string $url): ?array
$temp = tempnam(sys_get_temp_dir(), 'media_');
try {
$response = Http::timeout(20)
$response = $this->safeHttp->guardedRequest($url, followRedirects: false)
->timeout(20)
->sink($temp)
->withOptions([
'allow_redirects' => false,
'progress' => static function ($total, $downloaded) use ($cap): void {
if ($downloaded > $cap) {
throw new RuntimeException('exceeded max bytes');

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,70 @@
<?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;
}
// Cache a plain array, never the DTO: a rich object does not round-trip
// cleanly through every cache driver (it comes back as an incomplete
// class), whereas primitives always do.
$data = Cache::remember(
'link_card:'.sha1($url),
now()->addMinutes(self::CACHE_MINUTES),
fn (): ?array => $this->build($url)?->toArray(),
);
return $data === null ? null : LinkCardMetadata::fromArray($data);
}
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,61 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\LinkCard;
use Illuminate\Support\Str;
use Illuminate\Support\Uri;
final readonly class LinkCardMetadata
{
public function __construct(
public string $uri,
public string $title,
public string $description,
public ?string $imageUrl,
) {}
/**
* Rebuild the DTO from its cached array form (the inverse of toArray, minus
* the derived `domain`, which toArray recomputes).
*
* @param array{uri?: string, title?: string, description?: string, image?: ?string} $data
*/
public static function fromArray(array $data): self
{
return new self(
uri: (string) data_get($data, 'uri', ''),
title: (string) data_get($data, 'title', ''),
description: (string) data_get($data, 'description', ''),
imageUrl: data_get($data, 'image'),
);
}
/**
* @return array{uri: string, domain: string, title: string, description: string, image: ?string}
*/
public function toArray(): array
{
return [
'uri' => $this->uri,
'domain' => $this->domain(),
'title' => $this->title,
'description' => $this->description,
'image' => $this->imageUrl,
];
}
/**
* The bare display host for the card (e.g. "nyt.com" from
* "https://www.nyt.com/x"), so the frontend renders it without parsing URLs.
*/
private function domain(): string
{
$host = Uri::of($this->uri)->host();
return $host === null || $host === ''
? $this->uri
: Str::chopStart($host, 'www.');
}
}

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

@ -16,6 +16,22 @@
'self_hosted' => env('SELF_HOSTED', true),
/*
|--------------------------------------------------------------------------
| Security
|--------------------------------------------------------------------------
|
| SafeHttpFetcher blocks requests to private/reserved IP ranges (SSRF
| protection) by default. Self-hosted operators who need to fetch from
| their own internal network (e.g. an internal RSS feed or webhook) can
| opt in here. Leave disabled unless you understand the SSRF risk.
|
*/
'security' => [
'allow_private_network' => (bool) env('TRYPOST_ALLOW_PRIVATE_NETWORK', false),
],
/*
|--------------------------------------------------------------------------
| Billing

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,31 @@
<script setup lang="ts">
import type { LinkCard } from '@/composables/useLinkCard';
defineProps<{ card: LinkCard }>();
</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">{{ card.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

@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, 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';
@ -22,6 +24,11 @@ interface Props {
const props = defineProps<Props>();
const username = computed(() => props.socialAccount.username || props.socialAccount.display_name);
const { card: linkCard, loading: linkCardLoading } = useLinkCard(
toRef(props, 'content'),
toRef(props, 'media'),
);
</script>
<template>
@ -108,6 +115,13 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
</div>
</div>
<!-- Link preview card -->
<div
v-if="media.length === 0 && linkCardLoading"
class="mt-3 h-24 animate-pulse rounded-2xl border border-[#e0e0e0] bg-neutral-100 dark:border-[#262626] dark:bg-[#181818]"
></div>
<LinkCard v-else-if="media.length === 0 && linkCard" :card="linkCard" />
<!-- Location (after media) -->
<div v-if="media.length > 0" class="mt-2 flex items-center gap-1">
<svg class="h-4 w-4 text-[#999999]" viewBox="0 0 24 24" fill="none" stroke="currentColor"

View file

@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, 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';
@ -22,6 +24,11 @@ interface Props {
const props = defineProps<Props>();
const username = computed(() => props.socialAccount.username || 'username');
const { card: linkCard, loading: linkCardLoading } = useLinkCard(
toRef(props, 'content'),
toRef(props, 'media'),
);
</script>
<template>
@ -111,6 +118,13 @@ const username = computed(() => props.socialAccount.username || 'username');
</div>
</div>
<!-- Link preview card -->
<div
v-if="media.length === 0 && linkCardLoading"
class="mt-3 h-24 animate-pulse rounded-2xl border border-[#cfd9de] bg-[#f7f9f9] dark:border-[#2f3336] dark:bg-[#16181c]"
></div>
<LinkCard v-else-if="media.length === 0 && linkCard" :card="linkCard" />
<!-- Timestamp & Views -->
<div class="mt-3 text-[15px] text-[#536471] dark:text-[#71767b]">
<span>4:21 PM · Jan 20, 2026</span>

View file

@ -0,0 +1,66 @@
import { useHttp } from '@inertiajs/vue3';
import { watchDebounced } from '@vueuse/core';
import { computed, ref, watch, type Ref } from 'vue';
import { linkPreview } from '@/routes/app/posts';
import type { MediaItem } from '@/types/media';
export interface LinkCard {
uri: string;
domain: string;
title: string;
description: string;
image: string | null;
}
/**
* Live link-preview card for the composer. Detects the first link in `content`
* a rough match is enough, because the backend re-detects it and returns the
* exact, trimmed URL as `card.uri` and resolves its OpenGraph card, but only
* when no media is attached (media suppresses the link card on every platform).
*/
export const useLinkCard = (content: Ref<string>, media: Ref<MediaItem[]>) => {
const card = ref<LinkCard | null>(null);
const loading = ref(false);
const http = useHttp<{ url: string }, LinkCard | null>({ url: '' });
const url = computed(() =>
media.value.length > 0 ? null : (content.value.match(/https?:\/\/\S+/)?.[0] ?? null),
);
const fetchCard = async (target: string): Promise<void> => {
loading.value = true;
try {
http.url = target;
const data = await http.post(linkPreview.url());
card.value = data?.uri ? data : null;
} catch {
card.value = null;
} finally {
loading.value = false;
}
};
// The link went away (media attached, or URL removed) — drop the card at once.
watch(url, (next) => {
if (!next) {
card.value = null;
loading.value = false;
}
});
// A new link appeared — fetch it. `watch` only fires on change, so the same
// URL is never re-requested; the debounce keeps it off every keystroke.
watchDebounced(
url,
(next) => {
if (next) {
void fetchCard(next);
}
},
{ debounce: 400, immediate: true },
);
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

@ -13,6 +13,9 @@
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
// A public IP literal as the host lets SafeHttpFetcher's SSRF guard pass without
// a real DNS lookup; Http::fake() intercepts the request before any network I/O.
beforeEach(function () {
$result = createApiTestToken();
$this->user = $result['user'];
@ -282,7 +285,7 @@
$this->socialAccount->update(['is_active' => true]);
Http::fake([
'cdn.example.com/listing.jpg' => Http::response(
'93.184.216.34/listing.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
@ -292,7 +295,7 @@
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'External media post',
'media' => [['url' => 'https://cdn.example.com/listing.jpg']],
'media' => [['url' => 'https://93.184.216.34/listing.jpg']],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
@ -303,7 +306,7 @@
expect($media)->toHaveCount(1)
->and(data_get($media, '0.path'))->not->toBeNull()
->and(data_get($media, '0.url'))->not->toContain('cdn.example.com');
->and(data_get($media, '0.url'))->not->toContain('93.184.216.34');
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
});
@ -311,7 +314,7 @@
$this->socialAccount->update(['is_active' => true]);
Http::fake([
'cdn.example.com/car.jpg' => Http::response(
'93.184.216.34/car.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
@ -322,7 +325,7 @@
->postJson(route('api.posts.store'), [
'content' => 'External alt post',
'media' => [[
'url' => 'https://cdn.example.com/car.jpg',
'url' => 'https://93.184.216.34/car.jpg',
'meta' => ['alt_text' => 'A red car parked on a hill'],
]],
'platforms' => [
@ -340,12 +343,12 @@
it('rejects creating a post when an external media url cannot be fetched', function () {
$this->socialAccount->update(['is_active' => true]);
Http::fake(['cdn.example.com/missing.jpg' => Http::response(null, 404)]);
Http::fake(['93.184.216.34/missing.jpg' => Http::response(null, 404)]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Broken media post',
'media' => [['url' => 'https://cdn.example.com/missing.jpg']],
'media' => [['url' => 'https://93.184.216.34/missing.jpg']],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
@ -361,20 +364,20 @@
$this->socialAccount->update(['is_active' => true]);
Http::fake([
'cdn.example.com/good.jpg' => Http::response(
'93.184.216.34/good.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
),
'cdn.example.com/missing.jpg' => Http::response(null, 404),
'93.184.216.34/missing.jpg' => Http::response(null, 404),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Partial media post',
'media' => [
['url' => 'https://cdn.example.com/good.jpg'],
['url' => 'https://cdn.example.com/missing.jpg'],
['url' => 'https://93.184.216.34/good.jpg'],
['url' => 'https://93.184.216.34/missing.jpg'],
],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
@ -391,20 +394,20 @@
$this->socialAccount->update(['is_active' => true]);
Http::fake([
'cdn.example.com/good.jpg' => Http::response(
'93.184.216.34/good.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
),
'cdn.example.com/timeout.jpg' => fn () => throw new ConnectionException('Connection timed out'),
'93.184.216.34/timeout.jpg' => fn () => throw new ConnectionException('Connection timed out'),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Timeout media post',
'media' => [
['url' => 'https://cdn.example.com/good.jpg'],
['url' => 'https://cdn.example.com/timeout.jpg'],
['url' => 'https://93.184.216.34/good.jpg'],
['url' => 'https://93.184.216.34/timeout.jpg'],
],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
@ -421,12 +424,12 @@
$this->socialAccount->update(['is_active' => true]);
// Downloads fine (200) but the bytes are not a supported media type.
Http::fake(['cdn.example.com/notes.txt' => Http::response('just some text', 200)]);
Http::fake(['93.184.216.34/notes.txt' => Http::response('just some text', 200)]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Bad type post',
'media' => [['url' => 'https://cdn.example.com/notes.txt']],
'media' => [['url' => 'https://93.184.216.34/notes.txt']],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
@ -442,7 +445,7 @@
$this->socialAccount->update(['is_active' => true]);
Http::fake([
'cdn.example.com/external.jpg' => Http::response(
'93.184.216.34/external.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
@ -454,7 +457,7 @@
'content' => 'Mixed media post',
'media' => [
['id' => 'hosted-1', 'path' => 'assets/already.jpg', 'url' => 'https://cdn.trypost.test/assets/already.jpg', 'type' => 'image'],
['url' => 'https://cdn.example.com/external.jpg'],
['url' => 'https://93.184.216.34/external.jpg'],
],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
@ -466,7 +469,7 @@
expect($media)->toHaveCount(2)
->and(data_get($media, '0.path'))->toBe('assets/already.jpg')
->and(data_get($media, '1.url'))->not->toContain('cdn.example.com')
->and(data_get($media, '1.url'))->not->toContain('93.184.216.34')
->and(data_get($media, '1.path'))->not->toBeNull();
// Only the external URL is hosted; the passed-through item creates no new row.
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
@ -497,7 +500,7 @@
it('downloads and hosts an external media url when updating a post', function () {
Http::fake([
'cdn.example.com/listing.jpg' => Http::response(
'93.184.216.34/listing.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
@ -507,7 +510,7 @@
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $this->post), [
'status' => 'draft',
'media' => [['url' => 'https://cdn.example.com/listing.jpg']],
'media' => [['url' => 'https://93.184.216.34/listing.jpg']],
])
->assertOk();
@ -515,18 +518,18 @@
expect($media)->toHaveCount(1)
->and(data_get($media, '0.path'))->not->toBeNull()
->and(data_get($media, '0.url'))->not->toContain('cdn.example.com');
->and(data_get($media, '0.url'))->not->toContain('93.184.216.34');
});
it('rejects updating a post when an external media url cannot be fetched', function () {
Http::fake(['cdn.example.com/missing.jpg' => Http::response(null, 404)]);
Http::fake(['93.184.216.34/missing.jpg' => Http::response(null, 404)]);
$original = $this->post->fresh()->media;
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $this->post), [
'status' => 'draft',
'media' => [['url' => 'https://cdn.example.com/missing.jpg']],
'media' => [['url' => 'https://93.184.216.34/missing.jpg']],
])
->assertUnprocessable()
->assertJsonValidationErrors(['media']);

View file

@ -0,0 +1,53 @@
<?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',
'domain' => '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

@ -164,6 +164,43 @@
$response->assertJsonPath('type', 'image');
});
test('rejects a raw private-network url before it reaches the controller', function () {
// StoreAssetFromUrlRequest already allow-lists the host to
// images.unsplash.com / media*.giphy.com, so a bare private-IP url like
// 127.0.0.1 never reaches the controller — it 422s at the FormRequest
// layer. The SSRF guard below is defense-in-depth against a redirect
// (or DNS rebind) from one of the allow-listed hosts to an internal one.
$response = $this->actingAs($this->user)
->postJson(route('app.assets.store-from-url'), [
'url' => 'http://127.0.0.1/evil.jpg',
'filename' => 'evil.jpg',
]);
$response->assertUnprocessable();
});
test('blocks store asset from url when an allow-listed host redirects to a private ip', function () {
Http::fake([
'images.unsplash.com/*' => Http::response('', 302, ['Location' => 'http://127.0.0.1/internal']),
'http://127.0.0.1/*' => Http::response('internal secret', 200),
]);
$unsplash = $this->mock(UnsplashService::class);
$unsplash->shouldReceive('trackDownload')->once();
$response = $this->actingAs($this->user)
->postJson(route('app.assets.store-from-url'), [
'url' => 'https://images.unsplash.com/photo-test',
'filename' => 'unsplash-test.jpg',
'download_location' => 'https://api.unsplash.com/photos/test/download',
]);
$response->assertStatus(400);
Http::assertNotSent(fn ($r) => str_contains($r->url(), '127.0.0.1'));
expect(Media::count())->toBe(0);
});
test('chunked upload completes with single chunk', function () {
// Use real PNG bytes so mime_content_type detects image/png. The MIME
// is sniffed from content magic bytes, not the X-File-Name header.

View file

@ -280,7 +280,7 @@
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed.xml']);
expect($result->status)->toBe(NodeRunStatus::Failed);
expect($result->error['status'])->toBe(500);
expect($result->error['message'])->toContain('500');
});
it('fails on a malformed RSS feed', function () {
@ -313,6 +313,40 @@
expect($result->output['fetched']['key'])->toBe('d');
});
it('never follows a feed redirect that targets a private or internal host', function () {
Http::fake([
'https://93.184.216.34/feed' => Http::response('', 302, ['Location' => 'http://127.0.0.1/internal']),
'http://127.0.0.1/*' => Http::response(feedFixture('rss_old'), 200),
]);
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://93.184.216.34/feed']);
expect($result->status)->toBe(NodeRunStatus::Failed);
Http::assertNotSent(fn ($request) => str_contains($request->url(), '127.0.0.1'));
});
it('still follows a legitimate public-to-public feed redirect', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake([
'https://93.184.216.34/feed' => Http::response('', 301, ['Location' => 'https://1.1.1.1/feed']),
'https://1.1.1.1/feed' => Http::response(feedFixture('rss_old'), 200),
]);
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://93.184.216.34/feed']);
expect($result->status)->toBe(NodeRunStatus::Completed);
Http::assertSentInOrder([
fn ($request) => str_contains($request->url(), '93.184.216.34'),
fn ($request) => str_contains($request->url(), '1.1.1.1'),
]);
});
it('falls back to the link as the dedup key when an item has no guid', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_no_guid'), 200)]);

View file

@ -53,6 +53,47 @@
Http::assertNothingSent();
});
it('never follows a redirect that targets a private or internal host', function () {
Http::fake([
'https://93.184.216.34/*' => Http::response('', 302, ['Location' => 'http://127.0.0.1/internal']),
'http://127.0.0.1/*' => Http::response('internal secret', 200),
]);
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
$result = app(RunHttpRequestNode::class)($run, [
'url' => 'https://93.184.216.34/start',
'method' => 'GET',
'auth_type' => 'none',
]);
expect($result->status)->toBe(NodeRunStatus::Failed);
Http::assertNotSent(fn ($request) => str_contains($request->url(), '127.0.0.1'));
});
it('still follows a legitimate public-to-public redirect', function () {
Http::fake([
'https://93.184.216.34/*' => Http::response('', 301, ['Location' => 'https://1.1.1.1/final']),
'https://1.1.1.1/final' => Http::response(['ok' => true], 200),
]);
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
$result = app(RunHttpRequestNode::class)($run, [
'url' => 'https://93.184.216.34/start',
'method' => 'GET',
'auth_type' => 'none',
]);
expect($result->status)->toBe(NodeRunStatus::Completed);
Http::assertSentInOrder([
fn ($request) => str_contains($request->url(), '93.184.216.34'),
fn ($request) => str_contains($request->url(), '1.1.1.1'),
]);
});
it('processes first new item and spawns siblings when items_path is set', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake([

View file

@ -200,6 +200,27 @@
Http::assertSent(fn ($request) => $request->hasHeader('X-Token', 'tok-123'));
});
it('never follows a redirect to a private or internal host', function () {
Http::fake([
'https://93.184.216.34/*' => Http::response('', 302, ['Location' => 'http://127.0.0.1/internal']),
'http://127.0.0.1/*' => Http::response('internal secret', 200),
]);
$run = AutomationRun::factory()->create();
$result = app(RunWebhookNode::class)($run, [
'url' => 'https://93.184.216.34/hook',
'method' => 'POST',
'payload_template' => '{}',
]);
// The 3xx is returned as-is (not followed), so the node completes with the
// redirect status rather than the internal host's response.
expect($result->status)->toBe(Status::Completed);
expect($result->output['webhook']['status'])->toBe(302);
Http::assertNotSent(fn ($request) => str_contains($request->url(), '127.0.0.1'));
});
it('fails cleanly when the request throws a connection exception', function () {
Http::fake(fn () => throw new ConnectionException('connection timed out'));

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,67 @@
<?php
declare(strict_types=1);
use App\Services\Social\LinkCard\LinkCardFetcher;
use App\Services\Social\LinkCard\LinkCardMetadata;
use Illuminate\Support\Facades\Cache;
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);
$first = $fetcher->fetch('https://example.com');
$second = $fetcher->fetch('https://example.com');
Http::assertSentCount(1);
// The cache must hold a primitive array, not the DTO — a rich object does
// not round-trip through every cache driver (it comes back as an incomplete
// class). A cache hit still reconstructs a proper DTO.
expect(Cache::get('link_card:'.sha1('https://example.com')))->toBeArray();
expect($second)->toBeInstanceOf(LinkCardMetadata::class)
->and($second->uri)->toBe($first->uri)
->and($second->title)->toBe('Cached');
});

View file

@ -346,18 +346,20 @@ function captureLinkedInConnectScopes(object $test): array
'refresh_token' => 'test-refresh-token',
'expires_in' => 5184000,
'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'],
'person' => ['id' => 'person-avatar', 'name' => 'John Doe', 'avatar' => 'https://media.example.com/avatar.jpg', 'vanity_name' => 'johndoe'],
'person' => ['id' => 'person-avatar', 'name' => 'John Doe', 'avatar' => 'https://93.184.216.34/avatar.jpg', 'vanity_name' => 'johndoe'],
'organizations' => [],
]]);
// A public IP literal as the host lets SafeHttpFetcher's SSRF guard pass
// without a real DNS lookup; Http::fake() intercepts before any network I/O.
Http::fake([
'https://media.example.com/avatar.jpg' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
'https://93.184.216.34/avatar.jpg' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
]);
$this->actingAs($this->user)->post(route('app.social.linkedin.select'), ['type' => 'person']);
// The avatar download (uploadFromUrl) ran and a stored path was persisted.
Http::assertSent(fn ($request) => $request->url() === 'https://media.example.com/avatar.jpg');
Http::assertSent(fn ($request) => $request->url() === 'https://93.184.216.34/avatar.jpg');
$account = SocialAccount::where('platform_user_id', 'person-avatar')->first();
expect($account->getRawOriginal('avatar_url'))->not->toBeNull();

View file

@ -93,3 +93,26 @@
expect($result)->toBeNull();
});
test('uploadFromUrl returns null for a private-network url and never requests it', function () {
Http::fake();
$result = uploadFromUrl('http://127.0.0.1/evil.jpg');
expect($result)->toBeNull();
Http::assertNothingSent();
});
test('uploadFromUrl attempts the internal fetch when allow_private_network is enabled', function () {
config(['trypost.security.allow_private_network' => true]);
Storage::fake();
Http::fake([
'http://127.0.0.1/internal.jpg' => Http::response('fake-image-content', 200, ['Content-Type' => 'image/jpeg']),
]);
$result = uploadFromUrl('http://127.0.0.1/internal.jpg');
expect($result)->not->toBeNull();
Http::assertSent(fn ($request) => str_contains($request->url(), '127.0.0.1'));
});

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
use App\Services\Brand\SafeHttpFetcher;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
// Public IP literals let SafeHttpFetcher's SSRF guard pass without a real DNS
// lookup; Http::fake intercepts the request before any network I/O.
test('blocks a redirect whose location resolves to a private ip', function () {
Http::fake([
// The malicious hop responds successfully (not with an error/exception) so a
// vulnerable implementation that auto-follows it would return this body —
// proving the guard, not an unrelated stray-request failure, stopped it.
'https://93.184.216.34/start' => Http::response('', 302, ['Location' => 'http://127.0.0.1/internal']),
'http://127.0.0.1/internal' => Http::response('internal secret', 200),
]);
expect(app(SafeHttpFetcher::class)->tryGet('https://93.184.216.34/start'))->toBeNull();
Http::assertSent(fn ($request) => str_contains($request->url(), '93.184.216.34'));
Http::assertNotSent(fn ($request) => str_contains($request->url(), '127.0.0.1'));
});
test('follows a legitimate redirect from one public host to another', function () {
Http::fake([
'https://93.184.216.34/start' => Http::response('', 301, ['Location' => 'https://1.1.1.1/final']),
'https://1.1.1.1/final' => Http::response('final body', 200),
]);
$response = app(SafeHttpFetcher::class)->get('https://93.184.216.34/start');
expect($response->status())->toBe(200)
->and($response->body())->toBe('final body');
Http::assertSentInOrder([
fn ($request) => str_contains($request->url(), '93.184.216.34'),
fn ($request) => str_contains($request->url(), '1.1.1.1'),
]);
});
test('resolves a relative location header against the current url before guarding', function () {
Http::fake([
'https://93.184.216.34/start' => Http::response('', 302, ['Location' => '/final']),
'https://93.184.216.34/final' => Http::response('final body', 200),
]);
$response = app(SafeHttpFetcher::class)->get('https://93.184.216.34/start');
expect($response->status())->toBe(200)
->and($response->body())->toBe('final body');
});
test('throws when a redirect chain exceeds the redirect cap', function () {
Http::fake([
'https://93.184.216.34/start' => Http::response('', 302, ['Location' => 'https://93.184.216.35/hop']),
'https://93.184.216.35/hop' => Http::response('', 302, ['Location' => 'https://93.184.216.36/hop']),
'https://93.184.216.36/hop' => Http::response('', 302, ['Location' => 'https://93.184.216.37/hop']),
'https://93.184.216.37/hop' => Http::response('', 302, ['Location' => 'https://93.184.216.38/hop']),
]);
expect(fn () => app(SafeHttpFetcher::class)->get('https://93.184.216.34/start'))
->toThrow(RuntimeException::class);
expect(app(SafeHttpFetcher::class)->tryGet('https://93.184.216.34/start'))->toBeNull();
// The cap (3) is hit after following 3 redirects (4 requests); the next hop must never fire.
Http::assertNotSent(fn ($request) => str_contains($request->url(), '93.184.216.38'));
});
test('guardAgainstSsrf blocks a private ip by default', function () {
expect(fn () => app(SafeHttpFetcher::class)->guardAgainstSsrf('http://127.0.0.1/x'))
->toThrow(RuntimeException::class);
});
test('guardAgainstSsrf allows a private ip when allow_private_network is enabled', function () {
config(['trypost.security.allow_private_network' => true]);
app(SafeHttpFetcher::class)->guardAgainstSsrf('http://127.0.0.1/x');
})->throwsNoExceptions();
test('guardedRequest throws for a private ip by default', function () {
expect(fn () => app(SafeHttpFetcher::class)->guardedRequest('http://127.0.0.1/x'))
->toThrow(RuntimeException::class);
});
test('guardedRequest returns a PendingRequest for a public url', function () {
expect(app(SafeHttpFetcher::class)->guardedRequest('https://93.184.216.34/x'))
->toBeInstanceOf(PendingRequest::class);
});

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
use App\Models\Media;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Post\MediaAttacher;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
// Public IP literals let SafeHttpFetcher's SSRF guard pass without a real DNS
// lookup; Http::fake intercepts the request before any network I/O.
beforeEach(function () {
Storage::fake();
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
});
test('attaches a media item downloaded from a public url', function () {
Http::fake([
'https://93.184.216.34/photo.png' => Http::response(
file_get_contents(__DIR__.'/../../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
),
]);
$result = app(MediaAttacher::class)->attachFromUrls($this->post, [
['url' => 'https://93.184.216.34/photo.png'],
]);
expect($result['failed'])->toBeEmpty()
->and($result['attached'])->toHaveCount(1);
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
});
test('blocks a private-network url and never requests it', function () {
Http::fake();
$result = app(MediaAttacher::class)->attachFromUrls($this->post, [
['url' => 'http://127.0.0.1/evil.jpg'],
]);
expect($result['attached'])->toBeEmpty()
->and($result['failed'])->toBe(['http://127.0.0.1/evil.jpg']);
Http::assertNothingSent();
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0);
});
test('attempts the internal fetch when allow_private_network is enabled', function () {
config(['trypost.security.allow_private_network' => true]);
Http::fake([
'http://127.0.0.1/internal.png' => Http::response(
file_get_contents(__DIR__.'/../../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
),
]);
$result = app(MediaAttacher::class)->attachFromUrls($this->post, [
['url' => 'http://127.0.0.1/internal.png'],
]);
expect($result['failed'])->toBeEmpty()
->and($result['attached'])->toHaveCount(1);
Http::assertSent(fn ($request) => str_contains($request->url(), '127.0.0.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');
});