diff --git a/app/Http/Controllers/Api/PostController.php b/app/Http/Controllers/Api/PostController.php index 51940ced..85f20cf5 100644 --- a/app/Http/Controllers/Api/PostController.php +++ b/app/Http/Controllers/Api/PostController.php @@ -8,6 +8,8 @@ use App\Actions\Post\DeletePost; use App\Actions\Post\UpdatePost; use App\Enums\Post\Action as PostAction; +use App\Http\Requests\Api\Post\StorePostRequest; +use App\Http\Requests\Api\Post\UpdatePostRequest; use App\Http\Resources\Api\PostResource; use App\Models\Post; use Illuminate\Http\JsonResponse; @@ -38,21 +40,12 @@ public function show(Request $request, Post $post): PostResource return new PostResource($post); } - public function store(Request $request): JsonResponse + public function store(StorePostRequest $request): JsonResponse { - $validated = $request->validate([ - 'platforms' => ['required', 'array', 'min:1'], - 'platforms.*.social_account_id' => ['required', 'uuid'], - 'platforms.*.content_type' => ['required', 'string'], - 'platforms.*.content' => ['nullable', 'string'], - 'scheduled_at' => ['nullable', 'date', 'after:now'], - 'status' => ['nullable', 'string', 'in:draft,scheduled,publishing'], - ]); - $post = CreatePost::execute( $request->workspace, $request->workspace->owner, - $validated + $request->validated() ); $post->load(['postPlatforms.socialAccount']); @@ -62,23 +55,13 @@ public function store(Request $request): JsonResponse ->setStatusCode(Response::HTTP_CREATED); } - public function update(Request $request, Post $post): PostResource|JsonResponse + public function update(UpdatePostRequest $request, Post $post): PostResource|JsonResponse { if ($post->workspace_id !== $request->workspace->id) { abort(Response::HTTP_NOT_FOUND); } - $validated = $request->validate([ - 'platforms' => ['sometimes', 'array'], - 'platforms.*.id' => ['required', 'uuid'], - 'platforms.*.content' => ['nullable', 'string'], - 'scheduled_at' => ['nullable', 'date'], - 'status' => ['nullable', 'string', 'in:draft,scheduled,publishing'], - 'label_ids' => ['sometimes', 'array'], - 'label_ids.*' => ['uuid'], - ]); - - $result = UpdatePost::execute($request->workspace, $post, $validated); + $result = UpdatePost::execute($request->workspace, $post, $request->validated()); if (data_get($result, 'action') === PostAction::AlreadyPublished) { return response()->json( diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index ec23a96d..20f63c06 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -163,7 +163,7 @@ public function callback(Request $request): View 'display_name' => data_get($profile, 'name', data_get($profile, 'username')), 'avatar_url' => $avatarPath, 'access_token' => $longLivedToken, - 'refresh_token' => null, + 'refresh_token' => $longLivedToken, 'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null, 'scopes' => $this->scopes, ]); diff --git a/app/Http/Requests/Api/Post/StorePostRequest.php b/app/Http/Requests/Api/Post/StorePostRequest.php new file mode 100644 index 00000000..2fd77853 --- /dev/null +++ b/app/Http/Requests/Api/Post/StorePostRequest.php @@ -0,0 +1,30 @@ + ['required', 'array', 'min:1'], + 'platforms.*.social_account_id' => ['required', 'uuid'], + 'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))], + 'platforms.*.content' => ['nullable', 'string', 'max:63206'], + 'scheduled_at' => ['nullable', 'date', 'after:now'], + 'status' => ['nullable', 'string', Rule::in(array_column(Status::cases(), 'value'))], + ]; + } +} diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php new file mode 100644 index 00000000..c439b430 --- /dev/null +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -0,0 +1,34 @@ + ['required', 'string', Rule::in(array_column(Status::cases(), 'value'))], + 'synced' => ['required', 'boolean'], + 'platforms' => ['required', 'array'], + 'platforms.*.id' => ['required', 'uuid'], + 'platforms.*.content' => ['nullable', 'string', 'max:63206'], + 'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))], + 'platforms.*.meta' => ['nullable', 'array'], + 'scheduled_at' => ['nullable', 'date'], + 'label_ids' => ['sometimes', 'array'], + 'label_ids.*' => ['uuid'], + ]; + } +} diff --git a/app/Http/Requests/App/Media/StoreMediaRequest.php b/app/Http/Requests/App/Media/StoreMediaRequest.php index 47e7cc98..7e689055 100644 --- a/app/Http/Requests/App/Media/StoreMediaRequest.php +++ b/app/Http/Requests/App/Media/StoreMediaRequest.php @@ -26,7 +26,7 @@ public function rules(): array 'media' => [ 'required', 'file', - 'max:512000', // 500MB + 'max:1048576', // 1GB 'mimetypes:image/jpeg,image/png,image/gif,image/webp,video/mp4,video/quicktime,video/webm,application/pdf', ], 'model' => [ @@ -50,7 +50,7 @@ public function messages(): array { return [ 'media.required' => 'The file is required.', - 'media.max' => 'The file must be at most 500MB.', + 'media.max' => 'The file must be at most 1GB.', 'media.mimetypes' => 'File type not supported.', 'model.required' => 'The model is required.', 'model.in' => 'Invalid model type.', diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index d0c2972b..51732221 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -4,6 +4,8 @@ namespace App\Http\Requests\App\Post; +use App\Enums\Post\Status; +use App\Enums\PostPlatform\ContentType; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -17,11 +19,14 @@ public function authorize(): bool public function rules(): array { return [ - 'status' => ['sometimes', 'string'], + 'status' => ['required', 'string', Rule::in(array_column(Status::cases(), 'value'))], + 'synced' => ['required', 'boolean'], 'scheduled_at' => ['sometimes', 'nullable', 'string'], - 'platforms' => ['sometimes', 'array'], + 'platforms' => ['required', 'array'], 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id ?? $this->route('post'))], - 'platforms.*.content' => ['nullable', 'string', 'max:5000'], + 'platforms.*.content' => ['nullable', 'string', 'max:63206'], + 'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))], + 'platforms.*.meta' => ['nullable', 'array'], 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)], ]; @@ -30,6 +35,12 @@ public function rules(): array public function messages(): array { return [ + 'status.required' => 'The post status is required.', + 'status.in' => 'Invalid post status.', + 'synced.required' => 'The synced field is required.', + 'platforms.required' => 'At least one platform is required.', + 'platforms.*.content_type.required' => 'The content type is required for each platform.', + 'platforms.*.content_type.in' => 'Invalid content type.', 'scheduled_at.after' => 'The scheduled date must be in the future.', ]; } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index ba8afdef..7b583d94 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -33,14 +33,21 @@ class PublishToSocialPlatform implements ShouldQueue { use Queueable; - public int $tries = 3; + public int $tries = 1; - public int $backoff = 60; + public int $timeout = 600; // 10 minutes — large video uploads need time public function __construct(public PostPlatform $postPlatform) {} public function handle(): void { + // Idempotency: skip if already published (prevents duplicate posts on retry) + $this->postPlatform->refresh(); + + if ($this->postPlatform->status === PostPlatformStatus::Published) { + return; + } + if (! $this->postPlatform->socialAccount->is_active) { $this->postPlatform->markAsFailed(__('posts.errors.account_inactive')); $this->updatePostStatus(); @@ -169,6 +176,23 @@ private function notifySuccess(Post $post): void ); } + public function failed(?\Throwable $exception): void + { + Log::error('PublishToSocialPlatform job failed permanently', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $exception?->getMessage(), + ]); + + $this->postPlatform->refresh(); + + if ($this->postPlatform->status !== PostPlatformStatus::Published) { + $this->postPlatform->markAsFailed($exception?->getMessage() ?? 'Unknown error'); + $this->updatePostStatus(); + $this->broadcastStatus(); + } + } + private function notifyFailure(Post $post): void { $owner = $post->workspace->owner; diff --git a/app/Models/Media.php b/app/Models/Media.php index e9f17f7c..93667ada 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -58,6 +58,28 @@ protected function url(): Attribute ); } + public function isVideo(): bool + { + if ($this->mime_type) { + return str_starts_with($this->mime_type, 'video/'); + } + + $extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION)); + + return in_array($extension, ['mp4', 'mov', 'avi', 'wmv', 'webm', 'mkv', 'm4v']); + } + + public function isImage(): bool + { + if ($this->mime_type) { + return str_starts_with($this->mime_type, 'image/'); + } + + $extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION)); + + return in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic', 'heif']); + } + public function getTemporaryUrl(int $expirationMinutes = 60): string { return Storage::temporaryUrl( diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 3374c994..4664f33f 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -31,7 +31,7 @@ public function publish(PostPlatform $postPlatform): array if ($medias->count() > 0) { $images = []; foreach ($medias->take(4) as $media) { - if (str_starts_with($media->mime_type, 'image/')) { + if ($media->isImage()) { $blob = $this->uploadBlob($account, $service, $media->url, $media->mime_type); if ($blob) { $images[] = [ @@ -110,29 +110,38 @@ public function publish(PostPlatform $postPlatform): array private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType): ?array { - try { - $imageContent = file_get_contents($url); + $tempFile = tempnam(sys_get_temp_dir(), 'bsky_blob_'); - if ($imageContent === false) { - Log::error('Bluesky failed to read image', ['url' => $url]); + try { + Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url); + + $fileSize = filesize($tempFile); + + if ($fileSize === false || $fileSize === 0) { + Log::error('Bluesky failed to download media', ['url' => $url]); return null; } // Bluesky has 1MB limit for images - if (strlen($imageContent) > 1000000) { + if (str_starts_with($mimeType, 'image/') && $fileSize > 1000000) { Log::warning('Bluesky image exceeds 1MB limit', [ - 'size' => strlen($imageContent), + 'size' => $fileSize, 'url' => $url, ]); - // TODO: Resize image if needed } + $stream = fopen($tempFile, 'r'); + $response = Http::withToken($account->access_token) ->withHeaders(['Content-Type' => $mimeType]) - ->withBody($imageContent, $mimeType) + ->withBody($stream, $mimeType) ->post("{$service}/xrpc/com.atproto.repo.uploadBlob"); + if (is_resource($stream)) { + fclose($stream); + } + if ($response->failed()) { Log::error('Bluesky blob upload failed', [ 'status' => $response->status(), @@ -150,6 +159,8 @@ private function uploadBlob(SocialAccount $account, string $service, string $url ]); return null; + } finally { + @unlink($tempFile); } } diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 670f625c..31a7397d 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -54,7 +54,8 @@ private function refreshTokenIfNeeded(SocialAccount $account): void Platform::TikTok => $this->refreshTikTokToken($account), Platform::Pinterest => $this->refreshPinterestToken($account), Platform::Threads => $this->refreshThreadsToken($account), - // Facebook, Instagram use long-lived tokens without refresh mechanism + Platform::Instagram => $this->refreshInstagramToken($account), + // Facebook uses page tokens that don't expire // Mastodon tokens don't expire default => null, }; @@ -278,9 +279,35 @@ private function refreshThreadsToken(SocialAccount $account): void } $data = $response->json(); + $newToken = data_get($data, 'access_token'); $account->update([ - 'access_token' => data_get($data, 'access_token'), + 'access_token' => $newToken, + 'refresh_token' => $newToken, + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + + $account->refresh(); + } + + private function refreshInstagramToken(SocialAccount $account): void + { + $response = Http::get('https://graph.instagram.com/refresh_access_token', [ + 'grant_type' => 'ig_refresh_token', + 'access_token' => $account->access_token, + ]); + + if ($response->failed()) { + Log::error('ConnectionVerifier: Instagram token refresh failed', ['body' => $response->body()]); + throw new TokenExpiredException('Failed to refresh Instagram token'); + } + + $data = $response->json(); + $newToken = data_get($data, 'access_token'); + + $account->update([ + 'access_token' => $newToken, + 'refresh_token' => $newToken, 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, ]); diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index b293970d..c356c7e4 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -62,8 +62,8 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte } $firstMedia = $media->first(); - $isVideo = str_starts_with($firstMedia->mime_type, 'video/'); - $isImage = str_starts_with($firstMedia->mime_type, 'image/'); + $isVideo = $firstMedia->isVideo(); + $isImage = $firstMedia->isImage(); if ($isVideo) { return $this->publishVideoPost($pageId, $accessToken, $content, $firstMedia); @@ -145,7 +145,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str $attachedMedia = []; foreach ($mediaCollection as $media) { - if (! str_starts_with($media->mime_type, 'image/')) { + if (! $media->isImage()) { continue; } @@ -292,7 +292,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra { Log::info('Facebook publishing story', ['page_id' => $pageId]); - $isVideo = str_starts_with($media->mime_type, 'video/'); + $isVideo = $media->isVideo(); if ($isVideo) { // Video story diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 0ab13e5b..9dfc687f 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -7,6 +7,7 @@ use App\Enums\PostPlatform\ContentType; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; +use App\Models\SocialAccount; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -36,6 +37,12 @@ class InstagramPublisher public function publish(PostPlatform $postPlatform): array { $account = $postPlatform->socialAccount; + + if ($account->is_token_expired || $account->is_token_expiring_soon) { + $this->refreshToken($account); + $account->refresh(); + } + $instagramId = $account->platform_user_id; $accessToken = $account->access_token; @@ -51,13 +58,26 @@ public function publish(PostPlatform $postPlatform): array return match ($contentType) { ContentType::InstagramReel => $this->publishReel($instagramId, $accessToken, $postPlatform->content, $firstMedia), ContentType::InstagramStory => $this->publishStory($instagramId, $accessToken, $firstMedia), - ContentType::InstagramFeed => $media->count() > 1 - ? $this->publishCarousel($instagramId, $accessToken, $postPlatform->content, $media) - : $this->publishSingleImage($instagramId, $accessToken, $postPlatform->content, $firstMedia), + ContentType::InstagramFeed => $this->publishFeed($instagramId, $accessToken, $postPlatform->content, $media), default => throw new \Exception("Unsupported Instagram content type: {$contentType?->value}"), }; } + private function publishFeed(string $instagramId, string $accessToken, ?string $content, $media): array + { + if ($media->count() > 1) { + return $this->publishCarousel($instagramId, $accessToken, $content, $media); + } + + $firstMedia = $media->first(); + + if ($firstMedia->isVideo()) { + return $this->publishReel($instagramId, $accessToken, $content, $firstMedia); + } + + return $this->publishSingleImage($instagramId, $accessToken, $content, $firstMedia); + } + private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media): array { Log::info('Instagram publishing single image', ['instagram_id' => $instagramId, 'image_url' => $media->url]); @@ -132,7 +152,7 @@ private function publishStory(string $instagramId, string $accessToken, $media): { Log::info('Instagram publishing story', ['instagram_id' => $instagramId]); - $isVideo = str_starts_with($media->mime_type, 'video/'); + $isVideo = $media->isVideo(); $params = [ 'media_type' => 'STORIES', @@ -180,7 +200,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri $childContainers = []; foreach ($mediaCollection as $media) { - $isVideo = str_starts_with($media->mime_type, 'video/'); + $isVideo = $media->isVideo(); $params = [ 'is_carousel_item' => 'true', @@ -311,6 +331,31 @@ private function waitForMediaProcessing(string $containerId, string $accessToken Log::warning('Instagram media processing timeout, proceeding anyway'); } + private function refreshToken(SocialAccount $account): void + { + $response = Http::get('https://graph.instagram.com/refresh_access_token', [ + 'grant_type' => 'ig_refresh_token', + 'access_token' => $account->access_token, + ]); + + if ($response->failed()) { + Log::error('Instagram token refresh failed', ['body' => $response->body()]); + + throw new TokenExpiredException('Failed to refresh Instagram token: '.$response->body()); + } + + $data = $response->json(); + $newToken = data_get($data, 'access_token'); + + $account->update([ + 'access_token' => $newToken, + 'refresh_token' => $newToken, + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, + ]); + + Log::info('Instagram token refreshed successfully'); + } + private function handleApiError(Response $response, string $context): void { $body = $response->json() ?? []; diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index a9a82ba9..6d389e2e 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -168,7 +168,7 @@ private function publishCarousel(string $organizationUrn, ?string $content, $med $carouselItems = []; foreach ($mediaCollection as $media) { - if (! str_starts_with($media->mime_type, 'image/')) { + if (! $media->isImage()) { continue; } @@ -307,8 +307,20 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string private function uploadVideo($mediaItem, string $ownerUrn): ?string { - $videoContent = file_get_contents($mediaItem->url); - $fileSize = strlen($videoContent); + $tempFile = tempnam(sys_get_temp_dir(), 'lip_video_'); + + try { + return $this->doUploadVideo($tempFile, $mediaItem, $ownerUrn); + } finally { + @unlink($tempFile); + } + } + + private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): ?string + { + Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); + + $fileSize = (int) filesize($tempFile); Log::info('LinkedIn Page initializing video upload', [ 'owner' => $ownerUrn, @@ -353,7 +365,11 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string $firstByte = $instruction['firstByte']; $lastByte = $instruction['lastByte']; - $chunkData = substr($videoContent, $firstByte, $lastByte - $firstByte + 1); + $chunkLength = $lastByte - $firstByte + 1; + $handle = fopen($tempFile, 'r'); + fseek($handle, $firstByte); + $chunkData = fread($handle, $chunkLength); + fclose($handle); Log::info('LinkedIn Page uploading video chunk', [ 'index' => $index, diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index cde8361a..08a08e09 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -155,7 +155,7 @@ private function publishCarousel(string $personUrn, ?string $content, $mediaColl $carouselItems = []; foreach ($mediaCollection as $media) { - if (! str_starts_with($media->mime_type, 'image/')) { + if (! $media->isImage()) { continue; } @@ -288,8 +288,20 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string private function uploadVideo($mediaItem, string $ownerUrn): ?string { - $videoContent = file_get_contents($mediaItem->url); - $fileSize = strlen($videoContent); + $tempFile = tempnam(sys_get_temp_dir(), 'li_video_'); + + try { + return $this->doUploadVideo($tempFile, $mediaItem, $ownerUrn); + } finally { + @unlink($tempFile); + } + } + + private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): ?string + { + Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); + + $fileSize = (int) filesize($tempFile); Log::info('LinkedIn initializing video upload', [ 'owner' => $ownerUrn, @@ -334,7 +346,11 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string $firstByte = $instruction['firstByte']; $lastByte = $instruction['lastByte']; - $chunkData = substr($videoContent, $firstByte, $lastByte - $firstByte + 1); + $chunkLength = $lastByte - $firstByte + 1; + $handle = fopen($tempFile, 'r'); + fseek($handle, $firstByte); + $chunkData = fread($handle, $chunkLength); + fclose($handle); Log::info('LinkedIn uploading video chunk', [ 'index' => $index, diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php index eefce539..0ab1e232 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -71,22 +71,24 @@ public function publish(PostPlatform $postPlatform): array private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename): ?string { + $tempFile = tempnam(sys_get_temp_dir(), 'masto_media_'); + try { - $fileContent = file_get_contents($url); - if ($fileContent === false) { - Log::error('Mastodon failed to read media', ['url' => $url]); + Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url); + + if (filesize($tempFile) === 0) { + Log::error('Mastodon failed to download media', ['url' => $url]); return null; } - // Determine filename from URL if not provided $name = $filename ?? basename(parse_url($url, PHP_URL_PATH)); if (empty($name)) { $name = 'media'; } $response = Http::withToken($account->access_token) - ->attach('file', $fileContent, $name) + ->attach('file', fopen($tempFile, 'r'), $name) ->post("{$instance}/api/v1/media"); if ($response->failed()) { @@ -110,6 +112,8 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u ]); return null; + } finally { + @unlink($tempFile); } } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index 59c3ab01..3eab3de0 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -160,9 +160,14 @@ private function publishVideoPin(PostPlatform $postPlatform): array $multipart[] = ['name' => $key, 'contents' => $value]; } - // Get video content - $videoContent = file_get_contents($media->url); + // Download video to temp file (memory-safe) + $tempFile = tempnam(sys_get_temp_dir(), 'pin_video_'); + Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->url); + + $videoContent = fopen($tempFile, 'r'); + if ($videoContent === false) { + @unlink($tempFile); throw new \Exception('Failed to read video file'); } @@ -173,8 +178,14 @@ private function publishVideoPin(PostPlatform $postPlatform): array ]; $uploadResponse = Http::asMultipart() + ->timeout(600) ->post($uploadUrl, $multipart); + if (is_resource($videoContent)) { + fclose($videoContent); + } + @unlink($tempFile); + if ($uploadResponse->failed()) { Log::error('Pinterest video upload failed', [ 'status' => $uploadResponse->status(), diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index 366b80b9..0d72d978 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -57,7 +57,7 @@ public function publish(PostPlatform $postPlatform): array } $firstMedia = $media->first(); - $isVideo = str_starts_with($firstMedia->mime_type, 'video/'); + $isVideo = $firstMedia->isVideo(); // Single media if ($media->count() === 1) { @@ -164,7 +164,7 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c $childContainers = []; foreach ($mediaCollection as $media) { - $isVideo = str_starts_with($media->mime_type, 'video/'); + $isVideo = $media->isVideo(); $params = [ 'is_carousel_item' => 'true', @@ -315,8 +315,11 @@ private function refreshToken(SocialAccount $account): void $data = $response->json(); + $newToken = data_get($data, 'access_token'); + $account->update([ - 'access_token' => data_get($data, 'access_token'), + 'access_token' => $newToken, + 'refresh_token' => $newToken, 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, ]); diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 2c9debf4..79d9099f 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -53,8 +53,8 @@ public function publish(PostPlatform $postPlatform): array } $firstMedia = $media->first(); - $isVideo = str_starts_with($firstMedia->mime_type, 'video/'); - $isImage = str_starts_with($firstMedia->mime_type, 'image/'); + $isVideo = $firstMedia->isVideo(); + $isImage = $firstMedia->isImage(); if ($isVideo) { return $this->publishVideo($postPlatform, $firstMedia); @@ -76,6 +76,37 @@ private function getHttpClient(): PendingRequest ->timeout(120); } + private function queryCreatorInfo(): array + { + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/post/publish/creator_info/query/"); + + if ($response->failed()) { + Log::warning('TikTok creator_info query failed', ['body' => $response->body()]); + + return ['privacy_level' => 'SELF_ONLY']; + } + + $data = data_get($response->json(), 'data', []); + $privacyOptions = data_get($data, 'privacy_level_options', ['SELF_ONLY']); + + // Prefer PUBLIC_TO_EVERYONE > MUTUAL_FOLLOW_FRIENDS > FOLLOWER_OF_CREATOR > SELF_ONLY + $preferred = ['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY']; + + $privacyLevel = 'SELF_ONLY'; + foreach ($preferred as $level) { + if (in_array($level, $privacyOptions)) { + $privacyLevel = $level; + break; + } + } + + return [ + 'privacy_level' => $privacyLevel, + 'max_video_post_duration_sec' => data_get($data, 'max_video_post_duration_sec'), + ]; + } + private function publishVideo(PostPlatform $postPlatform, $media): array { Log::info('TikTok publishing video', [ @@ -84,11 +115,13 @@ private function publishVideo(PostPlatform $postPlatform, $media): array 'content' => $postPlatform->content, ]); + $creatorInfo = $this->queryCreatorInfo(); + $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/video/init/", [ 'post_info' => [ 'title' => $postPlatform->content ?? '', - 'privacy_level' => 'SELF_ONLY', + 'privacy_level' => data_get($creatorInfo, 'privacy_level'), 'disable_duet' => false, 'disable_comment' => false, 'disable_stitch' => false, @@ -129,7 +162,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): array { $photoUrls = $mediaCollection - ->filter(fn ($m) => str_starts_with($m->mime_type, 'image/')) + ->filter(fn ($m) => $m->isImage()) ->map(fn ($m) => $m->url) ->values() ->toArray(); @@ -144,11 +177,13 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar 'content' => $postPlatform->content, ]); + $creatorInfo = $this->queryCreatorInfo(); + $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/content/init/", [ 'post_info' => [ 'title' => $postPlatform->content ?? '', - 'privacy_level' => 'SELF_ONLY', + 'privacy_level' => data_get($creatorInfo, 'privacy_level'), 'disable_comment' => false, ], 'source_info' => [ diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index bab888d0..9967e844 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -111,28 +111,24 @@ private function getHttpClient(): PendingRequest private function uploadMedia($mediaItem): ?array { - $mediaContent = file_get_contents($mediaItem->url); $mimeType = $mediaItem->mime_type; - $fileSize = strlen($mediaContent); - // Create temp file + // Download to temp file (memory-safe) $tempFile = tempnam(sys_get_temp_dir(), 'x_media_'); - file_put_contents($tempFile, $mediaContent); try { + Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url); + + $fileSize = filesize($tempFile); $mediaCategory = $this->getMediaCategory($mimeType, $fileSize); $isVideo = str_starts_with($mimeType, 'video/'); $isGif = $mimeType === 'image/gif'; - // Use chunked upload for: - // - Videos (always) - // - GIFs (need async processing) - // - Files > 5MB (API limit for simple upload) $useChunkedUpload = $isVideo || $isGif || $fileSize > 5 * 1024 * 1024; if ($useChunkedUpload) { - return $this->chunkedUpload($mediaContent, $mimeType, $mediaCategory); + return $this->chunkedUpload($tempFile, $fileSize, $mimeType, $mediaCategory); } // Simple upload for small images @@ -162,7 +158,6 @@ private function uploadMedia($mediaItem): ?array $responseData = $response->json(); - // v2 API returns data.id $mediaId = $responseData['data']['id'] ?? $responseData['media_id'] ?? null; if ($isGif && $mediaId) { @@ -171,23 +166,19 @@ private function uploadMedia($mediaItem): ?array return $responseData; } finally { - if (file_exists($tempFile)) { - unlink($tempFile); - } + @unlink($tempFile); } } - private function chunkedUpload(string $mediaContent, string $mimeType, string $mediaCategory): array + private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeType, string $mediaCategory): array { - $totalBytes = strlen($mediaContent); - Log::info('X chunked upload INIT', [ 'total_bytes' => $totalBytes, 'media_type' => $mimeType, 'media_category' => $mediaCategory, ]); - // INIT - Use dedicated initialize endpoint + // INIT $initResponse = Http::withToken($this->accessToken) ->timeout(60) ->post("{$this->baseUrl}/2/media/upload/initialize", [ @@ -213,18 +204,24 @@ private function chunkedUpload(string $mediaContent, string $mimeType, string $m Log::info('X chunked upload INIT success', ['media_id' => $mediaId]); - // APPEND - Upload in 1MB chunks (API limit) - $chunkSize = 1 * 1024 * 1024; - $chunks = str_split($mediaContent, $chunkSize); + // APPEND - Read from temp file in 5MB chunks (memory-safe) + $chunkSize = 5 * 1024 * 1024; + $handle = fopen($tempFile, 'r'); + $index = 0; + + while (! feof($handle)) { + $chunk = fread($handle, $chunkSize); + + if ($chunk === '' || $chunk === false) { + break; + } - foreach ($chunks as $index => $chunk) { Log::info('X chunked upload APPEND', [ 'media_id' => $mediaId, 'segment' => $index, 'chunk_size' => strlen($chunk), ]); - // APPEND uses the new v2 endpoint with media_id in URL $appendResponse = Http::withToken($this->accessToken) ->timeout(300) ->attach('media', $chunk, 'chunk'.$index) @@ -240,8 +237,12 @@ private function chunkedUpload(string $mediaContent, string $mimeType, string $m ]); $this->handleApiError($appendResponse, 'Failed to append chunk'); } + + $index++; } + fclose($handle); + // FINALIZE - Use the new v2 endpoint Log::info('X chunked upload FINALIZE', ['media_id' => $mediaId]); diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 6d438fbd..8f7f132f 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -7,33 +7,18 @@ use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; -use Illuminate\Http\Client\PendingRequest; -use Illuminate\Http\Client\Response; +use Google\Client as GoogleClient; +use Google\Service\YouTube; +use Google\Service\YouTube\Video; +use Google\Service\YouTube\VideoSnippet; +use Google\Service\YouTube\VideoStatus; +use Google_Http_MediaFileUpload; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class YouTubePublisher { - /** - * Google/YouTube API error codes that indicate token issues. - * - * @see https://developers.google.com/youtube/v3/docs/errors - */ - private const TOKEN_ERROR_CODES = [ - 'invalid_grant', - 'invalid_token', - 'unauthorized', - ]; - - private const TOKEN_ERROR_REASONS = [ - 'authError', - 'forbidden', - 'unauthorized', - ]; - - private string $baseUrl = 'https://www.googleapis.com'; - - private string $accessToken; + private const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks public function publish(PostPlatform $postPlatform): array { @@ -44,8 +29,6 @@ public function publish(PostPlatform $postPlatform): array $account->refresh(); } - $this->accessToken = $account->access_token; - $media = $postPlatform->media; if ($media->isEmpty()) { @@ -53,22 +36,38 @@ public function publish(PostPlatform $postPlatform): array } $firstMedia = $media->first(); - $isVideo = str_starts_with($firstMedia->mime_type, 'video/'); - if (! $isVideo) { + if (! $firstMedia->isVideo()) { throw new \Exception('YouTube Shorts only supports video content.'); } - return $this->publishShort($postPlatform, $firstMedia); + return $this->publishShort($postPlatform, $firstMedia, $account); } - private function getHttpClient(): PendingRequest + private function createGoogleClient(SocialAccount $account): GoogleClient { - return Http::withToken($this->accessToken) - ->timeout(600); + $client = new GoogleClient; + $client->setClientId(config('services.google.client_id')); + $client->setClientSecret(config('services.google.client_secret')); + + $tokenData = [ + 'access_token' => $account->access_token, + 'created' => $account->token_expires_at + ? $account->token_expires_at->subHour()->getTimestamp() + : time(), + 'expires_in' => 3600, + ]; + + if ($account->refresh_token) { + $tokenData['refresh_token'] = $account->refresh_token; + } + + $client->setAccessToken($tokenData); + + return $client; } - private function publishShort(PostPlatform $postPlatform, $media): array + private function publishShort(PostPlatform $postPlatform, $media, SocialAccount $account): array { if (empty($postPlatform->content)) { throw new \Exception('YouTube Shorts require a title. Please add text to your post.'); @@ -82,90 +81,114 @@ private function publishShort(PostPlatform $postPlatform, $media): array 'title' => $title, ]); - // Step 1: Get video content - $videoContent = file_get_contents($media->url); - $fileSize = strlen($videoContent); + $tempFile = tempnam(sys_get_temp_dir(), 'yt_upload_'); + $handle = null; - Log::info('YouTube video file size', ['size' => $fileSize]); + try { + // Download video to temp file (memory-safe) + $downloadResponse = Http::withOptions(['sink' => $tempFile]) + ->timeout(600) + ->get($media->url); - // Step 2: Initialize resumable upload - $initResponse = $this->getHttpClient() - ->withHeaders([ - 'Content-Type' => 'application/json; charset=UTF-8', - 'X-Upload-Content-Length' => $fileSize, - 'X-Upload-Content-Type' => $media->mime_type, - ]) - ->post("{$this->baseUrl}/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status", [ - 'snippet' => [ - 'title' => $title, - 'description' => $description, - 'categoryId' => '22', // People & Blogs - ], - 'status' => [ - 'privacyStatus' => 'public', - 'selfDeclaredMadeForKids' => false, - ], + if ($downloadResponse->failed()) { + throw new \Exception('Failed to download video for YouTube upload: HTTP '.$downloadResponse->status()); + } + + $fileSize = filesize($tempFile); + + if ($fileSize === false || $fileSize < 1024) { + throw new \Exception('Downloaded video is too small or empty ('.$fileSize.' bytes), aborting upload'); + } + + Log::info('YouTube video downloaded', ['size' => $fileSize]); + + // Set up Google Client with deferred mode for resumable upload + $client = $this->createGoogleClient($account); + $client->setDefer(true); + + $youtube = new YouTube($client); + + // Build video metadata + $snippet = new VideoSnippet; + $snippet->setTitle($title); + $snippet->setDescription($description); + $snippet->setCategoryId('22'); + + $status = new VideoStatus; + $status->setPrivacyStatus('public'); + $status->setSelfDeclaredMadeForKids(false); + + $video = new Video; + $video->setSnippet($snippet); + $video->setStatus($status); + + // Initialize resumable upload request + $insertRequest = $youtube->videos->insert('snippet,status', $video); + + $mediaUpload = new Google_Http_MediaFileUpload( + $client, + $insertRequest, + $media->mime_type ?: 'video/mp4', + null, + true, + self::CHUNK_SIZE + ); + $mediaUpload->setFileSize($fileSize); + + // Upload in chunks (memory-safe for large files) + $uploadStatus = false; + $handle = fopen($tempFile, 'r'); + + if ($handle === false) { + throw new \Exception('Failed to open temp file for YouTube upload'); + } + + while (! $uploadStatus && ! feof($handle)) { + $chunk = fread($handle, self::CHUNK_SIZE); + $uploadStatus = $mediaUpload->nextChunk($chunk); + } + + fclose($handle); + $handle = null; + + $client->setDefer(false); + + if (! $uploadStatus instanceof Video) { + throw new \Exception('YouTube upload failed: no video object returned'); + } + + $videoId = $uploadStatus->getId(); + + Log::info('YouTube upload success', ['video_id' => $videoId]); + + return [ + 'id' => $videoId, + 'url' => "https://www.youtube.com/shorts/{$videoId}", + ]; + } catch (\Google\Service\Exception $e) { + $this->handleGoogleError($e); + } catch (\Throwable $e) { + Log::error('YouTube upload failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), ]); - if ($initResponse->failed()) { - Log::error('YouTube upload init failed', [ - 'status' => $initResponse->status(), - 'body' => $initResponse->body(), - ]); - $this->handleApiError($initResponse, 'YouTube API error'); + throw $e; + } finally { + if ($handle !== null && is_resource($handle)) { + fclose($handle); + } + + @unlink($tempFile); } - - $uploadUrl = $initResponse->header('Location'); - - if (! $uploadUrl) { - throw new \Exception('YouTube did not return an upload URL'); - } - - Log::info('YouTube upload initialized', ['uploadUrl' => $uploadUrl]); - - // Step 3: Upload the video content - $uploadResponse = Http::withToken($this->accessToken) - ->withHeaders([ - 'Content-Type' => $media->mime_type, - 'Content-Length' => $fileSize, - ]) - ->timeout(600) - ->withBody($videoContent, $media->mime_type) - ->put($uploadUrl); - - if ($uploadResponse->failed()) { - Log::error('YouTube video upload failed', [ - 'status' => $uploadResponse->status(), - 'body' => $uploadResponse->body(), - ]); - $this->handleApiError($uploadResponse, 'YouTube upload error'); - } - - $data = $uploadResponse->json(); - - Log::info('YouTube upload response', ['data' => $data]); - - $videoId = data_get($data, 'id', null); - - if (! $videoId) { - throw new \Exception('YouTube did not return a video ID'); - } - - return [ - 'id' => $videoId, - 'url' => "https://www.youtube.com/shorts/{$videoId}", - ]; } private function buildTitle(string $content): string { - // YouTube title max is 100 characters - // For Shorts, add #Shorts hashtag to help YouTube classify it $maxLength = 100; $shortsTag = ' #Shorts'; $availableLength = $maxLength - strlen($shortsTag); - // Get first line or first sentence as title $title = strtok($content, "\n"); $title = strtok($title, '.'); @@ -191,7 +214,8 @@ private function refreshToken(SocialAccount $account): void if ($response->failed()) { Log::error('YouTube token refresh failed', ['body' => $response->body()]); - $this->handleApiError($response, 'Failed to refresh YouTube token'); + + throw new TokenExpiredException('Failed to refresh YouTube token: '.$response->body()); } $data = $response->json(); @@ -205,36 +229,24 @@ private function refreshToken(SocialAccount $account): void Log::info('YouTube token refreshed successfully'); } - private function handleApiError(Response $response, string $context): void + private function handleGoogleError(\Google\Service\Exception $e): never { - $body = $response->json() ?? []; + $message = $e->getMessage(); + $errors = $e->getErrors(); + $reason = data_get($errors, '0.reason'); - // Google OAuth error format - $errorCode = $body['error'] ?? null; - $errorDescription = $body['error_description'] ?? null; + Log::error('YouTube API error', [ + 'message' => $message, + 'reason' => $reason, + 'errors' => $errors, + ]); - // YouTube API error format - $error = $body['error'] ?? []; - if (is_array($error)) { - $errors = $error['errors'] ?? []; - $reason = $errors[0]['reason'] ?? null; - $message = $error['message'] ?? $errorDescription ?? $response->body(); - } else { - $reason = null; - $message = $errorDescription ?? $response->body(); + $tokenReasons = ['authError', 'forbidden', 'unauthorized', 'invalid_grant']; + + if ($e->getCode() === 401 || in_array($reason, $tokenReasons)) { + throw new TokenExpiredException("YouTube API error: {$message}", $reason); } - $isTokenError = $response->status() === 401 - || in_array($errorCode, self::TOKEN_ERROR_CODES) - || in_array($reason, self::TOKEN_ERROR_REASONS); - - if ($isTokenError) { - throw new TokenExpiredException( - "{$context}: {$message}", - is_string($errorCode) ? $errorCode : $reason - ); - } - - throw new \Exception("{$context}: {$message}"); + throw new \Exception("YouTube API error: {$message}"); } } diff --git a/composer.json b/composer.json index a26f30aa..b24713b0 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ ], "require": { "php": "^8.2", + "google/apiclient": "^2.19", "inertiajs/inertia-laravel": "^3.0", "laravel/ai": "^0.4.2", "laravel/boost": "^2.0", diff --git a/composer.lock b/composer.lock index d1cc3695..0a2fb6dd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f97b0298cdb7a80d1c5d1706165b6fe9", + "content-hash": "2f1a57707f17ee68dc1090c641f8d753", "packages": [ { "name": "aws/aws-crt-php", @@ -971,6 +971,184 @@ ], "time": "2025-12-03T09:33:47+00:00" }, + { + "name": "google/apiclient", + "version": "v2.19.2", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-api-php-client.git", + "reference": "703ba9acfaf4ba71306108207feafb6d1d137eb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-api-php-client/zipball/703ba9acfaf4ba71306108207feafb6d1d137eb0", + "reference": "703ba9acfaf4ba71306108207feafb6d1d137eb0", + "shasum": "" + }, + "require": { + "firebase/php-jwt": "^6.0||^7.0", + "google/apiclient-services": "~0.350", + "google/auth": "^1.37", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.6", + "monolog/monolog": "^2.9||^3.0", + "php": "^8.1", + "phpseclib/phpseclib": "^3.0.50" + }, + "require-dev": { + "cache/filesystem-adapter": "^1.1", + "composer/composer": "^2.9", + "phpcompatibility/php-compatibility": "^9.2", + "phpspec/prophecy-phpunit": "^2.1", + "phpunit/phpunit": "^9.6", + "squizlabs/php_codesniffer": "^3.8", + "symfony/css-selector": "~2.1", + "symfony/dom-crawler": "~2.1" + }, + "suggest": { + "cache/filesystem-adapter": "For caching certs and tokens (using Google\\Client::setCache)" + }, + "type": "library", + "extra": { + "component": { + "entry": "src/Client.php" + }, + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/aliases.php" + ], + "psr-4": { + "Google\\": "src/" + }, + "classmap": [ + "src/aliases.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Client library for Google APIs", + "homepage": "http://developers.google.com/api-client-library/php", + "keywords": [ + "google" + ], + "support": { + "issues": "https://github.com/googleapis/google-api-php-client/issues", + "source": "https://github.com/googleapis/google-api-php-client/tree/v2.19.2" + }, + "time": "2026-03-30T18:54:44+00:00" + }, + { + "name": "google/apiclient-services", + "version": "v0.435.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-api-php-client-services.git", + "reference": "1edf0f5f2876945c372366107b4d7a387b17a6b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/1edf0f5f2876945c372366107b4d7a387b17a6b9", + "reference": "1edf0f5f2876945c372366107b4d7a387b17a6b9", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "autoload": { + "files": [ + "autoload.php" + ], + "psr-4": { + "Google\\Service\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Client library for Google APIs", + "homepage": "http://developers.google.com/api-client-library/php", + "keywords": [ + "google" + ], + "support": { + "issues": "https://github.com/googleapis/google-api-php-client-services/issues", + "source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.435.0" + }, + "time": "2026-03-01T01:14:26+00:00" + }, + { + "name": "google/auth", + "version": "v1.50.1", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-auth-library-php.git", + "reference": "870c17ee3a1d73338d39a9ffa77a700ba77f5a83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/870c17ee3a1d73338d39a9ffa77a700ba77f5a83", + "reference": "870c17ee3a1d73338d39a9ffa77a700ba77f5a83", + "shasum": "" + }, + "require": { + "firebase/php-jwt": "^6.0||^7.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.4.5", + "php": "^8.1", + "psr/cache": "^2.0||^3.0", + "psr/http-message": "^1.1||^2.0", + "psr/log": "^2.0||^3.0" + }, + "require-dev": { + "guzzlehttp/promises": "^2.0", + "kelvinmo/simplejwt": "^1.1.0", + "phpseclib/phpseclib": "^3.0.35", + "phpspec/prophecy-phpunit": "^2.1", + "phpunit/phpunit": "^9.6", + "sebastian/comparator": ">=1.2.3", + "squizlabs/php_codesniffer": "^4.0", + "symfony/filesystem": "^6.3||^7.3", + "symfony/process": "^6.0||^7.0", + "webmozart/assert": "^1.11||^2.0" + }, + "suggest": { + "phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2." + }, + "type": "library", + "autoload": { + "psr-4": { + "Google\\Auth\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Google Auth Library for PHP", + "homepage": "https://github.com/google/google-auth-library-php", + "keywords": [ + "Authentication", + "google", + "oauth2" + ], + "support": { + "docs": "https://cloud.google.com/php/docs/reference/auth/latest", + "issues": "https://github.com/googleapis/google-auth-library-php/issues", + "source": "https://github.com/googleapis/google-auth-library-php/tree/v1.50.1" + }, + "time": "2026-03-18T20:03:29+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.4", @@ -4729,6 +4907,55 @@ ], "time": "2026-03-12T17:55:23+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, { "name": "psr/clock", "version": "1.0.0", diff --git a/config/horizon.php b/config/horizon.php index 2611efd6..57ec0c84 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -221,7 +221,7 @@ 'maxJobs' => 0, 'memory' => 128, 'tries' => 1, - 'timeout' => 60, + 'timeout' => 630, 'nice' => 0, ], ], diff --git a/config/queue.php b/config/queue.php index 629cb73d..e4aef098 100644 --- a/config/queue.php +++ b/config/queue.php @@ -70,7 +70,7 @@ 'driver' => 'redis', 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 660), 'block_for' => null, 'after_commit' => false, ], diff --git a/resources/js/pages/posts/Edit.vue b/resources/js/pages/posts/Edit.vue index a3d2984b..d51b6bd9 100644 --- a/resources/js/pages/posts/Edit.vue +++ b/resources/js/pages/posts/Edit.vue @@ -344,6 +344,7 @@ const getDefaultContentType = (platform: string): string => { 'threads': 'threads_post', 'pinterest': 'pinterest_pin', 'bluesky': 'bluesky_post', + 'mastodon': 'mastodon_post', }; return defaults[platform] || ''; }; diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index 03e20b6a..3a35890e 100644 --- a/tests/Feature/Api/PostApiTest.php +++ b/tests/Feature/Api/PostApiTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\Post\Status as PostStatus; +use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Enums\UserWorkspace\Role; use App\Models\ApiToken; @@ -125,10 +126,13 @@ $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) ->putJson(route('api.posts.update', $post), [ + 'status' => 'draft', + 'synced' => true, 'platforms' => [ [ 'id' => $postPlatform->id, 'content' => 'Updated content', + 'content_type' => ContentType::LinkedInPost->value, ], ], ]) @@ -137,13 +141,32 @@ it('cannot update post from another workspace', function () { $otherWorkspace = Workspace::factory()->create(); + $otherSocialAccount = SocialAccount::factory()->create([ + 'workspace_id' => $otherWorkspace->id, + 'platform' => Platform::LinkedIn, + ]); $post = Post::factory()->create([ 'workspace_id' => $otherWorkspace->id, 'user_id' => $this->user->id, ]); + $postPlatform = PostPlatform::factory()->linkedin()->create([ + 'post_id' => $post->id, + 'social_account_id' => $otherSocialAccount->id, + 'enabled' => true, + ]); $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) - ->putJson(route('api.posts.update', $post), []) + ->putJson(route('api.posts.update', $post), [ + 'status' => 'draft', + 'synced' => true, + 'platforms' => [ + [ + 'id' => $postPlatform->id, + 'content' => 'Test', + 'content_type' => ContentType::LinkedInPost->value, + ], + ], + ]) ->assertNotFound(); }); @@ -154,8 +177,24 @@ 'status' => PostStatus::Published, ]); + $postPlatform = PostPlatform::factory()->linkedin()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + 'enabled' => true, + ]); + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) - ->putJson(route('api.posts.update', $post), []) + ->putJson(route('api.posts.update', $post), [ + 'status' => 'draft', + 'synced' => true, + 'platforms' => [ + [ + 'id' => $postPlatform->id, + 'content' => 'Test', + 'content_type' => ContentType::LinkedInPost->value, + ], + ], + ]) ->assertUnprocessable(); }); diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 27a4f0cf..4eb34a22 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -258,3 +258,24 @@ expect($this->post->status)->toBe(PostStatus::Failed); Queue::assertPushed(SendNotification::class); }); + +test('publish to social platform skips if already published (idempotency)', function () { + Event::fake(); + + // Mark as already published + $this->postPlatform->update([ + 'status' => PlatformStatus::Published, + 'platform_post_id' => 'existing-123', + ]); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldNotReceive('publish'); + + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + // Should not have called publish + $this->postPlatform->refresh(); + expect($this->postPlatform->platform_post_id)->toBe('existing-123'); +}); diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index dc281610..1c275c75 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -206,6 +206,7 @@ $response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [ 'status' => 'draft', + 'synced' => true, 'platforms' => [ [ 'id' => $postPlatform->id, @@ -219,6 +220,7 @@ $postPlatform->refresh(); expect($postPlatform->content)->toBe('Updated content'); + expect($postPlatform->content_type)->toBe(ContentType::LinkedInPost); }); test('update post cannot update published posts', function () { @@ -228,8 +230,21 @@ 'status' => PostStatus::Published, ]); + $postPlatform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + ]); + $response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [ 'status' => 'draft', + 'synced' => true, + 'platforms' => [ + [ + 'id' => $postPlatform->id, + 'content' => 'Test content', + 'content_type' => ContentType::LinkedInPost->value, + ], + ], ]); $response->assertRedirect(); @@ -254,6 +269,7 @@ $response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [ 'status' => 'publishing', + 'synced' => true, 'platforms' => [ [ 'id' => $postPlatform->id, @@ -381,6 +397,7 @@ $response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [ 'status' => 'draft', + 'synced' => true, 'platforms' => [ [ 'id' => $postPlatform->id, @@ -418,6 +435,7 @@ $response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [ 'status' => 'draft', + 'synced' => true, 'platforms' => [ [ 'id' => $postPlatform->id, @@ -456,6 +474,7 @@ // Update with different labels $response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [ 'status' => 'draft', + 'synced' => true, 'platforms' => [ [ 'id' => $postPlatform->id, @@ -487,6 +506,7 @@ $response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [ 'status' => 'draft', + 'synced' => true, 'platforms' => [ [ 'id' => $postPlatform->id, diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index c09c6454..50e7baaa 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -236,9 +236,14 @@ Http::assertSent(fn ($request) => str_contains($request->url(), 'graph.facebook.com')); }); -test('does not refresh instagram token as it uses long-lived tokens', function () { +test('refreshes instagram token when expired', function () { Http::fake([ - 'graph.instagram.com/*' => Http::response(['id' => '123', 'username' => 'test'], 200), + 'graph.instagram.com/refresh_access_token*' => Http::response([ + 'access_token' => 'new-instagram-token', + 'token_type' => 'bearer', + 'expires_in' => 5184000, + ], 200), + 'graph.instagram.com/v24.0/me*' => Http::response(['id' => '123', 'username' => 'test'], 200), ]); $account = SocialAccount::factory()->instagram()->create([ @@ -250,8 +255,12 @@ expect($result)->toBeTrue(); - Http::assertSentCount(1); - Http::assertSent(fn ($request) => str_contains($request->url(), 'graph.instagram.com')); + $account->refresh(); + expect($account->access_token)->toBe('new-instagram-token'); + expect($account->refresh_token)->toBe('new-instagram-token'); + + Http::assertSentCount(2); + Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token')); }); test('refreshes token when expiring soon', function () { diff --git a/tests/Unit/Traits/HasMediaTest.php b/tests/Unit/Traits/HasMediaTest.php index c563412c..d334d357 100644 --- a/tests/Unit/Traits/HasMediaTest.php +++ b/tests/Unit/Traits/HasMediaTest.php @@ -222,3 +222,36 @@ expect($user->has_photo)->toBeTrue(); expect($user->photo_url)->not->toBeNull(); }); + +test('isVideo detects mp4 files', function () { + $media = new Media(['path' => 'medias/test.mp4']); + expect($media->isVideo())->toBeTrue(); + expect($media->isImage())->toBeFalse(); +}); + +test('isVideo detects mov files', function () { + $media = new Media(['path' => 'medias/test.mov']); + expect($media->isVideo())->toBeTrue(); +}); + +test('isImage detects jpg files', function () { + $media = new Media(['path' => 'medias/test.jpg']); + expect($media->isImage())->toBeTrue(); + expect($media->isVideo())->toBeFalse(); +}); + +test('isImage detects png files', function () { + $media = new Media(['path' => 'medias/test.png']); + expect($media->isImage())->toBeTrue(); +}); + +test('isVideo returns false for image files', function () { + $media = new Media(['path' => 'medias/test.webp']); + expect($media->isVideo())->toBeFalse(); + expect($media->isImage())->toBeTrue(); +}); + +test('mp4 with quicktime mime still detected as video by path', function () { + $media = new Media(['path' => 'medias/test.mp4', 'mime_type' => 'video/quicktime']); + expect($media->isVideo())->toBeTrue(); +});