Merge pull request #185 from trypostit/fix/bluesky-link-preview-card
Link preview cards for Bluesky, X and Threads
This commit is contained in:
commit
5f0346951d
36 changed files with 1519 additions and 84 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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'), [
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
25
app/Http/Controllers/App/LinkPreviewController.php
Normal file
25
app/Http/Controllers/App/LinkPreviewController.php
Normal 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());
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/App/Post/LinkPreviewRequest.php
Normal file
25
app/Http/Requests/App/Post/LinkPreviewRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
70
app/Services/Social/LinkCard/LinkCardFetcher.php
Normal file
70
app/Services/Social/LinkCard/LinkCardFetcher.php
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
61
app/Services/Social/LinkCard/LinkCardMetadata.php
Normal file
61
app/Services/Social/LinkCard/LinkCardMetadata.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
75
app/Services/Social/LinkCard/OpenGraphExtractor.php
Normal file
75
app/Services/Social/LinkCard/OpenGraphExtractor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
38
app/Support/UrlDetector.php
Normal file
38
app/Support/UrlDetector.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
31
resources/js/components/posts/previews/LinkCard.vue
Normal file
31
resources/js/components/posts/previews/LinkCard.vue
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
66
resources/js/composables/useLinkCard.ts
Normal file
66
resources/js/composables/useLinkCard.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
53
tests/Feature/App/Post/LinkPreviewControllerTest.php
Normal file
53
tests/Feature/App/Post/LinkPreviewControllerTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
92
tests/Unit/Services/Brand/SafeHttpFetcherTest.php
Normal file
92
tests/Unit/Services/Brand/SafeHttpFetcherTest.php
Normal 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);
|
||||
});
|
||||
79
tests/Unit/Services/Post/MediaAttacherTest.php
Normal file
79
tests/Unit/Services/Post/MediaAttacherTest.php
Normal 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'));
|
||||
});
|
||||
|
|
@ -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]);
|
||||
});
|
||||
28
tests/Unit/Support/UrlDetectorTest.php
Normal file
28
tests/Unit/Support/UrlDetectorTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue