From 564f157e44a214df1c0df78f4847aca9236399b2 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 21:21:39 -0300 Subject: [PATCH 01/17] Fix MCP upload rate limits and Instagram Reel duration caps. Key signed uploads by workspace so ChatGPT's shared egress IPs don't throttle tenants together, raise the MCP upload cap to 300MB, and expose accurate Reel max durations via API/MCP. Co-authored-by: Cursor --- app/Enums/PostPlatform/ContentType.php | 26 +++++++++++++ .../Api/PlatformContentTypesResource.php | 1 + .../Tools/Platform/ListContentTypesTool.php | 3 +- app/Mcp/Tools/Post/RequestMediaUploadTool.php | 2 +- app/Providers/AppServiceProvider.php | 11 ++++++ config/ai.php | 2 +- lang/ar/posts.php | 2 +- lang/de/posts.php | 2 +- lang/el/posts.php | 2 +- lang/en/posts.php | 2 +- lang/es/posts.php | 2 +- lang/fr/posts.php | 2 +- lang/it/posts.php | 2 +- lang/ja/posts.php | 2 +- lang/ko/posts.php | 2 +- lang/nl/posts.php | 2 +- lang/pl/posts.php | 2 +- lang/pt-BR/posts.php | 2 +- lang/ru/posts.php | 2 +- lang/tr/posts.php | 2 +- lang/zh/posts.php | 2 +- routes/api.php | 2 +- tests/Feature/Api/PlatformApiTest.php | 18 ++++++++- tests/Feature/Api/UploadControllerTest.php | 37 +++++++++++++++---- tests/Feature/Mcp/PlatformToolTest.php | 29 +++++++++++++++ .../Mcp/RequestMediaUploadToolTest.php | 2 +- tests/Unit/Enums/ContentTypeTest.php | 10 ++++- 27 files changed, 142 insertions(+), 31 deletions(-) diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index b9338bc91..936337cc0 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -179,6 +179,32 @@ public function maxMediaCount(): int }; } + /** + * Maximum video duration in seconds for this content type, when the + * platform publishes a hard cap via API. Null when unlimited, unknown, + * or enforced dynamically (e.g. TikTok creator_info). + * + * Mirrors resources/js/composables/useMediaRules.ts. + */ + public function maxVideoDurationSec(): ?int + { + return match ($this) { + self::InstagramFeed => 60, + self::InstagramReel => 15 * 60, + self::InstagramStory => 60, + self::FacebookPost => 240 * 60, + self::FacebookReel => 90, + self::FacebookStory => 60, + self::LinkedInPost, self::LinkedInPagePost => 10 * 60, + self::YouTubeShort => 3 * 60, + self::PinterestVideoPin => 15 * 60, + self::XPost => 140, + self::ThreadsPost => 5 * 60, + self::BlueskyPost => 60, + default => null, + }; + } + public function supportsVideo(): bool { return match ($this) { diff --git a/app/Http/Resources/Api/PlatformContentTypesResource.php b/app/Http/Resources/Api/PlatformContentTypesResource.php index 1082de9fd..0d9132cb0 100644 --- a/app/Http/Resources/Api/PlatformContentTypesResource.php +++ b/app/Http/Resources/Api/PlatformContentTypesResource.php @@ -42,6 +42,7 @@ public function toArray(Request $request): array 'description' => $type->description(), 'max_media_count' => $type->maxMediaCount(), 'requires_media' => $type->requiresMedia(), + 'max_video_duration_sec' => $type->maxVideoDurationSec(), ], array_values(ContentType::forPlatform($platform)), ), diff --git a/app/Mcp/Tools/Platform/ListContentTypesTool.php b/app/Mcp/Tools/Platform/ListContentTypesTool.php index a7b3b632c..e240f3a27 100644 --- a/app/Mcp/Tools/Platform/ListContentTypesTool.php +++ b/app/Mcp/Tools/Platform/ListContentTypesTool.php @@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('List the valid content_types per social platform plus their constraints (max content length, recommended length, max media count, whether media is required, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')] +#[Description('List the valid content_types per social platform plus their constraints (max content length, recommended length, max media count, whether media is required, max video duration in seconds, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')] class ListContentTypesTool extends Tool { public function handle(Request $request): ResponseFactory @@ -29,6 +29,7 @@ public function handle(Request $request): ResponseFactory 'description' => $type->description(), 'max_media_count' => $type->maxMediaCount(), 'requires_media' => $type->requiresMedia(), + 'max_video_duration_sec' => $type->maxVideoDurationSec(), ], array_values(ContentType::forPlatform($platform)), ); diff --git a/app/Mcp/Tools/Post/RequestMediaUploadTool.php b/app/Mcp/Tools/Post/RequestMediaUploadTool.php index 2b380e507..889f120f3 100644 --- a/app/Mcp/Tools/Post/RequestMediaUploadTool.php +++ b/app/Mcp/Tools/Post/RequestMediaUploadTool.php @@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image, video, or PDF document, up to the workspace upload cap — 50 MB by default) directly to this workspace. Returns an upload_token and upload_url. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')] +#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image, video, or PDF document, up to the workspace upload cap — 300 MB by default) directly to this workspace. Returns an upload_token and upload_url. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')] class RequestMediaUploadTool extends Tool { public function handle(Request $request): Response|ResponseFactory diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 960c0244b..10c739ad7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -159,6 +159,17 @@ protected function configureRateLimiting(): void return Limit::perMinute(60)->by($request->workspace?->id ?: $request->ip()); }); + + // MCP signed uploads arrive from ChatGPT's shared egress IPs. Key by + // workspace_id (bound into the signed URL) so tenants don't share a bucket. + RateLimiter::for('mcp-uploads', function (Request $request) { + $workspaceId = (string) $request->query('workspace_id'); + + return [ + Limit::perMinute(60)->by('workspace:'.$workspaceId), + Limit::perMinute(600)->by('ip:'.$request->ip()), + ]; + }); } protected function configureStripeWebhooks(): void diff --git a/config/ai.php b/config/ai.php index c28a3160f..0034ca58c 100644 --- a/config/ai.php +++ b/config/ai.php @@ -53,7 +53,7 @@ 'mcp' => [ 'upload' => [ - 'max_size_mb' => (int) env('MCP_UPLOAD_MAX_SIZE_MB', 50), + 'max_size_mb' => (int) env('MCP_UPLOAD_MAX_SIZE_MB', 300), 'url_ttl_minutes' => (int) env('MCP_UPLOAD_URL_TTL_MINUTES', 15), ], ], diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 03d07a145..8d5901e5c 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'ريل', - 'description' => 'فيديو قصير حتى 90 ثانية', + 'description' => 'فيديو قصير حتى 15 دقيقة', ], 'instagram_story' => [ 'label' => 'قصة', diff --git a/lang/de/posts.php b/lang/de/posts.php index f876ef0e3..0aeef2b44 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -466,7 +466,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Kurzes Video bis zu 90 Sekunden', + 'description' => 'Kurzes Video bis zu 15 Minuten', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/el/posts.php b/lang/el/posts.php index 975fbdaed..470ff022e 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Σύντομο βίντεο έως 90 δευτερόλεπτα', + 'description' => 'Σύντομο βίντεο έως 15 λεπτά', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/en/posts.php b/lang/en/posts.php index dde760132..f15f8f151 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Short video up to 90 seconds', + 'description' => 'Short video up to 15 minutes', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/es/posts.php b/lang/es/posts.php index 6f53b10f4..61b04344c 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Video corto de hasta 90 segundos', + 'description' => 'Video corto de hasta 15 minutos', ], 'instagram_story' => [ 'label' => 'Historia', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 2887da406..7cbd880e7 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Courte vidéo jusqu\'à 90 secondes', + 'description' => 'Courte vidéo jusqu\'à 15 minutes', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/it/posts.php b/lang/it/posts.php index 6baf99966..ea9ca8de0 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Video breve fino a 90 secondi', + 'description' => 'Video breve fino a 15 minuti', ], 'instagram_story' => [ 'label' => 'Storia', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index 352e6ddd5..4c205ec4b 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'リール', - 'description' => '最大 90 秒のショート動画', + 'description' => '最大 15 分のショート動画', ], 'instagram_story' => [ 'label' => 'ストーリー', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 86f1f1ab5..2e58a2e72 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => '릴스', - 'description' => '최대 90초 짧은 동영상', + 'description' => '최대 15분 짧은 동영상', ], 'instagram_story' => [ 'label' => '스토리', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 75a6af89a..f13035b55 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => 'Korte video tot 90 seconden', + 'description' => 'Korte video tot 15 minuten', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index c30419ecc..3af64e343 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Rolka', - 'description' => 'Krótki film do 90 sekund', + 'description' => 'Krótki film do 15 minut', ], 'instagram_story' => [ 'label' => 'Relacja', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 513599545..d7d8856ae 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reels', - 'description' => 'Vídeo curto de até 90 segundos', + 'description' => 'Vídeo curto de até 15 minutos', ], 'instagram_story' => [ 'label' => 'Story', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index c97df56c6..c180e074d 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reels', - 'description' => 'Короткое видео до 90 секунд', + 'description' => 'Короткое видео до 15 минут', ], 'instagram_story' => [ 'label' => 'История', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 59007cc6d..2babf59fc 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -466,7 +466,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => '90 saniyeye kadar kısa video', + 'description' => '15 dakikaya kadar kısa video', ], 'instagram_story' => [ 'label' => 'Hikaye', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index fc5d3cb3d..0fec3de58 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -464,7 +464,7 @@ ], 'instagram_reel' => [ 'label' => 'Reel', - 'description' => '最长 90 秒的短视频', + 'description' => '最长 15 分钟的短视频', ], 'instagram_story' => [ 'label' => '快拍', diff --git a/routes/api.php b/routes/api.php index fe3c48f18..9c603e794 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,7 +13,7 @@ use Illuminate\Support\Facades\Route; Route::post('/uploads/{token}', [UploadController::class, 'store']) - ->middleware(['signed', 'throttle:10,1']) + ->middleware(['signed', 'throttle:mcp-uploads']) ->where('token', '[0-9a-f-]{36}') ->name('api.uploads.store'); diff --git a/tests/Feature/Api/PlatformApiTest.php b/tests/Feature/Api/PlatformApiTest.php index 204253c6e..954229b70 100644 --- a/tests/Feature/Api/PlatformApiTest.php +++ b/tests/Feature/Api/PlatformApiTest.php @@ -10,7 +10,7 @@ }); it('lists content types per platform', function () { - $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) ->getJson(route('api.content-types')) ->assertOk() ->assertJsonStructure([ @@ -23,11 +23,25 @@ 'allowed_media_types', 'default_content_type', 'content_types' => [ - '*' => ['value', 'label', 'description', 'max_media_count', 'requires_media'], + '*' => [ + 'value', + 'label', + 'description', + 'max_media_count', + 'requires_media', + 'max_video_duration_sec', + ], ], ], ], ]); + + $platforms = collect($response->json('platforms')); + $instagramTypes = collect($platforms->firstWhere('platform', 'instagram')['content_types']); + $facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']); + + expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900); + expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90); }); it('rejects content-types without auth', function () { diff --git a/tests/Feature/Api/UploadControllerTest.php b/tests/Feature/Api/UploadControllerTest.php index ac69900e9..fc0969d1c 100644 --- a/tests/Feature/Api/UploadControllerTest.php +++ b/tests/Feature/Api/UploadControllerTest.php @@ -102,9 +102,11 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = expect(Media::where('upload_token', $token)->count())->toBe(1); }); -test('rejects file larger than 50MB', function () { +test('rejects file larger than the MCP upload cap', function () { + config(['ai.mcp.upload.max_size_mb' => 1]); + $token = (string) Str::uuid(); - $file = UploadedFile::fake()->create('huge.mp4', 51 * 1024 + 1, 'video/mp4'); + $file = UploadedFile::fake()->create('huge.mp4', 1024 + 1, 'video/mp4'); $response = $this->postJson(signedUploadUrl($this->workspace, $token), ['media' => $file]); @@ -122,18 +124,37 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = expect(Media::where('upload_token', $token)->exists())->toBeFalse(); }); -test('rate limits floods from the same IP', function () { - for ($i = 0; $i < 10; $i++) { +test('rate limits floods from the same workspace', function () { + for ($i = 0; $i < 60; $i++) { $this->postJson( signedUploadUrl($this->workspace, (string) Str::uuid()), ['media' => UploadedFile::fake()->image("f{$i}.png", 16, 16)], - ); + )->assertSuccessful(); } - $response = $this->postJson( + $this->postJson( signedUploadUrl($this->workspace, (string) Str::uuid()), ['media' => UploadedFile::fake()->image('over.png', 16, 16)], - ); + )->assertStatus(429); +}); + +test('different workspaces on the same IP do not share the upload rate limit', function () { + $otherWorkspace = Workspace::factory()->create(); + + for ($i = 0; $i < 60; $i++) { + $this->postJson( + signedUploadUrl($this->workspace, (string) Str::uuid()), + ['media' => UploadedFile::fake()->image("a{$i}.png", 16, 16)], + )->assertSuccessful(); + } + + $this->postJson( + signedUploadUrl($this->workspace, (string) Str::uuid()), + ['media' => UploadedFile::fake()->image('blocked.png', 16, 16)], + )->assertStatus(429); - $response->assertStatus(429); + $this->postJson( + signedUploadUrl($otherWorkspace, (string) Str::uuid()), + ['media' => UploadedFile::fake()->image('other.png', 16, 16)], + )->assertSuccessful(); }); diff --git a/tests/Feature/Mcp/PlatformToolTest.php b/tests/Feature/Mcp/PlatformToolTest.php index e603f7cf2..e8828b208 100644 --- a/tests/Feature/Mcp/PlatformToolTest.php +++ b/tests/Feature/Mcp/PlatformToolTest.php @@ -33,6 +33,18 @@ 'default_content_type', 'content_types', ]) + ->has('content_types', fn (AssertableJson $types) => $types + ->each(fn (AssertableJson $type) => $type + ->hasAll([ + 'value', + 'label', + 'description', + 'max_media_count', + 'requires_media', + 'max_video_duration_sec', + ]) + ) + ) ) ); }); @@ -45,3 +57,20 @@ $response->assertOk() ->assertSee(['linkedin', 'linkedin_post', 'x_post', 'instagram_feed', 'mastodon_post']); }); + +test('list content types exposes reel max video durations', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(ListContentTypesTool::class, []); + + $response->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->etc(); + + $platforms = collect($json->toArray()['platforms']); + $instagramTypes = collect($platforms->firstWhere('platform', 'instagram')['content_types']); + $facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']); + + expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900); + expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90); + }); +}); diff --git a/tests/Feature/Mcp/RequestMediaUploadToolTest.php b/tests/Feature/Mcp/RequestMediaUploadToolTest.php index 8ce7afbce..72041356b 100644 --- a/tests/Feature/Mcp/RequestMediaUploadToolTest.php +++ b/tests/Feature/Mcp/RequestMediaUploadToolTest.php @@ -26,7 +26,7 @@ $json->has('upload_token') ->has('upload_url') ->has('expires_at') - ->where('max_bytes', 52428800) + ->where('max_bytes', 300 * 1024 * 1024) ->where('field_name', 'media') ->etc(); }); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index 6dd1ba83d..d37ac5e21 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -21,11 +21,19 @@ test('content type has correct descriptions', function () { expect(ContentType::InstagramFeed->description())->toContain('feed'); - expect(ContentType::InstagramReel->description())->toContain('90 seconds'); + expect(ContentType::InstagramReel->description())->toContain('15 minutes'); + expect(ContentType::FacebookReel->description())->toContain('90 seconds'); expect(ContentType::InstagramStory->description())->toContain('24 hours'); expect(ContentType::YouTubeShort->description())->toContain('3 minutes'); }); +test('content type exposes max video duration in seconds', function () { + expect(ContentType::InstagramReel->maxVideoDurationSec())->toBe(15 * 60); + expect(ContentType::FacebookReel->maxVideoDurationSec())->toBe(90); + expect(ContentType::YouTubeShort->maxVideoDurationSec())->toBe(3 * 60); + expect(ContentType::TikTokVideo->maxVideoDurationSec())->toBeNull(); +}); + test('content type maps to correct platform', function () { expect(ContentType::InstagramFeed->platform())->toBe(Platform::Instagram); expect(ContentType::InstagramReel->platform())->toBe(Platform::Instagram); From 3175fbe8c2cd943e8935094c21acf3c8566c9dd1 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 21:29:41 -0300 Subject: [PATCH 02/17] Centralize MCP upload size caps on trypost.media. Drop the separate ai.mcp.upload.max_size_mb default and reuse Media\Type limits so MCP matches web/API (1GB video ceiling with per-type enforcement). Co-authored-by: Cursor --- app/Http/Requests/Api/StoreUploadRequest.php | 34 +++++++++++++++++-- app/Mcp/Tools/Post/AttachMediaFromUrlTool.php | 2 +- app/Mcp/Tools/Post/RequestMediaUploadTool.php | 6 ++-- config/ai.php | 8 ++--- tests/Feature/Api/UploadControllerTest.php | 19 +++++++++-- .../Mcp/RequestMediaUploadToolTest.php | 3 +- 6 files changed, 58 insertions(+), 14 deletions(-) diff --git a/app/Http/Requests/Api/StoreUploadRequest.php b/app/Http/Requests/Api/StoreUploadRequest.php index f07237ed5..4e9665522 100644 --- a/app/Http/Requests/Api/StoreUploadRequest.php +++ b/app/Http/Requests/Api/StoreUploadRequest.php @@ -6,6 +6,7 @@ use App\Enums\Media\Type as MediaType; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Validator; class StoreUploadRequest extends FormRequest { @@ -25,15 +26,42 @@ public function rules(): array MediaType::Document->allowedMimeTypes(), )); - $maxKb = (int) config('ai.mcp.upload.max_size_mb') * 1024; - return [ + // Largest per-type cap as the FormRequest upper bound; per-type + // enforcement runs in withValidator() below — same pattern as + // StoreMediaRequest + PostController::storeMedia. 'media' => [ 'required', 'file', - "max:{$maxKb}", + 'max:'.MediaType::Video->maxSizeInKb(), "mimetypes:{$allowed}", ], ]; } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $file = $this->file('media'); + + if ($file === null || $validator->errors()->isNotEmpty()) { + return; + } + + $type = MediaType::fromMime((string) $file->getMimeType()); + + if ($type === null) { + $validator->errors()->add('media', 'This file type is not supported.'); + + return; + } + + if ($file->getSize() > $type->maxSizeInBytes()) { + $validator->errors()->add( + 'media', + "File size exceeds the maximum allowed for {$type->value} ({$type->maxSizeInMb()} MB).", + ); + } + }); + } } diff --git a/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php b/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php index c8dc44329..aa0adcf7c 100644 --- a/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php +++ b/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php @@ -58,7 +58,7 @@ public function schema(JsonSchema $schema): array 'alt' => $u->string()->description('Optional accessibility alt text for the image (ignored for video/PDF, which have no alt text).'), ])) ->required() - ->description('Media to attach. Max 10 per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'), + ->description('Media to attach. Max 10 per call. Per-type size caps match config trypost.media (image/video/document). Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'), ]; } } diff --git a/app/Mcp/Tools/Post/RequestMediaUploadTool.php b/app/Mcp/Tools/Post/RequestMediaUploadTool.php index 889f120f3..1352458e2 100644 --- a/app/Mcp/Tools/Post/RequestMediaUploadTool.php +++ b/app/Mcp/Tools/Post/RequestMediaUploadTool.php @@ -4,6 +4,7 @@ namespace App\Mcp\Tools\Post; +use App\Enums\Media\Type as MediaType; use Carbon\CarbonImmutable; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\Facades\URL; @@ -14,7 +15,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image, video, or PDF document, up to the workspace upload cap — 300 MB by default) directly to this workspace. Returns an upload_token and upload_url. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')] +#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image, video, or PDF document) directly to this workspace. Size caps match web/API media limits (video up to the configured video max; images and PDFs use their own smaller caps). Returns an upload_token, upload_url, and max_bytes. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')] class RequestMediaUploadTool extends Tool { public function handle(Request $request): Response|ResponseFactory @@ -22,7 +23,8 @@ public function handle(Request $request): Response|ResponseFactory $user = $request->user(); $workspaceId = $user->current_workspace_id; - $maxBytes = (int) config('ai.mcp.upload.max_size_mb') * 1024 * 1024; + // Same ceiling the web/API FormRequests use (largest per-type cap). + $maxBytes = MediaType::Video->maxSizeInBytes(); $ttlMinutes = (int) config('ai.mcp.upload.url_ttl_minutes'); $token = (string) Str::uuid(); diff --git a/config/ai.php b/config/ai.php index 0034ca58c..228d6fc96 100644 --- a/config/ai.php +++ b/config/ai.php @@ -44,16 +44,14 @@ | MCP Media Upload |-------------------------------------------------------------------------- | - | Settings for the MCP signed-URL upload flow. The hard cap is enforced - | per upload regardless of media type and is intentionally smaller than - | the per-type caps in config('trypost.media') because uploads flow - | through the app server. + | Settings for the MCP signed-URL upload flow. Size caps come from + | config('trypost.media.max_size_mb') via Media\Type — same source as + | web and REST API uploads. | */ 'mcp' => [ 'upload' => [ - 'max_size_mb' => (int) env('MCP_UPLOAD_MAX_SIZE_MB', 300), 'url_ttl_minutes' => (int) env('MCP_UPLOAD_URL_TTL_MINUTES', 15), ], ], diff --git a/tests/Feature/Api/UploadControllerTest.php b/tests/Feature/Api/UploadControllerTest.php index fc0969d1c..8ac2cf9ec 100644 --- a/tests/Feature/Api/UploadControllerTest.php +++ b/tests/Feature/Api/UploadControllerTest.php @@ -102,8 +102,8 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = expect(Media::where('upload_token', $token)->count())->toBe(1); }); -test('rejects file larger than the MCP upload cap', function () { - config(['ai.mcp.upload.max_size_mb' => 1]); +test('rejects file larger than the per-type media cap', function () { + config(['trypost.media.max_size_mb.video' => 1]); $token = (string) Str::uuid(); $file = UploadedFile::fake()->create('huge.mp4', 1024 + 1, 'video/mp4'); @@ -114,6 +114,21 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = expect(Media::where('upload_token', $token)->exists())->toBeFalse(); }); +test('rejects an image larger than the image media cap even when under the video ceiling', function () { + config([ + 'trypost.media.max_size_mb.image' => 1, + 'trypost.media.max_size_mb.video' => 1024, + ]); + + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->create('big.jpg', 1024 + 1, 'image/jpeg'); + + $response = $this->postJson(signedUploadUrl($this->workspace, $token), ['media' => $file]); + + $response->assertStatus(422); + expect(Media::where('upload_token', $token)->exists())->toBeFalse(); +}); + test('rejects disallowed mime type', function () { $token = (string) Str::uuid(); $file = UploadedFile::fake()->create('evil.exe', 10, 'application/octet-stream'); diff --git a/tests/Feature/Mcp/RequestMediaUploadToolTest.php b/tests/Feature/Mcp/RequestMediaUploadToolTest.php index 72041356b..51db448d6 100644 --- a/tests/Feature/Mcp/RequestMediaUploadToolTest.php +++ b/tests/Feature/Mcp/RequestMediaUploadToolTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\Media\Type as MediaType; use App\Enums\UserWorkspace\Role; use App\Mcp\Servers\TryPostServer; use App\Mcp\Tools\Post\RequestMediaUploadTool; @@ -26,7 +27,7 @@ $json->has('upload_token') ->has('upload_url') ->has('expires_at') - ->where('max_bytes', 300 * 1024 * 1024) + ->where('max_bytes', MediaType::Video->maxSizeInBytes()) ->where('field_name', 'media') ->etc(); }); From 302d902f755fb14af3175ed154bab25d0664aab5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 21:33:39 -0300 Subject: [PATCH 03/17] Move signed upload URL TTL into config/trypost.php. Keep API media upload settings out of the Laravel AI package config so they are not overwritten on package updates. Co-authored-by: Cursor --- app/Mcp/Tools/Post/RequestMediaUploadTool.php | 2 +- config/ai.php | 17 ----------------- config/trypost.php | 4 ++++ 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/app/Mcp/Tools/Post/RequestMediaUploadTool.php b/app/Mcp/Tools/Post/RequestMediaUploadTool.php index 1352458e2..2f1e4b9cc 100644 --- a/app/Mcp/Tools/Post/RequestMediaUploadTool.php +++ b/app/Mcp/Tools/Post/RequestMediaUploadTool.php @@ -25,7 +25,7 @@ public function handle(Request $request): Response|ResponseFactory // Same ceiling the web/API FormRequests use (largest per-type cap). $maxBytes = MediaType::Video->maxSizeInBytes(); - $ttlMinutes = (int) config('ai.mcp.upload.url_ttl_minutes'); + $ttlMinutes = (int) config('trypost.media.signed_upload_url_ttl_minutes'); $token = (string) Str::uuid(); $expiresAt = CarbonImmutable::now()->addMinutes($ttlMinutes); diff --git a/config/ai.php b/config/ai.php index 228d6fc96..1a393ff01 100644 --- a/config/ai.php +++ b/config/ai.php @@ -39,23 +39,6 @@ ], ], - /* - |-------------------------------------------------------------------------- - | MCP Media Upload - |-------------------------------------------------------------------------- - | - | Settings for the MCP signed-URL upload flow. Size caps come from - | config('trypost.media.max_size_mb') via Media\Type — same source as - | web and REST API uploads. - | - */ - - 'mcp' => [ - 'upload' => [ - 'url_ttl_minutes' => (int) env('MCP_UPLOAD_URL_TTL_MINUTES', 15), - ], - ], - /* |-------------------------------------------------------------------------- | AI Providers diff --git a/config/trypost.php b/config/trypost.php index 96749e8ae..3196567ec 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -56,6 +56,9 @@ | uploads (StoreAssetRequest, AssetController::storeChunked), URL | fetches (MediaAttacher), and the MediaType enum all read from here. | + | signed_upload_url_ttl_minutes controls the temporary signed POST URL + | issued for api.uploads.store (MCP / direct upload flow). + | */ 'media' => [ @@ -65,6 +68,7 @@ // LinkedIn caps document (PDF carousel) uploads at 100MB. 'document' => (int) env('MEDIA_DOCUMENT_MAX_SIZE_MB', 100), ], + 'signed_upload_url_ttl_minutes' => (int) env('MEDIA_SIGNED_UPLOAD_URL_TTL_MINUTES', 15), ], /* From 3d4a42c7b05dd264b52073180504589736129cb7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 21:36:53 -0300 Subject: [PATCH 04/17] Share content-type video duration caps to the frontend via Inertia. Keep ContentType as the single source of truth and stop hardcoding maxVideoDurationSec in useMediaRules. Co-authored-by: Cursor --- app/Enums/PostPlatform/ContentType.php | 22 ++++++- .../Middleware/App/HandleInertiaRequests.php | 12 ++++ resources/js/app.ts | 3 + resources/js/composables/useMediaRules.ts | 61 +++++++++++++------ resources/js/lib/contentTypeMediaRules.ts | 23 +++++++ resources/js/ssr.ts | 9 ++- resources/js/types/index.d.ts | 5 ++ .../ContentTypeMediaRulesShareTest.php | 27 ++++++++ tests/Unit/Enums/ContentTypeTest.php | 9 +++ 9 files changed, 149 insertions(+), 22 deletions(-) create mode 100644 resources/js/lib/contentTypeMediaRules.ts create mode 100644 tests/Feature/ContentTypeMediaRulesShareTest.php diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 936337cc0..7de5e1003 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -184,7 +184,8 @@ public function maxMediaCount(): int * platform publishes a hard cap via API. Null when unlimited, unknown, * or enforced dynamically (e.g. TikTok creator_info). * - * Mirrors resources/js/composables/useMediaRules.ts. + * Single source of truth for web (via Inertia shared props), REST API, + * and MCP content-type listings. */ public function maxVideoDurationSec(): ?int { @@ -205,6 +206,25 @@ public function maxVideoDurationSec(): ?int }; } + /** + * Media-rule fields the Vue editor needs. Shared once via Inertia so the + * frontend does not hardcode the same numbers. + * + * @return array + */ + public static function mediaRulesForFrontend(): array + { + $rules = []; + + foreach (self::cases() as $type) { + $rules[$type->value] = [ + 'max_video_duration_sec' => $type->maxVideoDurationSec(), + ]; + } + + return $rules; + } + public function supportsVideo(): bool { return match ($this) { diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index 5e51d6d89..167185be8 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -4,6 +4,7 @@ namespace App\Http\Middleware\App; +use App\Enums\PostPlatform\ContentType; use App\Http\Resources\App\HandleInertiaRequests\AuthAccountResource; use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource; use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource; @@ -62,4 +63,15 @@ public function share(Request $request): array 'githubAuthEnabled' => config('trypost.github_auth_enabled'), ]; } + + /** + * @return array + */ + public function shareOnce(Request $request): array + { + return [ + ...parent::shareOnce($request), + 'contentTypeMediaRules' => fn (): array => ContentType::mediaRulesForFrontend(), + ]; + } } diff --git a/resources/js/app.ts b/resources/js/app.ts index de757e698..6bdb68d69 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -9,6 +9,7 @@ import { createApp, h } from 'vue'; import { initializeDataLayer } from './datalayer'; import dayjs from './dayjs'; +import { syncContentTypeMediaRules } from './lib/contentTypeMediaRules'; import { capturePageview, initializePostHog, syncPostHogContext } from './posthog'; import type { Auth } from './types'; @@ -46,10 +47,12 @@ createInertiaApp({ // re-attach the right workspace group. initializePostHog(); syncPostHogContext(props.initialPage); + syncContentTypeMediaRules(props.initialPage); capturePageview(); router.on('navigate', (event) => { syncPostHogContext(event.detail.page); + syncContentTypeMediaRules(event.detail.page); capturePageview(); }); diff --git a/resources/js/composables/useMediaRules.ts b/resources/js/composables/useMediaRules.ts index e8782d1e4..60d11898d 100644 --- a/resources/js/composables/useMediaRules.ts +++ b/resources/js/composables/useMediaRules.ts @@ -1,5 +1,6 @@ import { computed, type Ref, type ComputedRef } from 'vue'; +import { maxVideoDurationSecFor } from '@/lib/contentTypeMediaRules'; import { fromMimeType, MediaType } from '@/lib/mediaType'; export interface MediaRules { @@ -23,24 +24,29 @@ export interface MediaRules { const MB = 1024 * 1024; const GB = 1024 * MB; +/** + * Client-side media constraints that are not duration caps. + * Video duration comes from ContentType::mediaRulesForFrontend() via + * Inertia shared once-props (see contentTypeMediaRules). + */ const CONTENT_TYPE_RULES: Record = { // Instagram instagram_feed: { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, + maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, aspectRatioMin: 0.8, aspectRatioMax: 1.91, }, instagram_reel: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxVideoBytes: 1 * GB, maxVideoDurationSec: 15 * 60, + maxVideoBytes: 1 * GB, aspectRatioMin: 0.5, aspectRatioMax: 0.6, }, instagram_story: { maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, + maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, aspectRatioMin: 0.5, aspectRatioMax: 0.6, autoFitsImage: true, }, @@ -48,18 +54,18 @@ const CONTENT_TYPE_RULES: Record = { facebook_post: { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false, acceptsGif: false, - maxImageBytes: 4 * MB, maxVideoBytes: 10 * GB, maxVideoDurationSec: 240 * 60, + maxImageBytes: 4 * MB, maxVideoBytes: 10 * GB, }, facebook_reel: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxVideoBytes: 1 * GB, maxVideoDurationSec: 90, + maxVideoBytes: 1 * GB, aspectRatioMin: 0.5, aspectRatioMax: 0.6, }, facebook_story: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxVideoBytes: 1 * GB, maxVideoDurationSec: 60, + maxVideoBytes: 1 * GB, aspectRatioMin: 0.5, aspectRatioMax: 0.6, }, @@ -69,12 +75,12 @@ const CONTENT_TYPE_RULES: Record = { linkedin_post: { maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true, requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true, - maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB, + maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxDocumentBytes: 100 * MB, }, linkedin_page_post: { maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true, requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true, - maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB, + maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxDocumentBytes: 100 * MB, }, // TikTok @@ -92,7 +98,7 @@ const CONTENT_TYPE_RULES: Record = { youtube_short: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxVideoBytes: 256 * GB, maxVideoDurationSec: 3 * 60, + maxVideoBytes: 256 * GB, aspectRatioMin: 0.5, aspectRatioMax: 0.6, }, @@ -105,7 +111,7 @@ const CONTENT_TYPE_RULES: Record = { pinterest_video_pin: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxVideoBytes: 2 * GB, maxVideoDurationSec: 15 * 60, + maxVideoBytes: 2 * GB, }, pinterest_carousel: { maxFiles: 5, minFiles: 2, acceptImages: true, acceptVideos: false, requiresMedia: true, @@ -117,14 +123,14 @@ const CONTENT_TYPE_RULES: Record = { x_post: { maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, acceptsGif: true, - maxImageBytes: 5 * MB, maxVideoBytes: 512 * MB, maxVideoDurationSec: 140, + maxImageBytes: 5 * MB, maxVideoBytes: 512 * MB, }, // Threads threads_post: { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false, acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, maxVideoDurationSec: 5 * 60, + maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, }, // Bluesky — accepts GIF; tight image size (auto-resized by backend). @@ -132,7 +138,7 @@ const CONTENT_TYPE_RULES: Record = { bluesky_post: { maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, acceptsGif: true, forbidsMixedMedia: true, - maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, + maxVideoBytes: 100 * MB, }, // Mastodon — accepts GIF @@ -151,9 +157,24 @@ const DEFAULT_RULES: MediaRules = { acceptsGif: true, }; -export function useMediaRules(contentType: Ref | ComputedRef) { +const withSharedDuration = (contentType: string, rules: MediaRules): MediaRules => { + const maxVideoDurationSec = maxVideoDurationSecFor(contentType); + + if (maxVideoDurationSec === undefined) { + return rules; + } + + return { + ...rules, + maxVideoDurationSec, + }; +}; + +export const useMediaRules = (contentType: Ref | ComputedRef) => { const rules = computed(() => { - return CONTENT_TYPE_RULES[contentType.value] || DEFAULT_RULES; + const base = CONTENT_TYPE_RULES[contentType.value] || DEFAULT_RULES; + + return withSharedDuration(contentType.value, base); }); const acceptMimeTypes = computed(() => { @@ -214,8 +235,10 @@ export function useMediaRules(contentType: Ref | ComputedRef) { isValidFileType, getAcceptDescription, }; -} +}; -export function getMediaRulesForContentType(contentType: string): MediaRules { - return CONTENT_TYPE_RULES[contentType] || DEFAULT_RULES; -} +export const getMediaRulesForContentType = (contentType: string): MediaRules => { + const base = CONTENT_TYPE_RULES[contentType] || DEFAULT_RULES; + + return withSharedDuration(contentType, base); +}; diff --git a/resources/js/lib/contentTypeMediaRules.ts b/resources/js/lib/contentTypeMediaRules.ts new file mode 100644 index 000000000..5ecb541cf --- /dev/null +++ b/resources/js/lib/contentTypeMediaRules.ts @@ -0,0 +1,23 @@ +import type { Page } from '@inertiajs/core'; + +export type ContentTypeMediaRule = { + max_video_duration_sec: number | null; +}; + +type ContentTypeMediaRulesMap = Record; + +let cachedRules: ContentTypeMediaRulesMap | null = null; + +export const syncContentTypeMediaRules = (page: Page): void => { + const rules = page.props.contentTypeMediaRules as ContentTypeMediaRulesMap | undefined; + + if (rules) { + cachedRules = rules; + } +}; + +export const maxVideoDurationSecFor = (contentType: string): number | undefined => { + const seconds = cachedRules?.[contentType]?.max_video_duration_sec; + + return typeof seconds === 'number' ? seconds : undefined; +}; diff --git a/resources/js/ssr.ts b/resources/js/ssr.ts index b57a46385..0c43b75eb 100644 --- a/resources/js/ssr.ts +++ b/resources/js/ssr.ts @@ -4,6 +4,8 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { createSSRApp, DefineComponent, h } from 'vue'; import { renderToString } from 'vue/server-renderer'; +import { syncContentTypeMediaRules } from './lib/contentTypeMediaRules'; + const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; createServer( @@ -17,8 +19,11 @@ createServer( `./pages/${name}.vue`, import.meta.glob('./pages/**/*.vue'), ), - setup: ({ App, props, plugin }) => - createSSRApp({ render: () => h(App, props) }).use(plugin), + setup: ({ App, props, plugin }) => { + syncContentTypeMediaRules(props.initialPage); + + return createSSRApp({ render: () => h(App, props) }).use(plugin); + }, }), { cluster: true }, ); diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index e95a50378..53a18636a 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -62,12 +62,17 @@ export interface NavItem { badge?: string; } +export interface ContentTypeMediaRule { + max_video_duration_sec: number | null; +} + export interface SharedData { name: string; auth: Auth; flash: FlashData; sidebarOpen: boolean; selfHosted: boolean; + contentTypeMediaRules?: Record; [key: string]: unknown; } diff --git a/tests/Feature/ContentTypeMediaRulesShareTest.php b/tests/Feature/ContentTypeMediaRulesShareTest.php new file mode 100644 index 000000000..cd78219d0 --- /dev/null +++ b/tests/Feature/ContentTypeMediaRulesShareTest.php @@ -0,0 +1,27 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +test('inertia shares content type media rules for the frontend', function () { + $this->actingAs($this->user) + ->get(route('app.posts.index')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('contentTypeMediaRules') + ->where('contentTypeMediaRules.instagram_reel.max_video_duration_sec', 900) + ->where('contentTypeMediaRules.facebook_reel.max_video_duration_sec', 90) + ->where('contentTypeMediaRules.tiktok_video.max_video_duration_sec', null) + ); +}); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index d37ac5e21..18a9e1eca 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -34,6 +34,15 @@ expect(ContentType::TikTokVideo->maxVideoDurationSec())->toBeNull(); }); +test('media rules for frontend expose duration caps keyed by content type', function () { + $rules = ContentType::mediaRulesForFrontend(); + + expect($rules['instagram_reel']['max_video_duration_sec'])->toBe(900); + expect($rules['facebook_reel']['max_video_duration_sec'])->toBe(90); + expect($rules['tiktok_video']['max_video_duration_sec'])->toBeNull(); + expect($rules)->toHaveCount(count(ContentType::cases())); +}); + test('content type maps to correct platform', function () { expect(ContentType::InstagramFeed->platform())->toBe(Platform::Instagram); expect(ContentType::InstagramReel->platform())->toBe(Platform::Instagram); From b1cc1a6f1efc566eadb25b2337057dec643c7f20 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 21:44:52 -0300 Subject: [PATCH 05/17] Raise MCP upload IP backstop to 1200/min. Give ChatGPT's shared egress more headroom across tenants while keeping the per-workspace cap at 60. Co-authored-by: Cursor --- app/Providers/AppServiceProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 10c739ad7..1daad530e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -167,7 +167,7 @@ protected function configureRateLimiting(): void return [ Limit::perMinute(60)->by('workspace:'.$workspaceId), - Limit::perMinute(600)->by('ip:'.$request->ip()), + Limit::perMinute(1200)->by('ip:'.$request->ip()), ]; }); } From ff0bfa8a06eb2db7e77ece3a1130f868896265ed Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 21:47:51 -0300 Subject: [PATCH 06/17] Expose Pinterest board IDs via MCP and REST API. Agents need board_id to publish pins; list boards per connected account so create/update can set platforms[].meta.board_id. Co-authored-by: Cursor --- .../SocialAccount/ListPinterestBoards.php | 29 +++++++ .../Api/SocialAccountController.php | 23 ++++++ app/Mcp/Servers/TryPostServer.php | 2 + app/Mcp/Tools/Post/CreatePostTool.php | 2 +- app/Mcp/Tools/Post/UpdatePostTool.php | 2 +- .../SocialAccount/ListPinterestBoardsTool.php | 48 +++++++++++ routes/api.php | 3 + tests/Feature/Api/PinterestBoardsApiTest.php | 79 +++++++++++++++++++ .../Mcp/ListPinterestBoardsToolTest.php | 79 +++++++++++++++++++ 9 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 app/Actions/SocialAccount/ListPinterestBoards.php create mode 100644 app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php create mode 100644 tests/Feature/Api/PinterestBoardsApiTest.php create mode 100644 tests/Feature/Mcp/ListPinterestBoardsToolTest.php diff --git a/app/Actions/SocialAccount/ListPinterestBoards.php b/app/Actions/SocialAccount/ListPinterestBoards.php new file mode 100644 index 000000000..0785486e6 --- /dev/null +++ b/app/Actions/SocialAccount/ListPinterestBoards.php @@ -0,0 +1,29 @@ + + */ + public static function execute(SocialAccount $account): array + { + $boards = app(PinterestPublisher::class)->getBoards($account); + + return Collection::make($boards) + ->map(fn (mixed $board): array => [ + 'id' => (string) data_get($board, 'id'), + 'name' => (string) data_get($board, 'name'), + ]) + ->filter(fn (array $board): bool => $board['id'] !== '') + ->values() + ->all(); + } +} diff --git a/app/Http/Controllers/Api/SocialAccountController.php b/app/Http/Controllers/Api/SocialAccountController.php index b6827dc58..bc4cf4c58 100644 --- a/app/Http/Controllers/Api/SocialAccountController.php +++ b/app/Http/Controllers/Api/SocialAccountController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api; +use App\Actions\SocialAccount\ListPinterestBoards; use App\Actions\SocialAccount\ToggleSocialAccount; +use App\Enums\SocialAccount\Platform; use App\Http\Resources\Api\SocialAccountResource; use App\Models\SocialAccount; use Illuminate\Http\JsonResponse; @@ -34,4 +36,25 @@ public function toggle(Request $request, SocialAccount $account): SocialAccountR return new SocialAccountResource($account); } + + public function boards(Request $request, SocialAccount $account): JsonResponse + { + if ($account->workspace_id !== $request->user()->currentWorkspace->id) { + return response()->json( + ['message' => 'Account not found.'], + Response::HTTP_NOT_FOUND, + ); + } + + if ($account->platform !== Platform::Pinterest) { + return response()->json( + ['message' => 'Boards are only available for Pinterest accounts.'], + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + return response()->json([ + 'boards' => ListPinterestBoards::execute($account), + ]); + } } diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php index cd2d18502..5f661dfbc 100644 --- a/app/Mcp/Servers/TryPostServer.php +++ b/app/Mcp/Servers/TryPostServer.php @@ -27,6 +27,7 @@ use App\Mcp\Tools\Signature\DeleteSignatureTool; use App\Mcp\Tools\Signature\ListSignaturesTool; use App\Mcp\Tools\Signature\UpdateSignatureTool; +use App\Mcp\Tools\SocialAccount\ListPinterestBoardsTool; use App\Mcp\Tools\SocialAccount\ListSocialAccountsTool; use App\Mcp\Tools\SocialAccount\ToggleSocialAccountTool; use App\Mcp\Tools\Workspace\GetWorkspaceTool; @@ -73,6 +74,7 @@ class TryPostServer extends Server // Social Accounts ListSocialAccountsTool::class, + ListPinterestBoardsTool::class, ToggleSocialAccountTool::class, // Workspace diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index 50c1ce08d..c2c61324a 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -63,7 +63,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'), 'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'), - 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), + 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first). Discord: channel_id (required to publish), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), ])) ->description('Platforms to publish on. Accounts not listed remain available but disabled.'), ]; diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 18dea6c55..bf2c9cb82 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -105,7 +105,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'), 'content_type' => $p->string()->description('New content_type for this platform.'), - 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions, embeds. Merged with existing meta.'), + 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first). Discord: channel_id (required to publish), mentions, embeds. Merged with existing meta.'), ])) ->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'), ]; diff --git a/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php b/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php new file mode 100644 index 000000000..d8dae901a --- /dev/null +++ b/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php @@ -0,0 +1,48 @@ +validate([ + 'account_id' => ['required', 'string', 'uuid'], + ]); + + $account = SocialAccount::where('workspace_id', $request->user()->current_workspace_id) + ->find(data_get($validated, 'account_id')); + + if (! $account) { + return Response::error('Social account not found.'); + } + + if ($account->platform !== Platform::Pinterest) { + return Response::error('This tool only works with Pinterest social accounts.'); + } + + return Response::structured([ + 'boards' => ListPinterestBoards::execute($account), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'account_id' => $schema->string()->required()->description('The UUID of the connected Pinterest social account.'), + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 9c603e794..85a86b44d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -50,6 +50,9 @@ // Social Accounts Route::get('/social-accounts', [SocialAccountController::class, 'index'])->name('api.social-accounts.index'); Route::put('/social-accounts/{account}/toggle', [SocialAccountController::class, 'toggle'])->name('api.social-accounts.toggle'); + Route::get('/social-accounts/{account}/boards', [SocialAccountController::class, 'boards']) + ->middleware('throttle:60,1') + ->name('api.social-accounts.boards'); // API Keys Route::get('/api-keys', [ApiKeyController::class, 'index'])->name('api.api-keys.index'); diff --git a/tests/Feature/Api/PinterestBoardsApiTest.php b/tests/Feature/Api/PinterestBoardsApiTest.php new file mode 100644 index 000000000..01967708c --- /dev/null +++ b/tests/Feature/Api/PinterestBoardsApiTest.php @@ -0,0 +1,79 @@ +user = $result['user']; + $this->workspace = $result['workspace']; + $this->plainToken = $result['plain_token']; +}); + +it('lists pinterest boards for a connected account', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'pinterest-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'items' => [ + ['id' => 'board_1', 'name' => 'Ideas', 'privacy' => 'PUBLIC'], + ['id' => 'board_2', 'name' => 'Product', 'privacy' => 'PUBLIC'], + ], + ], 200), + ]); + + $response = $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]); + + $response->assertOk(); + $response->assertExactJson([ + 'boards' => [ + ['id' => 'board_1', 'name' => 'Ideas'], + ['id' => 'board_2', 'name' => 'Product'], + ], + ]); +}); + +it('rejects boards listing for non-pinterest accounts', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + ]); + + $response = $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]); + + $response->assertUnprocessable(); + $response->assertJsonPath('message', 'Boards are only available for Pinterest accounts.'); +}); + +it('cannot list boards for an account from another workspace', function () { + $otherWorkspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $response = $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]); + + $response->assertNotFound(); +}); + +it('requires authentication to list boards', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->getJson(route('api.social-accounts.boards', $account)) + ->assertUnauthorized(); +}); diff --git a/tests/Feature/Mcp/ListPinterestBoardsToolTest.php b/tests/Feature/Mcp/ListPinterestBoardsToolTest.php new file mode 100644 index 000000000..6dec26b57 --- /dev/null +++ b/tests/Feature/Mcp/ListPinterestBoardsToolTest.php @@ -0,0 +1,79 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +test('lists pinterest boards as id and name', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'pinterest-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'items' => [ + ['id' => 'board_1', 'name' => 'Ideas', 'privacy' => 'PUBLIC'], + ['id' => 'board_2', 'name' => 'Product'], + ], + ], 200), + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]); + + $response->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->has('boards', 2) + ->where('boards.0.id', 'board_1') + ->where('boards.0.name', 'Ideas') + ->where('boards.1.id', 'board_2') + ->where('boards.1.name', 'Product'); + }); +}); + +test('rejects non-pinterest accounts', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['This tool only works with Pinterest social accounts.']); +}); + +test('cannot list boards for another workspace account', function () { + $otherWorkspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['Social account not found.']); +}); + +test('validates account_id is required', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, []); + + $response->assertHasErrors(); +}); From b28b18ef72824576699e35899a1f37797def0513 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 22:00:07 -0300 Subject: [PATCH 07/17] Address PR review: stream MCP uploads and close listing gaps. Stream signed uploads through addMediaFromPath, return per-type max_bytes, harden Pinterest/Discord listing errors and pagination, and keep frontend duration fallbacks when Inertia once-props have not synced. Co-authored-by: Cursor --- .../SocialAccount/ListDiscordChannels.php | 19 ++++ .../Api/SocialAccountController.php | 60 +++++++++++- app/Http/Controllers/Api/UploadController.php | 17 +++- app/Mcp/Servers/TryPostServer.php | 2 + app/Mcp/Tools/Post/CreatePostTool.php | 2 +- app/Mcp/Tools/Post/RequestMediaUploadTool.php | 11 ++- app/Mcp/Tools/Post/UpdatePostTool.php | 2 +- .../SocialAccount/ListDiscordChannelsTool.php | 53 +++++++++++ .../SocialAccount/ListPinterestBoardsTool.php | 14 ++- app/Models/Traits/HasMedia.php | 15 ++- app/Services/Social/PinterestPublisher.php | 43 +++++++-- config/trypost.php | 5 +- resources/js/composables/useMediaRules.ts | 46 +++++---- resources/js/lib/contentTypeMediaRules.ts | 20 +++- routes/api.php | 3 + tests/Feature/Api/DiscordChannelsApiTest.php | 94 ++++++++++++++++++ tests/Feature/Api/PinterestBoardsApiTest.php | 57 +++++++++++ tests/Feature/Api/UploadControllerTest.php | 29 ++++++ .../Mcp/ListDiscordChannelsToolTest.php | 95 +++++++++++++++++++ .../Mcp/ListPinterestBoardsToolTest.php | 18 ++++ .../Mcp/RequestMediaUploadToolTest.php | 3 + .../Social/PinterestPublisherTest.php | 25 +++++ 22 files changed, 584 insertions(+), 49 deletions(-) create mode 100644 app/Actions/SocialAccount/ListDiscordChannels.php create mode 100644 app/Mcp/Tools/SocialAccount/ListDiscordChannelsTool.php create mode 100644 tests/Feature/Api/DiscordChannelsApiTest.php create mode 100644 tests/Feature/Mcp/ListDiscordChannelsToolTest.php diff --git a/app/Actions/SocialAccount/ListDiscordChannels.php b/app/Actions/SocialAccount/ListDiscordChannels.php new file mode 100644 index 000000000..9e0cebf32 --- /dev/null +++ b/app/Actions/SocialAccount/ListDiscordChannels.php @@ -0,0 +1,19 @@ + + */ + public static function execute(SocialAccount $account): array + { + return app(DiscordClient::class)->channels((string) $account->platform_user_id); + } +} diff --git a/app/Http/Controllers/Api/SocialAccountController.php b/app/Http/Controllers/Api/SocialAccountController.php index bc4cf4c58..29b99e8d2 100644 --- a/app/Http/Controllers/Api/SocialAccountController.php +++ b/app/Http/Controllers/Api/SocialAccountController.php @@ -4,9 +4,14 @@ namespace App\Http\Controllers\Api; +use App\Actions\SocialAccount\ListDiscordChannels; use App\Actions\SocialAccount\ListPinterestBoards; use App\Actions\SocialAccount\ToggleSocialAccount; use App\Enums\SocialAccount\Platform; +use App\Exceptions\PlatformUnavailableException; +use App\Exceptions\Social\ErrorCategory; +use App\Exceptions\Social\PinterestPublishException; +use App\Exceptions\TokenExpiredException; use App\Http\Resources\Api\SocialAccountResource; use App\Models\SocialAccount; use Illuminate\Http\JsonResponse; @@ -53,8 +58,57 @@ public function boards(Request $request, SocialAccount $account): JsonResponse ); } - return response()->json([ - 'boards' => ListPinterestBoards::execute($account), - ]); + try { + return response()->json([ + 'boards' => ListPinterestBoards::execute($account), + ]); + } catch (TokenExpiredException $e) { + return response()->json( + ['message' => $e->getMessage()], + Response::HTTP_UNAUTHORIZED, + ); + } catch (PinterestPublishException $e) { + return response()->json( + ['message' => $e->userMessage], + $this->statusForPinterestCategory($e->category), + ); + } + } + + public function channels(Request $request, SocialAccount $account): JsonResponse + { + if ($account->workspace_id !== $request->user()->currentWorkspace->id) { + return response()->json( + ['message' => 'Account not found.'], + Response::HTTP_NOT_FOUND, + ); + } + + if ($account->platform !== Platform::Discord) { + return response()->json( + ['message' => 'Channels are only available for Discord accounts.'], + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + try { + return response()->json([ + 'channels' => ListDiscordChannels::execute($account), + ]); + } catch (PlatformUnavailableException $e) { + return response()->json( + ['message' => $e->getMessage()], + Response::HTTP_BAD_GATEWAY, + ); + } + } + + private function statusForPinterestCategory(ErrorCategory $category): int + { + return match ($category) { + ErrorCategory::RateLimit => Response::HTTP_TOO_MANY_REQUESTS, + ErrorCategory::Permission => Response::HTTP_FORBIDDEN, + default => Response::HTTP_BAD_GATEWAY, + }; } } diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index e6104975c..253e34973 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -35,9 +35,22 @@ public function store(StoreUploadRequest $request, string $token): JsonResponse } $workspace = Workspace::findOrFail((string) $request->query('workspace_id')); + $file = $request->file('media'); + $path = $file->getRealPath(); - $media = DB::transaction(function () use ($workspace, $request, $token): Media { - $media = $workspace->addMedia($request->file('media'), 'assets'); + // Stream from PHP's temp upload path — do not load the whole file into + // memory (addMedia() uses file_get_contents; videos can be up to 1GB). + if ($path === false) { + abort(Response::HTTP_UNPROCESSABLE_ENTITY, 'Unable to read uploaded file.'); + } + + $media = DB::transaction(function () use ($workspace, $file, $path, $token): Media { + $media = $workspace->addMediaFromPath( + $path, + $file->getClientOriginalName(), + 'assets', + mimeType: (string) $file->getMimeType(), + ); $media->upload_token = $token; $media->save(); diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php index 5f661dfbc..e9f709a20 100644 --- a/app/Mcp/Servers/TryPostServer.php +++ b/app/Mcp/Servers/TryPostServer.php @@ -27,6 +27,7 @@ use App\Mcp\Tools\Signature\DeleteSignatureTool; use App\Mcp\Tools\Signature\ListSignaturesTool; use App\Mcp\Tools\Signature\UpdateSignatureTool; +use App\Mcp\Tools\SocialAccount\ListDiscordChannelsTool; use App\Mcp\Tools\SocialAccount\ListPinterestBoardsTool; use App\Mcp\Tools\SocialAccount\ListSocialAccountsTool; use App\Mcp\Tools\SocialAccount\ToggleSocialAccountTool; @@ -75,6 +76,7 @@ class TryPostServer extends Server // Social Accounts ListSocialAccountsTool::class, ListPinterestBoardsTool::class, + ListDiscordChannelsTool::class, ToggleSocialAccountTool::class, // Workspace diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index c2c61324a..73730b92a 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -63,7 +63,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'), 'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'), - 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first). Discord: channel_id (required to publish), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), + 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first). Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), ])) ->description('Platforms to publish on. Accounts not listed remain available but disabled.'), ]; diff --git a/app/Mcp/Tools/Post/RequestMediaUploadTool.php b/app/Mcp/Tools/Post/RequestMediaUploadTool.php index 2f1e4b9cc..6585016a2 100644 --- a/app/Mcp/Tools/Post/RequestMediaUploadTool.php +++ b/app/Mcp/Tools/Post/RequestMediaUploadTool.php @@ -15,7 +15,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image, video, or PDF document) directly to this workspace. Size caps match web/API media limits (video up to the configured video max; images and PDFs use their own smaller caps). Returns an upload_token, upload_url, and max_bytes. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')] +#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image, video, or PDF document) directly to this workspace. Size caps match web/API media limits — see max_bytes_by_type (max_bytes is the overall ceiling, equal to the video cap). Returns an upload_token, upload_url, max_bytes, and max_bytes_by_type. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')] class RequestMediaUploadTool extends Tool { public function handle(Request $request): Response|ResponseFactory @@ -23,8 +23,6 @@ public function handle(Request $request): Response|ResponseFactory $user = $request->user(); $workspaceId = $user->current_workspace_id; - // Same ceiling the web/API FormRequests use (largest per-type cap). - $maxBytes = MediaType::Video->maxSizeInBytes(); $ttlMinutes = (int) config('trypost.media.signed_upload_url_ttl_minutes'); $token = (string) Str::uuid(); @@ -40,7 +38,12 @@ public function handle(Request $request): Response|ResponseFactory 'upload_token' => $token, 'upload_url' => $uploadUrl, 'expires_at' => $expiresAt->toIso8601String(), - 'max_bytes' => $maxBytes, + 'max_bytes' => MediaType::Video->maxSizeInBytes(), + 'max_bytes_by_type' => [ + 'image' => MediaType::Image->maxSizeInBytes(), + 'video' => MediaType::Video->maxSizeInBytes(), + 'document' => MediaType::Document->maxSizeInBytes(), + ], 'field_name' => 'media', ]); } diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index bf2c9cb82..46e643b64 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -105,7 +105,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'), 'content_type' => $p->string()->description('New content_type for this platform.'), - 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first). Discord: channel_id (required to publish), mentions, embeds. Merged with existing meta.'), + 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first). Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta.'), ])) ->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'), ]; diff --git a/app/Mcp/Tools/SocialAccount/ListDiscordChannelsTool.php b/app/Mcp/Tools/SocialAccount/ListDiscordChannelsTool.php new file mode 100644 index 000000000..704c4c479 --- /dev/null +++ b/app/Mcp/Tools/SocialAccount/ListDiscordChannelsTool.php @@ -0,0 +1,53 @@ +validate([ + 'account_id' => ['required', 'string', 'uuid'], + ]); + + $account = SocialAccount::where('workspace_id', $request->user()->current_workspace_id) + ->find(data_get($validated, 'account_id')); + + if (! $account) { + return Response::error('Social account not found.'); + } + + if ($account->platform !== Platform::Discord) { + return Response::error('This tool only works with Discord social accounts.'); + } + + try { + return Response::structured([ + 'channels' => ListDiscordChannels::execute($account), + ]); + } catch (PlatformUnavailableException $e) { + return Response::error($e->getMessage()); + } + } + + public function schema(JsonSchema $schema): array + { + return [ + 'account_id' => $schema->string()->required()->description('The UUID of the connected Discord social account.'), + ]; + } +} diff --git a/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php b/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php index d8dae901a..9a039281c 100644 --- a/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php +++ b/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php @@ -6,6 +6,8 @@ use App\Actions\SocialAccount\ListPinterestBoards; use App\Enums\SocialAccount\Platform; +use App\Exceptions\Social\PinterestPublishException; +use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; @@ -34,9 +36,15 @@ public function handle(Request $request): Response|ResponseFactory return Response::error('This tool only works with Pinterest social accounts.'); } - return Response::structured([ - 'boards' => ListPinterestBoards::execute($account), - ]); + try { + return Response::structured([ + 'boards' => ListPinterestBoards::execute($account), + ]); + } catch (TokenExpiredException $e) { + return Response::error($e->getMessage()); + } catch (PinterestPublishException $e) { + return Response::error($e->userMessage); + } } public function schema(JsonSchema $schema): array diff --git a/app/Models/Traits/HasMedia.php b/app/Models/Traits/HasMedia.php index da4b39eb8..8c41e0a99 100644 --- a/app/Models/Traits/HasMedia.php +++ b/app/Models/Traits/HasMedia.php @@ -16,6 +16,7 @@ use Intervention\Image\Drivers\Gd\Driver; use Intervention\Image\Encoders\JpegEncoder; use Intervention\Image\ImageManager; +use InvalidArgumentException; use RuntimeException; trait HasMedia @@ -109,13 +110,21 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr * Add media from a file path (used for chunked uploads). * Images are normalized in memory; videos/PDFs are streamed to storage. */ - public function addMediaFromPath(string $filePath, string $originalFilename, string $collection = 'default', array $meta = [], ?string $groupId = null): Media + public function addMediaFromPath(string $filePath, string $originalFilename, string $collection = 'default', array $meta = [], ?string $groupId = null, ?string $mimeType = null): Media { if ($this->isSingleMediaCollection($collection)) { $this->clearMediaCollection($collection); } - $mimeType = mime_content_type($filePath); + // Prefer an explicit MIME (e.g. from UploadedFile after FormRequest + // validation) — mime_content_type() misclassifies empty test fakes and + // some freshly written temps as application/x-empty. + $mimeType ??= mime_content_type($filePath) ?: null; + + if ($mimeType === null) { + throw new InvalidArgumentException("Unable to determine MIME type for media file: {$filePath}"); + } + $type = $this->getMediaType($mimeType); $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); @@ -239,7 +248,7 @@ private function streamFileToStorage(string $filePath, string $mimeType, string private function getMediaType(string $mimeType): string { return (Type::classify($mimeType) - ?? throw new \InvalidArgumentException("Unsupported media MIME type: {$mimeType}"))->value; + ?? throw new InvalidArgumentException("Unsupported media MIME type: {$mimeType}"))->value; } private function getMediaMeta(UploadedFile $file, string $type): array diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index 2d97128d0..6efa47b0b 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -405,7 +405,9 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId, } /** - * Get user's boards for board selection. + * Get user's boards for board selection (follows Pinterest bookmark pagination). + * + * @return list> */ public function getBoards(SocialAccount $account): array { @@ -413,17 +415,40 @@ public function getBoards(SocialAccount $account): array app(ConnectionVerifier::class)->refreshToken($account); } - $response = $this->socialHttp()->withToken($account->access_token) - ->get($this->baseUrl.'/boards', [ - 'page_size' => 100, - ]); + $boards = []; + $bookmark = null; + $maxPages = 20; - if ($response->failed()) { - Log::error('Pinterest get boards failed', ['body' => $this->redactResponseBody($response->body())]); - $this->handleApiError($response); + for ($page = 0; $page < $maxPages; $page++) { + $query = ['page_size' => 100]; + + if (filled($bookmark)) { + $query['bookmark'] = $bookmark; + } + + $response = $this->socialHttp()->withToken($account->access_token) + ->get($this->baseUrl.'/boards', $query); + + if ($response->failed()) { + Log::error('Pinterest get boards failed', ['body' => $this->redactResponseBody($response->body())]); + $this->handleApiError($response); + } + + $payload = $response->json() ?? []; + $items = data_get($payload, 'items', []); + + if (is_array($items) && $items !== []) { + array_push($boards, ...$items); + } + + $bookmark = data_get($payload, 'bookmark'); + + if (blank($bookmark)) { + break; + } } - return $response->json()['items'] ?? []; + return $boards; } private function handleApiError(Response $response): never diff --git a/config/trypost.php b/config/trypost.php index 3196567ec..f41bc29b9 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -58,6 +58,9 @@ | | signed_upload_url_ttl_minutes controls the temporary signed POST URL | issued for api.uploads.store (MCP / direct upload flow). + | MEDIA_SIGNED_UPLOAD_URL_TTL_MINUTES is preferred; MCP_UPLOAD_URL_TTL_MINUTES + | remains as a legacy fallback. MCP_UPLOAD_MAX_SIZE_MB was removed — size + | caps come from max_size_mb above (uploads stream to storage). | */ @@ -68,7 +71,7 @@ // LinkedIn caps document (PDF carousel) uploads at 100MB. 'document' => (int) env('MEDIA_DOCUMENT_MAX_SIZE_MB', 100), ], - 'signed_upload_url_ttl_minutes' => (int) env('MEDIA_SIGNED_UPLOAD_URL_TTL_MINUTES', 15), + 'signed_upload_url_ttl_minutes' => (int) (env('MEDIA_SIGNED_UPLOAD_URL_TTL_MINUTES') ?? env('MCP_UPLOAD_URL_TTL_MINUTES', 15)), ], /* diff --git a/resources/js/composables/useMediaRules.ts b/resources/js/composables/useMediaRules.ts index 60d11898d..089f71cf3 100644 --- a/resources/js/composables/useMediaRules.ts +++ b/resources/js/composables/useMediaRules.ts @@ -25,28 +25,28 @@ const MB = 1024 * 1024; const GB = 1024 * MB; /** - * Client-side media constraints that are not duration caps. - * Video duration comes from ContentType::mediaRulesForFrontend() via - * Inertia shared once-props (see contentTypeMediaRules). + * Client-side media constraints. Duration values here are fallbacks when + * Inertia shared contentTypeMediaRules has not synced yet; once synced, + * ContentType::maxVideoDurationSec() wins via withSharedDuration(). */ const CONTENT_TYPE_RULES: Record = { // Instagram instagram_feed: { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, + maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, aspectRatioMin: 0.8, aspectRatioMax: 1.91, }, instagram_reel: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxVideoBytes: 1 * GB, + maxVideoBytes: 1 * GB, maxVideoDurationSec: 15 * 60, aspectRatioMin: 0.5, aspectRatioMax: 0.6, }, instagram_story: { maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, + maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, aspectRatioMin: 0.5, aspectRatioMax: 0.6, autoFitsImage: true, }, @@ -54,18 +54,18 @@ const CONTENT_TYPE_RULES: Record = { facebook_post: { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false, acceptsGif: false, - maxImageBytes: 4 * MB, maxVideoBytes: 10 * GB, + maxImageBytes: 4 * MB, maxVideoBytes: 10 * GB, maxVideoDurationSec: 240 * 60, }, facebook_reel: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxVideoBytes: 1 * GB, + maxVideoBytes: 1 * GB, maxVideoDurationSec: 90, aspectRatioMin: 0.5, aspectRatioMax: 0.6, }, facebook_story: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxVideoBytes: 1 * GB, + maxVideoBytes: 1 * GB, maxVideoDurationSec: 60, aspectRatioMin: 0.5, aspectRatioMax: 0.6, }, @@ -75,12 +75,12 @@ const CONTENT_TYPE_RULES: Record = { linkedin_post: { maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true, requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true, - maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxDocumentBytes: 100 * MB, + maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB, }, linkedin_page_post: { maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true, requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true, - maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxDocumentBytes: 100 * MB, + maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB, }, // TikTok @@ -98,7 +98,7 @@ const CONTENT_TYPE_RULES: Record = { youtube_short: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxVideoBytes: 256 * GB, + maxVideoBytes: 256 * GB, maxVideoDurationSec: 3 * 60, aspectRatioMin: 0.5, aspectRatioMax: 0.6, }, @@ -111,7 +111,7 @@ const CONTENT_TYPE_RULES: Record = { pinterest_video_pin: { maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, acceptsGif: false, - maxVideoBytes: 2 * GB, + maxVideoBytes: 2 * GB, maxVideoDurationSec: 15 * 60, }, pinterest_carousel: { maxFiles: 5, minFiles: 2, acceptImages: true, acceptVideos: false, requiresMedia: true, @@ -123,14 +123,14 @@ const CONTENT_TYPE_RULES: Record = { x_post: { maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, acceptsGif: true, - maxImageBytes: 5 * MB, maxVideoBytes: 512 * MB, + maxImageBytes: 5 * MB, maxVideoBytes: 512 * MB, maxVideoDurationSec: 140, }, // Threads threads_post: { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false, acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, + maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, maxVideoDurationSec: 5 * 60, }, // Bluesky — accepts GIF; tight image size (auto-resized by backend). @@ -138,7 +138,7 @@ const CONTENT_TYPE_RULES: Record = { bluesky_post: { maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, acceptsGif: true, forbidsMixedMedia: true, - maxVideoBytes: 100 * MB, + maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, }, // Mastodon — accepts GIF @@ -158,15 +158,23 @@ const DEFAULT_RULES: MediaRules = { }; const withSharedDuration = (contentType: string, rules: MediaRules): MediaRules => { - const maxVideoDurationSec = maxVideoDurationSecFor(contentType); + const shared = maxVideoDurationSecFor(contentType); - if (maxVideoDurationSec === undefined) { + // Cache not synced yet — keep local hardcoded fallbacks. + if (shared === undefined) { return rules; } + // Explicitly unlimited / dynamic on the server (e.g. TikTok). + if (shared === null) { + const { maxVideoDurationSec: _ignored, ...rest } = rules; + + return rest; + } + return { ...rules, - maxVideoDurationSec, + maxVideoDurationSec: shared, }; }; diff --git a/resources/js/lib/contentTypeMediaRules.ts b/resources/js/lib/contentTypeMediaRules.ts index 5ecb541cf..8742bd767 100644 --- a/resources/js/lib/contentTypeMediaRules.ts +++ b/resources/js/lib/contentTypeMediaRules.ts @@ -16,8 +16,22 @@ export const syncContentTypeMediaRules = (page: Page): void => { } }; -export const maxVideoDurationSecFor = (contentType: string): number | undefined => { - const seconds = cachedRules?.[contentType]?.max_video_duration_sec; +/** + * Shared Inertia duration for a content type. + * - `undefined` — cache not synced yet (or unknown type): keep local fallbacks + * - `null` — explicitly unlimited / dynamic (e.g. TikTok) + * - `number` — hard cap in seconds from ContentType::maxVideoDurationSec() + */ +export const maxVideoDurationSecFor = (contentType: string): number | null | undefined => { + if (cachedRules === null) { + return undefined; + } + + const rule = cachedRules[contentType]; + + if (rule === undefined) { + return undefined; + } - return typeof seconds === 'number' ? seconds : undefined; + return rule.max_video_duration_sec; }; diff --git a/routes/api.php b/routes/api.php index 85a86b44d..21828742d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -53,6 +53,9 @@ Route::get('/social-accounts/{account}/boards', [SocialAccountController::class, 'boards']) ->middleware('throttle:60,1') ->name('api.social-accounts.boards'); + Route::get('/social-accounts/{account}/channels', [SocialAccountController::class, 'channels']) + ->middleware('throttle:60,1') + ->name('api.social-accounts.channels'); // API Keys Route::get('/api-keys', [ApiKeyController::class, 'index'])->name('api.api-keys.index'); diff --git a/tests/Feature/Api/DiscordChannelsApiTest.php b/tests/Feature/Api/DiscordChannelsApiTest.php new file mode 100644 index 000000000..adefb1fd4 --- /dev/null +++ b/tests/Feature/Api/DiscordChannelsApiTest.php @@ -0,0 +1,94 @@ + 'BOTTOKEN', + 'services.discord.client_id' => '999000111', + ]); + + $result = createApiTestToken(); + $this->user = $result['user']; + $this->workspace = $result['workspace']; + $this->plainToken = $result['plain_token']; +}); + +it('lists discord channels for a connected account', function () { + $account = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => '111222333', + ]); + + Http::fake([ + config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response([ + ['id' => '1', 'name' => 'general', 'type' => 0], + ['id' => '2', 'name' => 'voice', 'type' => 2], + ['id' => '3', 'name' => 'news', 'type' => 5], + ], 200), + config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response([ + ['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072'], + ], 200), + config('trypost.platforms.discord.api').'/guilds/111222333/members/999000111' => Http::response(['roles' => []], 200), + ]); + + $response = $this->getJson(route('api.social-accounts.channels', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]); + + $response->assertOk(); + $response->assertExactJson([ + 'channels' => [ + ['id' => '1', 'name' => 'general'], + ['id' => '3', 'name' => 'news'], + ], + ]); +}); + +it('returns bad gateway when discord channel lookup fails', function () { + $account = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => '111222333', + ]); + + Http::fake([ + config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response('upstream down', 500), + ]); + + $this->getJson(route('api.social-accounts.channels', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]) + ->assertStatus(502) + ->assertJsonPath('message', 'Discord channel lookup failed (500).'); +}); + +it('rejects channels listing for non-discord accounts', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + ]); + + $this->getJson(route('api.social-accounts.channels', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]) + ->assertUnprocessable() + ->assertJsonPath('message', 'Channels are only available for Discord accounts.'); +}); + +it('cannot list channels for an account from another workspace', function () { + $otherWorkspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $this->getJson(route('api.social-accounts.channels', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ])->assertNotFound(); +}); diff --git a/tests/Feature/Api/PinterestBoardsApiTest.php b/tests/Feature/Api/PinterestBoardsApiTest.php index 01967708c..8817b6e5a 100644 --- a/tests/Feature/Api/PinterestBoardsApiTest.php +++ b/tests/Feature/Api/PinterestBoardsApiTest.php @@ -42,6 +42,63 @@ ]); }); +it('returns an empty boards list when pinterest has none', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'pinterest-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'items' => [], + ], 200), + ]); + + $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]) + ->assertOk() + ->assertExactJson(['boards' => []]); +}); + +it('returns unauthorized when the pinterest token is expired', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'expired-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'message' => 'Access token has expired or been revoked', + ], 401), + ]); + + $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]) + ->assertUnauthorized() + ->assertJsonPath('message', 'Access token has expired or been revoked'); +}); + +it('returns bad gateway when pinterest is unavailable', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'pinterest-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'message' => 'Internal error', + ], 500), + ]); + + $this->getJson(route('api.social-accounts.boards', $account), [ + 'Authorization' => "Bearer {$this->plainToken}", + ]) + ->assertStatus(502) + ->assertJsonPath('message', 'Pinterest server error. Please try again.'); +}); + it('rejects boards listing for non-pinterest accounts', function () { $account = SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/Api/UploadControllerTest.php b/tests/Feature/Api/UploadControllerTest.php index 8ac2cf9ec..9fa48ebe3 100644 --- a/tests/Feature/Api/UploadControllerTest.php +++ b/tests/Feature/Api/UploadControllerTest.php @@ -129,6 +129,35 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = expect(Media::where('upload_token', $token)->exists())->toBeFalse(); }); +test('rejects a document larger than the document media cap even when under the video ceiling', function () { + config([ + 'trypost.media.max_size_mb.document' => 1, + 'trypost.media.max_size_mb.video' => 1024, + ]); + + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->create('big.pdf', 1024 + 1, 'application/pdf'); + + $response = $this->postJson(signedUploadUrl($this->workspace, $token), ['media' => $file]); + + $response->assertStatus(422); + expect(Media::where('upload_token', $token)->exists())->toBeFalse(); +}); + +test('stores a video upload via the streaming path', function () { + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->create('clip.mp4', 256, 'video/mp4'); + + $this->post(signedUploadUrl($this->workspace, $token), [ + 'media' => $file, + ])->assertCreated(); + + $media = Media::where('upload_token', $token)->first(); + expect($media)->not->toBeNull() + ->and($media->type->value)->toBe('video') + ->and(Storage::exists($media->path))->toBeTrue(); +}); + test('rejects disallowed mime type', function () { $token = (string) Str::uuid(); $file = UploadedFile::fake()->create('evil.exe', 10, 'application/octet-stream'); diff --git a/tests/Feature/Mcp/ListDiscordChannelsToolTest.php b/tests/Feature/Mcp/ListDiscordChannelsToolTest.php new file mode 100644 index 000000000..c4c6d4241 --- /dev/null +++ b/tests/Feature/Mcp/ListDiscordChannelsToolTest.php @@ -0,0 +1,95 @@ + 'BOTTOKEN', + 'services.discord.client_id' => '999000111', + ]); + + $this->user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +test('lists discord channels as id and name', function () { + $account = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => '111222333', + ]); + + Http::fake([ + config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response([ + ['id' => '1', 'name' => 'general', 'type' => 0], + ['id' => '2', 'name' => 'voice', 'type' => 2], + ], 200), + config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response([ + ['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072'], + ], 200), + config('trypost.platforms.discord.api').'/guilds/111222333/members/999000111' => Http::response(['roles' => []], 200), + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListDiscordChannelsTool::class, ['account_id' => $account->id]); + + $response->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->has('channels', 1) + ->where('channels.0.id', '1') + ->where('channels.0.name', 'general'); + }); +}); + +test('returns an actionable error when discord is unavailable', function () { + $account = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => '111222333', + ]); + + Http::fake([ + config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response('upstream down', 500), + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListDiscordChannelsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['Discord channel lookup failed (500).']); +}); + +test('rejects non-discord accounts', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListDiscordChannelsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['This tool only works with Discord social accounts.']); +}); + +test('cannot list channels for another workspace account', function () { + $otherWorkspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListDiscordChannelsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['Social account not found.']); +}); diff --git a/tests/Feature/Mcp/ListPinterestBoardsToolTest.php b/tests/Feature/Mcp/ListPinterestBoardsToolTest.php index 6dec26b57..1f0f48c39 100644 --- a/tests/Feature/Mcp/ListPinterestBoardsToolTest.php +++ b/tests/Feature/Mcp/ListPinterestBoardsToolTest.php @@ -47,6 +47,24 @@ }); }); +test('returns an actionable error when pinterest token is expired', function () { + $account = SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'expired-token', + ]); + + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::response([ + 'message' => 'Access token has expired or been revoked', + ], 401), + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]); + + $response->assertHasErrors(['Access token has expired or been revoked']); +}); + test('rejects non-pinterest accounts', function () { $account = SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/Mcp/RequestMediaUploadToolTest.php b/tests/Feature/Mcp/RequestMediaUploadToolTest.php index 51db448d6..e7177eb06 100644 --- a/tests/Feature/Mcp/RequestMediaUploadToolTest.php +++ b/tests/Feature/Mcp/RequestMediaUploadToolTest.php @@ -28,6 +28,9 @@ ->has('upload_url') ->has('expires_at') ->where('max_bytes', MediaType::Video->maxSizeInBytes()) + ->where('max_bytes_by_type.image', MediaType::Image->maxSizeInBytes()) + ->where('max_bytes_by_type.video', MediaType::Video->maxSizeInBytes()) + ->where('max_bytes_by_type.document', MediaType::Document->maxSizeInBytes()) ->where('field_name', 'media') ->etc(); }); diff --git a/tests/Feature/Services/Social/PinterestPublisherTest.php b/tests/Feature/Services/Social/PinterestPublisherTest.php index c39e1ffd6..e2d62ef3a 100644 --- a/tests/Feature/Services/Social/PinterestPublisherTest.php +++ b/tests/Feature/Services/Social/PinterestPublisherTest.php @@ -518,6 +518,31 @@ expect($boards[0]['id'])->toBe('board_1'); }); +test('pinterest publisher paginates boards via bookmark', function () { + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::sequence() + ->push([ + 'items' => [ + ['id' => 'board_1', 'name' => 'Board 1'], + ], + 'bookmark' => 'page-2', + ], 200) + ->push([ + 'items' => [ + ['id' => 'board_2', 'name' => 'Board 2'], + ], + ], 200), + ]); + + $boards = $this->publisher->getBoards($this->socialAccount); + + expect($boards)->toHaveCount(2) + ->and($boards[0]['id'])->toBe('board_1') + ->and($boards[1]['id'])->toBe('board_2'); + + Http::assertSentCount(2); +}); + test('pinterest publisher can publish video pin', function () { $this->postPlatform->update(['content_type' => ContentType::PinterestVideoPin]); From 7b986fca9c5145e48254db1b09a26f1d7bf3abae Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 22:01:48 -0300 Subject: [PATCH 08/17] Centralize content-type media rules on the ContentType enum. Share the full editor rule set (sizes, durations, accepts, aspect bounds) via Inertia so useMediaRules no longer hardcodes MB/GB math, and expose per-type byte caps on API/MCP content-type listings. Co-authored-by: Cursor --- app/Enums/PostPlatform/ContentType.php | 161 ++++++++++++++- .../Api/PlatformContentTypesResource.php | 3 + .../Tools/Platform/ListContentTypesTool.php | 5 +- resources/js/composables/useMediaRules.ts | 185 ++---------------- resources/js/lib/contentTypeMediaRules.ts | 98 ++++++++-- resources/js/types/index.d.ts | 14 ++ tests/Feature/Api/PlatformApiTest.php | 4 + .../ContentTypeMediaRulesShareTest.php | 3 + tests/Feature/Mcp/PlatformToolTest.php | 4 + tests/Unit/Enums/ContentTypeTest.php | 29 ++- 10 files changed, 312 insertions(+), 194 deletions(-) diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 7de5e1003..a4e84983a 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -207,24 +207,173 @@ public function maxVideoDurationSec(): ?int } /** - * Media-rule fields the Vue editor needs. Shared once via Inertia so the - * frontend does not hardcode the same numbers. + * Minimum attachments required (0 = no floor beyond requiresMedia()). + */ + public function minMediaCount(): int + { + return match ($this) { + self::PinterestCarousel => 2, + self::TikTokPhoto => 1, + default => 0, + }; + } + + /** + * Whether animated GIFs are accepted (kept as GIF, not flattened to JPEG). + */ + public function acceptsGif(): bool + { + return match ($this) { + self::XPost, self::BlueskyPost, self::MastodonPost => true, + default => false, + }; + } + + /** + * Per-type image size cap in bytes. Null when images are not accepted or + * the platform has no tighter editor-side limit than the global media cap. + */ + public function maxImageBytes(): ?int + { + return match ($this) { + self::InstagramFeed, self::InstagramStory => self::bytesFromMb(8), + self::FacebookPost => self::bytesFromMb(4), + self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(5), + self::PinterestPin, self::PinterestCarousel => self::bytesFromMb(20), + self::XPost => self::bytesFromMb(5), + self::ThreadsPost => self::bytesFromMb(8), + self::MastodonPost => self::bytesFromMb(10), + default => null, + }; + } + + /** + * Per-type video size cap in bytes. Null when videos are not accepted or + * the platform has no tighter editor-side limit. + */ + public function maxVideoBytes(): ?int + { + return match ($this) { + self::InstagramFeed, self::InstagramStory => self::bytesFromMb(100), + self::InstagramReel => self::bytesFromGb(1), + self::FacebookPost => self::bytesFromGb(10), + self::FacebookReel, self::FacebookStory => self::bytesFromGb(1), + self::LinkedInPost, self::LinkedInPagePost => self::bytesFromGb(5), + self::YouTubeShort => self::bytesFromGb(256), + self::PinterestVideoPin => self::bytesFromGb(2), + self::XPost => self::bytesFromMb(512), + self::ThreadsPost => self::bytesFromGb(1), + self::BlueskyPost => self::bytesFromMb(100), + self::MastodonPost => self::bytesFromMb(40), + default => null, + }; + } + + /** + * Per-type PDF size cap in bytes. Null when documents are not accepted. + */ + public function maxDocumentBytes(): ?int + { + return match ($this) { + self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(100), + default => null, + }; + } + + /** + * Soft aspect-ratio window used by the Vue cropper / media picker. * - * @return array + * @return array{min: float, max: float}|null + */ + public function aspectRatioBounds(): ?array + { + return match ($this) { + self::InstagramFeed => ['min' => 0.8, 'max' => 1.91], + self::InstagramReel, self::InstagramStory, + self::FacebookReel, self::FacebookStory, + self::YouTubeShort => ['min' => 0.5, 'max' => 0.6], + default => null, + }; + } + + /** + * Whether the editor should auto-fit still images into the story frame. + */ + public function autoFitsImage(): bool + { + return $this === self::InstagramStory; + } + + /** + * Full media-rule payload for the Vue editor (and any other consumer). + * Shared once via Inertia — do not re-hardcode these numbers in JS. + * + * @return array{ + * max_files: int, + * min_files: int|null, + * accept_images: bool, + * accept_videos: bool, + * accept_documents: bool, + * requires_media: bool, + * accepts_gif: bool, + * forbids_mixed_media: bool, + * max_image_bytes: int|null, + * max_video_bytes: int|null, + * max_document_bytes: int|null, + * max_video_duration_sec: int|null, + * aspect_ratio_min: float|null, + * aspect_ratio_max: float|null, + * auto_fits_image: bool + * } + */ + public function mediaRules(): array + { + $bounds = $this->aspectRatioBounds(); + $minFiles = $this->minMediaCount(); + + return [ + 'max_files' => $this->maxMediaCount(), + 'min_files' => $minFiles > 0 ? $minFiles : null, + 'accept_images' => $this->supportsImage(), + 'accept_videos' => $this->supportsVideo(), + 'accept_documents' => $this->supportsDocument(), + 'requires_media' => $this->requiresMedia(), + 'accepts_gif' => $this->acceptsGif(), + 'forbids_mixed_media' => ! $this->supportsMixedMedia(), + 'max_image_bytes' => $this->maxImageBytes(), + 'max_video_bytes' => $this->maxVideoBytes(), + 'max_document_bytes' => $this->maxDocumentBytes(), + 'max_video_duration_sec' => $this->maxVideoDurationSec(), + 'aspect_ratio_min' => $bounds['min'] ?? null, + 'aspect_ratio_max' => $bounds['max'] ?? null, + 'auto_fits_image' => $this->autoFitsImage(), + ]; + } + + /** + * @return array> */ public static function mediaRulesForFrontend(): array { $rules = []; foreach (self::cases() as $type) { - $rules[$type->value] = [ - 'max_video_duration_sec' => $type->maxVideoDurationSec(), - ]; + $rules[$type->value] = $type->mediaRules(); } return $rules; } + private static function bytesFromMb(int $megabytes): int + { + return $megabytes * 1024 * 1024; + } + + private static function bytesFromGb(int $gigabytes): int + { + return $gigabytes * 1024 * 1024 * 1024; + } + public function supportsVideo(): bool { return match ($this) { diff --git a/app/Http/Resources/Api/PlatformContentTypesResource.php b/app/Http/Resources/Api/PlatformContentTypesResource.php index 0d9132cb0..428d72e8e 100644 --- a/app/Http/Resources/Api/PlatformContentTypesResource.php +++ b/app/Http/Resources/Api/PlatformContentTypesResource.php @@ -43,6 +43,9 @@ public function toArray(Request $request): array 'max_media_count' => $type->maxMediaCount(), 'requires_media' => $type->requiresMedia(), 'max_video_duration_sec' => $type->maxVideoDurationSec(), + 'max_image_bytes' => $type->maxImageBytes(), + 'max_video_bytes' => $type->maxVideoBytes(), + 'max_document_bytes' => $type->maxDocumentBytes(), ], array_values(ContentType::forPlatform($platform)), ), diff --git a/app/Mcp/Tools/Platform/ListContentTypesTool.php b/app/Mcp/Tools/Platform/ListContentTypesTool.php index e240f3a27..315bc550d 100644 --- a/app/Mcp/Tools/Platform/ListContentTypesTool.php +++ b/app/Mcp/Tools/Platform/ListContentTypesTool.php @@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('List the valid content_types per social platform plus their constraints (max content length, recommended length, max media count, whether media is required, max video duration in seconds, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')] +#[Description('List the valid content_types per social platform plus their constraints (max content length, recommended length, max media count, whether media is required, max video duration in seconds, per-type max image/video/document bytes, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')] class ListContentTypesTool extends Tool { public function handle(Request $request): ResponseFactory @@ -30,6 +30,9 @@ public function handle(Request $request): ResponseFactory 'max_media_count' => $type->maxMediaCount(), 'requires_media' => $type->requiresMedia(), 'max_video_duration_sec' => $type->maxVideoDurationSec(), + 'max_image_bytes' => $type->maxImageBytes(), + 'max_video_bytes' => $type->maxVideoBytes(), + 'max_document_bytes' => $type->maxDocumentBytes(), ], array_values(ContentType::forPlatform($platform)), ); diff --git a/resources/js/composables/useMediaRules.ts b/resources/js/composables/useMediaRules.ts index 089f71cf3..d38b829f2 100644 --- a/resources/js/composables/useMediaRules.ts +++ b/resources/js/composables/useMediaRules.ts @@ -1,154 +1,18 @@ import { computed, type Ref, type ComputedRef } from 'vue'; -import { maxVideoDurationSecFor } from '@/lib/contentTypeMediaRules'; +import { + mediaRuleFor, + toMediaRules, + type MediaRules, +} from '@/lib/contentTypeMediaRules'; import { fromMimeType, MediaType } from '@/lib/mediaType'; -export interface MediaRules { - maxFiles: number; - minFiles?: number; - acceptImages: boolean; - acceptVideos: boolean; - acceptDocuments?: boolean; - requiresMedia: boolean; - acceptsGif: boolean; - forbidsMixedMedia?: boolean; - maxImageBytes?: number; - maxVideoBytes?: number; - maxDocumentBytes?: number; - maxVideoDurationSec?: number; - aspectRatioMin?: number; - aspectRatioMax?: number; - autoFitsImage?: boolean; -} - -const MB = 1024 * 1024; -const GB = 1024 * MB; +export type { MediaRules }; /** - * Client-side media constraints. Duration values here are fallbacks when - * Inertia shared contentTypeMediaRules has not synced yet; once synced, - * ContentType::maxVideoDurationSec() wins via withSharedDuration(). + * Fallback only when Inertia once-props have not synced yet (or unknown type). + * Real limits live in App\Enums\PostPlatform\ContentType::mediaRules(). */ -const CONTENT_TYPE_RULES: Record = { - // Instagram - instagram_feed: { - maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, - aspectRatioMin: 0.8, aspectRatioMax: 1.91, - }, - instagram_reel: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 1 * GB, maxVideoDurationSec: 15 * 60, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, - }, - instagram_story: { - maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, autoFitsImage: true, - }, - - // Facebook - facebook_post: { - maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: false, - maxImageBytes: 4 * MB, maxVideoBytes: 10 * GB, maxVideoDurationSec: 240 * 60, - }, - facebook_reel: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 1 * GB, maxVideoDurationSec: 90, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, - }, - facebook_story: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 1 * GB, maxVideoDurationSec: 60, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, - }, - - // LinkedIn — one type per account kind; the publish format (single image, - // multi-image, video, or PDF document) is inferred from the attached media. - // Images (up to 10) XOR one video XOR one PDF, never mixed. - linkedin_post: { - maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true, - requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true, - maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB, - }, - linkedin_page_post: { - maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true, - requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true, - maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB, - }, - - // TikTok - tiktok_video: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - // maxVideoDurationSec is enforced dynamically via creator_info - }, - tiktok_photo: { - maxFiles: 35, minFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true, - acceptsGif: false, - }, - - // YouTube - youtube_short: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 256 * GB, maxVideoDurationSec: 3 * 60, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, - }, - - // Pinterest - pinterest_pin: { - maxFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 20 * MB, - }, - pinterest_video_pin: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 2 * GB, maxVideoDurationSec: 15 * 60, - }, - pinterest_carousel: { - maxFiles: 5, minFiles: 2, acceptImages: true, acceptVideos: false, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 20 * MB, - }, - - // X (Twitter) — accepts GIF with animation - x_post: { - maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: true, - maxImageBytes: 5 * MB, maxVideoBytes: 512 * MB, maxVideoDurationSec: 140, - }, - - // Threads - threads_post: { - maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, maxVideoDurationSec: 5 * 60, - }, - - // Bluesky — accepts GIF; tight image size (auto-resized by backend). - // The embed is images XOR video, so the two can't be combined in one post. - bluesky_post: { - maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: true, forbidsMixedMedia: true, - maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, - }, - - // Mastodon — accepts GIF - mastodon_post: { - maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: true, - maxImageBytes: 10 * MB, maxVideoBytes: 40 * MB, - }, -}; - const DEFAULT_RULES: MediaRules = { maxFiles: 10, acceptImages: true, @@ -157,33 +21,18 @@ const DEFAULT_RULES: MediaRules = { acceptsGif: true, }; -const withSharedDuration = (contentType: string, rules: MediaRules): MediaRules => { - const shared = maxVideoDurationSecFor(contentType); - - // Cache not synced yet — keep local hardcoded fallbacks. - if (shared === undefined) { - return rules; - } - - // Explicitly unlimited / dynamic on the server (e.g. TikTok). - if (shared === null) { - const { maxVideoDurationSec: _ignored, ...rest } = rules; +export const getMediaRulesForContentType = (contentType: string): MediaRules => { + const shared = mediaRuleFor(contentType); - return rest; + if (!shared) { + return DEFAULT_RULES; } - return { - ...rules, - maxVideoDurationSec: shared, - }; + return toMediaRules(shared); }; export const useMediaRules = (contentType: Ref | ComputedRef) => { - const rules = computed(() => { - const base = CONTENT_TYPE_RULES[contentType.value] || DEFAULT_RULES; - - return withSharedDuration(contentType.value, base); - }); + const rules = computed(() => getMediaRulesForContentType(contentType.value)); const acceptMimeTypes = computed(() => { const types: string[] = []; @@ -244,9 +93,3 @@ export const useMediaRules = (contentType: Ref | ComputedRef) => getAcceptDescription, }; }; - -export const getMediaRulesForContentType = (contentType: string): MediaRules => { - const base = CONTENT_TYPE_RULES[contentType] || DEFAULT_RULES; - - return withSharedDuration(contentType, base); -}; diff --git a/resources/js/lib/contentTypeMediaRules.ts b/resources/js/lib/contentTypeMediaRules.ts index 8742bd767..dd771f660 100644 --- a/resources/js/lib/contentTypeMediaRules.ts +++ b/resources/js/lib/contentTypeMediaRules.ts @@ -1,7 +1,43 @@ import type { Page } from '@inertiajs/core'; +/** CamelCase shape consumed by the Vue media picker / compliance checks. */ +export type MediaRules = { + maxFiles: number; + minFiles?: number; + acceptImages: boolean; + acceptVideos: boolean; + acceptDocuments?: boolean; + requiresMedia: boolean; + acceptsGif: boolean; + forbidsMixedMedia?: boolean; + maxImageBytes?: number; + maxVideoBytes?: number; + maxDocumentBytes?: number; + maxVideoDurationSec?: number; + aspectRatioMin?: number; + aspectRatioMax?: number; + autoFitsImage?: boolean; +}; + +/** + * Snake_case payload from ContentType::mediaRules() / mediaRulesForFrontend(). + */ export type ContentTypeMediaRule = { + max_files: number; + min_files: number | null; + accept_images: boolean; + accept_videos: boolean; + accept_documents: boolean; + requires_media: boolean; + accepts_gif: boolean; + forbids_mixed_media: boolean; + max_image_bytes: number | null; + max_video_bytes: number | null; + max_document_bytes: number | null; max_video_duration_sec: number | null; + aspect_ratio_min: number | null; + aspect_ratio_max: number | null; + auto_fits_image: boolean; }; type ContentTypeMediaRulesMap = Record; @@ -16,22 +52,58 @@ export const syncContentTypeMediaRules = (page: Page): void => { } }; -/** - * Shared Inertia duration for a content type. - * - `undefined` — cache not synced yet (or unknown type): keep local fallbacks - * - `null` — explicitly unlimited / dynamic (e.g. TikTok) - * - `number` — hard cap in seconds from ContentType::maxVideoDurationSec() - */ -export const maxVideoDurationSecFor = (contentType: string): number | null | undefined => { - if (cachedRules === null) { - return undefined; +export const mediaRuleFor = (contentType: string): ContentTypeMediaRule | undefined => { + return cachedRules?.[contentType]; +}; + +export const toMediaRules = (rule: ContentTypeMediaRule): MediaRules => { + const mapped: MediaRules = { + maxFiles: rule.max_files, + acceptImages: rule.accept_images, + acceptVideos: rule.accept_videos, + requiresMedia: rule.requires_media, + acceptsGif: rule.accepts_gif, + }; + + if (rule.min_files !== null) { + mapped.minFiles = rule.min_files; + } + + if (rule.accept_documents) { + mapped.acceptDocuments = true; + } + + if (rule.forbids_mixed_media) { + mapped.forbidsMixedMedia = true; + } + + if (rule.max_image_bytes !== null) { + mapped.maxImageBytes = rule.max_image_bytes; + } + + if (rule.max_video_bytes !== null) { + mapped.maxVideoBytes = rule.max_video_bytes; + } + + if (rule.max_document_bytes !== null) { + mapped.maxDocumentBytes = rule.max_document_bytes; + } + + if (rule.max_video_duration_sec !== null) { + mapped.maxVideoDurationSec = rule.max_video_duration_sec; } - const rule = cachedRules[contentType]; + if (rule.aspect_ratio_min !== null) { + mapped.aspectRatioMin = rule.aspect_ratio_min; + } + + if (rule.aspect_ratio_max !== null) { + mapped.aspectRatioMax = rule.aspect_ratio_max; + } - if (rule === undefined) { - return undefined; + if (rule.auto_fits_image) { + mapped.autoFitsImage = true; } - return rule.max_video_duration_sec; + return mapped; }; diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 53a18636a..970304528 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -63,7 +63,21 @@ export interface NavItem { } export interface ContentTypeMediaRule { + max_files: number; + min_files: number | null; + accept_images: boolean; + accept_videos: boolean; + accept_documents: boolean; + requires_media: boolean; + accepts_gif: boolean; + forbids_mixed_media: boolean; + max_image_bytes: number | null; + max_video_bytes: number | null; + max_document_bytes: number | null; max_video_duration_sec: number | null; + aspect_ratio_min: number | null; + aspect_ratio_max: number | null; + auto_fits_image: boolean; } export interface SharedData { diff --git a/tests/Feature/Api/PlatformApiTest.php b/tests/Feature/Api/PlatformApiTest.php index 954229b70..214351780 100644 --- a/tests/Feature/Api/PlatformApiTest.php +++ b/tests/Feature/Api/PlatformApiTest.php @@ -30,6 +30,9 @@ 'max_media_count', 'requires_media', 'max_video_duration_sec', + 'max_image_bytes', + 'max_video_bytes', + 'max_document_bytes', ], ], ], @@ -41,6 +44,7 @@ $facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']); expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900); + expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_bytes'])->toBe(1 * 1024 * 1024 * 1024); expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90); }); diff --git a/tests/Feature/ContentTypeMediaRulesShareTest.php b/tests/Feature/ContentTypeMediaRulesShareTest.php index cd78219d0..d5a8511c4 100644 --- a/tests/Feature/ContentTypeMediaRulesShareTest.php +++ b/tests/Feature/ContentTypeMediaRulesShareTest.php @@ -21,7 +21,10 @@ ->assertInertia(fn (AssertableInertia $page) => $page ->has('contentTypeMediaRules') ->where('contentTypeMediaRules.instagram_reel.max_video_duration_sec', 900) + ->where('contentTypeMediaRules.instagram_reel.max_video_bytes', 1 * 1024 * 1024 * 1024) + ->where('contentTypeMediaRules.instagram_reel.accept_images', false) ->where('contentTypeMediaRules.facebook_reel.max_video_duration_sec', 90) ->where('contentTypeMediaRules.tiktok_video.max_video_duration_sec', null) + ->where('contentTypeMediaRules.linkedin_post.max_document_bytes', 100 * 1024 * 1024) ); }); diff --git a/tests/Feature/Mcp/PlatformToolTest.php b/tests/Feature/Mcp/PlatformToolTest.php index e8828b208..684c5b242 100644 --- a/tests/Feature/Mcp/PlatformToolTest.php +++ b/tests/Feature/Mcp/PlatformToolTest.php @@ -42,6 +42,9 @@ 'max_media_count', 'requires_media', 'max_video_duration_sec', + 'max_image_bytes', + 'max_video_bytes', + 'max_document_bytes', ]) ) ) @@ -71,6 +74,7 @@ $facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']); expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900); + expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_bytes'])->toBe(1 * 1024 * 1024 * 1024); expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90); }); }); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index 18a9e1eca..9c826076a 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -34,13 +34,36 @@ expect(ContentType::TikTokVideo->maxVideoDurationSec())->toBeNull(); }); -test('media rules for frontend expose duration caps keyed by content type', function () { +test('media rules for frontend expose the full editor rule set keyed by content type', function () { $rules = ContentType::mediaRulesForFrontend(); - expect($rules['instagram_reel']['max_video_duration_sec'])->toBe(900); + expect($rules)->toHaveCount(count(ContentType::cases())); + + expect($rules['instagram_reel'])->toMatchArray([ + 'max_files' => 1, + 'accept_images' => false, + 'accept_videos' => true, + 'requires_media' => true, + 'max_video_bytes' => 1 * 1024 * 1024 * 1024, + 'max_video_duration_sec' => 900, + 'aspect_ratio_min' => 0.5, + 'aspect_ratio_max' => 0.6, + ]); + expect($rules['facebook_reel']['max_video_duration_sec'])->toBe(90); expect($rules['tiktok_video']['max_video_duration_sec'])->toBeNull(); - expect($rules)->toHaveCount(count(ContentType::cases())); + expect($rules['linkedin_post']['max_document_bytes'])->toBe(100 * 1024 * 1024); + expect($rules['pinterest_carousel']['min_files'])->toBe(2); + expect($rules['x_post']['accepts_gif'])->toBeTrue(); +}); + +test('media rules reuse enum capability helpers', function () { + $rules = ContentType::InstagramStory->mediaRules(); + + expect($rules['accept_images'])->toBe(ContentType::InstagramStory->supportsImage()) + ->and($rules['accept_videos'])->toBe(ContentType::InstagramStory->supportsVideo()) + ->and($rules['auto_fits_image'])->toBeTrue() + ->and($rules['max_files'])->toBe(ContentType::InstagramStory->maxMediaCount()); }); test('content type maps to correct platform', function () { From c040ba4686f1a2882f87b5fcdde860b9d0a283c3 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 22:09:35 -0300 Subject: [PATCH 09/17] Authorize API social accounts via SocialAccountPolicy. Replace repeated workspace_id checks with PostPolicy-style denyAsNotFound tenancy so cross-tenant lookups stay 404 without leaking existence. Co-authored-by: Cursor --- .../Api/SocialAccountController.php | 23 ++--------- app/Policies/SocialAccountPolicy.php | 26 ++++++++++++ .../Unit/Policies/SocialAccountPolicyTest.php | 40 +++++++++++++++++++ 3 files changed, 70 insertions(+), 19 deletions(-) create mode 100644 app/Policies/SocialAccountPolicy.php create mode 100644 tests/Unit/Policies/SocialAccountPolicyTest.php diff --git a/app/Http/Controllers/Api/SocialAccountController.php b/app/Http/Controllers/Api/SocialAccountController.php index 29b99e8d2..556c4776f 100644 --- a/app/Http/Controllers/Api/SocialAccountController.php +++ b/app/Http/Controllers/Api/SocialAccountController.php @@ -28,14 +28,9 @@ public function index(Request $request): AnonymousResourceCollection return SocialAccountResource::collection($accounts); } - public function toggle(Request $request, SocialAccount $account): SocialAccountResource|JsonResponse + public function toggle(Request $request, SocialAccount $account): SocialAccountResource { - if ($account->workspace_id !== $request->user()->currentWorkspace->id) { - return response()->json( - ['message' => 'Account not found.'], - Response::HTTP_NOT_FOUND, - ); - } + $this->authorize('view', $account); ToggleSocialAccount::execute($account); @@ -44,12 +39,7 @@ public function toggle(Request $request, SocialAccount $account): SocialAccountR public function boards(Request $request, SocialAccount $account): JsonResponse { - if ($account->workspace_id !== $request->user()->currentWorkspace->id) { - return response()->json( - ['message' => 'Account not found.'], - Response::HTTP_NOT_FOUND, - ); - } + $this->authorize('view', $account); if ($account->platform !== Platform::Pinterest) { return response()->json( @@ -77,12 +67,7 @@ public function boards(Request $request, SocialAccount $account): JsonResponse public function channels(Request $request, SocialAccount $account): JsonResponse { - if ($account->workspace_id !== $request->user()->currentWorkspace->id) { - return response()->json( - ['message' => 'Account not found.'], - Response::HTTP_NOT_FOUND, - ); - } + $this->authorize('view', $account); if ($account->platform !== Platform::Discord) { return response()->json( diff --git a/app/Policies/SocialAccountPolicy.php b/app/Policies/SocialAccountPolicy.php new file mode 100644 index 000000000..94422b759 --- /dev/null +++ b/app/Policies/SocialAccountPolicy.php @@ -0,0 +1,26 @@ +workspace_id !== $user->current_workspace_id) { + return Response::denyAsNotFound(); + } + + return true; + } +} diff --git a/tests/Unit/Policies/SocialAccountPolicyTest.php b/tests/Unit/Policies/SocialAccountPolicyTest.php new file mode 100644 index 000000000..99a6bc90e --- /dev/null +++ b/tests/Unit/Policies/SocialAccountPolicyTest.php @@ -0,0 +1,40 @@ +policy = new SocialAccountPolicy; +}); + +test('members of the current workspace can view a social account', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $workspace->members()->attach($user->id, ['role' => Role::Member->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $account = SocialAccount::factory()->create(['workspace_id' => $workspace->id]); + + expect($this->policy->view($user->fresh(), $account))->toBeTrue(); +}); + +test('cross-workspace social account lookups deny as not found', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $workspace->members()->attach($user->id, ['role' => Role::Member->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $otherWorkspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->create(['workspace_id' => $otherWorkspace->id]); + + $result = $this->policy->view($user->fresh(), $account); + + expect($result)->toBeInstanceOf(Response::class) + ->and($result->status())->toBe(404); +}); From 491895cbd9a7772b794cdeb8647beade67c4cfe9 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 22:12:50 -0300 Subject: [PATCH 10/17] Page through all Pinterest boards until the bookmark ends. Replace the 20-page hard stop with a while-loop that follows bookmarks to completion, keeping only safety breaks for a repeated cursor or an absurd page ceiling. Co-authored-by: Cursor --- app/Services/Social/PinterestPublisher.php | 37 ++++++++++++++++--- .../Social/PinterestPublisherTest.php | 35 +++++++++++++++++- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index 6efa47b0b..dbbf57430 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -405,7 +405,8 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId, } /** - * Get user's boards for board selection (follows Pinterest bookmark pagination). + * Get user's boards for board selection (follows Pinterest bookmark pagination + * until the API stops returning a bookmark). * * @return list> */ @@ -417,9 +418,23 @@ public function getBoards(SocialAccount $account): array $boards = []; $bookmark = null; - $maxPages = 20; + $pages = 0; + // Absurd ceiling only — normal accounts exit when bookmark is blank. + $maxPages = 1000; + + while (true) { + $pages++; + + if ($pages > $maxPages) { + Log::warning('Pinterest get boards hit safety page cap', [ + 'account_id' => $account->id, + 'pages' => $pages, + 'boards' => count($boards), + ]); + + break; + } - for ($page = 0; $page < $maxPages; $page++) { $query = ['page_size' => 100]; if (filled($bookmark)) { @@ -441,11 +456,23 @@ public function getBoards(SocialAccount $account): array array_push($boards, ...$items); } - $bookmark = data_get($payload, 'bookmark'); + $nextBookmark = data_get($payload, 'bookmark'); + + if (blank($nextBookmark)) { + break; + } + + if ($nextBookmark === $bookmark) { + Log::warning('Pinterest get boards returned a repeated bookmark', [ + 'account_id' => $account->id, + 'pages' => $pages, + 'boards' => count($boards), + ]); - if (blank($bookmark)) { break; } + + $bookmark = $nextBookmark; } return $boards; diff --git a/tests/Feature/Services/Social/PinterestPublisherTest.php b/tests/Feature/Services/Social/PinterestPublisherTest.php index e2d62ef3a..1d5b9ac54 100644 --- a/tests/Feature/Services/Social/PinterestPublisherTest.php +++ b/tests/Feature/Services/Social/PinterestPublisherTest.php @@ -531,14 +531,45 @@ 'items' => [ ['id' => 'board_2', 'name' => 'Board 2'], ], + 'bookmark' => 'page-3', + ], 200) + ->push([ + 'items' => [ + ['id' => 'board_3', 'name' => 'Board 3'], + ], ], 200), ]); $boards = $this->publisher->getBoards($this->socialAccount); - expect($boards)->toHaveCount(2) + expect($boards)->toHaveCount(3) ->and($boards[0]['id'])->toBe('board_1') - ->and($boards[1]['id'])->toBe('board_2'); + ->and($boards[1]['id'])->toBe('board_2') + ->and($boards[2]['id'])->toBe('board_3'); + + Http::assertSentCount(3); +}); + +test('pinterest publisher stops board pagination on a repeated bookmark', function () { + Http::fake([ + config('trypost.platforms.pinterest.api').'/boards*' => Http::sequence() + ->push([ + 'items' => [['id' => 'board_1', 'name' => 'Board 1']], + 'bookmark' => 'stuck', + ], 200) + ->push([ + 'items' => [['id' => 'board_2', 'name' => 'Board 2']], + 'bookmark' => 'stuck', + ], 200) + ->push([ + 'items' => [['id' => 'should_not_fetch', 'name' => 'Nope']], + ], 200), + ]); + + $boards = $this->publisher->getBoards($this->socialAccount); + + expect($boards)->toHaveCount(2) + ->and(collect($boards)->pluck('id')->all())->toBe(['board_1', 'board_2']); Http::assertSentCount(2); }); From 0d4e5fd9637a43cba7324a9b6166462439704908 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 22:37:57 -0300 Subject: [PATCH 11/17] Restore media-rule parity and release failed MCP upload tokens. Keep Instagram feed requiring media and Discord/Telegram accepting GIFs after centralization, skip the empty workspace rate-limit bucket, and clear the signed upload claim when persistence fails so retries work. Co-authored-by: Cursor --- app/Enums/PostPlatform/ContentType.php | 4 +- app/Http/Controllers/Api/UploadController.php | 51 +++++++++++-------- app/Providers/AppServiceProvider.php | 9 +++- resources/js/composables/useMediaRules.ts | 3 +- tests/Feature/Api/UploadControllerTest.php | 18 +++++++ .../ContentTypeMediaRulesShareTest.php | 2 + tests/Unit/Enums/ContentTypeTest.php | 49 +++++++++++++++++- 7 files changed, 110 insertions(+), 26 deletions(-) diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index a4e84983a..54e536bca 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -224,7 +224,8 @@ public function minMediaCount(): int public function acceptsGif(): bool { return match ($this) { - self::XPost, self::BlueskyPost, self::MastodonPost => true, + self::XPost, self::BlueskyPost, self::MastodonPost, + self::DiscordMessage, self::TelegramPost => true, default => false, }; } @@ -458,7 +459,6 @@ public function requiresMedia(): bool self::MastodonPost => false, self::TelegramPost => false, self::FacebookPost => false, - self::InstagramFeed => false, self::DiscordMessage => false, default => true, }; diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index 253e34973..e42ed7615 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\Response; +use Throwable; class UploadController extends Controller { @@ -26,7 +27,9 @@ public function store(StoreUploadRequest $request, string $token): JsonResponse $expiresAt - now()->timestamp + self::CACHE_TTL_BUFFER_SECONDS, ); - if (! Cache::add("mcp_upload:{$token}", true, $ttl)) { + $cacheKey = "mcp_upload:{$token}"; + + if (! Cache::add($cacheKey, true, $ttl)) { abort(Response::HTTP_CONFLICT); } @@ -34,28 +37,36 @@ public function store(StoreUploadRequest $request, string $token): JsonResponse abort(Response::HTTP_CONFLICT); } - $workspace = Workspace::findOrFail((string) $request->query('workspace_id')); - $file = $request->file('media'); - $path = $file->getRealPath(); + try { + $workspace = Workspace::findOrFail((string) $request->query('workspace_id')); + $file = $request->file('media'); + $path = $file->getRealPath(); - // Stream from PHP's temp upload path — do not load the whole file into - // memory (addMedia() uses file_get_contents; videos can be up to 1GB). - if ($path === false) { - abort(Response::HTTP_UNPROCESSABLE_ENTITY, 'Unable to read uploaded file.'); - } + // Stream from PHP's temp upload path — do not load the whole file into + // memory (addMedia() uses file_get_contents; videos can be up to 1GB). + if ($path === false) { + abort(Response::HTTP_UNPROCESSABLE_ENTITY, 'Unable to read uploaded file.'); + } - $media = DB::transaction(function () use ($workspace, $file, $path, $token): Media { - $media = $workspace->addMediaFromPath( - $path, - $file->getClientOriginalName(), - 'assets', - mimeType: (string) $file->getMimeType(), - ); - $media->upload_token = $token; - $media->save(); + $media = DB::transaction(function () use ($workspace, $file, $path, $token): Media { + $media = $workspace->addMediaFromPath( + $path, + $file->getClientOriginalName(), + 'assets', + mimeType: (string) $file->getMimeType(), + ); + $media->upload_token = $token; + $media->save(); - return $media; - }); + return $media; + }); + } catch (Throwable $e) { + // Claim is only permanent after Media is stored — release so the + // signed URL can be retried after a transient disk/storage failure. + Cache::forget($cacheKey); + + throw $e; + } return MediaUploadResource::make($media) ->response() diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1daad530e..30c2e7169 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -164,10 +164,15 @@ protected function configureRateLimiting(): void // workspace_id (bound into the signed URL) so tenants don't share a bucket. RateLimiter::for('mcp-uploads', function (Request $request) { $workspaceId = (string) $request->query('workspace_id'); + $ipLimit = Limit::perMinute(1200)->by('ip:'.$request->ip()); + + if ($workspaceId === '') { + return $ipLimit; + } return [ - Limit::perMinute(60)->by('workspace:'.$workspaceId), - Limit::perMinute(1200)->by('ip:'.$request->ip()), + Limit::perMinute(60)->by("workspace:{$workspaceId}"), + $ipLimit, ]; }); } diff --git a/resources/js/composables/useMediaRules.ts b/resources/js/composables/useMediaRules.ts index d38b829f2..8f63d69c7 100644 --- a/resources/js/composables/useMediaRules.ts +++ b/resources/js/composables/useMediaRules.ts @@ -12,13 +12,14 @@ export type { MediaRules }; /** * Fallback only when Inertia once-props have not synced yet (or unknown type). * Real limits live in App\Enums\PostPlatform\ContentType::mediaRules(). + * Keep this fail-closed (no GIF) — most content types reject animated GIFs. */ const DEFAULT_RULES: MediaRules = { maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: true, + acceptsGif: false, }; export const getMediaRulesForContentType = (contentType: string): MediaRules => { diff --git a/tests/Feature/Api/UploadControllerTest.php b/tests/Feature/Api/UploadControllerTest.php index 9fa48ebe3..90d3e60e0 100644 --- a/tests/Feature/Api/UploadControllerTest.php +++ b/tests/Feature/Api/UploadControllerTest.php @@ -8,6 +8,7 @@ use App\Models\Workspace; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\URL; use Illuminate\Support\Str; @@ -158,6 +159,23 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = ->and(Storage::exists($media->path))->toBeTrue(); }); +test('releases the upload token when media persistence fails', function () { + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->create('clip.mp4', 256, 'video/mp4'); + $cacheKey = "mcp_upload:{$token}"; + + DB::shouldReceive('transaction') + ->once() + ->andThrow(new RuntimeException('disk unavailable')); + + $this->postJson(signedUploadUrl($this->workspace, $token), [ + 'media' => $file, + ])->assertServerError(); + + expect(Cache::has($cacheKey))->toBeFalse() + ->and(Media::where('upload_token', $token)->exists())->toBeFalse(); +}); + test('rejects disallowed mime type', function () { $token = (string) Str::uuid(); $file = UploadedFile::fake()->create('evil.exe', 10, 'application/octet-stream'); diff --git a/tests/Feature/ContentTypeMediaRulesShareTest.php b/tests/Feature/ContentTypeMediaRulesShareTest.php index d5a8511c4..db38a8fa3 100644 --- a/tests/Feature/ContentTypeMediaRulesShareTest.php +++ b/tests/Feature/ContentTypeMediaRulesShareTest.php @@ -23,6 +23,8 @@ ->where('contentTypeMediaRules.instagram_reel.max_video_duration_sec', 900) ->where('contentTypeMediaRules.instagram_reel.max_video_bytes', 1 * 1024 * 1024 * 1024) ->where('contentTypeMediaRules.instagram_reel.accept_images', false) + ->where('contentTypeMediaRules.instagram_feed.requires_media', true) + ->where('contentTypeMediaRules.discord_message.accepts_gif', true) ->where('contentTypeMediaRules.facebook_reel.max_video_duration_sec', 90) ->where('contentTypeMediaRules.tiktok_video.max_video_duration_sec', null) ->where('contentTypeMediaRules.linkedin_post.max_document_bytes', 100 * 1024 * 1024) diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index 9c826076a..7266b0955 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -55,6 +55,9 @@ expect($rules['linkedin_post']['max_document_bytes'])->toBe(100 * 1024 * 1024); expect($rules['pinterest_carousel']['min_files'])->toBe(2); expect($rules['x_post']['accepts_gif'])->toBeTrue(); + expect($rules['instagram_feed']['requires_media'])->toBeTrue(); + expect($rules['discord_message']['accepts_gif'])->toBeTrue(); + expect($rules['telegram_post']['accepts_gif'])->toBeTrue(); }); test('media rules reuse enum capability helpers', function () { @@ -135,10 +138,10 @@ test('content type requires media correctly', function () { expect(ContentType::InstagramReel->requiresMedia())->toBeTrue(); + expect(ContentType::InstagramFeed->requiresMedia())->toBeTrue(); expect(ContentType::TikTokVideo->requiresMedia())->toBeTrue(); expect(ContentType::YouTubeShort->requiresMedia())->toBeTrue(); expect(ContentType::PinterestPin->requiresMedia())->toBeTrue(); - expect(ContentType::InstagramFeed->requiresMedia())->toBeFalse(); expect(ContentType::LinkedInPost->requiresMedia())->toBeFalse(); expect(ContentType::XPost->requiresMedia())->toBeFalse(); expect(ContentType::ThreadsPost->requiresMedia())->toBeFalse(); @@ -146,6 +149,50 @@ expect(ContentType::MastodonPost->requiresMedia())->toBeFalse(); }); +test('media rules keep editor parity for gif and requires_media flags', function () { + expect(ContentType::DiscordMessage->acceptsGif())->toBeTrue(); + expect(ContentType::TelegramPost->acceptsGif())->toBeTrue(); + expect(ContentType::InstagramFeed->mediaRules()['requires_media'])->toBeTrue(); + expect(ContentType::DiscordMessage->mediaRules()['accepts_gif'])->toBeTrue(); +}); + +/** + * Lock the flags that used to live in the Vue CONTENT_TYPE_RULES map so a + * future centralization drift cannot silently flip editor behavior again. + */ +test('media rules preserve pre-centralization requires_media and accepts_gif for mapped types', function () { + $expected = [ + 'instagram_feed' => [true, false], + 'instagram_reel' => [true, false], + 'instagram_story' => [true, false], + 'facebook_post' => [false, false], + 'facebook_reel' => [true, false], + 'facebook_story' => [true, false], + 'linkedin_post' => [false, false], + 'linkedin_page_post' => [false, false], + 'tiktok_video' => [true, false], + 'tiktok_photo' => [true, false], + 'youtube_short' => [true, false], + 'pinterest_pin' => [true, false], + 'pinterest_video_pin' => [true, false], + 'pinterest_carousel' => [true, false], + 'x_post' => [false, true], + 'threads_post' => [false, false], + 'bluesky_post' => [false, true], + 'mastodon_post' => [false, true], + // Previously fell through to DEFAULT_RULES (acceptsGif true). + 'discord_message' => [false, true], + 'telegram_post' => [false, true], + ]; + + foreach ($expected as $type => [$requiresMedia, $acceptsGif]) { + $rules = ContentType::from($type)->mediaRules(); + + expect($rules['requires_media'])->toBe($requiresMedia, "{$type}.requires_media") + ->and($rules['accepts_gif'])->toBe($acceptsGif, "{$type}.accepts_gif"); + } +}); + test('can get content types for platform', function () { $instagramTypes = ContentType::forPlatform(Platform::Instagram); From e4779dfcdfdffa0ff557f966715f307771c03730 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 22:49:04 -0300 Subject: [PATCH 12/17] Clamp media byte caps to upload limits and surface truncated board lists. Align editor/API/MCP size ceilings with trypost.media hard caps, return truncated from Pinterest board pagination stop conditions, and rename the signed-upload claim key and rate limiter away from the MCP-only naming. Co-authored-by: Cursor --- .../SocialAccount/ListPinterestBoards.php | 11 +- app/Enums/PostPlatform/ContentType.php | 39 ++++-- .../Api/SocialAccountController.php | 4 +- app/Http/Controllers/Api/UploadController.php | 8 +- .../SocialAccount/ListPinterestBoardsTool.php | 4 +- app/Providers/AppServiceProvider.php | 30 ++-- app/Services/Social/PinterestPublisher.php | 10 +- config/trypost.php | 6 + routes/api.php | 4 +- tests/Feature/Api/PinterestBoardsApiTest.php | 3 +- tests/Feature/Api/UploadControllerTest.php | 2 +- .../Mcp/ListPinterestBoardsToolTest.php | 3 +- .../Social/PinterestPublisherTest.php | 25 ++-- tests/Unit/Enums/ContentTypeTest.php | 131 ++++++++++++++---- 14 files changed, 205 insertions(+), 75 deletions(-) diff --git a/app/Actions/SocialAccount/ListPinterestBoards.php b/app/Actions/SocialAccount/ListPinterestBoards.php index 0785486e6..1d7413709 100644 --- a/app/Actions/SocialAccount/ListPinterestBoards.php +++ b/app/Actions/SocialAccount/ListPinterestBoards.php @@ -11,13 +11,13 @@ class ListPinterestBoards { /** - * @return list + * @return array{boards: list, truncated: bool} */ public static function execute(SocialAccount $account): array { - $boards = app(PinterestPublisher::class)->getBoards($account); + $result = app(PinterestPublisher::class)->getBoards($account); - return Collection::make($boards) + $boards = Collection::make(data_get($result, 'boards', [])) ->map(fn (mixed $board): array => [ 'id' => (string) data_get($board, 'id'), 'name' => (string) data_get($board, 'name'), @@ -25,5 +25,10 @@ public static function execute(SocialAccount $account): array ->filter(fn (array $board): bool => $board['id'] !== '') ->values() ->all(); + + return [ + 'boards' => $boards, + 'truncated' => (bool) data_get($result, 'truncated', false), + ]; } } diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 54e536bca..70aa293bb 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -4,6 +4,7 @@ namespace App\Enums\PostPlatform; +use App\Enums\Media\Type as MediaType; use App\Enums\SocialAccount\Platform as SocialPlatform; enum ContentType: string @@ -231,12 +232,13 @@ public function acceptsGif(): bool } /** - * Per-type image size cap in bytes. Null when images are not accepted or - * the platform has no tighter editor-side limit than the global media cap. + * Per-type image size cap in bytes, capped at the global upload hard limit + * (trypost.media.max_size_mb.image). Null when images are not accepted or + * the platform has no tighter editor-side limit than that hard cap. */ public function maxImageBytes(): ?int { - return match ($this) { + $bytes = match ($this) { self::InstagramFeed, self::InstagramStory => self::bytesFromMb(8), self::FacebookPost => self::bytesFromMb(4), self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(5), @@ -246,15 +248,18 @@ public function maxImageBytes(): ?int self::MastodonPost => self::bytesFromMb(10), default => null, }; + + return self::capToHardLimit($bytes, MediaType::Image); } /** - * Per-type video size cap in bytes. Null when videos are not accepted or - * the platform has no tighter editor-side limit. + * Per-type video size cap in bytes, capped at the global upload hard limit + * (trypost.media.max_size_mb.video). Null when videos are not accepted or + * the platform has no tighter editor-side limit than that hard cap. */ public function maxVideoBytes(): ?int { - return match ($this) { + $bytes = match ($this) { self::InstagramFeed, self::InstagramStory => self::bytesFromMb(100), self::InstagramReel => self::bytesFromGb(1), self::FacebookPost => self::bytesFromGb(10), @@ -268,17 +273,22 @@ public function maxVideoBytes(): ?int self::MastodonPost => self::bytesFromMb(40), default => null, }; + + return self::capToHardLimit($bytes, MediaType::Video); } /** - * Per-type PDF size cap in bytes. Null when documents are not accepted. + * Per-type PDF size cap in bytes, capped at the global upload hard limit. + * Null when documents are not accepted. */ public function maxDocumentBytes(): ?int { - return match ($this) { + $bytes = match ($this) { self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(100), default => null, }; + + return self::capToHardLimit($bytes, MediaType::Document); } /** @@ -375,6 +385,19 @@ private static function bytesFromGb(int $gigabytes): int return $gigabytes * 1024 * 1024 * 1024; } + /** + * Never advertise a soft platform ceiling above what trypost.media allows + * on upload (web / API / MCP signed URL). + */ + private static function capToHardLimit(?int $platformBytes, MediaType $type): ?int + { + if ($platformBytes === null) { + return null; + } + + return min($platformBytes, $type->maxSizeInBytes()); + } + public function supportsVideo(): bool { return match ($this) { diff --git a/app/Http/Controllers/Api/SocialAccountController.php b/app/Http/Controllers/Api/SocialAccountController.php index 556c4776f..2b31b785a 100644 --- a/app/Http/Controllers/Api/SocialAccountController.php +++ b/app/Http/Controllers/Api/SocialAccountController.php @@ -49,9 +49,7 @@ public function boards(Request $request, SocialAccount $account): JsonResponse } try { - return response()->json([ - 'boards' => ListPinterestBoards::execute($account), - ]); + return response()->json(ListPinterestBoards::execute($account)); } catch (TokenExpiredException $e) { return response()->json( ['message' => $e->getMessage()], diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index e42ed7615..00e2720d7 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -19,6 +19,12 @@ class UploadController extends Controller { private const CACHE_TTL_BUFFER_SECONDS = 60; + /** + * One-shot claim for a signed upload token (api.uploads.store). + * Survives across MCP and any other client that POSTs the signed URL. + */ + private const CLAIM_CACHE_PREFIX = 'media:signed-upload:'; + public function store(StoreUploadRequest $request, string $token): JsonResponse { $expiresAt = (int) $request->query('expires'); @@ -27,7 +33,7 @@ public function store(StoreUploadRequest $request, string $token): JsonResponse $expiresAt - now()->timestamp + self::CACHE_TTL_BUFFER_SECONDS, ); - $cacheKey = "mcp_upload:{$token}"; + $cacheKey = self::CLAIM_CACHE_PREFIX.$token; if (! Cache::add($cacheKey, true, $ttl)) { abort(Response::HTTP_CONFLICT); diff --git a/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php b/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php index 9a039281c..2227a193f 100644 --- a/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php +++ b/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php @@ -37,9 +37,7 @@ public function handle(Request $request): Response|ResponseFactory } try { - return Response::structured([ - 'boards' => ListPinterestBoards::execute($account), - ]); + return Response::structured(ListPinterestBoards::execute($account)); } catch (TokenExpiredException $e) { return Response::error($e->getMessage()); } catch (PinterestPublishException $e) { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 30c2e7169..61349bc7c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -160,20 +160,26 @@ protected function configureRateLimiting(): void return Limit::perMinute(60)->by($request->workspace?->id ?: $request->ip()); }); - // MCP signed uploads arrive from ChatGPT's shared egress IPs. Key by - // workspace_id (bound into the signed URL) so tenants don't share a bucket. - RateLimiter::for('mcp-uploads', function (Request $request) { - $workspaceId = (string) $request->query('workspace_id'); - $ipLimit = Limit::perMinute(1200)->by('ip:'.$request->ip()); - - if ($workspaceId === '') { - return $ipLimit; + // Signed media uploads (api.uploads.store). MCP hosts share egress IPs + // across tenants — key by workspace_id from the signed URL, with a high + // IP backstop so one client cannot flood every workspace. + RateLimiter::for('signed-uploads', function (Request $request) { + $limits = [ + Limit::perMinute((int) config('trypost.media.signed_upload_per_ip_per_minute')) + ->by("ip:{$request->ip()}"), + ]; + + $workspaceId = $request->query('workspace_id'); + + if (filled($workspaceId)) { + array_unshift( + $limits, + Limit::perMinute((int) config('trypost.media.signed_upload_per_workspace_per_minute')) + ->by("workspace:{$workspaceId}"), + ); } - return [ - Limit::perMinute(60)->by("workspace:{$workspaceId}"), - $ipLimit, - ]; + return $limits; }); } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index dbbf57430..87dfb1d5c 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -408,7 +408,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId, * Get user's boards for board selection (follows Pinterest bookmark pagination * until the API stops returning a bookmark). * - * @return list> + * @return array{boards: list>, truncated: bool} */ public function getBoards(SocialAccount $account): array { @@ -419,6 +419,7 @@ public function getBoards(SocialAccount $account): array $boards = []; $bookmark = null; $pages = 0; + $truncated = false; // Absurd ceiling only — normal accounts exit when bookmark is blank. $maxPages = 1000; @@ -431,6 +432,7 @@ public function getBoards(SocialAccount $account): array 'pages' => $pages, 'boards' => count($boards), ]); + $truncated = true; break; } @@ -468,6 +470,7 @@ public function getBoards(SocialAccount $account): array 'pages' => $pages, 'boards' => count($boards), ]); + $truncated = true; break; } @@ -475,7 +478,10 @@ public function getBoards(SocialAccount $account): array $bookmark = $nextBookmark; } - return $boards; + return [ + 'boards' => $boards, + 'truncated' => $truncated, + ]; } private function handleApiError(Response $response): never diff --git a/config/trypost.php b/config/trypost.php index f41bc29b9..08cd5054a 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -62,6 +62,10 @@ | remains as a legacy fallback. MCP_UPLOAD_MAX_SIZE_MB was removed — size | caps come from max_size_mb above (uploads stream to storage). | + | signed_upload_per_*_per_minute backs the signed-uploads rate limiter: + | workspace bucket first (tenants isolated on shared MCP egress), then a + | high IP backstop. + | */ 'media' => [ @@ -72,6 +76,8 @@ 'document' => (int) env('MEDIA_DOCUMENT_MAX_SIZE_MB', 100), ], 'signed_upload_url_ttl_minutes' => (int) (env('MEDIA_SIGNED_UPLOAD_URL_TTL_MINUTES') ?? env('MCP_UPLOAD_URL_TTL_MINUTES', 15)), + 'signed_upload_per_workspace_per_minute' => (int) env('MEDIA_SIGNED_UPLOAD_PER_WORKSPACE_PER_MINUTE', 60), + 'signed_upload_per_ip_per_minute' => (int) env('MEDIA_SIGNED_UPLOAD_PER_IP_PER_MINUTE', 1200), ], /* diff --git a/routes/api.php b/routes/api.php index 21828742d..0ed1f0b72 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,8 +13,8 @@ use Illuminate\Support\Facades\Route; Route::post('/uploads/{token}', [UploadController::class, 'store']) - ->middleware(['signed', 'throttle:mcp-uploads']) - ->where('token', '[0-9a-f-]{36}') + ->middleware(['signed', 'throttle:signed-uploads']) + ->whereUuid('token') ->name('api.uploads.store'); Route::middleware(['auth:api', 'workspace.token', 'throttle:api'])->group(function () { diff --git a/tests/Feature/Api/PinterestBoardsApiTest.php b/tests/Feature/Api/PinterestBoardsApiTest.php index 8817b6e5a..db7fbf706 100644 --- a/tests/Feature/Api/PinterestBoardsApiTest.php +++ b/tests/Feature/Api/PinterestBoardsApiTest.php @@ -39,6 +39,7 @@ ['id' => 'board_1', 'name' => 'Ideas'], ['id' => 'board_2', 'name' => 'Product'], ], + 'truncated' => false, ]); }); @@ -58,7 +59,7 @@ 'Authorization' => "Bearer {$this->plainToken}", ]) ->assertOk() - ->assertExactJson(['boards' => []]); + ->assertExactJson(['boards' => [], 'truncated' => false]); }); it('returns unauthorized when the pinterest token is expired', function () { diff --git a/tests/Feature/Api/UploadControllerTest.php b/tests/Feature/Api/UploadControllerTest.php index 90d3e60e0..dc8e66e4f 100644 --- a/tests/Feature/Api/UploadControllerTest.php +++ b/tests/Feature/Api/UploadControllerTest.php @@ -162,7 +162,7 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = test('releases the upload token when media persistence fails', function () { $token = (string) Str::uuid(); $file = UploadedFile::fake()->create('clip.mp4', 256, 'video/mp4'); - $cacheKey = "mcp_upload:{$token}"; + $cacheKey = "media:signed-upload:{$token}"; DB::shouldReceive('transaction') ->once() diff --git a/tests/Feature/Mcp/ListPinterestBoardsToolTest.php b/tests/Feature/Mcp/ListPinterestBoardsToolTest.php index 1f0f48c39..7f7575e7f 100644 --- a/tests/Feature/Mcp/ListPinterestBoardsToolTest.php +++ b/tests/Feature/Mcp/ListPinterestBoardsToolTest.php @@ -43,7 +43,8 @@ ->where('boards.0.id', 'board_1') ->where('boards.0.name', 'Ideas') ->where('boards.1.id', 'board_2') - ->where('boards.1.name', 'Product'); + ->where('boards.1.name', 'Product') + ->where('truncated', false); }); }); diff --git a/tests/Feature/Services/Social/PinterestPublisherTest.php b/tests/Feature/Services/Social/PinterestPublisherTest.php index 1d5b9ac54..fbd9c0a95 100644 --- a/tests/Feature/Services/Social/PinterestPublisherTest.php +++ b/tests/Feature/Services/Social/PinterestPublisherTest.php @@ -512,10 +512,11 @@ ], 200), ]); - $boards = $this->publisher->getBoards($this->socialAccount); + $result = $this->publisher->getBoards($this->socialAccount); - expect($boards)->toHaveCount(2); - expect($boards[0]['id'])->toBe('board_1'); + expect($result['boards'])->toHaveCount(2) + ->and($result['boards'][0]['id'])->toBe('board_1') + ->and($result['truncated'])->toBeFalse(); }); test('pinterest publisher paginates boards via bookmark', function () { @@ -540,12 +541,13 @@ ], 200), ]); - $boards = $this->publisher->getBoards($this->socialAccount); + $result = $this->publisher->getBoards($this->socialAccount); - expect($boards)->toHaveCount(3) - ->and($boards[0]['id'])->toBe('board_1') - ->and($boards[1]['id'])->toBe('board_2') - ->and($boards[2]['id'])->toBe('board_3'); + expect($result['boards'])->toHaveCount(3) + ->and($result['boards'][0]['id'])->toBe('board_1') + ->and($result['boards'][1]['id'])->toBe('board_2') + ->and($result['boards'][2]['id'])->toBe('board_3') + ->and($result['truncated'])->toBeFalse(); Http::assertSentCount(3); }); @@ -566,10 +568,11 @@ ], 200), ]); - $boards = $this->publisher->getBoards($this->socialAccount); + $result = $this->publisher->getBoards($this->socialAccount); - expect($boards)->toHaveCount(2) - ->and(collect($boards)->pluck('id')->all())->toBe(['board_1', 'board_2']); + expect($result['boards'])->toHaveCount(2) + ->and(collect($result['boards'])->pluck('id')->all())->toBe(['board_1', 'board_2']) + ->and($result['truncated'])->toBeTrue(); Http::assertSentCount(2); }); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index 7266b0955..b47483af0 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\Media\Type as MediaType; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; @@ -157,42 +158,118 @@ }); /** - * Lock the flags that used to live in the Vue CONTENT_TYPE_RULES map so a - * future centralization drift cannot silently flip editor behavior again. + * Lock the flags / limits that used to live in the Vue CONTENT_TYPE_RULES map + * so a future centralization drift cannot silently flip editor behavior again. + * Byte caps are the post-clamp values (min(platform, trypost.media hard limit)). */ -test('media rules preserve pre-centralization requires_media and accepts_gif for mapped types', function () { +test('media rules preserve pre-centralization editor limits for mapped types', function () { + $mb = 1024 * 1024; + $gb = 1024 * $mb; + $hardImage = MediaType::Image->maxSizeInBytes(); + $hardVideo = MediaType::Video->maxSizeInBytes(); + $hardDocument = MediaType::Document->maxSizeInBytes(); + $expected = [ - 'instagram_feed' => [true, false], - 'instagram_reel' => [true, false], - 'instagram_story' => [true, false], - 'facebook_post' => [false, false], - 'facebook_reel' => [true, false], - 'facebook_story' => [true, false], - 'linkedin_post' => [false, false], - 'linkedin_page_post' => [false, false], - 'tiktok_video' => [true, false], - 'tiktok_photo' => [true, false], - 'youtube_short' => [true, false], - 'pinterest_pin' => [true, false], - 'pinterest_video_pin' => [true, false], - 'pinterest_carousel' => [true, false], - 'x_post' => [false, true], - 'threads_post' => [false, false], - 'bluesky_post' => [false, true], - 'mastodon_post' => [false, true], - // Previously fell through to DEFAULT_RULES (acceptsGif true). - 'discord_message' => [false, true], - 'telegram_post' => [false, true], + 'instagram_feed' => [ + 'requires_media' => true, + 'accepts_gif' => false, + 'max_files' => 10, + 'max_video_duration_sec' => 60, + 'max_image_bytes' => min(8 * $mb, $hardImage), + 'max_video_bytes' => min(100 * $mb, $hardVideo), + ], + 'instagram_reel' => [ + 'requires_media' => true, + 'accepts_gif' => false, + 'max_files' => 1, + 'max_video_duration_sec' => 900, + 'max_video_bytes' => min(1 * $gb, $hardVideo), + ], + 'youtube_short' => [ + 'requires_media' => true, + 'accepts_gif' => false, + 'max_files' => 1, + 'max_video_duration_sec' => 180, + // Platform advertises 256GB; editor must not exceed upload hard cap. + 'max_video_bytes' => $hardVideo, + ], + 'pinterest_pin' => [ + 'requires_media' => true, + 'accepts_gif' => false, + 'max_files' => 1, + // Platform advertises 20MB; hard image cap is typically 10MB. + 'max_image_bytes' => $hardImage, + ], + 'facebook_post' => [ + 'requires_media' => false, + 'accepts_gif' => false, + 'max_files' => 10, + 'max_video_duration_sec' => 240 * 60, + 'max_video_bytes' => $hardVideo, + ], + 'linkedin_post' => [ + 'requires_media' => false, + 'accepts_gif' => false, + 'max_files' => 10, + 'max_document_bytes' => min(100 * $mb, $hardDocument), + 'max_video_bytes' => $hardVideo, + ], + 'x_post' => [ + 'requires_media' => false, + 'accepts_gif' => true, + 'max_files' => 4, + 'max_video_duration_sec' => 140, + 'max_video_bytes' => min(512 * $mb, $hardVideo), + ], + 'discord_message' => [ + 'requires_media' => false, + 'accepts_gif' => true, + 'max_files' => 10, + ], + 'telegram_post' => [ + 'requires_media' => false, + 'accepts_gif' => true, + 'max_files' => 10, + ], ]; - foreach ($expected as $type => [$requiresMedia, $acceptsGif]) { + foreach ($expected as $type => $fields) { $rules = ContentType::from($type)->mediaRules(); - expect($rules['requires_media'])->toBe($requiresMedia, "{$type}.requires_media") - ->and($rules['accepts_gif'])->toBe($acceptsGif, "{$type}.accepts_gif"); + foreach ($fields as $key => $value) { + expect($rules[$key])->toBe($value, "{$type}.{$key}"); + } } }); +test('media byte caps never exceed the global upload hard limits', function () { + $hardImage = MediaType::Image->maxSizeInBytes(); + $hardVideo = MediaType::Video->maxSizeInBytes(); + $hardDocument = MediaType::Document->maxSizeInBytes(); + + foreach (ContentType::cases() as $type) { + $image = $type->maxImageBytes(); + $video = $type->maxVideoBytes(); + $document = $type->maxDocumentBytes(); + + if ($image !== null) { + expect($image)->toBeLessThanOrEqual($hardImage, "{$type->value}.max_image_bytes"); + } + + if ($video !== null) { + expect($video)->toBeLessThanOrEqual($hardVideo, "{$type->value}.max_video_bytes"); + } + + if ($document !== null) { + expect($document)->toBeLessThanOrEqual($hardDocument, "{$type->value}.max_document_bytes"); + } + } + + expect(ContentType::YouTubeShort->maxVideoBytes())->toBe($hardVideo) + ->and(ContentType::PinterestPin->maxImageBytes())->toBe($hardImage) + ->and(ContentType::FacebookPost->maxVideoBytes())->toBe($hardVideo); +}); + test('can get content types for platform', function () { $instagramTypes = ContentType::forPlatform(Platform::Instagram); From d8e43bcfbb18d9b991f64e1a4340a0c844be904a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 22:54:20 -0300 Subject: [PATCH 13/17] Unwrap Pinterest board lists for the web editors. getBoards now returns {boards, truncated}; pass only the boards array into Inertia pinterestBoards so post and automation pickers keep receiving an array. Co-authored-by: Cursor --- .../Automation/Automation/GetAutomationEditorData.php | 6 +++++- app/Http/Controllers/App/PostController.php | 6 +++++- tests/Feature/Automation/Automation/ReadActionsTest.php | 5 ++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/Actions/Automation/Automation/GetAutomationEditorData.php b/app/Actions/Automation/Automation/GetAutomationEditorData.php index 157e4ee13..e633b77b2 100644 --- a/app/Actions/Automation/Automation/GetAutomationEditorData.php +++ b/app/Actions/Automation/Automation/GetAutomationEditorData.php @@ -34,7 +34,11 @@ public function __invoke(Automation $automation): array ->where('platform', Platform::Pinterest) ->mapWithKeys(fn ($account) => [ $account->id => rescue( - fn () => $this->pinterestPublisher->getBoards($account), + fn () => data_get( + $this->pinterestPublisher->getBoards($account), + 'boards', + [], + ), [], report: false, ), diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 7ef6f6332..544d6dcc6 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -260,7 +260,11 @@ public function edit(Request $request, Post $post): Response|RedirectResponse ->where('platform', Platform::Pinterest) ->mapWithKeys(fn ($account) => [ $account->id => rescue( - fn () => app(PinterestPublisher::class)->getBoards($account), + fn () => data_get( + app(PinterestPublisher::class)->getBoards($account), + 'boards', + [], + ), [], report: false, ), diff --git a/tests/Feature/Automation/Automation/ReadActionsTest.php b/tests/Feature/Automation/Automation/ReadActionsTest.php index dd9675057..24b6e4c28 100644 --- a/tests/Feature/Automation/Automation/ReadActionsTest.php +++ b/tests/Feature/Automation/Automation/ReadActionsTest.php @@ -135,7 +135,10 @@ }); it('maps pinterest boards for pinterest accounts', function () { - $this->mock(PinterestPublisher::class, fn ($mock) => $mock->shouldReceive('getBoards')->andReturn([['id' => 'b1']])); + $this->mock(PinterestPublisher::class, fn ($mock) => $mock->shouldReceive('getBoards')->andReturn([ + 'boards' => [['id' => 'b1']], + 'truncated' => false, + ])); $this->mock(TikTokCreatorInfo::class); $workspace = Workspace::factory()->create(); From b9194b9c7d39d654c69b19c2cf53c1012a55a705 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 23:02:40 -0300 Subject: [PATCH 14/17] Surface Pinterest board truncation in the web editors. Share {boards, truncated} via ListPinterestBoards into Inertia and warn in the board picker when pagination stopped early, matching API/MCP. Co-authored-by: Cursor --- .../Automation/GetAutomationEditorData.php | 13 ++++--------- app/Http/Controllers/App/PostController.php | 10 +++------- lang/ar/posts.php | 1 + lang/de/posts.php | 1 + lang/el/posts.php | 1 + lang/en/posts.php | 1 + lang/es/posts.php | 1 + lang/fr/posts.php | 1 + lang/it/posts.php | 1 + lang/ja/posts.php | 1 + lang/ko/posts.php | 1 + lang/nl/posts.php | 1 + lang/pl/posts.php | 1 + lang/pt-BR/posts.php | 1 + lang/ru/posts.php | 1 + lang/tr/posts.php | 1 + lang/zh/posts.php | 1 + resources/js/components/ChannelConfigurator.vue | 1 + .../automations/config/GenerateNodeConfig.vue | 12 ++++++++---- .../components/posts/editor/PinterestSettings.vue | 9 +++++++++ .../js/components/posts/editor/PostEditorTabs.vue | 4 ++-- .../js/components/posts/editor/ScheduleTab.vue | 15 +++++++++++---- resources/js/pages/posts/Edit.vue | 4 ++-- resources/js/types/channel.ts | 1 + resources/js/types/index.d.ts | 6 ++++++ .../Automation/Automation/ReadActionsTest.php | 9 ++++++--- 26 files changed, 68 insertions(+), 31 deletions(-) diff --git a/app/Actions/Automation/Automation/GetAutomationEditorData.php b/app/Actions/Automation/Automation/GetAutomationEditorData.php index e633b77b2..3db30dadf 100644 --- a/app/Actions/Automation/Automation/GetAutomationEditorData.php +++ b/app/Actions/Automation/Automation/GetAutomationEditorData.php @@ -4,10 +4,10 @@ namespace App\Actions\Automation\Automation; +use App\Actions\SocialAccount\ListPinterestBoards; use App\Enums\SocialAccount\Platform; use App\Models\Automation; use App\Models\SocialAccount; -use App\Services\Social\PinterestPublisher; use App\Services\Social\TikTokCreatorInfo; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Collection as SupportCollection; @@ -15,14 +15,13 @@ class GetAutomationEditorData { public function __construct( - private PinterestPublisher $pinterestPublisher, private TikTokCreatorInfo $tikTokCreatorInfo, ) {} /** * @return array{ * socialAccounts: Collection, - * pinterestBoards: SupportCollection>, + * pinterestBoards: SupportCollection, truncated: bool}>, * tiktokCreatorInfos: SupportCollection, * } */ @@ -34,12 +33,8 @@ public function __invoke(Automation $automation): array ->where('platform', Platform::Pinterest) ->mapWithKeys(fn ($account) => [ $account->id => rescue( - fn () => data_get( - $this->pinterestPublisher->getBoards($account), - 'boards', - [], - ), - [], + fn () => ListPinterestBoards::execute($account), + ['boards' => [], 'truncated' => false], report: false, ), ]); diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 544d6dcc6..c1ebc395b 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -9,6 +9,7 @@ use App\Actions\Post\DuplicatePost; use App\Actions\Post\SyncPostPlatforms; use App\Actions\Post\UpdatePost; +use App\Actions\SocialAccount\ListPinterestBoards; use App\Ai\Templates\AiContentTemplate; use App\Ai\Templates\AiTemplateRegistry; use App\Enums\Post\Action as PostAction; @@ -23,7 +24,6 @@ use App\Models\Post; use App\Models\PostPlatform; use App\Services\Post\PostMetricsFetcher; -use App\Services\Social\PinterestPublisher; use App\Services\Social\TikTokCreatorInfo; use App\Support\PostStatusRules; use Carbon\Carbon; @@ -260,12 +260,8 @@ public function edit(Request $request, Post $post): Response|RedirectResponse ->where('platform', Platform::Pinterest) ->mapWithKeys(fn ($account) => [ $account->id => rescue( - fn () => data_get( - app(PinterestPublisher::class)->getBoards($account), - 'boards', - [], - ), - [], + fn () => ListPinterestBoards::execute($account), + ['boards' => [], 'truncated' => false], report: false, ), ]); diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 8d5901e5c..3f3b800f6 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -154,6 +154,7 @@ 'board' => 'اللوحة', 'select_board' => 'اختر لوحة', 'no_boards' => 'لم يتم العثور على لوحات Pinterest. أنشئ واحدة في حسابك على Pinterest أولًا.', + 'boards_truncated' => 'تعذر تحميل بعض اللوحات. إذا كانت لوحتك مفقودة، افتح Pinterest وحاول مرة أخرى.', 'search_board' => 'البحث في اللوحات...', 'no_board_found' => 'لا توجد لوحة مطابقة لبحثك.', 'board_required' => 'اختر لوحة Pinterest لنشر هذا المنشور.', diff --git a/lang/de/posts.php b/lang/de/posts.php index 0aeef2b44..de87cbeda 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -156,6 +156,7 @@ 'board' => 'Pinnwand', 'select_board' => 'Pinnwand auswählen', 'no_boards' => 'Keine Pinterest-Pinnwände gefunden. Erstelle zuerst eine in deinem Pinterest-Konto.', + 'boards_truncated' => 'Einige Pinnwände konnten nicht geladen werden. Fehlt deine, öffne Pinterest und versuche es erneut.', 'search_board' => 'Pinnwände suchen...', 'no_board_found' => 'Keine Pinnwand passt zu deiner Suche.', 'board_required' => 'Wähle eine Pinterest-Pinnwand, um diesen Beitrag zu veröffentlichen.', diff --git a/lang/el/posts.php b/lang/el/posts.php index 470ff022e..03e36c604 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -154,6 +154,7 @@ 'board' => 'Πίνακας', 'select_board' => 'Επιλέξτε έναν πίνακα', 'no_boards' => 'Δεν βρέθηκαν πίνακες Pinterest. Δημιουργήστε πρώτα έναν στον λογαριασμό σας Pinterest.', + 'boards_truncated' => 'Ορισμένοι πίνακες δεν φορτώθηκαν. Αν λείπει ο δικός σας, ανοίξτε το Pinterest και δοκιμάστε ξανά.', 'search_board' => 'Αναζήτηση πινάκων...', 'no_board_found' => 'Κανένας πίνακας δεν ταιριάζει με την αναζήτησή σας.', 'board_required' => 'Επιλέξτε έναν πίνακα Pinterest για να δημοσιεύσετε αυτή τη δημοσίευση.', diff --git a/lang/en/posts.php b/lang/en/posts.php index f15f8f151..8878a7078 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -154,6 +154,7 @@ 'board' => 'Board', 'select_board' => 'Select a board', 'no_boards' => 'No Pinterest boards found. Create one in your Pinterest account first.', + 'boards_truncated' => 'Some boards could not be loaded. If yours is missing, open Pinterest and try again.', 'search_board' => 'Search boards...', 'no_board_found' => 'No board matches your search.', 'board_required' => 'Select a Pinterest board to publish this post.', diff --git a/lang/es/posts.php b/lang/es/posts.php index 61b04344c..9e00e3253 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -154,6 +154,7 @@ 'board' => 'Tablero', 'select_board' => 'Selecciona un tablero', 'no_boards' => 'No se encontraron tableros de Pinterest. Crea uno en tu cuenta de Pinterest primero.', + 'boards_truncated' => 'No se pudieron cargar algunos tableros. Si falta el tuyo, abre Pinterest e inténtalo de nuevo.', 'search_board' => 'Buscar tableros...', 'no_board_found' => 'Ningún tablero coincide con tu búsqueda.', 'board_required' => 'Selecciona un tablero de Pinterest para publicar este post.', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 7cbd880e7..79630288e 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -154,6 +154,7 @@ 'board' => 'Tableau', 'select_board' => 'Sélectionner un tableau', 'no_boards' => 'Aucun tableau Pinterest trouvé. Créez-en un dans votre compte Pinterest d\'abord.', + 'boards_truncated' => 'Certains tableaux n’ont pas pu être chargés. S’il manque le vôtre, ouvrez Pinterest et réessayez.', 'search_board' => 'Rechercher des tableaux...', 'no_board_found' => 'Aucun tableau ne correspond à votre recherche.', 'board_required' => 'Sélectionnez un tableau Pinterest pour publier cette publication.', diff --git a/lang/it/posts.php b/lang/it/posts.php index ea9ca8de0..3f0d43e0e 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -154,6 +154,7 @@ 'board' => 'Bacheca', 'select_board' => 'Seleziona una bacheca', 'no_boards' => 'Nessuna bacheca Pinterest trovata. Creane una nel tuo account Pinterest prima.', + 'boards_truncated' => 'Alcune bacheche non sono state caricate. Se manca la tua, apri Pinterest e riprova.', 'search_board' => 'Cerca bacheche...', 'no_board_found' => 'Nessuna bacheca corrisponde alla ricerca.', 'board_required' => 'Seleziona una bacheca Pinterest per pubblicare questo post.', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index 4c205ec4b..53cc227a4 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -154,6 +154,7 @@ 'board' => 'ボード', 'select_board' => 'ボードを選択', 'no_boards' => 'Pinterest ボードが見つかりません。先に Pinterest アカウントで作成してください。', + 'boards_truncated' => '一部のボードを読み込めませんでした。見つからない場合は Pinterest を開いて再試行してください。', 'search_board' => 'ボードを検索...', 'no_board_found' => '検索に一致するボードがありません。', 'board_required' => 'この投稿を公開する Pinterest ボードを選択してください。', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 2e58a2e72..8bb2ae08e 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -154,6 +154,7 @@ 'board' => '보드', 'select_board' => '보드 선택', 'no_boards' => 'Pinterest 보드를 찾을 수 없습니다. 먼저 Pinterest 계정에서 보드를 만드세요.', + 'boards_truncated' => '일부 보드를 불러오지 못했습니다. 내 보드가 없다면 Pinterest를 연 뒤 다시 시도하세요.', 'search_board' => '보드 검색...', 'no_board_found' => '검색과 일치하는 보드가 없습니다.', 'board_required' => '이 게시물을 게시할 Pinterest 보드를 선택하세요.', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index f13035b55..eaa2335da 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -154,6 +154,7 @@ 'board' => 'Bord', 'select_board' => 'Selecteer een bord', 'no_boards' => 'Geen Pinterest-borden gevonden. Maak er eerst een aan in je Pinterest-account.', + 'boards_truncated' => 'Sommige borden konden niet worden geladen. Ontbreekt die van jou, open Pinterest en probeer opnieuw.', 'search_board' => 'Borden zoeken...', 'no_board_found' => 'Geen bord komt overeen met je zoekopdracht.', 'board_required' => 'Selecteer een Pinterest-bord om deze post te publiceren.', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 3af64e343..74271f4ab 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -154,6 +154,7 @@ 'board' => 'Tablica', 'select_board' => 'Wybierz tablicę', 'no_boards' => 'Nie znaleziono tablic Pinterest. Najpierw utwórz jedną na swoim koncie Pinterest.', + 'boards_truncated' => 'Nie udało się wczytać niektórych tablic. Jeśli brakuje twojej, otwórz Pinterest i spróbuj ponownie.', 'search_board' => 'Szukaj tablic...', 'no_board_found' => 'Brak tablic pasujących do wyszukiwania.', 'board_required' => 'Wybierz tablicę Pinterest, aby opublikować ten post.', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index d7d8856ae..8d40e9611 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -154,6 +154,7 @@ 'board' => 'Quadro', 'select_board' => 'Selecione um quadro', 'no_boards' => 'Nenhum quadro do Pinterest encontrado. Crie um na sua conta do Pinterest primeiro.', + 'boards_truncated' => 'Alguns boards não puderam ser carregados. Se o seu não aparecer, abra o Pinterest e tente de novo.', 'search_board' => 'Pesquisar quadros...', 'no_board_found' => 'Nenhum quadro encontrado.', 'board_required' => 'Selecione um quadro do Pinterest para publicar este post.', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index c180e074d..a2efc84e5 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -154,6 +154,7 @@ 'board' => 'Доска', 'select_board' => 'Выберите доску', 'no_boards' => 'Доски Pinterest не найдены. Сначала создайте доску в своём аккаунте Pinterest.', + 'boards_truncated' => 'Некоторые доски не удалось загрузить. Если вашей нет, откройте Pinterest и попробуйте снова.', 'search_board' => 'Поиск досок...', 'no_board_found' => 'Нет досок по вашему запросу.', 'board_required' => 'Выберите доску Pinterest, чтобы опубликовать этот пост.', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 2babf59fc..a70247885 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -156,6 +156,7 @@ 'board' => 'Pano', 'select_board' => 'Bir pano seçin', 'no_boards' => 'Pinterest panosu bulunamadı. Önce Pinterest hesabınızda bir tane oluşturun.', + 'boards_truncated' => 'Bazı panolar yüklenemedi. Sizinki eksikse Pinterest’i açıp tekrar deneyin.', 'search_board' => 'Pano ara...', 'no_board_found' => 'Aramanızla eşleşen pano yok.', 'board_required' => 'Bu gönderiyi yayınlamak için bir Pinterest panosu seçin.', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 0fec3de58..9cf3a6a71 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -154,6 +154,7 @@ 'board' => '图板', 'select_board' => '选择一个图板', 'no_boards' => '未找到 Pinterest 图板。请先在你的 Pinterest 账号中创建一个。', + 'boards_truncated' => '部分图板未能加载。如果没有看到你的图板,请打开 Pinterest 后重试。', 'search_board' => '搜索图板…', 'no_board_found' => '没有与搜索匹配的图板。', 'board_required' => '请选择一个 Pinterest 图板以发布此帖子。', diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index 1feb31e29..ce3d00e87 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -153,6 +153,7 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel :content-type="channel.contentType" :media="media" :boards="channel.boards ?? []" + :boards-truncated="channel.boardsTruncated ?? false" :meta="channel.meta" :disabled="disabled" :preview-only="previewOnly" diff --git a/resources/js/components/automations/config/GenerateNodeConfig.vue b/resources/js/components/automations/config/GenerateNodeConfig.vue index 1d73d91bc..17a35e874 100644 --- a/resources/js/components/automations/config/GenerateNodeConfig.vue +++ b/resources/js/components/automations/config/GenerateNodeConfig.vue @@ -13,7 +13,7 @@ import { Switch } from '@/components/ui/switch'; import { useExpandedEditor } from '@/composables/useExpandedEditor'; import { getMediaRulesForContentType } from '@/composables/useMediaRules'; import { getMediaIncompatibilityReason, getPlatformMetaIssue } from '@/composables/usePostCompliance'; -import type { PinterestBoard } from '@/types'; +import type { PinterestBoard, PinterestBoardsPayload } from '@/types'; import type { Channel } from '@/types/channel'; import { ContentType } from '@/types/content-type'; import type { MediaItem } from '@/types/media'; @@ -91,8 +91,8 @@ const platformConfigs = computed>(() => { return raw ?? {}; }); -const pinterestBoards = computed>(() => { - const raw = page.props.pinterestBoards as Record | undefined; +const pinterestBoards = computed>(() => { + const raw = page.props.pinterestBoards as Record | undefined; return raw ?? {}; }); @@ -209,7 +209,10 @@ const getCreatorInfo = (account: SocialAccount): TikTokCreatorInfo | null => tiktokCreatorInfos.value[account.id] ?? null; const getBoards = (account: SocialAccount): PinterestBoard[] => - pinterestBoards.value[account.id] ?? []; + pinterestBoards.value[account.id]?.boards ?? []; + +const boardsTruncated = (account: SocialAccount): boolean => + pinterestBoards.value[account.id]?.truncated ?? false; // Image-capability is derived from the SAME media rules the post editor uses // (per content type), never a hardcoded list — facebook_post, tiktok_photo, @@ -279,6 +282,7 @@ const channels = computed(() => publishConfig: getPublishConfig(account), creatorInfo: getCreatorInfo(account), boards: getBoards(account), + boardsTruncated: boardsTruncated(account), }; }), ); diff --git a/resources/js/components/posts/editor/PinterestSettings.vue b/resources/js/components/posts/editor/PinterestSettings.vue index ea6e64793..09e65e00e 100644 --- a/resources/js/components/posts/editor/PinterestSettings.vue +++ b/resources/js/components/posts/editor/PinterestSettings.vue @@ -39,6 +39,7 @@ interface Props { contentType: string; media: MediaItem[]; boards: PinterestBoard[]; + boardsTruncated?: boolean; meta: Record; disabled?: boolean; previewOnly?: boolean; @@ -47,6 +48,7 @@ interface Props { const props = withDefaults(defineProps(), { disabled: false, previewOnly: false, + boardsTruncated: false, }); const emit = defineEmits<{ @@ -187,6 +189,13 @@ const boardError = computed(() => { +

