feat(bluesky): add link preview cards for posts
Bluesky does not hydrate link cards server-side, so build the app.bsky.embed.external embed at publish time: detect the first URL, scrape its OpenGraph metadata, and re-upload the og:image as the card thumb. Works for web, API and MCP. Adds a posts/link-preview endpoint so the editor renders the card live. The thumb download is SSRF-guarded and does not follow redirects.
This commit is contained in:
parent
450b6fd3de
commit
8648ed9720
17 changed files with 986 additions and 25 deletions
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
65
app/Services/Social/LinkCard/LinkCardFetcher.php
Normal file
65
app/Services/Social/LinkCard/LinkCardFetcher.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social\LinkCard;
|
||||
|
||||
use App\Services\Brand\SafeHttpFetcher;
|
||||
use App\Support\UrlDetector;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Builds a link preview card (OpenGraph metadata) for the first URL in a piece
|
||||
* of text. Shared by BlueskyPublisher (publish-time embed) and the editor
|
||||
* preview endpoint. Returns null whenever a card cannot or should not be built,
|
||||
* so callers degrade gracefully.
|
||||
*/
|
||||
class LinkCardFetcher
|
||||
{
|
||||
/** Cards for a public URL are identical for everyone; cache briefly and globally. */
|
||||
private const int CACHE_MINUTES = 10;
|
||||
|
||||
public function __construct(
|
||||
private readonly SafeHttpFetcher $http = new SafeHttpFetcher,
|
||||
private readonly OpenGraphExtractor $extractor = new OpenGraphExtractor,
|
||||
) {}
|
||||
|
||||
public function fetch(string $text): ?LinkCardMetadata
|
||||
{
|
||||
$url = UrlDetector::firstUrl($text);
|
||||
|
||||
if ($url === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Cache::remember(
|
||||
'link_card:'.sha1($url),
|
||||
now()->addMinutes(self::CACHE_MINUTES),
|
||||
fn (): ?LinkCardMetadata => $this->build($url),
|
||||
);
|
||||
}
|
||||
|
||||
private function build(string $url): ?LinkCardMetadata
|
||||
{
|
||||
$response = $this->http->tryGet($url);
|
||||
|
||||
if ($response === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = $this->extractor->extract($response->body(), $url);
|
||||
$title = data_get($meta, 'title');
|
||||
$description = data_get($meta, 'description');
|
||||
|
||||
if ($title === null && $description === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LinkCardMetadata(
|
||||
uri: $url,
|
||||
title: $title ?? '',
|
||||
description: $description ?? '',
|
||||
imageUrl: data_get($meta, 'image'),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
app/Services/Social/LinkCard/LinkCardMetadata.php
Normal file
28
app/Services/Social/LinkCard/LinkCardMetadata.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social\LinkCard;
|
||||
|
||||
final readonly class LinkCardMetadata
|
||||
{
|
||||
public function __construct(
|
||||
public string $uri,
|
||||
public string $title,
|
||||
public string $description,
|
||||
public ?string $imageUrl,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{uri: string, title: string, description: string, image: ?string}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uri' => $this->uri,
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'image' => $this->imageUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
41
resources/js/components/posts/previews/LinkCard.vue
Normal file
41
resources/js/components/posts/previews/LinkCard.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { LinkCard } from '@/composables/useLinkCard';
|
||||
|
||||
const props = defineProps<{ card: LinkCard }>();
|
||||
|
||||
const domain = computed(() => {
|
||||
try {
|
||||
return new URL(props.card.uri).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return props.card.uri;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-3 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
|
||||
<img
|
||||
v-if="card.image"
|
||||
:src="card.image"
|
||||
:alt="card.title"
|
||||
class="aspect-[1.91/1] w-full object-cover"
|
||||
/>
|
||||
<div class="px-3 py-2">
|
||||
<div class="text-[13px] text-neutral-500 dark:text-neutral-400">{{ domain }}</div>
|
||||
<div
|
||||
v-if="card.title"
|
||||
class="mt-0.5 text-[15px] font-semibold text-neutral-900 dark:text-neutral-100 line-clamp-2"
|
||||
>
|
||||
{{ card.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="card.description"
|
||||
class="mt-0.5 text-[14px] text-neutral-500 dark:text-neutral-400 line-clamp-2"
|
||||
>
|
||||
{{ card.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
87
resources/js/composables/useLinkCard.ts
Normal file
87
resources/js/composables/useLinkCard.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useHttp } from '@inertiajs/vue3';
|
||||
import { onBeforeUnmount, ref, watch, type Ref } from 'vue';
|
||||
|
||||
import debounce from '@/debounce';
|
||||
import { linkPreview } from '@/routes/app/posts';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
export interface LinkCard {
|
||||
uri: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
const URL_RE = /(https?:\/\/[^\s]+)/;
|
||||
|
||||
const firstUrl = (text: string): string | null => {
|
||||
const match = text.match(URL_RE);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let url = match[0];
|
||||
if (/[.,;:!?]$/.test(url)) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
if (url.endsWith(')') && !url.includes('(')) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const useLinkCard = (content: Ref<string>, media: Ref<MediaItem[]>) => {
|
||||
const card = ref<LinkCard | null>(null);
|
||||
const loading = ref(false);
|
||||
const lastAttemptedUrl = ref<string | null>(null);
|
||||
const http = useHttp<{ url: string }, LinkCard | null>({ url: '' });
|
||||
|
||||
const fetchCard = async (url: string): Promise<void> => {
|
||||
lastAttemptedUrl.value = url;
|
||||
loading.value = true;
|
||||
try {
|
||||
http.url = url;
|
||||
const data = await http.post(linkPreview.url());
|
||||
card.value = data && data.uri ? data : null;
|
||||
} catch {
|
||||
card.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const debounced = debounce((url: string) => {
|
||||
void fetchCard(url);
|
||||
}, 400);
|
||||
|
||||
watch(
|
||||
[content, media],
|
||||
() => {
|
||||
if (media.value.length > 0) {
|
||||
card.value = null;
|
||||
loading.value = false;
|
||||
lastAttemptedUrl.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const url = firstUrl(content.value);
|
||||
if (!url) {
|
||||
card.value = null;
|
||||
loading.value = false;
|
||||
lastAttemptedUrl.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (url === lastAttemptedUrl.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
debounced(url);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => debounced.cancel());
|
||||
|
||||
return { card, loading };
|
||||
};
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
52
tests/Feature/App/Post/LinkPreviewControllerTest.php
Normal file
52
tests/Feature/App/Post/LinkPreviewControllerTest.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->user->update(['current_workspace_id' => $workspace->id]);
|
||||
});
|
||||
|
||||
test('returns a link card for a valid url', function () {
|
||||
Http::fake([
|
||||
'https://example.com' => Http::response(
|
||||
'<html><head><meta property="og:title" content="Example">'
|
||||
.'<meta property="og:description" content="Desc">'
|
||||
.'<meta property="og:image" content="https://example.com/card.png"></head></html>',
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.link-preview'), ['url' => 'https://example.com'])
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'uri' => 'https://example.com',
|
||||
'title' => 'Example',
|
||||
'description' => 'Desc',
|
||||
'image' => 'https://example.com/card.png',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns no content when there is no card', function () {
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.link-preview'), ['url' => 'http://127.0.0.1/private'])
|
||||
->assertNoContent();
|
||||
});
|
||||
|
||||
test('validates that a url is required', function () {
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.link-preview'), [])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
});
|
||||
|
||||
test('requires authentication', function () {
|
||||
$this->postJson(route('app.posts.link-preview'), ['url' => 'https://example.com'])
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
|
@ -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,57 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Social\LinkCard\LinkCardFetcher;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
// example.com resolves to a public IP so the SafeHttpFetcher SSRF guard passes;
|
||||
// Http::fake then intercepts the actual request.
|
||||
test('fetches a card from the first url in the text', function () {
|
||||
Http::fake([
|
||||
'https://example.com' => Http::response(
|
||||
'<html><head><meta property="og:title" content="Example Title">'
|
||||
.'<meta property="og:description" content="Example description">'
|
||||
.'<meta property="og:image" content="https://example.com/card.png">'
|
||||
.'</head></html>',
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$card = app(LinkCardFetcher::class)->fetch('look at https://example.com today');
|
||||
|
||||
expect($card)->not->toBeNull()
|
||||
->and($card->uri)->toBe('https://example.com')
|
||||
->and($card->title)->toBe('Example Title')
|
||||
->and($card->description)->toBe('Example description')
|
||||
->and($card->imageUrl)->toBe('https://example.com/card.png');
|
||||
});
|
||||
|
||||
test('returns null when the text has no url', function () {
|
||||
expect(app(LinkCardFetcher::class)->fetch('no links here'))->toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for a private-network url (ssrf guard)', function () {
|
||||
expect(app(LinkCardFetcher::class)->fetch('internal http://127.0.0.1/admin'))->toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when the page has no title or description', function () {
|
||||
Http::fake(['https://example.com' => Http::response('<html><body>nothing</body></html>', 200)]);
|
||||
|
||||
expect(app(LinkCardFetcher::class)->fetch('see https://example.com'))->toBeNull();
|
||||
});
|
||||
|
||||
test('caches the result so a repeated url is fetched once', function () {
|
||||
Http::fake([
|
||||
'https://example.com' => Http::response(
|
||||
'<html><head><meta property="og:title" content="Cached"></head></html>',
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$fetcher = app(LinkCardFetcher::class);
|
||||
$fetcher->fetch('https://example.com');
|
||||
$fetcher->fetch('https://example.com');
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
|
@ -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