From 770266d36b9658f789291ab58eb15503f52a6bcd Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 4 May 2026 14:54:47 -0300 Subject: [PATCH] refactor: centralize media size limits in config + drop Document type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things in one move: 1. Centralize per-type size caps in config/trypost.php under media.max_size_mb. The MediaType enum now reads from there: MediaType::Image->maxSizeInMb() // 10 (env: MEDIA_IMAGE_MAX_SIZE_MB) MediaType::Video->maxSizeInMb() // 1024 (env: MEDIA_VIDEO_MAX_SIZE_MB) Plus convenience helpers maxSizeInBytes() and maxSizeInKb() so callers don't have to multiply themselves. StoreAssetRequest now uses MediaType::Video->maxSizeInKb() in its 'max:' rule and mimes derived from MediaType::{Image,Video}->allowedMimeTypes(). storeChunked validation moved to the new StoreChunkedAssetRequest FormRequest (also reads from the enum). MediaAttacher uses MediaType::Video->maxSizeInBytes() as the streaming-abort threshold and enforces the per-type cap after MIME resolution. 2. Drop MediaType::Document. We never accepted PDFs anywhere — the StoreAssetRequest mimes list excluded them, the storeChunked extension regex excluded them, MediaAttacher excluded them. The only places that referenced Document were: - Platform::allowedMediaTypes for LinkedIn/LinkedInPage (declared but unreachable) - HasMedia::getMediaType fallback when MIME wasn't image/video/* Both now cleaned up. HasMedia::getMediaType throws InvalidArgumentException for unsupported MIMEs instead of silently returning a fake 'document' type. Platform::LinkedIn now matches every other social platform: [Image, Video]. 3. Add MediaType::fromMime($mime): ?self — replaces the inline mime → type loop that MediaAttacher used to roll. Returns null for unsupported MIMEs (caller decides how to react). Tests: - MediaTypeTest rewritten for the new shape (no Document, config-driven sizes, fromMime + size-helper coverage). - PlatformTest no longer asserts Document on LinkedIn. - HasMediaTest replaces the 'detects document type' case with one that asserts the throw on unsupported MIMEs. The 'add media from path' test now uses real PNG bytes from the fixture. - AssetControllerTest chunked tests use real PNG bytes and assert 422 (FormRequest unprocessable) for malformed Content-Range headers, matching the new validation layer. --- app/Enums/Media/Type.php | 37 ++++++++--- app/Enums/SocialAccount/Platform.php | 2 +- app/Http/Controllers/App/AssetController.php | 27 ++------ .../Requests/App/Asset/StoreAssetRequest.php | 12 +++- .../App/Asset/StoreChunkedAssetRequest.php | 65 ++++++++++++++++++ app/Models/Traits/HasMedia.php | 2 +- app/Services/Post/MediaAttacher.php | 66 ++++++++----------- config/trypost.php | 18 +++++ tests/Feature/AssetControllerTest.php | 13 ++-- tests/Unit/Enums/MediaTypeTest.php | 25 +++++-- tests/Unit/Enums/PlatformTest.php | 2 +- tests/Unit/Traits/HasMediaTest.php | 13 ++-- 12 files changed, 194 insertions(+), 88 deletions(-) create mode 100644 app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php diff --git a/app/Enums/Media/Type.php b/app/Enums/Media/Type.php index 2c942dc1..a06e420a 100644 --- a/app/Enums/Media/Type.php +++ b/app/Enums/Media/Type.php @@ -8,32 +8,53 @@ enum Type: string { case Image = 'image'; case Video = 'video'; - case Document = 'document'; public function label(): string { return match ($this) { self::Image => 'Imagem', self::Video => 'Vídeo', - self::Document => 'Documento', }; } + /** + * @return array + */ public function allowedMimeTypes(): array { return match ($this) { self::Image => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], self::Video => ['video/mp4', 'video/quicktime', 'video/webm'], - self::Document => ['application/pdf'], }; } public function maxSizeInMb(): int { - return match ($this) { - self::Image => 10, - self::Video => 2048, - self::Document => 100, - }; + return (int) config("trypost.media.max_size_mb.{$this->value}"); + } + + public function maxSizeInBytes(): int + { + return $this->maxSizeInMb() * 1024 * 1024; + } + + public function maxSizeInKb(): int + { + return $this->maxSizeInMb() * 1024; + } + + /** + * Resolve a Type from a MIME string. Returns null when the MIME is + * not in any type's allow-list. + */ + public static function fromMime(string $mime): ?self + { + foreach (self::cases() as $type) { + if (in_array($mime, $type->allowedMimeTypes(), true)) { + return $type; + } + } + + return null; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index ae93a5a5..264db101 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -59,7 +59,7 @@ public function color(): string public function allowedMediaTypes(): array { return match ($this) { - self::LinkedIn, self::LinkedInPage => [MediaType::Image, MediaType::Video, MediaType::Document], + self::LinkedIn, self::LinkedInPage => [MediaType::Image, MediaType::Video], self::X => [MediaType::Image, MediaType::Video], self::TikTok => [MediaType::Video], self::YouTube => [MediaType::Video], diff --git a/app/Http/Controllers/App/AssetController.php b/app/Http/Controllers/App/AssetController.php index 8942e0b8..eab0588d 100644 --- a/app/Http/Controllers/App/AssetController.php +++ b/app/Http/Controllers/App/AssetController.php @@ -6,6 +6,7 @@ use App\Http\Requests\App\Asset\StoreAssetFromUrlRequest; use App\Http\Requests\App\Asset\StoreAssetRequest; +use App\Http\Requests\App\Asset\StoreChunkedAssetRequest; use App\Http\Resources\App\MediaResource; use App\Models\Media; use App\Services\UnsplashService; @@ -66,33 +67,17 @@ public function store(StoreAssetRequest $request): MediaResource return new MediaResource($media); } - public function storeChunked(Request $request): JsonResponse + public function storeChunked(StoreChunkedAssetRequest $request): JsonResponse { $workspace = $request->user()->currentWorkspace; $this->authorize('createPost', $workspace); - $contentRange = $request->header('Content-Range'); + $rangeStart = (int) $request->validated('range_start'); + $rangeEnd = (int) $request->validated('range_end'); + $totalSize = (int) $request->validated('total_size'); + $fileName = (string) $request->validated('file_name'); - preg_match('/bytes (\d+)-(\d+)\/(\d+)/', $contentRange, $matches); - - if (empty($matches)) { - abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Invalid Content-Range header'); - } - - $rangeStart = (int) $matches[1]; - $rangeEnd = (int) $matches[2]; - $totalSize = (int) $matches[3]; - - $fileName = $request->header('X-File-Name', 'upload'); - - if (! preg_match('/\.(jpe?g|png|gif|webp|mp4)$/i', $fileName)) { - abort(SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'File type not supported.'); - } - - if ($totalSize > 1073741824) { // 1GB - abort(SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'File size exceeds the maximum allowed (1GB).'); - } $identifier = md5($request->user()->id.$fileName.$totalSize); $tempFile = storage_path("app/private/chunks/{$identifier}"); diff --git a/app/Http/Requests/App/Asset/StoreAssetRequest.php b/app/Http/Requests/App/Asset/StoreAssetRequest.php index ef6f7154..7f649b0c 100644 --- a/app/Http/Requests/App/Asset/StoreAssetRequest.php +++ b/app/Http/Requests/App/Asset/StoreAssetRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\App\Asset; +use App\Enums\Media\Type as MediaType; use Illuminate\Foundation\Http\FormRequest; class StoreAssetRequest extends FormRequest @@ -15,8 +16,17 @@ public function authorize(): bool public function rules(): array { + $allowedMimes = [...MediaType::Image->allowedMimeTypes(), ...MediaType::Video->allowedMimeTypes()]; + return [ - 'media' => ['required', 'file', 'max:1048576', 'mimetypes:image/jpeg,image/png,image/gif,image/webp,video/mp4'], + // Use the largest per-type cap as the upper bound; per-type + // enforcement happens after the upload via the Media model. + 'media' => [ + 'required', + 'file', + 'max:'.MediaType::Video->maxSizeInKb(), + 'mimetypes:'.implode(',', $allowedMimes), + ], 'meta' => ['sometimes', 'array'], 'meta.width' => ['sometimes', 'integer', 'min:1'], 'meta.height' => ['sometimes', 'integer', 'min:1'], diff --git a/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php b/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php new file mode 100644 index 00000000..7a0975ab --- /dev/null +++ b/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php @@ -0,0 +1,65 @@ +header('Content-Range'), + $matches, + ); + + $this->merge([ + 'range_start' => isset($matches[1]) ? (int) $matches[1] : null, + 'range_end' => isset($matches[2]) ? (int) $matches[2] : null, + 'total_size' => isset($matches[3]) ? (int) $matches[3] : null, + 'file_name' => $this->header('X-File-Name', 'upload'), + ]); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'range_start' => ['required', 'integer', 'min:0'], + 'range_end' => ['required', 'integer', 'gte:range_start'], + 'total_size' => ['required', 'integer', 'min:1', 'max:'.MediaType::Video->maxSizeInBytes()], + 'file_name' => ['required', 'string', 'regex:/\.(jpe?g|png|gif|webp|mp4)$/i'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'range_start.required' => 'Invalid Content-Range header', + 'range_end.required' => 'Invalid Content-Range header', + 'total_size.required' => 'Invalid Content-Range header', + 'total_size.max' => 'File size exceeds the maximum allowed ('.MediaType::Video->maxSizeInMb().' MB).', + 'file_name.regex' => 'File type not supported.', + ]; + } +} diff --git a/app/Models/Traits/HasMedia.php b/app/Models/Traits/HasMedia.php index aaf32537..c1b9cb5e 100644 --- a/app/Models/Traits/HasMedia.php +++ b/app/Models/Traits/HasMedia.php @@ -164,7 +164,7 @@ private function getMediaType(string $mimeType): string return 'video'; } - return 'document'; + throw new \InvalidArgumentException("Unsupported media MIME type: {$mimeType}"); } private function getMediaMeta(UploadedFile $file, string $type): array diff --git a/app/Services/Post/MediaAttacher.php b/app/Services/Post/MediaAttacher.php index 15d3eba8..13a6160d 100644 --- a/app/Services/Post/MediaAttacher.php +++ b/app/Services/Post/MediaAttacher.php @@ -22,27 +22,23 @@ * * Flow per URL: * 1. Stream the body to a temp file via Http::sink + a progress - * callback that aborts mid-download once MAX_BYTES is exceeded. - * 2. Validate the Content-Type against the MediaType enum's allow-list - * AND the intersection of allowed media types across the post's - * enabled platforms. - * 3. Hand off to `Workspace::addMediaFromPath()` so storage path, + * callback that aborts once we exceed the largest configured + * per-type cap (video = 1 GB by default, see config/trypost.php). + * 2. Resolve the MediaType from the response's Content-Type via + * `MediaType::fromMime()`. Reject if the type isn't accepted by + * the intersection of platforms enabled on the post. + * 3. Enforce the per-type cap (`MediaType::Image->maxSizeInBytes()` + * vs `Video`) — a 100 MB jpeg is rejected even though we + * streamed up to the video cap. + * 4. Hand off to `Workspace::addMediaFromPath()` so storage path, * MIME re-detection, image normalization, and the Media row stay * in one place (same path as the web upload flow). - * 4. Append the resulting media item to the post's `media[]` JSON + * 5. Append the resulting media item to the post's `media[]` JSON * column under a row lock so concurrent attach calls don't clobber * each other. */ class MediaAttacher { - /** - * Cap on URL-fetched payloads. Smaller than the web upload cap (1 GB) - * because URL fetches have different operational constraints: - * bandwidth, timeout, and unbounded user input. 50 MB covers a long - * photo or a short video; bigger files should be uploaded directly. - */ - private const MAX_BYTES = 50 * 1024 * 1024; - /** * @param array $urls * @return array{attached: array>, failed: array} @@ -79,6 +75,11 @@ public function attachFromUrls(Post $post, array $urls): array */ private function processOne(Workspace $workspace, string $url, array $allowedTypes): ?array { + // Use the largest configured per-type cap as the streaming-abort + // threshold; the actual per-type limit is checked below once we + // know the MIME. + $streamCap = MediaType::Video->maxSizeInBytes(); + $temp = tempnam(sys_get_temp_dir(), 'media_'); try { @@ -86,25 +87,34 @@ private function processOne(Workspace $workspace, string $url, array $allowedTyp ->sink($temp) ->withOptions([ 'allow_redirects' => false, - 'progress' => static function ($total, $downloaded): void { - if ($downloaded > self::MAX_BYTES) { + 'progress' => static function ($total, $downloaded) use ($streamCap): void { + if ($downloaded > $streamCap) { throw new RuntimeException('exceeded max bytes'); } }, ]) ->get($url); - if (! $response->successful() || filesize($temp) === 0) { + $bytes = filesize($temp) ?: 0; + + if (! $response->successful() || $bytes === 0) { return null; } $mime = trim(explode(';', (string) $response->header('Content-Type'))[0]); - $type = $this->resolveType($mime); + $type = MediaType::fromMime($mime); if ($type === null || ! in_array($type, $allowedTypes, true)) { return null; } + // Per-type size enforcement. Image is 10 MB even though we + // streamed up to the video cap, so a 100 MB jpeg is rejected + // here before we persist it. + if ($bytes > $type->maxSizeInBytes()) { + return null; + } + $originalFilename = basename(parse_url($url, PHP_URL_PATH) ?? '') ?: 'download.bin'; $media = $workspace->addMediaFromPath($temp, $originalFilename, 'assets'); @@ -167,24 +177,4 @@ private function allowedMediaTypesFor(Post $post): array return array_map(fn ($value) => MediaType::from($value), $intersection); } - - /** - * Resolve the MediaType for a given MIME by walking the enum's own - * allow-list. Document is excluded from URL fetches (no PDFs via URL - * — those go through direct upload only). - */ - private function resolveType(?string $mime): ?MediaType - { - if ($mime === null || $mime === '') { - return null; - } - - foreach ([MediaType::Image, MediaType::Video] as $type) { - if (in_array($mime, $type->allowedMimeTypes(), true)) { - return $type; - } - } - - return null; - } } diff --git a/config/trypost.php b/config/trypost.php index c0606acd..ea202f01 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -16,6 +16,24 @@ 'self_hosted' => env('SELF_HOSTED', true), + /* + |-------------------------------------------------------------------------- + | Media Size Limits + |-------------------------------------------------------------------------- + | + | Per-type size caps in megabytes. Single source of truth — direct + | uploads (StoreAssetRequest, AssetController::storeChunked), URL + | fetches (MediaAttacher), and the MediaType enum all read from here. + | + */ + + 'media' => [ + 'max_size_mb' => [ + 'image' => (int) env('MEDIA_IMAGE_MAX_SIZE_MB', 10), + 'video' => (int) env('MEDIA_VIDEO_MAX_SIZE_MB', 1024), + ], + ], + /* |-------------------------------------------------------------------------- | Google Authentication diff --git a/tests/Feature/AssetControllerTest.php b/tests/Feature/AssetControllerTest.php index 056bdf8b..c9eb0003 100644 --- a/tests/Feature/AssetControllerTest.php +++ b/tests/Feature/AssetControllerTest.php @@ -165,15 +165,18 @@ }); test('chunked upload completes with single chunk', function () { - $content = str_repeat('x', 1000); + // Use real PNG bytes so mime_content_type detects image/png. The MIME + // is sniffed from content magic bytes, not the X-File-Name header. + $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); + $size = strlen($content); $response = $this->actingAs($this->user)->call( 'POST', route('app.assets.store-chunked'), [], [], [], [ - 'HTTP_CONTENT_RANGE' => 'bytes 0-999/1000', - 'HTTP_X_FILE_NAME' => 'test-video.mp4', + 'HTTP_CONTENT_RANGE' => 'bytes 0-'.($size - 1).'/'.$size, + 'HTTP_X_FILE_NAME' => 'test.png', 'HTTP_ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/octet-stream', ], @@ -236,7 +239,9 @@ 'data', ); - $response->assertStatus(400); + // FormRequest validation surfaces parse failures as 422 + // (range_start / range_end / total_size all required). + $response->assertUnprocessable(); }); test('chunked upload rejects unauthenticated', function () { diff --git a/tests/Unit/Enums/MediaTypeTest.php b/tests/Unit/Enums/MediaTypeTest.php index 4e2cb377..3e288bf4 100644 --- a/tests/Unit/Enums/MediaTypeTest.php +++ b/tests/Unit/Enums/MediaTypeTest.php @@ -7,23 +7,36 @@ test('media type has correct values', function () { expect(Type::Image->value)->toBe('image'); expect(Type::Video->value)->toBe('video'); - expect(Type::Document->value)->toBe('document'); }); test('media type has labels', function () { expect(Type::Image->label())->toBe('Imagem'); expect(Type::Video->label())->toBe('Vídeo'); - expect(Type::Document->label())->toBe('Documento'); }); test('media type has allowed mime types', function () { expect(Type::Image->allowedMimeTypes())->toContain('image/jpeg', 'image/png'); expect(Type::Video->allowedMimeTypes())->toContain('video/mp4', 'video/quicktime'); - expect(Type::Document->allowedMimeTypes())->toContain('application/pdf'); }); -test('media type has max size in mb', function () { +test('media type max size in mb is read from config', function () { + config(['trypost.media.max_size_mb.image' => 10]); + config(['trypost.media.max_size_mb.video' => 1024]); + expect(Type::Image->maxSizeInMb())->toBe(10); - expect(Type::Video->maxSizeInMb())->toBe(2048); - expect(Type::Document->maxSizeInMb())->toBe(100); + expect(Type::Video->maxSizeInMb())->toBe(1024); +}); + +test('media type exposes derived size units', function () { + config(['trypost.media.max_size_mb.video' => 1024]); + + expect(Type::Video->maxSizeInKb())->toBe(1024 * 1024); + expect(Type::Video->maxSizeInBytes())->toBe(1024 * 1024 * 1024); +}); + +test('media type resolves from mime', function () { + expect(Type::fromMime('image/jpeg'))->toBe(Type::Image); + expect(Type::fromMime('video/mp4'))->toBe(Type::Video); + expect(Type::fromMime('application/pdf'))->toBeNull(); + expect(Type::fromMime('not-a-mime'))->toBeNull(); }); diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index 094ae6fb..e1f44c32 100644 --- a/tests/Unit/Enums/PlatformTest.php +++ b/tests/Unit/Enums/PlatformTest.php @@ -35,7 +35,7 @@ }); test('platform has correct allowed media types', function () { - expect(Platform::LinkedIn->allowedMediaTypes())->toContain(MediaType::Image, MediaType::Video, MediaType::Document); + expect(Platform::LinkedIn->allowedMediaTypes())->toContain(MediaType::Image, MediaType::Video); expect(Platform::X->allowedMediaTypes())->toContain(MediaType::Image, MediaType::Video); expect(Platform::TikTok->allowedMediaTypes())->toBe([MediaType::Video]); expect(Platform::YouTube->allowedMediaTypes())->toBe([MediaType::Video]); diff --git a/tests/Unit/Traits/HasMediaTest.php b/tests/Unit/Traits/HasMediaTest.php index f6363a43..b5157f40 100644 --- a/tests/Unit/Traits/HasMediaTest.php +++ b/tests/Unit/Traits/HasMediaTest.php @@ -107,12 +107,12 @@ $workspace = Workspace::factory()->create(); $tempFile = tempnam(sys_get_temp_dir(), 'test'); - file_put_contents($tempFile, 'fake image content'); + file_put_contents($tempFile, file_get_contents(__DIR__.'/../../fixtures/1x1.png')); - $media = $workspace->addMediaFromPath($tempFile, 'uploaded.jpg', 'logo'); + $media = $workspace->addMediaFromPath($tempFile, 'uploaded.png', 'logo'); expect($media)->toBeInstanceOf(Media::class); - expect($media->original_filename)->toBe('uploaded.jpg'); + expect($media->original_filename)->toBe('uploaded.png'); Storage::assertExists($media->path); unlink($tempFile); @@ -166,13 +166,12 @@ expect($media->type->value)->toBe('video'); }); -test('add media detects document type', function () { +test('add media throws on unsupported MIME type', function () { $workspace = Workspace::factory()->create(); $file = UploadedFile::fake()->create('document.pdf', 1000, 'application/pdf'); - $media = $workspace->addMedia($file, 'logo'); - - expect($media->type->value)->toBe('document'); + expect(fn () => $workspace->addMedia($file, 'logo')) + ->toThrow(InvalidArgumentException::class); }); test('add media includes custom meta', function () {