diff --git a/app/Actions/SocialAccount/ListPinterestBoards.php b/app/Actions/SocialAccount/ListPinterestBoards.php index 0785486e..1d741370 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 54e536bc..70aa293b 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 556c4776..2b31b785 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 e42ed761..00e2720d 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 9a039281..2227a193 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 30c2e716..61349bc7 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()); + // 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()}"), + ]; - if ($workspaceId === '') { - return $ipLimit; + $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 dbbf5743..87dfb1d5 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 f41bc29b..08cd5054 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 21828742..0ed1f0b7 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 8817b6e5..db7fbf70 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 90d3e60e..dc8e66e4 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 1f0f48c3..7f7575e7 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 1d5b9ac5..fbd9c0a9 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 7266b095..b47483af 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);