refactor: centralize media size limits in config + drop Document type

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.
This commit is contained in:
Paulo Castellano 2026-05-04 14:54:47 -03:00
parent ac361349da
commit 770266d36b
12 changed files with 194 additions and 88 deletions

View file

@ -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<int, string>
*/
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;
}
}

View file

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

View file

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

View file

@ -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'],

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Asset;
use App\Enums\Media\Type as MediaType;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validates a chunked upload request. The chunk metadata (offset / total
* size / filename) is encoded in the `Content-Range` and `X-File-Name`
* headers, not in the body, so we lift it into the request bag via
* `prepareForValidation` and then run standard rules against it.
*/
class StoreChunkedAssetRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
preg_match(
'/bytes (\d+)-(\d+)\/(\d+)/',
(string) $this->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<string, array<int, string>>
*/
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<string, string>
*/
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.',
];
}
}

View file

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

View file

@ -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<int, string> $urls
* @return array{attached: array<int, array<string, mixed>>, failed: array<int, string>}
@ -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;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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