diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 64a7e1a27..028694017 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Dto\MediaItem; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; @@ -53,8 +54,8 @@ public function publish(PostPlatform $postPlatform): array $aspectRatio = data_get($postPlatform->meta, 'aspect_ratio'); return match ($contentType) { - ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $content, $media->first()), - ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $media->first()), + ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $content, $this->requireVideo($media->first(), 'Reels')), + ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $this->requireVideo($media->first(), 'Stories')), ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media, $aspectRatio), default => throw new FacebookPublishException( userMessage: "Unsupported Facebook content type: {$contentType?->value}", @@ -265,10 +266,61 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $ ]; } - private function publishReel(string $pageId, string $accessToken, ?string $content, $media): array + private function publishReel(string $pageId, string $accessToken, ?string $content, MediaItem $media): array { - // Phase 1 (start) — graph endpoint returns video_id + upload_url. - $startResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [ + $finishPayload = ['video_state' => 'PUBLISHED']; + + if ($content !== null && $content !== '') { + $finishPayload['description'] = $content; + } + + [$videoId, $finishData] = $this->publishResumableVideo($pageId, $accessToken, 'video_reels', $media, $finishPayload); + $reelId = data_get($finishData, 'id', $videoId); + + return [ + 'id' => $reelId, + 'url' => "https://www.facebook.com/reel/{$reelId}", + ]; + } + + private function publishStory(string $pageId, string $accessToken, MediaItem $media): array + { + [$videoId, $finishData] = $this->publishResumableVideo($pageId, $accessToken, 'video_stories', $media); + $storyId = data_get($finishData, 'post_id', $videoId); + + return [ + 'id' => $storyId, + 'url' => "https://www.facebook.com/stories/{$pageId}/{$storyId}", + ]; + } + + private function requireVideo(?MediaItem $media, string $format): MediaItem + { + if ($media === null || ! $media->isVideo()) { + throw new FacebookPublishException( + userMessage: "Facebook {$format} require a video file.", + category: ErrorCategory::MediaFormat, + ); + } + + return $media; + } + + /** + * Meta's resumable video flow shared by Reels and Stories: `start` on the + * Graph edge hands back a rupload `upload_url`, the bytes go there, and + * `finish` on the same edge publishes. Transferring through the Graph edge + * instead of the `upload_url` leaves the session empty and `finish` fails + * with error 6000. + * + * @param array $finishPayload + * @return array{0: string, 1: array} + */ + private function publishResumableVideo(string $pageId, string $accessToken, string $edge, MediaItem $media, array $finishPayload = []): array + { + $endpoint = "{$this->baseUrl}/{$pageId}/{$edge}"; + + $startResponse = $this->facebookHttp()->post($endpoint, [ 'upload_phase' => 'start', 'access_token' => $accessToken, ]); @@ -281,22 +333,42 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte $videoId = data_get($startData, 'video_id'); $uploadUrl = data_get($startData, 'upload_url'); - if (! $videoId || ! $uploadUrl) { + if (! filled($videoId) || ! is_string($uploadUrl) || ! filled($uploadUrl)) { throw new FacebookPublishException( - userMessage: 'Facebook did not return upload_url for reel start.', + userMessage: 'Facebook did not start the video upload. Please try again.', category: ErrorCategory::ServerError, - platformErrorCode: null, rawResponse: $startResponse->body(), ); } - // Phase 2 (transfer, local-file flow) — download our hosted - // media then POST raw bytes to upload_url with the Offset and - // file_size headers Facebook requires (the docs describe a - // hosted-file shortcut with `file_url` in the body, but rupload - // rejects it with "Header Offset not convertable to unsigned - // long" — the headers are required either way). - $tempFile = tempnam(sys_get_temp_dir(), 'fb_reel_'); + $this->uploadVideoToRupload($uploadUrl, $accessToken, $media); + + $finishResponse = $this->facebookHttp()->post($endpoint, [ + 'upload_phase' => 'finish', + 'video_id' => $videoId, + 'access_token' => $accessToken, + ...$finishPayload, + ]); + + if ($finishResponse->failed()) { + $this->handleApiError($finishResponse); + } + + return [(string) $videoId, $finishResponse->json() ?? []]; + } + + private function uploadVideoToRupload(string $uploadUrl, string $accessToken, MediaItem $media): void + { + $this->assertRuploadUrl($uploadUrl); + + $tempFile = tempnam(sys_get_temp_dir(), 'fb_rupload_'); + + if ($tempFile === false) { + throw new FacebookPublishException( + userMessage: 'Could not prepare the Facebook video for upload.', + category: ErrorCategory::ServerError, + ); + } try { $download = Http::withOptions(['sink' => $tempFile]) @@ -305,16 +377,30 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte if ($download->failed()) { throw new FacebookPublishException( - userMessage: 'Could not download media for Facebook reel.', + userMessage: 'Could not download media for Facebook.', category: ErrorCategory::ServerError, platformErrorCode: (string) $download->status(), - rawResponse: null, ); } $fileSize = filesize($tempFile); + + if ($fileSize === false || $fileSize < 1) { + throw new FacebookPublishException( + userMessage: 'The downloaded Facebook video is empty.', + category: ErrorCategory::MediaFormat, + ); + } + $stream = fopen($tempFile, 'rb'); + if ($stream === false) { + throw new FacebookPublishException( + userMessage: 'Could not prepare the Facebook video for upload.', + category: ErrorCategory::ServerError, + ); + } + try { $uploadResponse = Http::withHeaders([ 'Authorization' => "OAuth {$accessToken}", @@ -331,95 +417,40 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte } if ($uploadResponse->failed()) { + Log::error('Facebook rupload transfer failed', [ + 'body' => $this->redactResponseBody($uploadResponse->body()), + ]); $this->handleApiError($uploadResponse); } + + if (data_get($uploadResponse->json(), 'success') !== true) { + throw new FacebookPublishException( + userMessage: 'Facebook did not accept the video upload. Please try again.', + category: ErrorCategory::ServerError, + rawResponse: $uploadResponse->body(), + ); + } } finally { - if (! unlink($tempFile)) { - Log::warning('Facebook reel temp file cleanup failed', ['path' => $tempFile]); + if (file_exists($tempFile) && ! unlink($tempFile)) { + Log::warning('Facebook rupload temp file cleanup failed', ['path' => $tempFile]); } } - - // Phase 3 (finish) — publish the reel. - $finishPayload = [ - 'upload_phase' => 'finish', - 'video_id' => $videoId, - 'video_state' => 'PUBLISHED', - 'access_token' => $accessToken, - ]; - - if ($content !== null && $content !== '') { - $finishPayload['description'] = $content; - } - - $finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", $finishPayload); - - if ($finishResponse->failed()) { - $this->handleApiError($finishResponse); - } - - $finishData = $finishResponse->json(); - $reelId = $finishData['id'] ?? $videoId; - - return [ - 'id' => $reelId, - 'url' => "https://www.facebook.com/reel/{$reelId}", - ]; } - private function publishStory(string $pageId, string $accessToken, $media): array + private function assertRuploadUrl(string $uploadUrl): void { - if (! $media->isVideo()) { - throw new FacebookPublishException( - userMessage: 'Facebook Stories require a video file.', - category: ErrorCategory::MediaFormat, - ); - } - - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ - 'upload_phase' => 'start', - 'access_token' => $accessToken, - ]); + $parts = parse_url($uploadUrl); + $scheme = data_get($parts, 'scheme'); + $host = data_get($parts, 'host'); + $allowedHost = config('trypost.platforms.facebook.rupload_host'); - if ($response->failed()) { - $this->handleApiError($response); - } - - $videoId = $response->json()['video_id'] ?? null; - - if (! $videoId) { + if ($scheme !== 'https' || $host !== $allowedHost) { throw new FacebookPublishException( - userMessage: 'Facebook did not accept the story video. Please try again.', + userMessage: 'Facebook returned an invalid upload URL.', category: ErrorCategory::ServerError, + rawResponse: $uploadUrl, ); } - - $transferResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$videoId}", [ - 'upload_phase' => 'transfer', - 'video_file_chunk' => $media->url, - 'access_token' => $accessToken, - ]); - - if ($transferResponse->failed()) { - Log::error('Facebook video story transfer failed', ['body' => $this->redactResponseBody($transferResponse->body())]); - $this->handleApiError($transferResponse); - } - - $finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ - 'upload_phase' => 'finish', - 'video_id' => $videoId, - 'access_token' => $accessToken, - ]); - - if ($finishResponse->failed()) { - $this->handleApiError($finishResponse); - } - - $storyId = $finishResponse->json()['post_id'] ?? $videoId; - - return [ - 'id' => $storyId, - 'url' => "https://www.facebook.com/stories/{$pageId}/{$storyId}", - ]; } private function handleApiError(Response $response): never diff --git a/config/trypost.php b/config/trypost.php index aed84cd6b..a03a69ff5 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -201,6 +201,7 @@ 'facebook' => [ 'enabled' => env('FACEBOOK_ENABLED', true), 'graph_api' => env('FACEBOOK_GRAPH_API', 'https://graph.facebook.com/v25.0'), + 'rupload_host' => env('FACEBOOK_RUPLOAD_HOST', 'rupload.facebook.com'), ], 'instagram' => [ 'enabled' => env('INSTAGRAM_ENABLED', true), diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 1a1ab1ba2..52230723b 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -25,6 +25,27 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string return (string) $image->encodeUsingMediaType('image/jpeg', quality: 80); } +/** + * @return array> + */ +function facebookVideoMedia(string $filename = 'video.mp4'): array +{ + return [ + [ + 'id' => "test-media-{$filename}", + 'path' => "media/2026-01/{$filename}", + 'url' => "https://example.com/media/2026-01/{$filename}", + 'mime_type' => 'video/mp4', + 'original_filename' => $filename, + ], + ]; +} + +dataset('facebook resumable video formats', [ + 'reel' => [ContentType::FacebookReel, 'video_reels'], + 'story' => [ContentType::FacebookStory, 'video_stories'], +]); + beforeEach(function () { $this->user = User::factory()->create(); $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); @@ -235,9 +256,6 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string expect($result['id'])->toBe('reel_123'); expect($result['url'])->toBe('https://www.facebook.com/reel/reel_123'); - // Assert the transfer phase: POST raw bytes to upload_url (rupload - // host) with OAuth header and the required Offset + file_size - // headers Facebook's rupload validator demands. Http::assertSent(function ($request) { if (! str_contains($request->url(), 'rupload.facebook.com')) { return false; @@ -249,117 +267,179 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string }); }); -test('facebook publisher fails reel publish when start does not return upload_url', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); +test('facebook publisher rejects resumable video formats without a video', function (ContentType $contentType) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => []]); + Http::fake(); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'require a video file.'); + + Http::assertNothingSent(); +})->with('facebook resumable video formats'); + +test('facebook publisher rejects an image for resumable video formats', function (ContentType $contentType) { + $this->postPlatform->update(['content_type' => $contentType]); $this->post->update([ 'media' => [ [ - 'id' => 'test-media-reel', - 'path' => 'media/2026-01/reel.mp4', - 'url' => 'https://example.com/media/2026-01/reel.mp4', - 'mime_type' => 'video/mp4', - 'original_filename' => 'reel.mp4', + 'id' => 'test-media-image', + 'path' => 'media/2026-01/image.jpg', + 'url' => 'https://example.com/media/2026-01/image.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'image.jpg', ], ], ]); - // Missing upload_url in the start response — should not silently - // proceed to a broken transfer (which is what the old code did). + Http::fake(); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'require a video file.'); + + Http::assertNothingSent(); +})->with('facebook resumable video formats'); + +test('facebook publisher fails when start does not return upload_url', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + Http::fake([ - '*/page_123/video_reels' => Http::response([ - 'video_id' => 'reel_video_123', - ], 200), + "*/page_123/{$edge}" => Http::response(['video_id' => 'video_123'], 200), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow( - FacebookPublishException::class, - 'Facebook did not return upload_url for reel start.' - ); -}); + ->toThrow(FacebookPublishException::class, 'Facebook did not start the video upload. Please try again.'); -test('facebook publisher fails reel publish with typed exception when media download fails', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); + Http::assertSentCount(1); +})->with('facebook resumable video formats'); - $this->post->update([ - 'media' => [ - [ - 'id' => 'test-media-reel', - 'path' => 'media/2026-01/reel.mp4', - 'url' => 'https://example.com/media/2026-01/reel.mp4', - 'mime_type' => 'video/mp4', - 'original_filename' => 'reel.mp4', - ], - ], - ]); +test('facebook publisher fails with typed exception when media download fails', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); - // start succeeds, but the media URL returns 404 — should surface as - // a typed FacebookPublishException (ServerError) instead of leaking - // a generic Exception that would land in the 'unknown' bucket. Http::fake([ - '*/page_123/video_reels' => Http::response([ - 'video_id' => 'reel_video_123', - 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_123', + "*/page_123/{$edge}" => Http::response([ + 'video_id' => 'video_123', + 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/video_123', ], 200), '*example.com/media/*' => Http::response('', 404), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow( - FacebookPublishException::class, - 'Could not download media for Facebook reel.' - ); -}); + ->toThrow(FacebookPublishException::class, 'Could not download media for Facebook.'); -test('facebook publisher rejects image story', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'rupload.facebook.com')); +})->with('facebook resumable video formats'); - $this->post->update([ - 'media' => [ - [ - 'id' => 'test-media-story', - 'path' => 'media/2026-01/story.jpg', - 'url' => 'https://example.com/media/2026-01/story.jpg', - 'mime_type' => 'image/jpeg', - 'original_filename' => 'story.jpg', - ], - ], +test('facebook publisher fails when downloaded video is empty', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + + Http::fake([ + "*/page_123/{$edge}" => Http::response([ + 'video_id' => 'video_123', + 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/video_123', + ], 200), + '*example.com/media/*' => Http::response('', 200), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(FacebookPublishException::class, 'Facebook Stories require a video file.'); -}); + ->toThrow(FacebookPublishException::class, 'The downloaded Facebook video is empty.'); -test('facebook publisher can publish video story', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'rupload.facebook.com')); +})->with('facebook resumable video formats'); - $this->post->update([ +test('facebook publisher rejects an upload_url outside the rupload host', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); - 'media' => [ - [ - 'id' => 'test-media-video-story', - 'path' => 'media/2026-01/story.mp4', - 'url' => 'https://example.com/media/2026-01/story.mp4', - 'mime_type' => 'video/mp4', - 'original_filename' => 'story.mp4', - ], - ], + Http::fake([ + "*/page_123/{$edge}" => Http::response([ + 'video_id' => 'video_123', + 'upload_url' => 'https://evil.example/steal-token', + ], 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook returned an invalid upload URL.'); + + Http::assertSentCount(1); +})->with('facebook resumable video formats'); + +test('facebook publisher does not finish when rupload does not confirm success', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + Http::fake([ + "*/page_123/{$edge}" => Http::response([ + 'video_id' => 'video_123', + 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/video_123', + ], 200), + '*example.com/media/*' => Http::response('fake-video-binary-content', 200), + '*rupload.facebook.com/*' => Http::response(['success' => false], 200), ]); + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook did not accept the video upload. Please try again.'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), "/page_123/{$edge}") + && $request['upload_phase'] === 'finish'); +})->with('facebook resumable video formats'); + +test('facebook publisher cleans up temp files after resumable video upload', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + + Http::fake([ + "*/page_123/{$edge}" => Http::sequence() + ->push([ + 'video_id' => 'video_cleanup_123', + 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/video_cleanup_123', + ], 200) + ->push(['success' => true, 'id' => 'reel_cleanup_456', 'post_id' => 'story_cleanup_456'], 200), + '*example.com/media/*' => Http::response('fake-video', 200), + '*rupload.facebook.com/*' => Http::response(['success' => true], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + expect(glob(sys_get_temp_dir().'/fb_rupload_*') ?: [])->toBeEmpty(); +})->with('facebook resumable video formats'); + +test('facebook publisher can publish video story', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookVideoMedia('story.mp4')]); + Http::fake([ '*/page_123/video_stories' => Http::sequence() - ->push(['video_id' => 'story_video_123'], 200) - ->push(['post_id' => 'video_story_post_123'], 200), - '*/story_video_123' => Http::response(['success' => true], 200), - '*' => Http::response('', 200), + ->push([ + 'video_id' => 'story_video_123', + 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/story_video_123', + ], 200) + ->push(['success' => true, 'post_id' => 'video_story_post_123'], 200), + '*example.com/media/*' => Http::response('fake-video-binary-content', 200), + '*rupload.facebook.com/*' => Http::response(['success' => true], 200), ]); $result = $this->publisher->publish($this->postPlatform); - expect($result)->toHaveKey('id'); expect($result['id'])->toBe('video_story_post_123'); + expect($result['url'])->toBe('https://www.facebook.com/stories/page_123/video_story_post_123'); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'rupload.facebook.com')) { + return false; + } + + return ($request->header('Offset')[0] ?? null) === '0' + && ($request->header('file_size')[0] ?? null) === (string) strlen('fake-video-binary-content') + && str_starts_with($request->header('Authorization')[0] ?? '', 'OAuth '); + }); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'graph.facebook.com') + && str_contains($request->url(), '/story_video_123')); }); test('facebook publisher throws exception on api error', function () { @@ -463,43 +543,6 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string ->toThrow(Exception::class, 'Facebook text posts require content'); }); -test('facebook publisher cleans up temp files after reel upload', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); - - $this->post->update([ - - 'media' => [ - [ - 'id' => 'test-media-reel', - 'path' => 'media/2026-01/reel.mp4', - 'url' => 'https://example.com/media/2026-01/reel.mp4', - 'mime_type' => 'video/mp4', - 'original_filename' => 'reel.mp4', - ], - ], - - ]); - - Http::fake([ - '*/page_123/video_reels' => Http::sequence() - ->push([ - 'video_id' => 'reel_video_cleanup_123', - 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_cleanup_123', - ], 200) - ->push(['id' => 'reel_cleanup_456', 'success' => true], 200), - '*example.com/media/*' => Http::response('fake-video', 200), - '*rupload.facebook.com/*' => Http::response(['success' => true], 200), - ]); - - $this->publisher->publish($this->postPlatform); - - // Assert no leftover fb_reel_ temp files exist - $tempDir = sys_get_temp_dir(); - $leftoverFiles = glob("{$tempDir}/fb_reel_*") ?: []; - - expect($leftoverFiles)->toBeEmpty(); -}); - test('facebook publisher can publish single image with null content', function () { $this->post->update([ 'content' => null,