From e034c0b5721cce270c17fe5f314b905eba14c7b6 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 1 Apr 2026 11:25:25 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20publishing=20hardening=20=E2=80=94=20co?= =?UTF-8?q?ntent=20sanitization,=20validation,=20scopes,=20stuck=20recover?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ContentSanitizer: strips HTML, converts bold/underline to Unicode for LinkedIn - Add backend content length validation in all 11 publishers via HasSocialHttpClient trait - Add scope verification before publishing — checks required scopes per platform - Add RecoverStuckPosts command (every 30min) — recovers posts stuck in publishing > 1h - Add requiredPublishScopes() to Platform enum - Update SocialAccount factory with correct scopes per platform state --- app/Console/Commands/RecoverStuckPosts.php | 59 ++++++++++ app/Enums/SocialAccount/Platform.php | 20 ++++ app/Jobs/PublishToSocialPlatform.php | 18 +++ app/Services/Social/BlueskyPublisher.php | 6 +- .../Social/Concerns/HasSocialHttpClient.php | 13 +++ app/Services/Social/ContentSanitizer.php | 91 +++++++++++++++ app/Services/Social/FacebookPublisher.php | 8 +- app/Services/Social/InstagramPublisher.php | 8 +- app/Services/Social/LinkedInPagePublisher.php | 16 ++- app/Services/Social/LinkedInPublisher.php | 16 ++- app/Services/Social/MastodonPublisher.php | 2 + app/Services/Social/PinterestPublisher.php | 2 + app/Services/Social/ThreadsPublisher.php | 14 ++- app/Services/Social/TikTokPublisher.php | 16 ++- app/Services/Social/XPublisher.php | 2 + app/Services/Social/YouTubePublisher.php | 14 ++- database/factories/SocialAccountFactory.php | 11 ++ routes/console.php | 2 + .../Commands/RecoverStuckPostsTest.php | 106 ++++++++++++++++++ .../Jobs/PublishToSocialPlatformTest.php | 15 +++ .../Concerns/HasSocialHttpClientTest.php | 47 ++++++++ .../Services/Social/ContentSanitizerTest.php | 56 +++++++++ 22 files changed, 509 insertions(+), 33 deletions(-) create mode 100644 app/Console/Commands/RecoverStuckPosts.php create mode 100644 app/Services/Social/ContentSanitizer.php create mode 100644 tests/Feature/Commands/RecoverStuckPostsTest.php create mode 100644 tests/Unit/Services/Social/ContentSanitizerTest.php diff --git a/app/Console/Commands/RecoverStuckPosts.php b/app/Console/Commands/RecoverStuckPosts.php new file mode 100644 index 00000000..ed3746ee --- /dev/null +++ b/app/Console/Commands/RecoverStuckPosts.php @@ -0,0 +1,59 @@ +where('status', PostStatus::Publishing) + ->where('updated_at', '<=', now()->subHour()) + ->each(function (Post $post) use (&$count) { + // Mark stuck platforms as failed + $post->postPlatforms() + ->where('enabled', true) + ->where('status', PlatformStatus::Publishing) + ->where('updated_at', '<=', now()->subHour()) + ->update([ + 'status' => PlatformStatus::Failed, + 'error_message' => 'Publishing timed out. Please try again.', + 'error_context' => json_encode([ + 'category' => 'timeout', + 'failed_at' => now()->toIso8601String(), + ]), + ]); + + // Recalculate post status + $enabledPlatforms = $post->postPlatforms()->where('enabled', true)->get(); + $total = $enabledPlatforms->count(); + $publishedCount = $enabledPlatforms->where('status', PlatformStatus::Published)->count(); + $failedCount = $enabledPlatforms->where('status', PlatformStatus::Failed)->count(); + + if ($publishedCount === $total) { + $post->markAsPublished(); + } elseif ($publishedCount > 0) { + $post->markAsPartiallyPublished(); + } else { + $post->markAsFailed(); + } + + $count++; + }); + + $this->info("Recovered {$count} stuck posts."); + } +} diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 6325935b..ff32645e 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -101,6 +101,26 @@ public function maxContentLength(): int }; } + /** + * @return array + */ + public function requiredPublishScopes(): array + { + return match ($this) { + self::Instagram => ['instagram_business_content_publish'], + self::Facebook => ['pages_manage_posts'], + self::TikTok => ['video.publish'], + self::YouTube => ['https://www.googleapis.com/auth/youtube.upload'], + self::LinkedIn => ['w_member_social'], + self::LinkedInPage => ['w_organization_social'], + self::X => ['tweet.write'], + self::Threads => ['threads_content_publish'], + self::Pinterest => ['pins:write'], + self::Bluesky => [], + self::Mastodon => ['write:statuses'], + }; + } + public function supportsTextOnly(): bool { return match ($this) { diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index f9a94d64..a8cd9925 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -69,6 +69,24 @@ public function handle(): void return; } + $requiredScopes = $this->postPlatform->platform->requiredPublishScopes(); + $accountScopes = $this->postPlatform->socialAccount->scopes ?? []; + + if (! empty($requiredScopes)) { + $missingScopes = array_diff($requiredScopes, $accountScopes); + + if (! empty($missingScopes)) { + $this->postPlatform->markAsFailed( + 'Missing permissions: '.implode(', ', $missingScopes).'. Please reconnect your account.', + ['category' => 'permission', 'missing_scopes' => $missingScopes, 'failed_at' => now()->toIso8601String()] + ); + $this->updatePostStatus(); + $this->broadcastStatus(); + + return; + } + } + $this->postPlatform->markAsPublishing(); $this->broadcastStatus(); diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 7e714795..b2b906ee 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -21,6 +21,10 @@ class BlueskyPublisher public function publish(PostPlatform $postPlatform): array { + $this->validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $account = $postPlatform->socialAccount; $service = $account->meta['service'] ?? 'https://bsky.social'; @@ -57,7 +61,7 @@ public function publish(PostPlatform $postPlatform): array } // Parse facets (links, mentions, hashtags) from text - $text = $postPlatform->content ?? ''; + $text = $content ?? ''; $facets = $this->parseFacets($text); // Create post record diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php index fa314be9..ac823ec0 100644 --- a/app/Services/Social/Concerns/HasSocialHttpClient.php +++ b/app/Services/Social/Concerns/HasSocialHttpClient.php @@ -4,11 +4,24 @@ namespace App\Services\Social\Concerns; +use App\Models\PostPlatform; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; trait HasSocialHttpClient { + protected function validateContentLength(PostPlatform $postPlatform): void + { + $maxLength = $postPlatform->platform->maxContentLength(); + $contentLength = mb_strlen($postPlatform->content ?? ''); + + if ($contentLength > $maxLength) { + throw new \Exception( + "Content exceeds {$postPlatform->platform->label()} limit of {$maxLength} characters ({$contentLength} provided)." + ); + } + } + protected function socialHttp(): PendingRequest { return Http::retry( diff --git a/app/Services/Social/ContentSanitizer.php b/app/Services/Social/ContentSanitizer.php new file mode 100644 index 00000000..9caf900f --- /dev/null +++ b/app/Services/Social/ContentSanitizer.php @@ -0,0 +1,91 @@ + $this->convertBoldAndStrip($content), + default => $this->stripHtml($content), + }; + } + + private function stripHtml(string $content): string + { + // Convert

tags to newlines + $content = preg_replace('/]*>/i', '', $content); + $content = str_replace('

', "\n", $content); + + // Convert
to newlines + $content = preg_replace('//i', "\n", $content); + + // Convert list items to dash prefix + $content = preg_replace('/]*>/i', '- ', $content); + $content = str_replace('', "\n", $content); + + // Strip remaining HTML tags + $content = strip_tags($content); + + // Decode HTML entities + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // Clean up excessive newlines (max 2 consecutive) + $content = preg_replace("/\n{3,}/", "\n\n", $content); + + return trim($content); + } + + private function convertBoldAndStrip(string $content): string + { + // Convert / to Unicode bold characters for LinkedIn + $content = preg_replace_callback( + '/<(?:strong|b)>(.*?)<\/(?:strong|b)>/si', + fn ($matches) => $this->toUnicodeBold($matches[1]), + $content + ); + + // Convert to Unicode underline + $content = preg_replace_callback( + '/(.*?)<\/u>/si', + fn ($matches) => $this->toUnicodeUnderline($matches[1]), + $content + ); + + return $this->stripHtml($content); + } + + private function toUnicodeBold(string $text): string + { + $map = [ + 'a' => '𝗮', 'b' => '𝗯', 'c' => '𝗰', 'd' => '𝗱', 'e' => '𝗲', + 'f' => '𝗳', 'g' => '𝗴', 'h' => '𝗵', 'i' => '𝗶', 'j' => '𝗷', + 'k' => '𝗸', 'l' => '𝗹', 'm' => '𝗺', 'n' => '𝗻', 'o' => '𝗼', + 'p' => '𝗽', 'q' => '𝗾', 'r' => '𝗿', 's' => '𝘀', 't' => '𝘁', + 'u' => '𝘂', 'v' => '𝘃', 'w' => '𝘄', 'x' => '𝘅', 'y' => '𝘆', 'z' => '𝘇', + 'A' => '𝗔', 'B' => '𝗕', 'C' => '𝗖', 'D' => '𝗗', 'E' => '𝗘', + 'F' => '𝗙', 'G' => '𝗚', 'H' => '𝗛', 'I' => '𝗜', 'J' => '𝗝', + 'K' => '𝗞', 'L' => '𝗟', 'M' => '𝗠', 'N' => '𝗡', 'O' => '𝗢', + 'P' => '𝗣', 'Q' => '𝗤', 'R' => '𝗥', 'S' => '𝗦', 'T' => '𝗧', + 'U' => '𝗨', 'V' => '𝗩', 'W' => '𝗪', 'X' => '𝗫', 'Y' => '𝗬', 'Z' => '𝗭', + '0' => '𝟬', '1' => '𝟭', '2' => '𝟮', '3' => '𝟯', '4' => '𝟰', + '5' => '𝟱', '6' => '𝟲', '7' => '𝟳', '8' => '𝟴', '9' => '𝟵', + ]; + + return strtr($text, $map); + } + + private function toUnicodeUnderline(string $text): string + { + // Unicode combining underline character + return implode('', array_map( + fn ($char) => $char."\u{0332}", + mb_str_split($text) + )); + } +} diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 481ab06a..68f501b9 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -20,6 +20,10 @@ class FacebookPublisher public function publish(PostPlatform $postPlatform): array { + $this->validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $account = $postPlatform->socialAccount; $pageId = $account->platform_user_id; $accessToken = $account->access_token; @@ -28,9 +32,9 @@ public function publish(PostPlatform $postPlatform): array $contentType = $postPlatform->content_type; return match ($contentType) { - ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $postPlatform->content, $media->first()), + ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $content, $media->first()), ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $media->first()), - ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $postPlatform->content, $media), + ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media), default => throw new \Exception("Unsupported Facebook content type: {$contentType?->value}"), }; } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 88684c04..b8bf134d 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -22,6 +22,8 @@ class InstagramPublisher public function publish(PostPlatform $postPlatform): array { + $this->validateContentLength($postPlatform); + $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { @@ -32,6 +34,8 @@ public function publish(PostPlatform $postPlatform): array $instagramId = $account->platform_user_id; $accessToken = $account->access_token; + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $media = $postPlatform->media; if ($media->isEmpty()) { @@ -42,9 +46,9 @@ public function publish(PostPlatform $postPlatform): array $contentType = $postPlatform->content_type; return match ($contentType) { - ContentType::InstagramReel => $this->publishReel($instagramId, $accessToken, $postPlatform->content, $firstMedia), + ContentType::InstagramReel => $this->publishReel($instagramId, $accessToken, $content, $firstMedia), ContentType::InstagramStory => $this->publishStory($instagramId, $accessToken, $firstMedia), - ContentType::InstagramFeed => $this->publishFeed($instagramId, $accessToken, $postPlatform->content, $media), + ContentType::InstagramFeed => $this->publishFeed($instagramId, $accessToken, $content, $media), default => throw new \Exception("Unsupported Instagram content type: {$contentType?->value}"), }; } diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index 2b8acb15..0f890b9f 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -33,6 +33,10 @@ class LinkedInPagePublisher public function publish(PostPlatform $postPlatform): array { + $this->validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $this->account = $postPlatform->socialAccount; $this->hasRetried = false; @@ -54,16 +58,16 @@ public function publish(PostPlatform $postPlatform): array try { return match ($contentType) { - ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account), - ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account), + ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->media, $this->account), + ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->media, $this->account), default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"), }; } catch (TokenExpiredException $e) { - return $this->retryWithRefresh($postPlatform, $e); + return $this->retryWithRefresh($postPlatform, $content, $e); } } - private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredException $originalException): array + private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array { if ($this->hasRetried) { throw $originalException; @@ -81,8 +85,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredExcept $contentType = $postPlatform->content_type; return match ($contentType) { - ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account), - ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account), + ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->media, $this->account), + ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->media, $this->account), default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"), }; } catch (\Throwable $e) { diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 633d72bc..80ec8ee6 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -33,6 +33,10 @@ class LinkedInPublisher public function publish(PostPlatform $postPlatform): array { + $this->validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $this->account = $postPlatform->socialAccount; $this->hasRetried = false; @@ -48,16 +52,16 @@ public function publish(PostPlatform $postPlatform): array try { return match ($contentType) { - ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $postPlatform->content, $postPlatform->media), - ContentType::LinkedInPost => $this->publishPost($personUrn, $postPlatform->content, $postPlatform->media), + ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->media), + ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->media), default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"), }; } catch (TokenExpiredException $e) { - return $this->retryWithRefresh($postPlatform, $e); + return $this->retryWithRefresh($postPlatform, $content, $e); } } - private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredException $originalException): array + private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array { if ($this->hasRetried) { throw $originalException; @@ -74,8 +78,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredExcept $contentType = $postPlatform->content_type; return match ($contentType) { - ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $postPlatform->content, $postPlatform->media), - ContentType::LinkedInPost => $this->publishPost($personUrn, $postPlatform->content, $postPlatform->media), + ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->media), + ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->media), default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"), }; } catch (\Throwable $e) { diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php index b3f7d94b..4e37940a 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -20,6 +20,8 @@ class MastodonPublisher public function publish(PostPlatform $postPlatform): array { + $this->validateContentLength($postPlatform); + $account = $postPlatform->socialAccount; $instance = $account->meta['instance'] ?? 'https://mastodon.social'; diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index 379fb1b2..480e52af 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -23,6 +23,8 @@ class PinterestPublisher public function publish(PostPlatform $postPlatform): array { + $this->validateContentLength($postPlatform); + $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index de15003e..520cbdd5 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -20,6 +20,10 @@ class ThreadsPublisher public function publish(PostPlatform $postPlatform): array { + $this->validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { @@ -34,11 +38,11 @@ public function publish(PostPlatform $postPlatform): array // Text only post if ($media->isEmpty()) { - if (empty($postPlatform->content)) { + if (empty($content)) { throw new \Exception('Threads text posts require content. Please add text to your post.'); } - return $this->publishTextPost($userId, $accessToken, $postPlatform->content); + return $this->publishTextPost($userId, $accessToken, $content); } $firstMedia = $media->first(); @@ -47,14 +51,14 @@ public function publish(PostPlatform $postPlatform): array // Single media if ($media->count() === 1) { if ($isVideo) { - return $this->publishVideoPost($userId, $accessToken, $postPlatform->content, $firstMedia); + return $this->publishVideoPost($userId, $accessToken, $content, $firstMedia); } - return $this->publishImagePost($userId, $accessToken, $postPlatform->content, $firstMedia); + return $this->publishImagePost($userId, $accessToken, $content, $firstMedia); } // Multiple media - carousel - return $this->publishCarousel($userId, $accessToken, $postPlatform->content, $media); + return $this->publishCarousel($userId, $accessToken, $content, $media); } private function publishTextPost(string $userId, string $accessToken, string $content): array diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 085e3487..17262c1b 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -24,6 +24,10 @@ class TikTokPublisher public function publish(PostPlatform $postPlatform): array { + $this->validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { @@ -44,11 +48,11 @@ public function publish(PostPlatform $postPlatform): array $isImage = $firstMedia->isImage(); if ($isVideo) { - return $this->publishVideo($postPlatform, $firstMedia); + return $this->publishVideo($postPlatform, $firstMedia, $content); } if ($isImage) { - return $this->publishPhotos($postPlatform, $media); + return $this->publishPhotos($postPlatform, $media, $content); } throw new \Exception('TikTok only supports video or image content.'); @@ -93,14 +97,14 @@ private function queryCreatorInfo(): array ]; } - private function publishVideo(PostPlatform $postPlatform, $media): array + private function publishVideo(PostPlatform $postPlatform, $media, ?string $content): array { $creatorInfo = $this->queryCreatorInfo(); $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/video/init/", [ 'post_info' => [ - 'title' => $postPlatform->content ?? '', + 'title' => $content ?? '', 'privacy_level' => data_get($creatorInfo, 'privacy_level'), 'disable_duet' => false, 'disable_comment' => false, @@ -137,7 +141,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array ]; } - private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): array + private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?string $content): array { $photoUrls = $mediaCollection ->filter(fn ($m) => $m->isImage()) @@ -154,7 +158,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/content/init/", [ 'post_info' => [ - 'title' => $postPlatform->content ?? '', + 'title' => $content ?? '', 'privacy_level' => data_get($creatorInfo, 'privacy_level'), 'disable_comment' => false, ], diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 64979574..d9b483b0 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -26,6 +26,8 @@ class XPublisher public function publish(PostPlatform $postPlatform): array { + $this->validateContentLength($postPlatform); + $account = $postPlatform->socialAccount; // Refresh token if expired or expiring soon diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index cd6e5385..291f5d6c 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -26,6 +26,10 @@ class YouTubePublisher public function publish(PostPlatform $postPlatform): array { + $this->validateContentLength($postPlatform); + + $content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null; + $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { @@ -45,7 +49,7 @@ public function publish(PostPlatform $postPlatform): array throw new \Exception('YouTube Shorts only supports video content.'); } - return $this->publishShort($postPlatform, $firstMedia, $account); + return $this->publishShort($postPlatform, $firstMedia, $account, $content); } private function createGoogleClient(SocialAccount $account): GoogleClient @@ -73,14 +77,14 @@ private function createGoogleClient(SocialAccount $account): GoogleClient return $client; } - private function publishShort(PostPlatform $postPlatform, $media, SocialAccount $account): array + private function publishShort(PostPlatform $postPlatform, $media, SocialAccount $account, ?string $content): array { - if (empty($postPlatform->content)) { + if (empty($content)) { throw new \Exception('YouTube Shorts require a title. Please add text to your post.'); } - $title = $this->buildTitle($postPlatform->content); - $description = $postPlatform->content; + $title = $this->buildTitle($content); + $description = $content; $tempFile = tempnam(sys_get_temp_dir(), 'yt_upload_'); $handle = null; diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index 4083baf9..c98f924f 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -41,6 +41,7 @@ public function linkedin(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::LinkedIn, + 'scopes' => Platform::LinkedIn->requiredPublishScopes(), ]); } @@ -48,6 +49,7 @@ public function linkedinPage(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::LinkedInPage, + 'scopes' => Platform::LinkedInPage->requiredPublishScopes(), ]); } @@ -55,6 +57,7 @@ public function x(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::X, + 'scopes' => Platform::X->requiredPublishScopes(), ]); } @@ -62,6 +65,7 @@ public function tiktok(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::TikTok, + 'scopes' => Platform::TikTok->requiredPublishScopes(), ]); } @@ -69,6 +73,7 @@ public function youtube(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::YouTube, + 'scopes' => Platform::YouTube->requiredPublishScopes(), ]); } @@ -76,6 +81,7 @@ public function facebook(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::Facebook, + 'scopes' => Platform::Facebook->requiredPublishScopes(), ]); } @@ -83,6 +89,7 @@ public function instagram(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::Instagram, + 'scopes' => Platform::Instagram->requiredPublishScopes(), ]); } @@ -90,6 +97,7 @@ public function threads(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::Threads, + 'scopes' => Platform::Threads->requiredPublishScopes(), ]); } @@ -97,6 +105,7 @@ public function pinterest(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::Pinterest, + 'scopes' => Platform::Pinterest->requiredPublishScopes(), ]); } @@ -104,6 +113,7 @@ public function bluesky(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::Bluesky, + 'scopes' => Platform::Bluesky->requiredPublishScopes(), 'token_expires_at' => now()->addHours(2), 'meta' => [ 'service' => 'https://bsky.social', @@ -117,6 +127,7 @@ public function mastodon(): static { return $this->state(fn (array $attributes) => [ 'platform' => Platform::Mastodon, + 'scopes' => Platform::Mastodon->requiredPublishScopes(), 'token_expires_at' => null, // Mastodon tokens don't expire 'meta' => [ 'instance' => 'https://mastodon.social', diff --git a/routes/console.php b/routes/console.php index 0953d8c6..5b2f7644 100644 --- a/routes/console.php +++ b/routes/console.php @@ -4,9 +4,11 @@ use App\Console\Commands\CheckSocialConnections; use App\Console\Commands\ProcessScheduledPosts; +use App\Console\Commands\RecoverStuckPosts; use App\Console\Commands\RefreshExpiringTokens; use Illuminate\Support\Facades\Schedule; Schedule::command(ProcessScheduledPosts::class)->everyMinute(); Schedule::command(CheckSocialConnections::class)->daily(); Schedule::command(RefreshExpiringTokens::class)->hourly(); +Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes(); diff --git a/tests/Feature/Commands/RecoverStuckPostsTest.php b/tests/Feature/Commands/RecoverStuckPostsTest.php new file mode 100644 index 00000000..bc69db7b --- /dev/null +++ b/tests/Feature/Commands/RecoverStuckPostsTest.php @@ -0,0 +1,106 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->socialAccount = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + ]); +}); + +test('it recovers posts stuck in publishing for over 1 hour', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Publishing, + 'updated_at' => now()->subHours(2), + ]); + + $platform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + 'status' => PlatformStatus::Publishing, + 'enabled' => true, + 'updated_at' => now()->subHours(2), + ]); + + $this->artisan('social:recover-stuck-posts')->assertSuccessful(); + + $platform->refresh(); + $post->refresh(); + + expect($platform->status)->toBe(PlatformStatus::Failed); + expect($platform->error_message)->toBe('Publishing timed out. Please try again.'); + expect($post->status)->toBe(PostStatus::Failed); +}); + +test('it does not touch posts publishing for less than 1 hour', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Publishing, + 'updated_at' => now()->subMinutes(30), + ]); + + $platform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + 'status' => PlatformStatus::Publishing, + 'enabled' => true, + 'updated_at' => now()->subMinutes(30), + ]); + + $this->artisan('social:recover-stuck-posts')->assertSuccessful(); + + $platform->refresh(); + expect($platform->status)->toBe(PlatformStatus::Publishing); +}); + +test('it marks post as partially published when some platforms succeeded', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Publishing, + 'updated_at' => now()->subHours(2), + ]); + + // One succeeded + PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + 'status' => PlatformStatus::Published, + 'enabled' => true, + ]); + + // One stuck + $stuckPlatform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Instagram, + ])->id, + 'status' => PlatformStatus::Publishing, + 'enabled' => true, + 'updated_at' => now()->subHours(2), + ]); + + $this->artisan('social:recover-stuck-posts')->assertSuccessful(); + + $stuckPlatform->refresh(); + $post->refresh(); + + expect($stuckPlatform->status)->toBe(PlatformStatus::Failed); + expect($post->status)->toBe(PostStatus::PartiallyPublished); +}); diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 469e5521..72a1492b 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -385,6 +385,21 @@ expect($this->postPlatform->error_context['content_length'])->toBe(11); }); +test('publish to social platform fails when scopes are missing', function () { + Event::fake(); + + $this->socialAccount->update(['scopes' => ['user.info.basic']]); // missing w_member_social + $this->postPlatform->refresh(); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); + expect($this->postPlatform->error_message)->toContain('Missing permissions'); + expect($this->postPlatform->error_context['category'])->toBe('permission'); + expect($this->postPlatform->error_context['missing_scopes'])->toContain('w_member_social'); +}); + test('publish to social platform saves error context on token expired', function () { Event::fake(); Mail::fake(); diff --git a/tests/Unit/Services/Social/Concerns/HasSocialHttpClientTest.php b/tests/Unit/Services/Social/Concerns/HasSocialHttpClientTest.php index ee5f0643..70547665 100644 --- a/tests/Unit/Services/Social/Concerns/HasSocialHttpClientTest.php +++ b/tests/Unit/Services/Social/Concerns/HasSocialHttpClientTest.php @@ -2,6 +2,13 @@ declare(strict_types=1); +use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; +use App\Models\Post; +use App\Models\PostPlatform; +use App\Models\SocialAccount; +use App\Models\User; +use App\Models\Workspace; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -15,6 +22,11 @@ public function makeRequest(string $url): Response { return $this->socialHttp()->get($url); } + + public function callValidateContentLength(PostPlatform $postPlatform): void + { + $this->validateContentLength($postPlatform); + } }; }); @@ -66,3 +78,38 @@ public function makeRequest(string $url): Response expect($response->status())->toBe(200); Http::assertSentCount(1); }); + +test('validateContentLength passes when content is within limit', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]); + $post = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $user->id]); + + $postPlatform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $socialAccount->id, + 'platform' => Platform::LinkedIn, + 'content_type' => ContentType::LinkedInPost, + 'content' => str_repeat('a', 100), // well within LinkedIn's 3000 limit + ]); + + expect(fn () => $this->client->callValidateContentLength($postPlatform))->not->toThrow(Exception::class); +}); + +test('validateContentLength throws when content exceeds limit', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]); + $post = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $user->id]); + + $postPlatform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $socialAccount->id, + 'platform' => Platform::LinkedIn, + 'content_type' => ContentType::LinkedInPost, + 'content' => str_repeat('a', 4000), // exceeds LinkedIn's 3000 limit + ]); + + expect(fn () => $this->client->callValidateContentLength($postPlatform)) + ->toThrow(Exception::class, '3000'); +}); diff --git a/tests/Unit/Services/Social/ContentSanitizerTest.php b/tests/Unit/Services/Social/ContentSanitizerTest.php new file mode 100644 index 00000000..a2b073bd --- /dev/null +++ b/tests/Unit/Services/Social/ContentSanitizerTest.php @@ -0,0 +1,56 @@ +sanitize('

Hello world

', Platform::Instagram); + expect($result)->toBe('Hello world'); +}); + +test('it converts bold to unicode for linkedin', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('

Hello world

', Platform::LinkedIn); + expect($result)->toContain('𝘄𝗼𝗿𝗹𝗱'); +}); + +test('it converts p tags to newlines', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('

First paragraph

Second paragraph

', Platform::X); + expect($result)->toBe("First paragraph\nSecond paragraph"); +}); + +test('it converts br to newlines', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('Line one
Line two', Platform::Facebook); + expect($result)->toBe("Line one\nLine two"); +}); + +test('it decodes html entities', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('Tom & Jerry <3', Platform::X); + expect($result)->toBe('Tom & Jerry <3'); +}); + +test('it returns plain text unchanged', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('Just plain text', Platform::Instagram); + expect($result)->toBe('Just plain text'); +}); + +test('it handles null-safe empty content', function () { + // The publisher handles null check, sanitizer should handle empty string + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('', Platform::X); + expect($result)->toBe(''); +}); + +test('it converts list items to dashes', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('
  • Item one
  • Item two
', Platform::Facebook); + expect($result)->toContain('- Item one'); + expect($result)->toContain('- Item two'); +});