fix: overhaul social publishing — validation, uploads, token refresh

- Fix UpdatePostRequest missing content_type, synced, meta fields
  (content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
  — X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
  timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
This commit is contained in:
Paulo Castellano 2026-03-31 19:25:19 -03:00
parent bb6d0a2678
commit 9f3b8e547a
30 changed files with 870 additions and 234 deletions

View file

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

View file

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

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\Post;
use App\Enums\Post\Status;
use App\Enums\PostPlatform\ContentType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'platforms' => ['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'))],
];
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\Post;
use App\Enums\Post\Status;
use App\Enums\PostPlatform\ContentType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePostRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'status' => ['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'],
];
}
}

View file

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

View file

@ -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.',
];
}

View file

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

View file

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

View file

@ -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);
}
}

View file

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

View file

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

View file

@ -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() ?? [];

View file

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

View file

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

View file

@ -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);
}
}

View file

@ -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(),

View file

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

View file

@ -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' => [

View file

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

View file

@ -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}");
}
}

View file

@ -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",

229
composer.lock generated
View file

@ -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",

View file

@ -221,7 +221,7 @@
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 60,
'timeout' => 630,
'nice' => 0,
],
],

View file

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

View file

@ -344,6 +344,7 @@ const getDefaultContentType = (platform: string): string => {
'threads': 'threads_post',
'pinterest': 'pinterest_pin',
'bluesky': 'bluesky_post',
'mastodon': 'mastodon_post',
};
return defaults[platform] || '';
};

View file

@ -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();
});

View file

@ -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');
});

View file

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

View file

@ -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 () {

View file

@ -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();
});