diff --git a/app/Actions/Post/HostInlineMedia.php b/app/Actions/Post/HostInlineMedia.php new file mode 100644 index 00000000..3d0652c7 --- /dev/null +++ b/app/Actions/Post/HostInlineMedia.php @@ -0,0 +1,42 @@ + $allowedTypes + * @param array> $media + * @return array> + * + * @throws ValidationException + */ + public static function execute(Workspace $workspace, array $allowedTypes, array $media): array + { + if ($media === []) { + return $media; + } + + $result = app(MediaAttacher::class)->resolveInlineMedia($workspace, $allowedTypes, $media); + + if ($result['failed'] !== []) { + throw ValidationException::withMessages([ + 'media' => ['Could not fetch media from URL: '.implode(', ', $result['failed'])], + ]); + } + + return $result['media']; + } +} diff --git a/app/Http/Controllers/Api/PostController.php b/app/Http/Controllers/Api/PostController.php index 30be5e48..bd020741 100644 --- a/app/Http/Controllers/Api/PostController.php +++ b/app/Http/Controllers/Api/PostController.php @@ -6,6 +6,7 @@ use App\Actions\Post\CreatePost; use App\Actions\Post\DeletePost; +use App\Actions\Post\HostInlineMedia; use App\Actions\Post\UpdatePost; use App\Enums\Media\Type as MediaType; use App\Enums\Post\Action as PostAction; @@ -49,11 +50,18 @@ public function show(Request $request, Post $post): PostResource public function store(StorePostRequest $request): JsonResponse { - $post = CreatePost::execute( - $request->user()->currentWorkspace, - $request->user()->currentWorkspace->owner, - $request->validated() - ); + $workspace = $request->user()->currentWorkspace; + $data = $request->validated(); + + if (array_key_exists('media', $data)) { + $data['media'] = HostInlineMedia::execute( + $workspace, + Post::allowedMediaTypesFor($request->selectedPlatforms()), + $data['media'], + ); + } + + $post = CreatePost::execute($workspace, $workspace->owner, $data); $post->load(['postPlatforms.socialAccount']); @@ -66,7 +74,17 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso { $this->authorize('update', $post); - $result = UpdatePost::execute($request->user()->currentWorkspace, $post, $request->validated()); + $data = $request->validated(); + + if (array_key_exists('media', $data)) { + $data['media'] = HostInlineMedia::execute( + $request->user()->currentWorkspace, + $post->allowedMediaTypes(), + $data['media'], + ); + } + + $result = UpdatePost::execute($request->user()->currentWorkspace, $post, $data); if (data_get($result, 'action') === PostAction::Finalized) { return response()->json( diff --git a/app/Http/Requests/Api/Post/StorePostRequest.php b/app/Http/Requests/Api/Post/StorePostRequest.php index cbdb4c0a..00b98f24 100644 --- a/app/Http/Requests/Api/Post/StorePostRequest.php +++ b/app/Http/Requests/Api/Post/StorePostRequest.php @@ -9,6 +9,7 @@ use App\Models\SocialAccount; use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeMatchesPlatform; +use App\Support\PostMediaRules; use App\Support\PostPlatformMetaRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; @@ -35,7 +36,7 @@ public function rules(): array [new ContentFitsPlatformLimits($this->resolveSelectedPlatforms($workspaceId))] ), ], - 'media' => ['sometimes', 'array'], + ...PostMediaRules::rules(hosted: false), 'platforms' => ['required', 'array', 'min:1'], 'platforms.*.social_account_id' => [ 'required', @@ -60,6 +61,14 @@ public function rules(): array ]; } + /** + * @return Collection + */ + public function selectedPlatforms(): Collection + { + return $this->resolveSelectedPlatforms($this->user()->currentWorkspace->id)->values(); + } + /** * @return Collection */ diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 7f226fb8..ccd89e56 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -12,6 +12,7 @@ use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeCompatibleWithMedia; use App\Rules\ContentTypeMatchesPostPlatform; +use App\Support\PostMediaRules; use App\Support\PostPlatformMetaRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; @@ -44,7 +45,7 @@ public function rules(): array [new ContentFitsPlatformLimits($this->resolveSelectedPlatforms())] ), ], - 'media' => ['sometimes', 'array'], + ...PostMediaRules::rules(hosted: false), 'platforms' => ['sometimes', 'array'], 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post') instanceof Post ? $this->route('post')->id : $this->route('post'))], 'platforms.*.content_type' => [ diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index c246ff57..18d7f539 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -4,12 +4,12 @@ namespace App\Http\Requests\App\Post; -use App\Enums\Media\Source; use App\Enums\Post\Status; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeCompatibleWithMedia; +use App\Support\PostMediaRules; use App\Support\PostPlatformMetaRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; @@ -42,17 +42,7 @@ public function rules(): array [new ContentFitsPlatformLimits($this->resolveSelectedPlatforms())] ), ], - 'media' => ['sometimes', 'array'], - 'media.*.id' => ['required', 'string'], - 'media.*.path' => ['required', 'string', 'max:500'], - 'media.*.url' => ['required', 'string', 'max:2048'], - 'media.*.type' => ['sometimes', 'nullable', 'string', 'max:32'], - 'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'], - 'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'], - 'media.*.size' => ['sometimes', 'nullable', 'integer'], - 'media.*.meta' => ['sometimes', 'nullable', 'array'], - 'media.*.source' => ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))], - 'media.*.source_meta' => ['sometimes', 'nullable', 'array'], + ...PostMediaRules::rules(hosted: true), 'scheduled_at' => [ 'sometimes', 'nullable', diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 5bf96054..090540f2 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -165,7 +165,7 @@ public function handle(): void 'platform' => $this->postPlatform->platform->value, 'error' => $e->getMessage(), ]); - $this->postPlatform->markAsFailed($e->getMessage(), [ + $this->postPlatform->markAsFailed($this->safeFailureMessage($e), [ 'category' => 'unknown', 'failed_at' => now()->toIso8601String(), 'content_length' => mb_strlen($this->postPlatform->post->content ?? ''), @@ -223,6 +223,17 @@ private function broadcastStatus(): void PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); } + /** + * A user-safe failure message: only our own publish exceptions are shown + * verbatim; anything else is genericized so internals never reach the email. + */ + private function safeFailureMessage(\Throwable $e): string + { + return $e instanceof SocialPublishException + ? $e->userMessage + : 'An unexpected error occurred while publishing. Please try again.'; + } + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher { return match ($this->postPlatform->platform) { @@ -308,10 +319,13 @@ public function failed(?\Throwable $exception): void $this->postPlatform->refresh(); if ($this->postPlatform->status !== PostPlatformStatus::Published) { - $this->postPlatform->markAsFailed($exception?->getMessage() ?? 'Unknown error', [ - 'category' => 'job_failed', - 'failed_at' => now()->toIso8601String(), - ]); + $this->postPlatform->markAsFailed( + $exception ? $this->safeFailureMessage($exception) : 'Unknown error', + [ + 'category' => 'job_failed', + 'failed_at' => now()->toIso8601String(), + ] + ); $this->updatePostStatus(); $this->broadcastStatus(); } diff --git a/app/Models/Post.php b/app/Models/Post.php index eb7cffa6..1fa41456 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -7,6 +7,7 @@ use App\DataTransferObjects\MediaItem; use App\Enums\Media\Type; use App\Enums\Post\Status as PostStatus; +use App\Enums\SocialAccount\Platform; use App\Observers\PostObserver; use Database\Factories\PostFactory; use Illuminate\Database\Eloquent\Attributes\ObservedBy; @@ -150,12 +151,23 @@ public function allowedMediaTypes(): array ->pluck('socialAccount.platform') ->filter(); + return self::allowedMediaTypesFor($platforms); + } + + /** + * Media types acceptable across a set of platforms (intersection; empty = all). + * + * @param Collection $platforms + * @return array + */ + public static function allowedMediaTypesFor(Collection $platforms): array + { if ($platforms->isEmpty()) { return Type::cases(); } $sets = $platforms - ->map(fn ($platform) => array_map(fn ($type) => $type->value, $platform->allowedMediaTypes())) + ->map(fn (Platform $platform) => array_map(fn ($type) => $type->value, $platform->allowedMediaTypes())) ->all(); return array_map( diff --git a/app/Services/Post/MediaAttacher.php b/app/Services/Post/MediaAttacher.php index 924917ad..3319eb63 100644 --- a/app/Services/Post/MediaAttacher.php +++ b/app/Services/Post/MediaAttacher.php @@ -5,9 +5,12 @@ namespace App\Services\Post; use App\Enums\Media\Type as MediaType; +use App\Models\Media; use App\Models\Post; +use App\Models\Workspace; use Illuminate\Support\Facades\Http; use RuntimeException; +use Throwable; /** * Downloads public URLs and attaches them as media to a post — used by @@ -31,7 +34,7 @@ public function attachFromUrls(Post $post, array $urls): array $failed = []; foreach ($urls as $url) { - ($item = $this->attachOne($post, $url)) === null + ($item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url)) === null ? $failed[] = $url : $attached[] = $item; } @@ -44,9 +47,54 @@ public function attachFromUrls(Post $post, array $urls): array } /** + * Resolve an inline media array into hosted items: items with a `path` pass + * through, external URLs are downloaded and hosted. Atomic — when any item + * fails, media hosted in this call is rolled back so nothing is orphaned. + * + * @param array $allowedTypes + * @param array> $items + * @return array{media: array>, failed: array} + */ + public function resolveInlineMedia(Workspace $workspace, array $allowedTypes, array $items): array + { + $media = []; + $failed = []; + $hostedIds = []; + + foreach ($items as $item) { + if (filled(data_get($item, 'path'))) { + $media[] = $item; + + continue; + } + + $url = (string) data_get($item, 'url', ''); + $hosted = $this->fetchToWorkspace($workspace, $allowedTypes, $url); + + if ($hosted === null) { + $failed[] = $url; + + continue; + } + + $media[] = $hosted; + $hostedIds[] = data_get($hosted, 'id'); + } + + if ($failed !== [] && $hostedIds !== []) { + Media::query()->whereKey($hostedIds)->get()->each->delete(); + } + + return ['media' => $media, 'failed' => $failed]; + } + + /** + * Download a URL, validate its type, and store it on the workspace. + * + * @param array $allowedTypes * @return array|null */ - private function attachOne(Post $post, string $url): ?array + private function fetchToWorkspace(Workspace $workspace, array $allowedTypes, string $url): ?array { $download = $this->download($url); @@ -57,7 +105,7 @@ private function attachOne(Post $post, string $url): ?array try { $type = MediaType::fromMime($download['mime'] ?? ''); - if ($type === null || ! in_array($type, $post->allowedMediaTypes(), true)) { + if ($type === null || ! in_array($type, $allowedTypes, true)) { return null; } @@ -66,7 +114,7 @@ private function attachOne(Post $post, string $url): ?array } $name = basename(parse_url($url, PHP_URL_PATH) ?? '') ?: 'download.bin'; - $media = $post->workspace->addMediaFromPath($download['path'], $name, 'assets'); + $media = $workspace->addMediaFromPath($download['path'], $name, 'assets'); return [ 'id' => $media->id, @@ -106,7 +154,8 @@ private function download(string $url): ?array }, ]) ->get($url); - } catch (RuntimeException) { + } catch (Throwable) { + // Any fetch failure (timeout, DNS, refused, oversize) is a failed download. @unlink($temp); return null; diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index 16c8f7d2..5fd6f5e7 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -6,6 +6,7 @@ use App\Enums\Media\Type as MediaType; use App\Enums\SocialAccount\Platform; +use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\LinkedInPublishException; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; @@ -160,13 +161,19 @@ private function publishDocument(?string $content, $media, string $title): array $document = $media->first(fn ($item) => $item->isDocument()); if (! $document) { - throw new \Exception("No PDF document for {$this->label()} document post"); + throw new LinkedInPublishException( + userMessage: "No PDF document was found for this {$this->label()} document post.", + category: ErrorCategory::MediaFormat, + ); } $documentUrn = $this->uploadDocument($document); if (! $documentUrn) { - throw new \Exception("{$this->label()} document upload failed"); + throw new LinkedInPublishException( + userMessage: "{$this->label()} did not accept the document. Please try again.", + category: ErrorCategory::ServerError, + ); } $payload = $this->basePayload($content); @@ -262,7 +269,10 @@ private function uploadImage($mediaItem): ?string $imageUrn = data_get($initData, 'value.image'); if (! $uploadUrl || ! $imageUrn) { - throw new \Exception("{$this->label()} image upload init missing uploadUrl or image URN"); + 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_'); @@ -337,7 +347,10 @@ private function doUploadVideo(string $tempFile, $mediaItem): ?string $uploadToken = data_get($initData, 'value.uploadToken', ''); if (! $videoUrn || empty($uploadInstructions)) { - throw new \Exception("{$this->label()} video upload init missing video URN or upload instructions"); + throw new LinkedInPublishException( + userMessage: "{$this->label()} did not accept the video upload. Please try again.", + category: ErrorCategory::ServerError, + ); } $uploadedPartIds = []; @@ -414,7 +427,10 @@ private function uploadDocument($mediaItem): ?string $documentUrn = data_get($initData, 'value.document'); if (! $uploadUrl || ! $documentUrn) { - throw new \Exception("{$this->label()} document upload init missing uploadUrl or document URN"); + throw new LinkedInPublishException( + userMessage: "{$this->label()} did not accept the document upload. Please try again.", + category: ErrorCategory::ServerError, + ); } // The Documents API is not chunked — upload the PDF in a single request. @@ -475,13 +491,19 @@ private function waitForProcessing(string $resource, string $assetUrn, string $l } if ($status === 'PROCESSING_FAILED') { - throw new \Exception("{$this->label()} {$label} processing failed"); + throw new LinkedInPublishException( + userMessage: "{$this->label()} {$label} processing failed.", + category: ErrorCategory::ServerError, + ); } sleep($this->processingPollSeconds()); } - throw new \Exception("{$this->label()} {$label} processing did not complete in time"); + throw new LinkedInPublishException( + userMessage: "{$this->label()} {$label} processing did not complete in time.", + category: ErrorCategory::ServerError, + ); } /** @@ -505,7 +527,10 @@ private function downloadToTempFile(string $url, string $tempFile): void $response = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url); if ($response->failed()) { - throw new \Exception('Failed to download media: HTTP '.$response->status()); + throw new LinkedInPublishException( + userMessage: "Could not fetch the media to upload to {$this->label()}. Please try again.", + category: ErrorCategory::ServerError, + ); } } diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 9816fc9d..0a64cec4 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -55,7 +55,10 @@ public function publish(PostPlatform $postPlatform): array ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $content, $media->first()), ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $media->first()), ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media, $aspectRatio), - default => throw new \Exception("Unsupported Facebook content type: {$contentType?->value}"), + default => throw new FacebookPublishException( + userMessage: "Unsupported Facebook content type: {$contentType?->value}", + category: ErrorCategory::MediaFormat, + ), }; } @@ -64,7 +67,10 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte // Text only post if ($media->isEmpty()) { if ($content === null || $content === '') { - throw new \Exception('Facebook text posts require content. Please add text to your post.'); + throw new FacebookPublishException( + userMessage: 'Facebook text posts require content. Please add text to your post.', + category: ErrorCategory::MediaFormat, + ); } return $this->publishTextPost($pageId, $accessToken, $content); @@ -87,7 +93,10 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte return $this->publishMultiImagePost($pageId, $accessToken, $content, $media, $aspectRatio); } - throw new \Exception('Unsupported media type for Facebook'); + throw new FacebookPublishException( + userMessage: 'Unsupported media type for Facebook', + category: ErrorCategory::MediaFormat, + ); } private function publishTextPost(string $pageId, string $accessToken, string $content): array @@ -173,7 +182,10 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str } if (empty($attachedMedia)) { - throw new \Exception('Failed to upload any images to Facebook'); + throw new FacebookPublishException( + userMessage: 'Failed to upload any images to Facebook', + category: ErrorCategory::ServerError, + ); } // Create the post with attached media @@ -360,7 +372,10 @@ private function publishStory(string $pageId, string $accessToken, $media): arra $videoId = $response->json()['video_id'] ?? null; if (! $videoId) { - throw new \Exception('Facebook story upload failed: no video ID returned'); + throw new FacebookPublishException( + userMessage: 'Facebook did not accept the story video. Please try again.', + category: ErrorCategory::ServerError, + ); } $transferResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$videoId}", [ diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index 1c3b50fc..bfef3c53 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -5,6 +5,8 @@ namespace App\Services\Social; use App\Enums\SocialAccount\Platform; +use App\Exceptions\Social\ErrorCategory; +use App\Exceptions\Social\LinkedInPublishException; /** * Publishes posts to a LinkedIn company page on behalf of an administering member. @@ -21,7 +23,10 @@ protected function authorUrn(): string $organizationId = $this->account->meta['organization_id'] ?? null; if (! $organizationId) { - throw new \Exception('LinkedIn Page organization ID not configured'); + throw new LinkedInPublishException( + userMessage: 'LinkedIn Page organization ID not configured', + category: ErrorCategory::Permission, + ); } return "urn:li:organization:{$organizationId}"; diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index c37402f1..fe256fa3 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\ThreadsPublishException; use App\Models\PostPlatform; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -41,7 +42,10 @@ public function publish(PostPlatform $postPlatform): array // Text only post if ($media->isEmpty()) { if (empty($content)) { - throw new \Exception('Threads text posts require content. Please add text to your post.'); + throw new ThreadsPublishException( + userMessage: 'Threads text posts require content. Please add text to your post.', + category: ErrorCategory::MediaFormat, + ); } return $this->publishTextPost($userId, $accessToken, $content); @@ -83,7 +87,10 @@ private function publishTextPost(string $userId, string $accessToken, string $co $containerId = $containerResponse->json()['id'] ?? null; if (! $containerId) { - throw new \Exception('Threads text container creation failed: no container ID returned'); + throw new ThreadsPublishException( + userMessage: 'Threads did not accept the post. Please try again.', + category: ErrorCategory::ServerError, + ); } // Step 2: Publish @@ -111,7 +118,10 @@ private function publishImagePost(string $userId, string $accessToken, ?string $ $containerId = $containerResponse->json()['id'] ?? null; if (! $containerId) { - throw new \Exception('Threads image container creation failed: no container ID returned'); + throw new ThreadsPublishException( + userMessage: 'Threads did not accept the image. Please try again.', + category: ErrorCategory::ServerError, + ); } // Step 2: Wait for image processing @@ -142,7 +152,10 @@ private function publishVideoPost(string $userId, string $accessToken, ?string $ $containerId = $containerResponse->json()['id'] ?? null; if (! $containerId) { - throw new \Exception('Threads video container creation failed: no container ID returned'); + throw new ThreadsPublishException( + userMessage: 'Threads did not accept the video. Please try again.', + category: ErrorCategory::ServerError, + ); } // Wait for video processing @@ -198,7 +211,10 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c } if (empty($childContainers)) { - throw new \Exception('Failed to create any carousel items'); + throw new ThreadsPublishException( + userMessage: 'Failed to create any carousel items', + category: ErrorCategory::ServerError, + ); } // Step 2: Create carousel container @@ -219,7 +235,10 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c $carouselId = $carouselResponse->json()['id'] ?? null; if (! $carouselId) { - throw new \Exception('Threads carousel container creation failed: no container ID returned'); + throw new ThreadsPublishException( + userMessage: 'Threads did not accept the carousel. Please try again.', + category: ErrorCategory::ServerError, + ); } // Step 3: Publish carousel @@ -244,7 +263,10 @@ private function publishContainer(string $userId, string $accessToken, string $c $mediaId = $publishResponse->json()['id'] ?? null; if (! $mediaId) { - throw new \Exception('Threads publish failed: no media ID returned'); + throw new ThreadsPublishException( + userMessage: 'Threads did not accept the post. Please publish again.', + category: ErrorCategory::ServerError, + ); } // Get permalink @@ -289,14 +311,20 @@ private function waitForMediaProcessing(string $containerId, string $accessToken if ($status === 'ERROR') { $errorMessage = data_get($data, 'error_message', 'Unknown error'); - throw new \Exception('Threads media processing failed: '.$errorMessage); + throw new ThreadsPublishException( + userMessage: 'Threads media processing failed. Please try a different file.', + category: ErrorCategory::ServerError, + ); } sleep(3); } Log::warning('Threads media processing timeout', ['container_id' => $containerId]); - throw new \Exception('Threads media processing timeout after '.$maxAttempts.' attempts'); + throw new ThreadsPublishException( + userMessage: 'Threads took too long to process the media. Please try again.', + category: ErrorCategory::ServerError, + ); } private function handleApiError(Response $response): never diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index f7be5127..5a535605 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -6,6 +6,7 @@ use App\Enums\Media\Type as MediaType; use App\Enums\SocialAccount\Platform; +use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\XPublishException; use App\Models\PostPlatform; use App\Services\Media\MediaOptimizer; @@ -71,7 +72,10 @@ public function publish(PostPlatform $postPlatform): array } if (empty($content) && empty($mediaIds)) { - throw new \Exception('X posts require either text or media. Please add content to your post.'); + throw new XPublishException( + userMessage: 'X posts require either text or media. Please add content to your post.', + category: ErrorCategory::MediaFormat, + ); } $response = $this->getHttpClient() @@ -114,11 +118,25 @@ private function uploadMedia($mediaItem): ?array $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); if ($downloadResponse->failed()) { - throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status()); + throw new XPublishException( + userMessage: 'Could not fetch the media to upload to X. Please try again.', + category: ErrorCategory::ServerError, + ); + } + + if (blank($mimeType)) { + $mimeType = mime_content_type($tempFile) ?: null; + } + + if (blank($mimeType)) { + throw new XPublishException( + userMessage: 'Unsupported media type for X.', + category: ErrorCategory::MediaFormat, + ); } // Optimize images (skip GIFs — they need special handling) - if ($mediaItem->isImage() && ! MediaType::isGif($mimeType)) { + if (MediaType::classify($mimeType) === MediaType::Image && ! MediaType::isGif($mimeType)) { $optimizer = app(MediaOptimizer::class); $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::X); @unlink($tempFile); @@ -177,16 +195,21 @@ private function uploadMedia($mediaItem): ?array } } - private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeType, string $mediaCategory): array + private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeType, ?string $mediaCategory): array { + $initPayload = [ + 'media_type' => $mimeType, + 'total_bytes' => $totalBytes, + ]; + + if ($mediaCategory) { + $initPayload['media_category'] = $mediaCategory; + } + // INIT $initResponse = $this->socialHttp()->withToken($this->accessToken) ->timeout(60) - ->post("{$this->baseUrl}/media/upload/initialize", [ - 'media_type' => $mimeType, - 'media_category' => $mediaCategory, - 'total_bytes' => $totalBytes, - ]); + ->post("{$this->baseUrl}/media/upload/initialize", $initPayload); if ($initResponse->failed()) { Log::error('X chunked upload INIT error', [ @@ -200,7 +223,10 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy $mediaId = $initData['data']['id'] ?? $initData['media_id'] ?? null; if (! $mediaId) { - throw new \Exception('No media_id returned from INIT'); + throw new XPublishException( + userMessage: 'X did not accept the media upload. Please try again.', + category: ErrorCategory::ServerError, + ); } // APPEND - Read from temp file in 1MB chunks. Matches the diff --git a/app/Support/PostMediaRules.php b/app/Support/PostMediaRules.php new file mode 100644 index 00000000..e57c72dd --- /dev/null +++ b/app/Support/PostMediaRules.php @@ -0,0 +1,42 @@ + + */ + public static function rules(bool $hosted): array + { + return [ + 'media' => ['sometimes', 'array'], + 'media.*.id' => $hosted ? ['required', 'string'] : ['sometimes', 'nullable', 'string'], + 'media.*.path' => $hosted ? ['required', 'string', 'max:500'] : ['sometimes', 'nullable', 'string', 'max:500'], + 'media.*.url' => $hosted + ? ['required', 'string', 'max:2048'] + : ['required', 'string', 'max:2048', 'url:http,https'], + 'media.*.type' => ['sometimes', 'nullable', 'string', 'max:32'], + 'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'], + 'media.*.size' => ['sometimes', 'nullable', 'integer'], + 'media.*.meta' => ['sometimes', 'nullable', 'array'], + 'media.*.source' => ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))], + 'media.*.source_meta' => ['sometimes', 'nullable', 'array'], + ]; + } +} diff --git a/tests/Feature/Api/PostMediaApiTest.php b/tests/Feature/Api/PostMediaApiTest.php index 4a58edd6..daa7c3eb 100644 --- a/tests/Feature/Api/PostMediaApiTest.php +++ b/tests/Feature/Api/PostMediaApiTest.php @@ -8,6 +8,7 @@ use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Models\Workspace; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; @@ -219,3 +220,229 @@ ->assertUnprocessable() ->assertJsonValidationErrors(['media']); }); + +it('downloads and hosts an external media url when creating a post', function () { + $this->socialAccount->update(['is_active' => true]); + + Http::fake([ + 'cdn.example.com/listing.jpg' => Http::response( + file_get_contents(__DIR__.'/../../fixtures/1x1.png'), + 200, + ['Content-Type' => 'image/png'], + ), + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => 'External media post', + 'media' => [['url' => 'https://cdn.example.com/listing.jpg']], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]) + ->assertCreated(); + + $media = Post::where('content', 'External media post')->firstOrFail()->media; + + expect($media)->toHaveCount(1) + ->and(data_get($media, '0.path'))->not->toBeNull() + ->and(data_get($media, '0.url'))->not->toContain('cdn.example.com'); + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1); +}); + +it('rejects creating a post when an external media url cannot be fetched', function () { + $this->socialAccount->update(['is_active' => true]); + + Http::fake(['cdn.example.com/missing.jpg' => Http::response(null, 404)]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => 'Broken media post', + 'media' => [['url' => 'https://cdn.example.com/missing.jpg']], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['media']); + + expect(Post::where('content', 'Broken media post')->exists())->toBeFalse(); + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0); +}); + +it('rolls back already-hosted media when another url in the batch fails', function () { + $this->socialAccount->update(['is_active' => true]); + + Http::fake([ + 'cdn.example.com/good.jpg' => Http::response( + file_get_contents(__DIR__.'/../../fixtures/1x1.png'), + 200, + ['Content-Type' => 'image/png'], + ), + 'cdn.example.com/missing.jpg' => Http::response(null, 404), + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => 'Partial media post', + 'media' => [ + ['url' => 'https://cdn.example.com/good.jpg'], + ['url' => 'https://cdn.example.com/missing.jpg'], + ], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['media']); + + expect(Post::where('content', 'Partial media post')->exists())->toBeFalse(); + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0); +}); + +it('rejects and rolls back when a media url connection fails (timeout/dns)', function () { + $this->socialAccount->update(['is_active' => true]); + + Http::fake([ + 'cdn.example.com/good.jpg' => Http::response( + file_get_contents(__DIR__.'/../../fixtures/1x1.png'), + 200, + ['Content-Type' => 'image/png'], + ), + 'cdn.example.com/timeout.jpg' => fn () => throw new ConnectionException('Connection timed out'), + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => 'Timeout media post', + 'media' => [ + ['url' => 'https://cdn.example.com/good.jpg'], + ['url' => 'https://cdn.example.com/timeout.jpg'], + ], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['media']); + + expect(Post::where('content', 'Timeout media post')->exists())->toBeFalse(); + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0); +}); + +it('rejects creating a post when an external media url is not a supported type', function () { + $this->socialAccount->update(['is_active' => true]); + + // Downloads fine (200) but the bytes are not a supported media type. + Http::fake(['cdn.example.com/notes.txt' => Http::response('just some text', 200)]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => 'Bad type post', + 'media' => [['url' => 'https://cdn.example.com/notes.txt']], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['media']); + + expect(Post::where('content', 'Bad type post')->exists())->toBeFalse(); + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0); +}); + +it('keeps an already-hosted item and a freshly-hosted url in order', function () { + $this->socialAccount->update(['is_active' => true]); + + Http::fake([ + 'cdn.example.com/external.jpg' => Http::response( + file_get_contents(__DIR__.'/../../fixtures/1x1.png'), + 200, + ['Content-Type' => 'image/png'], + ), + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => 'Mixed media post', + 'media' => [ + ['id' => 'hosted-1', 'path' => 'assets/already.jpg', 'url' => 'https://cdn.trypost.test/assets/already.jpg', 'type' => 'image'], + ['url' => 'https://cdn.example.com/external.jpg'], + ], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]) + ->assertCreated(); + + $media = Post::where('content', 'Mixed media post')->firstOrFail()->media; + + expect($media)->toHaveCount(2) + ->and(data_get($media, '0.path'))->toBe('assets/already.jpg') + ->and(data_get($media, '1.url'))->not->toContain('cdn.example.com') + ->and(data_get($media, '1.path'))->not->toBeNull(); + // Only the external URL is hosted; the passed-through item creates no new row. + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1); +}); + +it('passes already-hosted media through on create without downloading', function () { + $this->socialAccount->update(['is_active' => true]); + Http::preventStrayRequests(); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => 'Hosted media post', + 'media' => [[ + 'id' => 'media-1', + 'path' => 'assets/foo.jpg', + 'url' => 'https://cdn.trypost.test/assets/foo.jpg', + 'type' => 'image', + ]], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]) + ->assertCreated(); + + expect(data_get(Post::where('content', 'Hosted media post')->firstOrFail()->media, '0.path'))->toBe('assets/foo.jpg'); + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0); +}); + +it('downloads and hosts an external media url when updating a post', function () { + Http::fake([ + 'cdn.example.com/listing.jpg' => Http::response( + file_get_contents(__DIR__.'/../../fixtures/1x1.png'), + 200, + ['Content-Type' => 'image/png'], + ), + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $this->post), [ + 'status' => 'draft', + 'media' => [['url' => 'https://cdn.example.com/listing.jpg']], + ]) + ->assertOk(); + + $media = $this->post->fresh()->media; + + expect($media)->toHaveCount(1) + ->and(data_get($media, '0.path'))->not->toBeNull() + ->and(data_get($media, '0.url'))->not->toContain('cdn.example.com'); +}); + +it('rejects updating a post when an external media url cannot be fetched', function () { + Http::fake(['cdn.example.com/missing.jpg' => Http::response(null, 404)]); + + $original = $this->post->fresh()->media; + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $this->post), [ + 'status' => 'draft', + 'media' => [['url' => 'https://cdn.example.com/missing.jpg']], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['media']); + + expect($this->post->fresh()->media)->toBe($original); +}); diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index cb3ff6d6..1c7230e9 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -181,7 +181,57 @@ $this->postPlatform->refresh(); expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); - expect($this->postPlatform->error_message)->toBe('API Error'); + expect($this->postPlatform->error_message)->toBe('An unexpected error occurred while publishing. Please try again.'); +}); + +test('publish keeps the vetted user message from a publish exception', function () { + Event::fake(); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow(new LinkedInPublishException( + userMessage: 'LinkedIn rejected this post.', + category: ErrorCategory::ContentPolicy, + )); + + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); + expect($this->postPlatform->error_message)->toBe('LinkedIn rejected this post.'); +}); + +test('publish never leaks a raw internal error to the failure record (and the email)', function () { + Event::fake(); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow(new TypeError( + 'X::getMediaCategory(): Argument #1 ($mimeType) must be of type string, null given, called in /home/forge/app.trypost.it/releases/72198060/app/Services/Social/XPublisher.php on line 130' + )); + + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); + expect($this->postPlatform->error_message)->toBe('An unexpected error occurred while publishing. Please try again.') + ->and($this->postPlatform->error_message)->not->toContain('/home/forge') + ->and($this->postPlatform->error_message)->not->toContain('getMediaCategory'); +}); + +test('the job-failed hook also genericizes a raw internal error', function () { + Event::fake(); + + (new PublishToSocialPlatform($this->postPlatform))->failed(new TypeError( + 'boom in /home/forge/app.trypost.it/releases/72198060/app/Services/Social/XPublisher.php on line 130' + )); + + $this->postPlatform->refresh(); + expect($this->postPlatform->status)->toBe(PlatformStatus::Failed) + ->and($this->postPlatform->error_message)->toBe('An unexpected error occurred while publishing. Please try again.') + ->and($this->postPlatform->error_message)->not->toContain('/home/forge'); }); test('publish to social platform marks account as token expired on auth failure', function () { diff --git a/tests/Feature/Services/Social/LinkedInPublisherTest.php b/tests/Feature/Services/Social/LinkedInPublisherTest.php index 94b837cc..23025e4d 100644 --- a/tests/Feature/Services/Social/LinkedInPublisherTest.php +++ b/tests/Feature/Services/Social/LinkedInPublisherTest.php @@ -4,6 +4,7 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Exceptions\Social\LinkedInPublishException; use App\Exceptions\TokenExpiredException; use App\Models\Post; use App\Models\PostPlatform; @@ -789,7 +790,7 @@ protected function processingPollSeconds(): int }); expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(Exception::class, 'missing uploadUrl or document URN'); + ->toThrow(LinkedInPublishException::class, 'did not accept the document upload'); Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts')); }); diff --git a/tests/Feature/Services/Social/XPublisherTest.php b/tests/Feature/Services/Social/XPublisherTest.php index 434d161a..9847eaa5 100644 --- a/tests/Feature/Services/Social/XPublisherTest.php +++ b/tests/Feature/Services/Social/XPublisherTest.php @@ -4,12 +4,14 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Exceptions\Social\XPublishException; use App\Exceptions\TokenExpiredException; use App\Models\Post; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Services\Media\MediaOptimizer; use App\Services\Social\XPublisher; use Illuminate\Support\Facades\Http; @@ -252,6 +254,46 @@ Http::assertSent(fn ($request) => str_contains($request->url(), '/2/media/gif_media_555')); }); +test('x publisher recovers a missing mime type from the downloaded bytes', function () { + $this->post->update([ + 'media' => [ + ['url' => 'https://cdn.example.com/listing'], + ], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'x_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, '/media/upload')) { + return Http::response(['data' => ['id' => 'media_id_111']], 200); + } + + if (str_contains($url, '/2/tweets')) { + return Http::response(['data' => ['id' => '1212121212', 'text' => 'Hello from X!']], 200); + } + + return Http::response( + file_get_contents(__DIR__.'/../../../fixtures/1x1.png'), + 200, + ['Content-Type' => 'image/png'], + ); + }); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('1212121212'); + Http::assertSent(fn ($request) => str_contains($request->url(), '/media/upload')); +}); + test('x publisher uploads video via chunked upload', function () { $this->post->update([ 'media' => [ @@ -353,3 +395,16 @@ expect($appendCount)->toBeGreaterThan(1); }); + +test('x publisher fails cleanly when media cannot be downloaded', function () { + $this->post->update([ + 'media' => [ + ['url' => 'https://cdn.example.com/listing', 'mime_type' => 'image/jpeg'], + ], + ]); + + Http::fake(['cdn.example.com/listing' => Http::response(null, 404)]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(XPublishException::class, 'Could not fetch the media to upload to X'); +}); diff --git a/tests/Unit/Support/PostMediaRulesTest.php b/tests/Unit/Support/PostMediaRulesTest.php new file mode 100644 index 00000000..0b4c0d20 --- /dev/null +++ b/tests/Unit/Support/PostMediaRulesTest.php @@ -0,0 +1,36 @@ +toContain('required') + ->and($rules['media.*.path'])->toContain('required') + ->and($rules['media.*.url'])->not->toContain('url:http,https'); +}); + +test('api media rules accept a bare external url with nullable id/path', function () { + $rules = PostMediaRules::rules(hosted: false); + + expect($rules['media.*.id'])->toContain('nullable') + ->and($rules['media.*.path'])->toContain('nullable') + ->and($rules['media.*.url'])->toContain('url:http,https'); +}); + +test('both variants keep the shared item keys so validated() preserves them', function () { + foreach ([true, false] as $hosted) { + expect(PostMediaRules::rules(hosted: $hosted))->toHaveKeys([ + 'media.*.id', + 'media.*.path', + 'media.*.url', + 'media.*.type', + 'media.*.mime_type', + 'media.*.original_filename', + 'media.*.size', + 'media.*.meta', + ]); + } +});