diff --git a/app/Http/Requests/Api/StoreUploadRequest.php b/app/Http/Requests/Api/StoreUploadRequest.php index f07237ed..4e966552 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 c8dc4432..aa0adcf7 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 889f120f..1352458e 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 0034ca58..228d6fc9 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 fc0969d1..8ac2cf9e 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 72041356..51db448d 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(); });