diff --git a/app/Exceptions/Social/FacebookPublishException.php b/app/Exceptions/Social/FacebookPublishException.php index ec277c4ac..258b59491 100644 --- a/app/Exceptions/Social/FacebookPublishException.php +++ b/app/Exceptions/Social/FacebookPublishException.php @@ -9,6 +9,30 @@ class FacebookPublishException extends SocialPublishException { + /** + * Graph codes that reject the Page feed `link` and leave the caption + * untouched: 1609005 (could not scrape the URL) and 1500 (invalid URL). + * + * @var list + */ + private const array LINK_REJECTION_CODES = ['1609005', '1500']; + + /** + * Subcode under a code 200 "Permissions error" that means the `link` + * pointed at facebook.com, which Pages may not share through the API. + */ + private const string FACEBOOK_URL_SUBCODE = '1609008'; + + public function __construct( + string $userMessage, + ErrorCategory $category, + ?string $platformErrorCode = null, + ?string $rawResponse = null, + public readonly ?string $platformErrorSubcode = null, + ) { + parent::__construct($userMessage, $category, $platformErrorCode, $rawResponse); + } + public static function fromApiResponse(mixed $response): static { /** @var Response $response */ @@ -66,9 +90,20 @@ public static function fromApiResponse(mixed $response): static category: $category, platformErrorCode: $errorCode !== null ? (string) $errorCode : null, rawResponse: $rawResponse, + platformErrorSubcode: $errorSubcode !== null ? (string) $errorSubcode : null, ); } + /** + * Whether Graph rejected the `link` rather than the post. The same caption + * publishes as plain text once the link is dropped. + */ + public function rejectsLink(): bool + { + return in_array($this->platformErrorCode, self::LINK_REJECTION_CODES, true) + || ($this->platformErrorCode === '200' && $this->platformErrorSubcode === self::FACEBOOK_URL_SUBCODE); + } + public function platform(): string { return 'facebook'; diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index f20724178..a541df9f8 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -11,24 +11,38 @@ use App\Exceptions\TokenExpiredException; 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 Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; +use RuntimeException; +use Throwable; /** * Shared LinkedIn publishing pipeline. The publish format follows the attached - * media — a PDF becomes a document post, 2+ images a multi-image post, and - * anything else (a single image/video or text only) a regular post. Subclasses - * provide the author identity (member vs. company page) and its public URL. + * media: a PDF becomes a document post, two or more images a multi-image post, + * and a single image or video a media post. A text post with a link becomes an + * article, because the Posts API does not scrape URLs. Subclasses provide the + * author identity (member or company page) and its public URL. */ abstract class AbstractLinkedInPublisher { use HasSocialHttpClient; + /** Article title must be under 400 characters, description under 4,086. */ + private const int ARTICLE_TITLE_MAX = 399; + + private const int ARTICLE_DESCRIPTION_MAX = 4085; + + private const int ARTICLE_THUMB_TIMEOUT_SECONDS = 10; + private string $apiVersion = '202601'; private string $accessToken; @@ -110,7 +124,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, $this->accessToken = $this->account->access_token; return $this->dispatchByMedia($content, $postPlatform); - } catch (\Throwable $e) { + } catch (Throwable $e) { Log::error("{$this->label()} refresh failed during retry", [ 'account_id' => $this->account->id, 'error' => $e->getMessage(), @@ -133,11 +147,122 @@ private function publishPost(?string $content, $media): array 'altText' => $item->isImage() ? $item->altTextFor($this->platform()) : null, ], fn ($v) => $v !== null)]; } + } else { + $article = $this->articleContent($content); + + if ($article !== null) { + $payload['content'] = ['article' => $article]; + } } return $this->createPost($payload, 'post creation'); } + /** + * Article payload for the first link in a text-only post. Null when there is + * no link, the page has no title, or the scrape fails: the post still goes + * out as text. The thumbnail is optional. + * + * @return array{source: string, title: string, description?: string, thumbnail?: string}|null + */ + private function articleContent(?string $content): ?array + { + $card = filled($content) ? $this->articleCard($content) : null; + + if ($card === null || blank($card->title)) { + return null; + } + + return array_filter([ + 'source' => $card->uri, + 'title' => Str::limit($card->title, self::ARTICLE_TITLE_MAX, ''), + 'description' => Str::limit($card->description, self::ARTICLE_DESCRIPTION_MAX, ''), + 'thumbnail' => $this->uploadArticleThumbnail($card), + ], filled(...)); + } + + private function articleCard(string $content): ?LinkCardMetadata + { + try { + return app(LinkCardFetcher::class)->fetch($content); + } catch (Throwable $e) { + Log::warning("{$this->label()} link preview lookup failed", ['error' => $e->getMessage()]); + + return null; + } + } + + /** + * Image URN for the card's og:image, or null when there is no usable image. + * A dead token still propagates so the publish retry can refresh it. + */ + private function uploadArticleThumbnail(LinkCardMetadata $card): ?string + { + if (blank($card->imageUrl)) { + return null; + } + + $tempFile = tempnam(sys_get_temp_dir(), 'li_article_'); + + if ($tempFile === false) { + return null; + } + + try { + $this->downloadArticleThumbnail($card->imageUrl, $tempFile); + + return $this->uploadLocalImage($tempFile); + } catch (TokenExpiredException $e) { + throw $e; + } catch (Throwable $e) { + Log::warning("{$this->label()} article thumbnail skipped", ['error' => $e->getMessage()]); + + return null; + } finally { + @unlink($tempFile); + } + } + + /** + * Redirects are not followed: the og:image URL is attacker-influenced and + * was only guarded against SSRF once. + */ + private function downloadArticleThumbnail(string $url, string $tempFile): void + { + $maxBytes = MediaType::Image->maxSizeInBytes(); + + $response = app(SafeHttpFetcher::class) + ->guardedRequest($url, followRedirects: false) + ->timeout(self::ARTICLE_THUMB_TIMEOUT_SECONDS) + ->sink($tempFile) + ->withOptions([ + 'progress' => static function ($total, $downloaded) use ($maxBytes): void { + if ($total > $maxBytes || $downloaded > $maxBytes) { + throw new RuntimeException('og:image exceeds the maximum image size'); + } + }, + ]) + ->get($url); + + if (! $response->successful()) { + throw new RuntimeException("og:image responded with HTTP {$response->status()}"); + } + + $size = filesize($tempFile); + + if ($size === false || $size === 0) { + throw new RuntimeException('og:image is empty'); + } + + if ($size > $maxBytes) { + throw new RuntimeException('og:image exceeds the maximum image size'); + } + + if (MediaType::classify(File::mimeType($tempFile) ?: '') !== MediaType::Image) { + throw new RuntimeException('og:image is not an image'); + } + } + private function publishCarousel(?string $content, $media): array { $images = []; @@ -260,6 +385,51 @@ private function uploadMedia($mediaItem): ?string } private function uploadImage($mediaItem): ?string + { + $tempFile = tempnam(sys_get_temp_dir(), 'li_image_'); + + if ($tempFile === false) { + throw new LinkedInPublishException( + userMessage: "Could not prepare the image for {$this->label()}. Please try again.", + category: ErrorCategory::ServerError, + ); + } + + try { + $this->downloadToTempFile($mediaItem->url, $tempFile); + + return $this->uploadLocalImage($tempFile); + } finally { + @unlink($tempFile); + } + } + + /** + * Upload an image already on disk, re-encoded for LinkedIn first. GIFs are + * sent as they are so the animation survives. The caller owns the given + * file; the optimized copy is removed here. + */ + private function uploadLocalImage(string $path): string + { + $mime = File::mimeType($path) ?: ''; + + if (MediaType::classify($mime) !== MediaType::Image || MediaType::isGif($mime)) { + return $this->uploadImageFile($path); + } + + $optimized = app(MediaOptimizer::class)->optimizeImage($path, $this->platform()); + + try { + return $this->uploadImageFile($optimized); + } finally { + @unlink($optimized); + } + } + + /** + * Initialize a LinkedIn image upload and PUT the bytes already on disk. + */ + private function uploadImageFile(string $path): string { $initResponse = $this->getHttpClient() ->post("{$this->baseUrl()}/rest/images?action=initializeUpload", [ @@ -275,45 +445,37 @@ private function uploadImage($mediaItem): ?string $uploadUrl = data_get($initData, 'value.uploadUrl'); $imageUrn = data_get($initData, 'value.image'); - if (! $uploadUrl || ! $imageUrn) { + if (! is_string($uploadUrl) || $uploadUrl === '' || ! is_string($imageUrn) || $imageUrn === '') { throw new LinkedInPublishException( userMessage: "{$this->label()} did not accept the image upload. Please try again.", category: ErrorCategory::ServerError, ); } - $tempFile = tempnam(sys_get_temp_dir(), 'li_image_'); + $stream = fopen($path, 'r'); - try { - $this->downloadToTempFile($mediaItem->url, $tempFile); - - $detectedMime = File::mimeType($tempFile) ?: ''; - if (MediaType::classify($detectedMime) === MediaType::Image && ! MediaType::isGif($detectedMime)) { - $optimizedPath = app(MediaOptimizer::class)->optimizeImage($tempFile, $this->platform()); - @unlink($tempFile); - $tempFile = $optimizedPath; - } - - $stream = fopen($tempFile, 'r'); - - $uploadResponse = Http::withToken($this->accessToken) - ->withHeaders(['Content-Type' => 'application/octet-stream']) - ->withBody($stream, 'application/octet-stream') - ->put($uploadUrl); + if ($stream === false) { + throw new LinkedInPublishException( + userMessage: "Could not read the image for {$this->label()}. Please try again.", + category: ErrorCategory::ServerError, + ); + } - if (is_resource($stream)) { - fclose($stream); - } + $uploadResponse = Http::withToken($this->accessToken) + ->withHeaders(['Content-Type' => 'application/octet-stream']) + ->withBody($stream, 'application/octet-stream') + ->put($uploadUrl); - if ($uploadResponse->failed()) { - Log::error("{$this->label()} image upload failed", ['body' => $this->redactResponseBody($uploadResponse->body())]); - $this->handleApiError($uploadResponse); - } + if (is_resource($stream)) { + fclose($stream); + } - return $imageUrn; - } finally { - @unlink($tempFile); + if ($uploadResponse->failed()) { + Log::error("{$this->label()} image upload failed", ['body' => $this->redactResponseBody($uploadResponse->body())]); + $this->handleApiError($uploadResponse); } + + return $imageUrn; } private function uploadVideo($mediaItem): ?string diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index b9faf1839..c62bc57b6 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -15,6 +15,7 @@ use App\Services\Social\Concerns\CropsImageForAspectRatio; use App\Services\Social\Concerns\HasSocialHttpClient; use App\Services\Social\Meta\GraphError; +use App\Support\FacebookLinkPreview; use Closure; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\PendingRequest; @@ -101,14 +102,60 @@ private function publishTextPost(string $pageId, string $accessToken, ?string $c ); } - $response = $this->postToGraph("{$pageId}/feed", [ - 'message' => $content, - 'access_token' => $accessToken, - ], 'text post'); + $link = FacebookLinkPreview::url($content); + + $response = $link === null + ? $this->postTextToFeed($pageId, $accessToken, $content, null) + : $this->postTextWithLink($pageId, $accessToken, $content, $link); return $this->feedPostResult(data_get($response->json(), 'id')); } + /** + * Facebook can reject the link and not the post: 1609005 (scrape failed), + * 1500 (invalid URL) or 200/1609008 (facebook.com URL). The caption + * published as plain text before `link` existed, so on those codes the + * card is dropped and the text is posted once more. Any other error fails + * the post. The first attempt stays quiet in the log because only the + * outcome of the retry says whether the publish failed. + */ + private function postTextWithLink(string $pageId, string $accessToken, string $content, string $link): Response + { + try { + return $this->postTextToFeed($pageId, $accessToken, $content, $link, reportFailure: false); + } catch (FacebookPublishException $exception) { + if (! $exception->rejectsLink()) { + Log::error('Facebook text post failed', [ + 'platform_error_code' => $exception->platformErrorCode, + 'body' => $this->redactResponseBody($exception->rawResponse ?? ''), + ]); + + throw $exception; + } + + Log::warning('Facebook rejected the link preview; publishing the text without it', [ + 'platform_error_code' => $exception->platformErrorCode, + 'platform_error_subcode' => $exception->platformErrorSubcode, + ]); + } + + return $this->postTextToFeed($pageId, $accessToken, $content, null); + } + + /** + * A text post carries `link` when the caption contains a URL. Facebook does + * not unfurl a URL left only in `message`; the Page Feed `link` field is + * what makes it scrape Open Graph and render the preview. + */ + private function postTextToFeed(string $pageId, string $accessToken, string $content, ?string $link, bool $reportFailure = true): Response + { + return $this->postToGraph("{$pageId}/feed", [ + 'message' => $content, + 'access_token' => $accessToken, + ...$this->optionalField('link', $link), + ], 'text post', $reportFailure); + } + /** * @return array{id: mixed, url: string} */ @@ -464,7 +511,7 @@ private function facebookHttp(): PendingRequest * * @param array $payload */ - private function postToGraph(string $path, array $payload, string $label): Response + private function postToGraph(string $path, array $payload, string $label, bool $reportFailure = true): Response { $response = $this->reachOrRetry( fn (): Response => $this->facebookHttp()->post("{$this->baseUrl}/{$path}", $payload), @@ -472,10 +519,13 @@ private function postToGraph(string $path, array $payload, string $label): Respo ); if ($response->failed()) { - Log::error("Facebook {$label} failed", [ - 'status' => $response->status(), - 'body' => $this->redactResponseBody($response->body()), - ]); + if ($reportFailure) { + Log::error("Facebook {$label} failed", [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + } + $this->handleApiError($response); } diff --git a/app/Support/FacebookLinkPreview.php b/app/Support/FacebookLinkPreview.php new file mode 100644 index 000000000..b690838f6 --- /dev/null +++ b/app/Support/FacebookLinkPreview.php @@ -0,0 +1,47 @@ + + */ + private const array OWNED_HOSTS = [ + 'facebook.com', + '*.facebook.com', + 'fb.com', + '*.fb.com', + 'fb.me', + '*.fb.me', + ]; + + public static function url(string $text): ?string + { + return Str::matchAll(UrlDetector::URL_PATTERN, $text) + ->map(fn (string $raw): string => UrlDetector::trimTrailingPunctuation($raw)) + ->first(fn (string $candidate): bool => ! self::isOwnedHost($candidate)); + } + + private static function isOwnedHost(string $url): bool + { + $host = parse_url($url, PHP_URL_HOST); + + if (! is_string($host) || $host === '') { + return false; + } + + return Str::is(self::OWNED_HOSTS, $host, ignoreCase: true); + } +} diff --git a/resources/js/components/posts/previews/FacebookPreview.vue b/resources/js/components/posts/previews/FacebookPreview.vue index a6ff12c77..be601ba80 100644 --- a/resources/js/components/posts/previews/FacebookPreview.vue +++ b/resources/js/components/posts/previews/FacebookPreview.vue @@ -1,11 +1,14 @@