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:
parent
0d4e5fd963
commit
e4779dfcdf
14 changed files with 204 additions and 74 deletions
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue