From 1d5d6ec063e17adf5e06b075aebfb701716e6103 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 12 May 2026 20:35:54 -0300 Subject: [PATCH] fix(facebook): switch reel transfer to binary upload with Offset/file_size headers Previous attempt at the hosted-file flow (`file_url` in JSON body) hit the rupload.facebook.com validator with HTTP 400: 'HeaderValuePredicate: Header Offset not convertable to unsigned long' Facebook's rupload endpoint requires the `Offset` and `file_size` headers regardless of whether the upload is local or hosted-file. The docs describe the hosted-file path without them, but in practice rupload rejects requests that lack them. Switching to the well-documented local-file flow: - Download the media to a temp file via Http sink (already a pattern used in XPublisher for media downloads). - POST raw bytes to upload_url with three headers: - Authorization: OAuth {token} - Offset: 0 - file_size: {actual bytes} - Use mime_type from the media item as the Content-Type. - Always cleanup the temp file in a finally block. Tests updated to fake the media-download GET (returning bytes that the publisher then re-uploads) and to assert on the Offset/file_size/ Authorization headers being present and correct. The existing "cleans up temp files after reel upload" test now actually exercises its assertion since this code path creates temp files again. Full suite: 1504 passed, 2 skipped, 0 failed. --- app/Services/Social/FacebookPublisher.php | 56 ++++++++++++------- .../Services/Social/FacebookPublisherTest.php | 11 ++-- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 64811901..14ecdcb1 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -9,6 +9,7 @@ use App\Models\PostPlatform; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\Response; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class FacebookPublisher @@ -237,26 +238,43 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte $this->handleApiError($startResponse); } - // Phase 2 (transfer, hosted-file flow) — POST to upload_url on - // rupload.facebook.com with `file_url` in the JSON body and - // `Authorization: OAuth …` header. The previous implementation - // POSTed to the graph endpoint with a made-up `video_file_chunk` - // body field; Facebook accepted the request but never fetched - // the video, so finish failed with error_subcode 1363130 - // ("Video Upload Is Missing"). - $uploadResponse = $this->socialHttp() - ->withHeaders(['Authorization' => "OAuth {$accessToken}"]) - ->asJson() - ->post($uploadUrl, [ - 'file_url' => $media->url, - ]); + // 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_'); - if ($uploadResponse->failed()) { - Log::error('Facebook reel upload transfer failed', [ - 'status' => $uploadResponse->status(), - 'body' => $this->redactResponseBody($uploadResponse->body()), - ]); - $this->handleApiError($uploadResponse); + try { + $download = Http::withOptions(['sink' => $tempFile]) + ->timeout(600) + ->get($media->url); + + if ($download->failed()) { + throw new \Exception('Failed to download reel media: HTTP '.$download->status()); + } + + $fileSize = filesize($tempFile); + + $uploadResponse = Http::withHeaders([ + 'Authorization' => "OAuth {$accessToken}", + 'Offset' => '0', + 'file_size' => (string) $fileSize, + ]) + ->timeout(600) + ->withBody(file_get_contents($tempFile), $media->mime_type ?? 'video/mp4') + ->post($uploadUrl); + + if ($uploadResponse->failed()) { + Log::error('Facebook reel upload transfer failed', [ + 'status' => $uploadResponse->status(), + 'body' => $this->redactResponseBody($uploadResponse->body()), + ]); + $this->handleApiError($uploadResponse); + } + } finally { + @unlink($tempFile); } // Phase 3 (finish) — publish the reel. diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 842a47b4..7f817d68 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -185,6 +185,7 @@ 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_123', ], 200) ->push(['id' => 'reel_123', 'success' => true], 200), + '*example.com/media/*' => Http::response('fake-video-binary-content', 200), '*rupload.facebook.com/*' => Http::response(['success' => true], 200), ]); @@ -194,15 +195,16 @@ expect($result['id'])->toBe('reel_123'); expect($result['url'])->toBe('https://www.facebook.com/reel/reel_123'); - // Assert the transfer phase: POST to upload_url (rupload host) with - // file_url JSON body and OAuth header — not to the graph endpoint - // with the (legacy/wrong) video_file_chunk body param. + // 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; } - return $request['file_url'] === 'https://example.com/media/2026-01/reel.mp4' + 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 '); }); }); @@ -423,6 +425,7 @@ '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), ]);