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 <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-07-24 22:49:04 -03:00
parent 0d4e5fd963
commit e4779dfcdf
14 changed files with 204 additions and 74 deletions

View file

@ -11,13 +11,13 @@
class ListPinterestBoards
{
/**
* @return list<array{id: string, name: string}>
* @return array{boards: list<array{id: string, name: string}>, 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),
];
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<array<string, mixed>>
* @return array{boards: list<array<string, mixed>>, 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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