diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 5bf037fe..add9fdc8 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -4,10 +4,12 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\BlueskyPublishException; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Media\MediaOptimizer; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -124,12 +126,13 @@ private function uploadBlob(SocialAccount $account, string $service, string $url return null; } - // Bluesky has 1MB limit for images - if (str_starts_with($mimeType, 'image/') && $fileSize > 1000000) { - Log::warning('Bluesky image exceeds 1MB limit', [ - 'size' => $fileSize, - 'url' => $url, - ]); + // Optimize images for Bluesky's 1MB limit + if (str_starts_with($mimeType, 'image/') && ! str_starts_with($mimeType, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Bluesky); + @unlink($tempFile); + $tempFile = $optimizedPath; + $mimeType = 'image/jpeg'; } $stream = fopen($tempFile, 'r'); diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index fc96be37..21c5575c 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -5,10 +5,12 @@ namespace App\Services\Social; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\LinkedInPublishException; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Media\MediaOptimizer; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -275,24 +277,40 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string Log::info('LinkedIn Page image init success', ['imageUrn' => $imageUrn]); - // Step 2: Upload binary data - $imageContent = file_get_contents($mediaItem->url); + // Step 2: Download and optimize image + $tempFile = tempnam(sys_get_temp_dir(), 'lip_image_'); - $uploadResponse = Http::withToken($this->accessToken) - ->withHeaders([ - 'Content-Type' => 'application/octet-stream', - ]) - ->withBody($imageContent, 'application/octet-stream') - ->put($uploadUrl); + try { + Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); - if ($uploadResponse->failed()) { - Log::error('LinkedIn Page image upload failed', ['body' => $uploadResponse->body()]); - $this->handleApiError($uploadResponse, 'Failed to upload LinkedIn Page image'); + $detectedMime = mime_content_type($tempFile) ?: ''; + if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::LinkedInPage); + @unlink($tempFile); + $tempFile = $optimizedPath; + } + + $imageContent = file_get_contents($tempFile); + + $uploadResponse = Http::withToken($this->accessToken) + ->withHeaders([ + 'Content-Type' => 'application/octet-stream', + ]) + ->withBody($imageContent, 'application/octet-stream') + ->put($uploadUrl); + + if ($uploadResponse->failed()) { + Log::error('LinkedIn Page image upload failed', ['body' => $uploadResponse->body()]); + $this->handleApiError($uploadResponse, 'Failed to upload LinkedIn Page image'); + } + + Log::info('LinkedIn Page image upload success', ['imageUrn' => $imageUrn]); + + return $imageUrn; + } finally { + @unlink($tempFile); } - - Log::info('LinkedIn Page image upload success', ['imageUrn' => $imageUrn]); - - return $imageUrn; } private function uploadVideo($mediaItem, string $ownerUrn): ?string diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 9e19cd35..23dd3d5d 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -5,10 +5,12 @@ namespace App\Services\Social; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\LinkedInPublishException; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Media\MediaOptimizer; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -256,24 +258,40 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string Log::info('LinkedIn image init success', ['imageUrn' => $imageUrn]); - // Step 2: Upload binary data - $imageContent = file_get_contents($mediaItem->url); + // Step 2: Download and optimize image + $tempFile = tempnam(sys_get_temp_dir(), 'li_image_'); - $uploadResponse = Http::withToken($this->accessToken) - ->withHeaders([ - 'Content-Type' => 'application/octet-stream', - ]) - ->withBody($imageContent, 'application/octet-stream') - ->put($uploadUrl); + try { + Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); - if ($uploadResponse->failed()) { - Log::error('LinkedIn image upload failed', ['body' => $uploadResponse->body()]); - $this->handleApiError($uploadResponse, 'Failed to upload LinkedIn image'); + $detectedMime = mime_content_type($tempFile) ?: ''; + if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::LinkedIn); + @unlink($tempFile); + $tempFile = $optimizedPath; + } + + $imageContent = file_get_contents($tempFile); + + $uploadResponse = Http::withToken($this->accessToken) + ->withHeaders([ + 'Content-Type' => 'application/octet-stream', + ]) + ->withBody($imageContent, 'application/octet-stream') + ->put($uploadUrl); + + if ($uploadResponse->failed()) { + Log::error('LinkedIn image upload failed', ['body' => $uploadResponse->body()]); + $this->handleApiError($uploadResponse, 'Failed to upload LinkedIn image'); + } + + Log::info('LinkedIn image upload success', ['imageUrn' => $imageUrn]); + + return $imageUrn; + } finally { + @unlink($tempFile); } - - Log::info('LinkedIn image upload success', ['imageUrn' => $imageUrn]); - - return $imageUrn; } private function uploadVideo($mediaItem, string $ownerUrn): ?string diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php index 5937d789..179b7c06 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -4,9 +4,11 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\MastodonPublishException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Media\MediaOptimizer; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -82,6 +84,15 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u return null; } + // Optimize images (skip GIFs) + $detectedMime = mime_content_type($tempFile) ?: ''; + if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Mastodon); + @unlink($tempFile); + $tempFile = $optimizedPath; + } + $name = $filename ?? basename(parse_url($url, PHP_URL_PATH)); if (empty($name)) { $name = 'media'; diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index d23ff2c2..1f7734f8 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -5,9 +5,11 @@ namespace App\Services\Social; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\PinterestPublishException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Media\MediaOptimizer; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -54,11 +56,31 @@ private function publishImagePin(PostPlatform $postPlatform): array 'image_url' => $media->url, ]); + // Download and optimize image + $tempFile = tempnam(sys_get_temp_dir(), 'pin_image_'); + + try { + Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->url); + + $detectedMime = mime_content_type($tempFile) ?: ''; + if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Pinterest); + @unlink($tempFile); + $tempFile = $optimizedPath; + } + + $imageBase64 = base64_encode(file_get_contents($tempFile)); + } finally { + @unlink($tempFile); + } + $payload = [ 'board_id' => $boardId, 'media_source' => [ - 'source_type' => 'image_url', - 'url' => $media->url, + 'source_type' => 'image_base64', + 'content_type' => 'image/jpeg', + 'data' => $imageBase64, ], ]; diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index f8a487fb..c4c71903 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -4,10 +4,12 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\XPublishException; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Media\MediaOptimizer; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -111,6 +113,15 @@ private function uploadMedia($mediaItem): ?array try { Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); + // Optimize images (skip GIFs — they need special handling) + if (str_starts_with($mimeType, 'image/') && ! str_starts_with($mimeType, 'image/gif')) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::X); + @unlink($tempFile); + $tempFile = $optimizedPath; + $mimeType = 'image/jpeg'; + } + $fileSize = filesize($tempFile); $mediaCategory = $this->getMediaCategory($mimeType, $fileSize); diff --git a/tests/Unit/Services/Social/PinterestPublisherTest.php b/tests/Unit/Services/Social/PinterestPublisherTest.php index 566f59c3..bbec012e 100644 --- a/tests/Unit/Services/Social/PinterestPublisherTest.php +++ b/tests/Unit/Services/Social/PinterestPublisherTest.php @@ -10,6 +10,7 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Services\Media\MediaOptimizer; use App\Services\Social\PinterestPublisher; use Illuminate\Support\Facades\Http; @@ -42,6 +43,17 @@ ]); $this->publisher = new PinterestPublisher; + + // Mock MediaOptimizer to return the same temp file path + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) { + // Copy to a new temp file to simulate optimization + $optimized = tempnam(sys_get_temp_dir(), 'pin_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); }); test('pinterest publisher can publish image pin', function () { @@ -59,6 +71,7 @@ '*/v5/pins' => Http::response([ 'id' => 'pin_123456', ], 200), + '*' => Http::response('fake-image-content', 200), ]); $result = $this->publisher->publish($this->postPlatform); @@ -113,12 +126,14 @@ '*/v5/pins' => Http::response([ 'id' => 'pin_123456', ], 200), + '*' => Http::response('fake-image-content', 200), ]); $this->publisher->publish($this->postPlatform); Http::assertSent(function ($request) { - return $request['board_id'] === 'board_123'; // from account meta + return str_contains($request->url(), '/v5/pins') + && $request['board_id'] === 'board_123'; // from account meta }); }); @@ -207,6 +222,7 @@ 'code' => 400, 'message' => 'Invalid request', ], 400), + '*' => Http::response('fake-image-content', 200), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) @@ -229,6 +245,7 @@ 'code' => 1, 'message' => 'Invalid access token', ], 401), + '*' => Http::response('fake-image-content', 200), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) @@ -257,6 +274,7 @@ '*/v5/pins' => Http::response([ 'id' => 'pin_123456', ], 200), + '*' => Http::response('fake-image-content', 200), ]); $this->publisher->publish($this->postPlatform); @@ -292,12 +310,14 @@ '*/v5/pins' => Http::response([ 'id' => 'pin_123456', ], 200), + '*' => Http::response('fake-image-content', 200), ]); $this->publisher->publish($this->postPlatform); Http::assertSent(function ($request) { - return $request['title'] === 'My Pin Title' + return str_contains($request->url(), '/v5/pins') + && $request['title'] === 'My Pin Title' && $request['link'] === 'https://example.com/my-page'; }); });