From 45dd3f4bdead2a54ff723e4d389874c5afee5f16 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 31 Mar 2026 22:41:26 -0300 Subject: [PATCH] fix: file handle leaks, return types, Facebook reel/story binary upload, null checks, cleanup --- app/Services/Social/BlueskyPublisher.php | 2 +- app/Services/Social/FacebookPublisher.php | 49 +++++++++++----- app/Services/Social/InstagramPublisher.php | 8 ++- app/Services/Social/LinkedInPagePublisher.php | 58 ++++++++++--------- app/Services/Social/LinkedInPublisher.php | 58 ++++++++++--------- app/Services/Social/MastodonPublisher.php | 2 +- app/Services/Social/PinterestPublisher.php | 2 +- app/Services/Social/TikTokPublisher.php | 4 +- app/Services/Social/XPublisher.php | 50 ++++++++-------- app/Services/Social/YouTubePublisher.php | 4 +- .../Services/Social/FacebookPublisherTest.php | 2 + 11 files changed, 136 insertions(+), 103 deletions(-) diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 41895497..ef8e7f3f 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -312,7 +312,7 @@ public function refreshToken(SocialAccount $account): void throw new TokenExpiredException('Bluesky session expired'); } - private function handleApiError(Response $response): void + private function handleApiError(Response $response): never { throw BlueskyPublishException::fromApiResponse($response); } diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 4f476c01..f651e868 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -219,17 +219,23 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte $videoId = data_get($data, 'video_id'); // Upload the video file - $uploadResponse = Http::post("{$this->baseUrl}/{$videoId}", [ - 'upload_phase' => 'transfer', - 'video_file_chunk' => $media->url, - 'access_token' => $accessToken, - ]); + $tempFile = tempnam(sys_get_temp_dir(), 'fb_reel_'); + try { + Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->url); - if ($uploadResponse->failed()) { - Log::error('Facebook reel upload transfer failed', [ - 'body' => $uploadResponse->body(), - ]); - $this->handleApiError($uploadResponse); + $uploadResponse = Http::withToken($accessToken) + ->attach('video_file_chunk', fopen($tempFile, 'r'), 'video.mp4') + ->post("{$this->baseUrl}/{$videoId}", [ + 'upload_phase' => 'transfer', + 'access_token' => $accessToken, + ]); + + if ($uploadResponse->failed()) { + Log::error('Facebook reel upload transfer failed', ['body' => $uploadResponse->body()]); + $this->handleApiError($uploadResponse); + } + } finally { + @unlink($tempFile); } // Finish and publish the reel @@ -275,11 +281,24 @@ private function publishStory(string $pageId, string $accessToken, $media): arra $videoId = $response->json()['video_id']; // Transfer the video - Http::post("{$this->baseUrl}/{$videoId}", [ - 'upload_phase' => 'transfer', - 'video_file_chunk' => $media->url, - 'access_token' => $accessToken, - ]); + $tempFile = tempnam(sys_get_temp_dir(), 'fb_story_'); + try { + Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->url); + + $transferResponse = Http::withToken($accessToken) + ->attach('video_file_chunk', fopen($tempFile, 'r'), 'video.mp4') + ->post("{$this->baseUrl}/{$videoId}", [ + 'upload_phase' => 'transfer', + 'access_token' => $accessToken, + ]); + + if ($transferResponse->failed()) { + Log::error('Facebook video story transfer failed', ['body' => $transferResponse->body()]); + $this->handleApiError($transferResponse); + } + } finally { + @unlink($tempFile); + } // Finish the story $finishResponse = Http::post("{$this->baseUrl}/{$pageId}/video_stories", [ diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 96c375f4..0585a596 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -191,7 +191,13 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri continue; } - $childId = $containerResponse->json()['id']; + $childId = $containerResponse->json()['id'] ?? null; + + if (! $childId) { + Log::error('Instagram carousel item creation returned no ID', ['body' => $containerResponse->body()]); + + continue; + } // Wait for video processing if needed if ($isVideo) { diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index f2fc484b..08b73ae2 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -344,40 +344,42 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): $uploadedPartIds = []; $handle = fopen($tempFile, 'r'); - foreach ($uploadInstructions as $index => $instruction) { - $uploadUrl = data_get($instruction, 'uploadUrl'); - $firstByte = data_get($instruction, 'firstByte'); - $lastByte = data_get($instruction, 'lastByte'); + try { + foreach ($uploadInstructions as $index => $instruction) { + $uploadUrl = data_get($instruction, 'uploadUrl'); + $firstByte = data_get($instruction, 'firstByte'); + $lastByte = data_get($instruction, 'lastByte'); - $chunkLength = $lastByte - $firstByte + 1; - fseek($handle, $firstByte); - $chunkData = fread($handle, $chunkLength); + $chunkLength = $lastByte - $firstByte + 1; + fseek($handle, $firstByte); + $chunkData = fread($handle, $chunkLength); - $chunkResponse = Http::withToken($this->accessToken) - ->withHeaders([ - 'Content-Type' => 'application/octet-stream', - ]) - ->timeout(600) - ->withBody($chunkData, 'application/octet-stream') - ->put($uploadUrl); + $chunkResponse = Http::withToken($this->accessToken) + ->withHeaders([ + 'Content-Type' => 'application/octet-stream', + ]) + ->timeout(600) + ->withBody($chunkData, 'application/octet-stream') + ->put($uploadUrl); + + if ($chunkResponse->failed()) { + Log::error('LinkedIn Page video chunk upload failed', [ + 'index' => $index, + 'body' => $chunkResponse->body(), + ]); + $this->handleApiError($chunkResponse); + } + + $etag = $chunkResponse->header('etag'); + if ($etag) { + $uploadedPartIds[] = $etag; + } - if ($chunkResponse->failed()) { - Log::error('LinkedIn Page video chunk upload failed', [ - 'index' => $index, - 'body' => $chunkResponse->body(), - ]); - $this->handleApiError($chunkResponse); } - - $etag = $chunkResponse->header('etag'); - if ($etag) { - $uploadedPartIds[] = $etag; - } - + } finally { + fclose($handle); } - fclose($handle); - // Step 3: Finalize upload $finalizeResponse = $this->getHttpClient() ->post("{$this->baseUrl}/rest/videos?action=finalizeUpload", [ diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 5d9605e3..1a9a3791 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -325,40 +325,42 @@ private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): $uploadedPartIds = []; $handle = fopen($tempFile, 'r'); - foreach ($uploadInstructions as $index => $instruction) { - $uploadUrl = data_get($instruction, 'uploadUrl'); - $firstByte = data_get($instruction, 'firstByte'); - $lastByte = data_get($instruction, 'lastByte'); + try { + foreach ($uploadInstructions as $index => $instruction) { + $uploadUrl = data_get($instruction, 'uploadUrl'); + $firstByte = data_get($instruction, 'firstByte'); + $lastByte = data_get($instruction, 'lastByte'); - $chunkLength = $lastByte - $firstByte + 1; - fseek($handle, $firstByte); - $chunkData = fread($handle, $chunkLength); + $chunkLength = $lastByte - $firstByte + 1; + fseek($handle, $firstByte); + $chunkData = fread($handle, $chunkLength); - $chunkResponse = Http::withToken($this->accessToken) - ->withHeaders([ - 'Content-Type' => 'application/octet-stream', - ]) - ->timeout(600) - ->withBody($chunkData, 'application/octet-stream') - ->put($uploadUrl); + $chunkResponse = Http::withToken($this->accessToken) + ->withHeaders([ + 'Content-Type' => 'application/octet-stream', + ]) + ->timeout(600) + ->withBody($chunkData, 'application/octet-stream') + ->put($uploadUrl); + + if ($chunkResponse->failed()) { + Log::error('LinkedIn video chunk upload failed', [ + 'index' => $index, + 'body' => $chunkResponse->body(), + ]); + $this->handleApiError($chunkResponse); + } + + $etag = $chunkResponse->header('etag'); + if ($etag) { + $uploadedPartIds[] = $etag; + } - if ($chunkResponse->failed()) { - Log::error('LinkedIn video chunk upload failed', [ - 'index' => $index, - 'body' => $chunkResponse->body(), - ]); - $this->handleApiError($chunkResponse); } - - $etag = $chunkResponse->header('etag'); - if ($etag) { - $uploadedPartIds[] = $etag; - } - + } finally { + fclose($handle); } - fclose($handle); - // Step 3: Finalize upload $finalizeResponse = $this->getHttpClient() ->post("{$this->baseUrl}/rest/videos?action=finalizeUpload", [ diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php index fa962cd1..bc78fd3f 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -121,7 +121,7 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u } } - private function handleApiError(Response $response): void + private function handleApiError(Response $response): never { throw MastodonPublishException::fromApiResponse($response); } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index b67f2fcd..c7cd9d29 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -332,7 +332,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId, throw new \Exception("Pinterest media processing timeout after {$maxAttempts} attempts"); } - public function refreshToken(SocialAccount $account): void + private function refreshToken(SocialAccount $account): void { $response = Http::asForm() ->withBasicAuth( diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 0243eab5..db45766c 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -127,7 +127,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array } // Wait for processing and get final status - $finalStatus = $this->waitForPublishStatus($publishId); + $this->waitForPublishStatus($publishId); return [ 'id' => $publishId, @@ -182,7 +182,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar } // Wait for processing and get final status - $finalStatus = $this->waitForPublishStatus($publishId); + $this->waitForPublishStatus($publishId); return [ 'id' => $publishId, diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 6fef7b57..947f9e18 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -195,34 +195,36 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy $handle = fopen($tempFile, 'r'); $index = 0; - while (! feof($handle)) { - $chunk = fread($handle, $chunkSize); + try { + while (! feof($handle)) { + $chunk = fread($handle, $chunkSize); - if ($chunk === '' || $chunk === false) { - break; + if ($chunk === '' || $chunk === false) { + break; + } + + $appendResponse = Http::withToken($this->accessToken) + ->timeout(300) + ->attach('media', $chunk, 'chunk'.$index) + ->post("{$this->baseUrl}/2/media/upload/{$mediaId}/append", [ + 'segment_index' => $index, + ]); + + if ($appendResponse->failed()) { + Log::error('X chunked upload APPEND error', [ + 'status' => $appendResponse->status(), + 'body' => $appendResponse->body(), + 'segment' => $index, + ]); + $this->handleApiError($appendResponse); + } + + $index++; } - - $appendResponse = Http::withToken($this->accessToken) - ->timeout(300) - ->attach('media', $chunk, 'chunk'.$index) - ->post("{$this->baseUrl}/2/media/upload/{$mediaId}/append", [ - 'segment_index' => $index, - ]); - - if ($appendResponse->failed()) { - Log::error('X chunked upload APPEND error', [ - 'status' => $appendResponse->status(), - 'body' => $appendResponse->body(), - 'segment' => $index, - ]); - $this->handleApiError($appendResponse); - } - - $index++; + } finally { + fclose($handle); } - fclose($handle); - // FINALIZE - Use the new v2 endpoint $finalizeResponse = Http::withToken($this->accessToken) ->timeout(60) diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 5d35230a..c373bfd3 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -183,8 +183,8 @@ private function buildTitle(string $content): string $shortsTag = ' #Shorts'; $availableLength = $maxLength - strlen($shortsTag); - $title = strtok($content, "\n"); - $title = strtok($title, '.'); + $firstLine = explode("\n", $content)[0]; + $title = explode('.', $firstLine)[0]; if (strlen($title) > $availableLength) { $title = substr($title, 0, $availableLength - 3).'...'; diff --git a/tests/Unit/Services/Social/FacebookPublisherTest.php b/tests/Unit/Services/Social/FacebookPublisherTest.php index 90dd1a30..0ac0291e 100644 --- a/tests/Unit/Services/Social/FacebookPublisherTest.php +++ b/tests/Unit/Services/Social/FacebookPublisherTest.php @@ -174,6 +174,7 @@ ->push(['video_id' => 'reel_video_123'], 200) ->push(['id' => 'reel_123', 'success' => true], 200), '*/reel_video_123' => Http::response(['success' => true], 200), + '*' => Http::response('', 200), ]); $result = $this->publisher->publish($this->postPlatform); @@ -230,6 +231,7 @@ ->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), ]); $result = $this->publisher->publish($this->postPlatform);