+ + {{ $t('posts.form.pinterest.boards_truncated') }} +

diff --git a/resources/js/components/posts/editor/PostEditorTabs.vue b/resources/js/components/posts/editor/PostEditorTabs.vue index c5ef75556..45ad8a6a4 100644 --- a/resources/js/components/posts/editor/PostEditorTabs.vue +++ b/resources/js/components/posts/editor/PostEditorTabs.vue @@ -5,7 +5,7 @@ import CommentsTab from '@/components/posts/editor/CommentsTab.vue'; import PreviewTab from '@/components/posts/editor/PreviewTab.vue'; import ScheduleTab from '@/components/posts/editor/ScheduleTab.vue'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import type { PinterestBoard } from '@/types'; +import type { PinterestBoardsPayload } from '@/types'; import type { MediaItem } from '@/types/media'; interface SocialAccount { @@ -62,7 +62,7 @@ const props = defineProps<{ labels: { id: string; name: string; color: string }[]; selectedLabelIds: string[]; tiktokCreatorInfos?: Record | null; - pinterestBoards?: Record | null; + pinterestBoards?: Record | null; isReadOnly: boolean; authUserId: string; initialHighlightCommentId: string | null; diff --git a/resources/js/components/posts/editor/ScheduleTab.vue b/resources/js/components/posts/editor/ScheduleTab.vue index 2bb93c235..f05e90f54 100644 --- a/resources/js/components/posts/editor/ScheduleTab.vue +++ b/resources/js/components/posts/editor/ScheduleTab.vue @@ -8,7 +8,7 @@ import { Badge } from '@/components/ui/badge'; import { usePageErrors } from '@/composables/usePageErrors'; import { getPlatformLogo } from '@/composables/usePlatformLogo'; import { isVideo } from '@/lib/mediaType'; -import type { PinterestBoard } from '@/types'; +import type { PinterestBoard, PinterestBoardsPayload } from '@/types'; import type { Channel } from '@/types/channel'; import type { MediaItem } from '@/types/media'; import { PostPlatformStatus } from '@/types/post'; @@ -77,7 +77,7 @@ const props = defineProps<{ platformContentTypes: Record; platformIssues?: Record; tiktokCreatorInfos?: Record | null; - pinterestBoards?: Record | null; + pinterestBoards?: Record | null; media?: MediaItem[]; }>(); @@ -94,8 +94,14 @@ const getPublishConfig = (pp: PostPlatform): Record | null => const getCreatorInfo = (pp: PostPlatform): TikTokCreatorInfo | null => pp.social_account_id ? props.tiktokCreatorInfos?.[pp.social_account_id] ?? null : null; -const getBoards = (pp: PostPlatform): PinterestBoard[] => - pp.social_account_id ? props.pinterestBoards?.[pp.social_account_id] ?? [] : []; +const boardsPayload = (pp: PostPlatform): PinterestBoardsPayload => + pp.social_account_id + ? props.pinterestBoards?.[pp.social_account_id] ?? { boards: [], truncated: false } + : { boards: [], truncated: false }; + +const getBoards = (pp: PostPlatform): PinterestBoard[] => boardsPayload(pp).boards; + +const boardsTruncated = (pp: PostPlatform): boolean => boardsPayload(pp).truncated; const videoDurationSec = computed(() => { const video = props.media?.find((m) => isVideo(m)); @@ -143,6 +149,7 @@ const channels = computed(() => publishConfig: getPublishConfig(pp), creatorInfo: getCreatorInfo(pp), boards: getBoards(pp), + boardsTruncated: boardsTruncated(pp), })), ); diff --git a/resources/js/pages/posts/Edit.vue b/resources/js/pages/posts/Edit.vue index 2b6e2f3ca..667a43f01 100644 --- a/resources/js/pages/posts/Edit.vue +++ b/resources/js/pages/posts/Edit.vue @@ -25,7 +25,7 @@ import dayjs from '@/dayjs'; import debounce from '@/debounce'; import AppLayout from '@/layouts/AppLayout.vue'; import { destroy as destroyPost, update as updatePost } from '@/routes/app/posts'; -import type { PinterestBoard } from '@/types'; +import type { PinterestBoardsPayload } from '@/types'; import type { MediaItem } from '@/types/media'; import { PostStatus } from '@/types/post'; @@ -86,7 +86,7 @@ const props = defineProps<{ post: Post; socialAccounts: SocialAccount[]; platformConfigs: Record; - pinterestBoards: Record; + pinterestBoards: Record; tiktokCreatorInfos?: Record | null; labels: { id: string; name: string; color: string }[]; signatures: { id: string; name: string; content: string }[]; diff --git a/resources/js/types/channel.ts b/resources/js/types/channel.ts index d2c58ebfa..81565fff7 100644 --- a/resources/js/types/channel.ts +++ b/resources/js/types/channel.ts @@ -40,4 +40,5 @@ export interface Channel { publishConfig?: Record | null; creatorInfo?: ChannelTikTokCreatorInfo | null; boards?: PinterestBoard[]; + boardsTruncated?: boolean; } diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 970304528..897ce048a 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -116,6 +116,12 @@ export interface PinterestBoard { name: string; } +/** Per-account payload from ListPinterestBoards (Inertia + API/MCP). */ +export interface PinterestBoardsPayload { + boards: PinterestBoard[]; + truncated: boolean; +} + export interface ContentLanguageOption { value: string; label: string; diff --git a/tests/Feature/Automation/Automation/ReadActionsTest.php b/tests/Feature/Automation/Automation/ReadActionsTest.php index 24b6e4c28..d4d778561 100644 --- a/tests/Feature/Automation/Automation/ReadActionsTest.php +++ b/tests/Feature/Automation/Automation/ReadActionsTest.php @@ -136,8 +136,8 @@ it('maps pinterest boards for pinterest accounts', function () { $this->mock(PinterestPublisher::class, fn ($mock) => $mock->shouldReceive('getBoards')->andReturn([ - 'boards' => [['id' => 'b1']], - 'truncated' => false, + 'boards' => [['id' => 'b1', 'name' => 'Ideas']], + 'truncated' => true, ])); $this->mock(TikTokCreatorInfo::class); @@ -147,5 +147,8 @@ $result = app(GetAutomationEditorData::class)($automation); - expect($result['pinterestBoards']->get($pinterest->id))->toBe([['id' => 'b1']]); + expect($result['pinterestBoards']->get($pinterest->id))->toBe([ + 'boards' => [['id' => 'b1', 'name' => 'Ideas']], + 'truncated' => true, + ]); }); From 6bfb88dedeb4aed148051076e523d2819f4a9afc Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 23:26:32 -0300 Subject: [PATCH 15/17] Fix Generate node clamping image count to 0 for Pinterest. Empty-account mount was clamping target_slide_count to 0 and never raising it when a media-required account was selected, so Pinterest showed a false requires-media error after picking a board. Co-authored-by: Cursor --- .../automations/config/GenerateNodeConfig.vue | 36 ++++++++++++---- .../Automation/GenerateNodeValidationTest.php | 41 +++++++++++++++++++ 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/resources/js/components/automations/config/GenerateNodeConfig.vue b/resources/js/components/automations/config/GenerateNodeConfig.vue index 17a35e874..983015c7e 100644 --- a/resources/js/components/automations/config/GenerateNodeConfig.vue +++ b/resources/js/components/automations/config/GenerateNodeConfig.vue @@ -232,11 +232,24 @@ const imageCountCap = computed(() => ), ); -// Single picker: 0 = no image (text-only), 1 = single image, 2+ = carousel. -const imageCountOptions = computed(() => - Array.from({ length: imageCountCap.value + 1 }, (_, i) => i), +// Floor at 1 when any selected account requires media (Pinterest pin, IG feed, +// etc.) — otherwise the empty-accounts clamp to 0 sticks after selecting them +// and surfaces a false "add an image" compliance error. +const minImageCount = computed(() => + local.value.accounts.some((a) => getMediaRulesForContentType(a.content_type).requiresMedia) + ? 1 + : 0, ); +// Single picker: 0 = no image (text-only), 1 = single image, 2+ = carousel. +// Option 0 is omitted when a media-required account is selected. +const imageCountOptions = computed(() => { + const min = Math.min(minImageCount.value, imageCountCap.value); + const max = imageCountCap.value; + + return Array.from({ length: Math.max(0, max - min + 1) }, (_, i) => i + min); +}); + const intendedImageCount = computed(() => local.value.target_slide_count); const syntheticImages = (count: number): MediaItem[] => @@ -254,14 +267,23 @@ const accountIssue = (accountId: string): string | null => { return account ? getPlatformMetaIssue(account.platform, entry.meta) : null; }; -// Clamp the chosen count to what the selected accounts actually allow — runs on -// mount too so legacy/over-cap values (or text-only accounts → 0) self-correct. +// Clamp the chosen count into [min, cap] for the current selection. Skip while +// no accounts are selected so the default of 1 is preserved until the user +// picks a destination (avoids clamp-to-0 → select Pinterest → stuck at 0). watch( - imageCountCap, - (cap) => { + [imageCountCap, minImageCount, () => local.value.accounts.length], + ([cap, min, accountCount]) => { + if (accountCount === 0) { + return; + } + if (local.value.target_slide_count > cap) { local.value.target_slide_count = cap; } + + if (local.value.target_slide_count < min) { + local.value.target_slide_count = Math.min(min, cap); + } }, { immediate: true }, ); diff --git a/tests/Feature/Automation/GenerateNodeValidationTest.php b/tests/Feature/Automation/GenerateNodeValidationTest.php index 097662c6a..1baf5f858 100644 --- a/tests/Feature/Automation/GenerateNodeValidationTest.php +++ b/tests/Feature/Automation/GenerateNodeValidationTest.php @@ -58,6 +58,47 @@ expect($automation->fresh()->nodes)->toHaveCount(2); }); +it('rejects a generate node that targets Pinterest with zero images', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->putJson(route('app.automations.update', $automation->id), [ + 'nodes' => [ + ['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']], + ['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [ + 'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_pin', 'meta' => ['board_id' => 'board-1']]], + 'prompt_template' => 'hi', + 'target_slide_count' => 0, + ]], + ], + 'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['nodes.1.data.accounts']); +}); + +it('allows saving a Pinterest pin generate node with one image', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [ + ['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']], + ['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [ + 'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_pin', 'meta' => ['board_id' => 'board-1']]], + 'prompt_template' => 'hi', + 'target_slide_count' => 1, + 'style' => 'tweet_card', + ]], + ], + 'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']], + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + expect($automation->fresh()->nodes)->toHaveCount(2); +}); + it('persists the brand toggles on the generate node', function () { $automation = Automation::factory()->for($this->workspace)->create(); From 4cbdfa37d75b2adc79910aa38d91ca1c920085c6 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 23:29:58 -0300 Subject: [PATCH 16/17] Hide video-only formats in the automation Generate node. AI generate only produces images, so Video Pin / Reel / TikTok Video are filtered out in previewOnly and rejected server-side with a clear error. Co-authored-by: Cursor --- app/Enums/PostPlatform/ContentType.php | 1 + .../Automation/GenerateNodeValidator.php | 10 +++--- lang/ar/automations.php | 1 + lang/de/automations.php | 1 + lang/el/automations.php | 1 + lang/en/automations.php | 1 + lang/es/automations.php | 1 + lang/fr/automations.php | 1 + lang/it/automations.php | 1 + lang/ja/automations.php | 1 + lang/ko/automations.php | 1 + lang/nl/automations.php | 1 + lang/pl/automations.php | 1 + lang/pt-BR/automations.php | 1 + lang/ru/automations.php | 1 + lang/tr/automations.php | 1 + lang/zh/automations.php | 1 + .../automations/config/GenerateNodeConfig.vue | 2 +- .../posts/editor/FacebookSettings.vue | 20 ++++++++++-- .../posts/editor/InstagramSettings.vue | 20 ++++++++++-- .../posts/editor/PinterestSettings.vue | 21 ++++++++++-- .../posts/editor/TikTokSettings.vue | 16 +++++++++- resources/js/lib/aiGenerateVariants.ts | 32 +++++++++++++++++++ .../Automation/GenerateNodeValidationTest.php | 19 +++++++++++ 24 files changed, 141 insertions(+), 15 deletions(-) create mode 100644 resources/js/lib/aiGenerateVariants.ts diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 70aa293bb..e97f45e74 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -506,6 +506,7 @@ public static function aiSupported(): array self::MastodonPost, self::FacebookPost, self::PinterestPin, + self::PinterestCarousel, ]; } diff --git a/app/Services/Automation/GenerateNodeValidator.php b/app/Services/Automation/GenerateNodeValidator.php index e04991b2e..633527a01 100644 --- a/app/Services/Automation/GenerateNodeValidator.php +++ b/app/Services/Automation/GenerateNodeValidator.php @@ -54,12 +54,14 @@ public function issueFor(array $config): ?string private function issueForAccount(ContentType $contentType, int $imageCount): ?string { - if ($contentType->requiresMedia() && $imageCount === 0) { - return __('posts.edit.compliance.requires_media'); + // Generate only produces images — video-only formats (Reel, Video Pin, …) + // can never be satisfied by this node. + if (! $contentType->supportsImage()) { + return __('automations.errors.generate_image_format_required'); } - if ($imageCount > 0 && ! $contentType->supportsImage()) { - return __('posts.edit.compliance.no_images'); + if ($contentType->requiresMedia() && $imageCount === 0) { + return __('posts.edit.compliance.requires_media'); } $max = min(self::MAX_GENERATED_IMAGES, $contentType->maxMediaCount()); diff --git a/lang/ar/automations.php b/lang/ar/automations.php index 229fedbc9..b2ca859b9 100644 --- a/lang/ar/automations.php +++ b/lang/ar/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'أطلق طلب HTTP استثناءً.', 'http_request_failed' => 'فشل طلب HTTP.', 'http_items_path_not_array' => 'لم يُفضِ مسار العناصر إلى قائمة.', + 'generate_image_format_required' => 'توليد الذكاء الاصطناعي ينشئ صورًا فقط. اختر تنسيق صورة (وليس فيديو).', ], ]; diff --git a/lang/de/automations.php b/lang/de/automations.php index 69c5ca546..528cbffe4 100644 --- a/lang/de/automations.php +++ b/lang/de/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'Die HTTP-Anfrage hat eine Ausnahme ausgelöst.', 'http_request_failed' => 'Die HTTP-Anfrage ist fehlgeschlagen.', 'http_items_path_not_array' => 'Der Pfad zu den Einträgen ergab keine Liste.', + 'generate_image_format_required' => 'KI-Generierung erstellt nur Bilder. Wähle ein Bildformat (kein Video).', ], ]; diff --git a/lang/el/automations.php b/lang/el/automations.php index d40fffbc3..da46f16ce 100644 --- a/lang/el/automations.php +++ b/lang/el/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'Το αίτημα HTTP προκάλεσε εξαίρεση.', 'http_request_failed' => 'Το αίτημα HTTP απέτυχε.', 'http_items_path_not_array' => 'Η διαδρομή στοιχείων δεν αντιστοιχήθηκε σε λίστα.', + 'generate_image_format_required' => 'Η δημιουργία AI παράγει μόνο εικόνες. Επίλεξε μορφή εικόνας (όχι βίντεο).', ], ]; diff --git a/lang/en/automations.php b/lang/en/automations.php index ecc6b353c..aa10e72bb 100644 --- a/lang/en/automations.php +++ b/lang/en/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'The HTTP request threw an exception.', 'http_request_failed' => 'The HTTP request failed.', 'http_items_path_not_array' => 'The items path did not resolve to a list.', + 'generate_image_format_required' => 'AI generate only creates images. Pick an image format (not video).', ], ]; diff --git a/lang/es/automations.php b/lang/es/automations.php index 33df62550..b8a05cd3a 100644 --- a/lang/es/automations.php +++ b/lang/es/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'La petición HTTP lanzó una excepción.', 'http_request_failed' => 'La petición HTTP falló.', 'http_items_path_not_array' => 'El items path no resolvió a una lista.', + 'generate_image_format_required' => 'La generación con IA solo crea imágenes. Elige un formato de imagen (no vídeo).', ], ]; diff --git a/lang/fr/automations.php b/lang/fr/automations.php index 835282c87..28e92a23f 100644 --- a/lang/fr/automations.php +++ b/lang/fr/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'La requête HTTP a levé une exception.', 'http_request_failed' => 'La requête HTTP a échoué.', 'http_items_path_not_array' => 'Le chemin des éléments ne correspond pas à une liste.', + 'generate_image_format_required' => 'La génération IA ne crée que des images. Choisissez un format image (pas vidéo).', ], ]; diff --git a/lang/it/automations.php b/lang/it/automations.php index 374902874..2d01db01c 100644 --- a/lang/it/automations.php +++ b/lang/it/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'La richiesta HTTP ha generato un\'eccezione.', 'http_request_failed' => 'La richiesta HTTP non è riuscita.', 'http_items_path_not_array' => 'Il percorso degli elementi non ha restituito un elenco.', + 'generate_image_format_required' => 'La generazione AI crea solo immagini. Scegli un formato immagine (non video).', ], ]; diff --git a/lang/ja/automations.php b/lang/ja/automations.php index 96f676418..0fd2f6cea 100644 --- a/lang/ja/automations.php +++ b/lang/ja/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'HTTP リクエストで例外が発生しました。', 'http_request_failed' => 'HTTP リクエストが失敗しました。', 'http_items_path_not_array' => 'アイテムのパスがリストとして解決されませんでした。', + 'generate_image_format_required' => 'AI生成は画像のみ作成します。画像フォーマットを選んでください(動画不可)。', ], ]; diff --git a/lang/ko/automations.php b/lang/ko/automations.php index 88af31f9f..420b941f6 100644 --- a/lang/ko/automations.php +++ b/lang/ko/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'HTTP 요청에서 예외가 발생했습니다.', 'http_request_failed' => 'HTTP 요청이 실패했습니다.', 'http_items_path_not_array' => '항목 경로가 목록으로 해석되지 않았습니다.', + 'generate_image_format_required' => 'AI 생성은 이미지만 만듭니다. 이미지 형식을 선택하세요(동영상 불가).', ], ]; diff --git a/lang/nl/automations.php b/lang/nl/automations.php index 8a0c082a4..eb5fde1cb 100644 --- a/lang/nl/automations.php +++ b/lang/nl/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'Het HTTP-verzoek gaf een uitzondering.', 'http_request_failed' => 'Het HTTP-verzoek is mislukt.', 'http_items_path_not_array' => 'Het itempad verwees niet naar een lijst.', + 'generate_image_format_required' => 'AI-generatie maakt alleen afbeeldingen. Kies een afbeeldingsformaat (geen video).', ], ]; diff --git a/lang/pl/automations.php b/lang/pl/automations.php index d40b87c8c..5a8984710 100644 --- a/lang/pl/automations.php +++ b/lang/pl/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'Żądanie HTTP zgłosiło wyjątek.', 'http_request_failed' => 'Żądanie HTTP nie powiodło się.', 'http_items_path_not_array' => 'Ścieżka do elementów nie zwróciła listy.', + 'generate_image_format_required' => 'Generowanie AI tworzy tylko obrazy. Wybierz format obrazu (nie wideo).', ], ]; diff --git a/lang/pt-BR/automations.php b/lang/pt-BR/automations.php index 7b4bdfcfd..3603ca2ac 100644 --- a/lang/pt-BR/automations.php +++ b/lang/pt-BR/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'A requisição HTTP lançou uma exceção.', 'http_request_failed' => 'A requisição HTTP falhou.', 'http_items_path_not_array' => 'O items path não resultou em uma lista.', + 'generate_image_format_required' => 'A geração por IA só cria imagens. Escolha um formato de imagem (não vídeo).', ], ]; diff --git a/lang/ru/automations.php b/lang/ru/automations.php index 2c059b83c..1de196ba1 100644 --- a/lang/ru/automations.php +++ b/lang/ru/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'HTTP-запрос вызвал исключение.', 'http_request_failed' => 'HTTP-запрос не удался.', 'http_items_path_not_array' => 'Путь к элементам не привёл к списку.', + 'generate_image_format_required' => 'ИИ генерирует только изображения. Выберите формат изображения (не видео).', ], ]; diff --git a/lang/tr/automations.php b/lang/tr/automations.php index a033f4f9d..402d06c3a 100644 --- a/lang/tr/automations.php +++ b/lang/tr/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'HTTP isteği bir istisna oluşturdu.', 'http_request_failed' => 'HTTP isteği başarısız oldu.', 'http_items_path_not_array' => 'Öğe yolu bir listeye çözümlenmedi.', + 'generate_image_format_required' => 'AI oluşturma yalnızca görsel üretir. Görsel formatı seçin (video değil).', ], ]; diff --git a/lang/zh/automations.php b/lang/zh/automations.php index b1643e2dc..e8bbb4e56 100644 --- a/lang/zh/automations.php +++ b/lang/zh/automations.php @@ -408,5 +408,6 @@ 'http_request_exception' => 'HTTP 请求抛出了异常。', 'http_request_failed' => 'HTTP 请求失败。', 'http_items_path_not_array' => '条目路径未解析为列表。', + 'generate_image_format_required' => 'AI 生成仅创建图片。请选择图片格式(不支持视频)。', ], ]; diff --git a/resources/js/components/automations/config/GenerateNodeConfig.vue b/resources/js/components/automations/config/GenerateNodeConfig.vue index 983015c7e..c860736a0 100644 --- a/resources/js/components/automations/config/GenerateNodeConfig.vue +++ b/resources/js/components/automations/config/GenerateNodeConfig.vue @@ -113,7 +113,7 @@ const defaultContentTypeFor = (platform: string): string => { case Platform.LinkedInPage: return ContentType.LinkedInPagePost; case Platform.TikTok: - return ContentType.TikTokVideo; + return ContentType.TikTokPhoto; case Platform.Pinterest: return ContentType.PinterestPin; case Platform.YouTube: diff --git a/resources/js/components/posts/editor/FacebookSettings.vue b/resources/js/components/posts/editor/FacebookSettings.vue index af379ebbc..65d5ef215 100644 --- a/resources/js/components/posts/editor/FacebookSettings.vue +++ b/resources/js/components/posts/editor/FacebookSettings.vue @@ -1,10 +1,11 @@