diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index b9338bc9..936337cc 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 1082de9f..0d9132cb 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 a7b3b632..e240f3a2 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 2b380e50..889f120f 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 960c0244..10c739ad 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 c28a3160..0034ca58 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 03d07a14..8d5901e5 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 f876ef0e..0aeef2b4 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 975fbdae..470ff022 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 dde76013..f15f8f15 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 6f53b10f..61b04344 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 2887da40..7cbd880e 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 6baf9996..ea9ca8de 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 352e6ddd..4c205ec4 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 86f1f1ab..2e58a2e7 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 75a6af89..f13035b5 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 c30419ec..3af64e34 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 51359954..d7d8856a 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 c97df56c..c180e074 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 59007cc6..2babf59f 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 fc5d3cb3..0fec3de5 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 fe3c48f1..9c603e79 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 204253c6..954229b7 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 ac69900e..fc0969d1 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)], - ); - - $response->assertStatus(429); + )->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); + + $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 e603f7cf..e8828b20 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 8ce7afbc..72041356 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 6dd1ba83..d37ac5e2 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);