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.
This commit is contained in:
Paulo Castellano 2026-05-12 20:35:54 -03:00
parent c379c4c14f
commit 1d5d6ec063
2 changed files with 44 additions and 23 deletions

View file

@ -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.

View file

@ -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),
]);