From e2f9f3db40fe8cddc809e7773d4488cccfe4ab63 Mon Sep 17 00:00:00 2001 From: Oscar Cedano Date: Sat, 7 Nov 2026 01:53:06 -0600 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=85=20feat:=20accept=20webp=20on=20te?= =?UTF-8?q?mporary=20profile=20and=20proof=20uploads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Cursor Composer 2.5 --- .cursor/rules/reports.mdc | 4 ++-- .../Public/StoreProfilePictureRequest.php | 2 +- app/Http/Requests/Public/StoreProofRequest.php | 2 +- openapi.yaml | 10 +++++----- tests/Support/WebpFixtures.php | 18 ++++++++++++++++++ 5 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 tests/Support/WebpFixtures.php diff --git a/.cursor/rules/reports.mdc b/.cursor/rules/reports.mdc index 0208538..db6699a 100644 --- a/.cursor/rules/reports.mdc +++ b/.cursor/rules/reports.mdc @@ -43,7 +43,7 @@ These bodies do not accept contacts, payment methods, or a profile picture. An i Upload the file before the report body. -- Profile picture: `POST /api/public/media/profiles` with `StoreProfilePictureRequest`, field `image`. One file, `jpg`, `jpeg`, or `png`, max 5120 KB. `MediaUsecase::storeTemporaryProfilePicture` stores it under `tmp/reports/profiles`. +- Profile picture: `POST /api/public/media/profiles` with `StoreProfilePictureRequest`, field `image`. One file, `jpg`, `jpeg`, `png`, or `webp`, max 5120 KB. `MediaUsecase::storeTemporaryProfilePicture` stores it under `tmp/reports/profiles`. - Proofs: `StoreProofRequest`, field `images`. At least one file, same mime types and size. `ReportUsecase::storeTemporaryProofs` stores them under `tmp/reports/proofs`. -`webp` is not an accepted upload type. The report body then passes those published temporary paths. `ReportUsecase` copies a profile into `reports/scammers/profiles` or `reports/organizations/profiles`, and proofs into `reports/scammers/proofs` or `reports/organizations/proofs`. A path that is not a published temporary file fails validation. +The report body then passes those published temporary paths. `ReportUsecase` copies a profile into `reports/scammers/profiles` or `reports/organizations/profiles`, and proofs into `reports/scammers/proofs` or `reports/organizations/proofs`. A path that is not a published temporary file fails validation. diff --git a/app/Http/Requests/Public/StoreProfilePictureRequest.php b/app/Http/Requests/Public/StoreProfilePictureRequest.php index 213ce0f..6503d13 100644 --- a/app/Http/Requests/Public/StoreProfilePictureRequest.php +++ b/app/Http/Requests/Public/StoreProfilePictureRequest.php @@ -14,7 +14,7 @@ public function authorize(): bool public function rules(): array { return [ - 'image' => ['required', 'file', 'mimes:jpg,jpeg,png', 'max:5120'], + 'image' => ['required', 'file', 'mimes:jpg,jpeg,png,webp', 'max:5120'], ]; } } diff --git a/app/Http/Requests/Public/StoreProofRequest.php b/app/Http/Requests/Public/StoreProofRequest.php index 5558265..598c280 100644 --- a/app/Http/Requests/Public/StoreProofRequest.php +++ b/app/Http/Requests/Public/StoreProofRequest.php @@ -15,7 +15,7 @@ public function rules(): array { return [ 'images' => ['required', 'array', 'min:1'], - 'images.*' => ['required', 'file', 'mimes:jpg,jpeg,png', 'max:5120'], + 'images.*' => ['required', 'file', 'mimes:jpg,jpeg,png,webp', 'max:5120'], ]; } } diff --git a/openapi.yaml b/openapi.yaml index 01617cc..2978548 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1191,7 +1191,7 @@ paths: operationId: storeTemporaryProfilePicture summary: Upload a temporary profile picture description: | - Accepts a JPG or PNG image up to 5 MB. The original is stored on the raw + Accepts a JPG, PNG, or WebP image up to 5 MB. The original is stored on the raw disk and must pass image moderation before a 640×640 cover is published on the public disk under `tmp/reports/profiles`. The response path is the public URL of that file. Uploading the same image again returns the @@ -1210,7 +1210,7 @@ paths: image: type: string format: binary - description: JPG or PNG image, 5 MB maximum. + description: JPG, PNG, or WebP image, 5 MB maximum. responses: "201": description: The image was stored. @@ -1230,7 +1230,7 @@ paths: operationId: storeTemporaryProof summary: Upload temporary proof images description: | - Accepts one or more JPG or PNG images, up to 5 MB each. Each original is + Accepts one or more JPG, PNG, or WebP images, up to 5 MB each. Each original is stored on the raw disk and must pass image moderation before it is published at its original size on the public disk under `tmp/reports/proofs`. Each response path is the public URL of that file. Uploading the same image @@ -1251,7 +1251,7 @@ paths: items: type: string format: binary - description: JPG or PNG images, 5 MB maximum each. + description: JPG, PNG, or WebP images, 5 MB maximum each. responses: "201": description: The images were stored. @@ -1601,7 +1601,7 @@ components: message: The request data is invalid. details: image: - - The image field must be a file of type: jpg, jpeg, png. + - The image field must be a file of type: jpg, jpeg, png, webp. RateLimited: description: Too many requests. Public routes allow 120 requests per minute per IP. content: diff --git a/tests/Support/WebpFixtures.php b/tests/Support/WebpFixtures.php new file mode 100644 index 0000000..03ff584 --- /dev/null +++ b/tests/Support/WebpFixtures.php @@ -0,0 +1,18 @@ + Date: Sat, 7 Nov 2026 01:53:09 -0600 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=85=20feat:=20publish=20temporary=20i?= =?UTF-8?q?mages=20under=20incoming=20md5=20and=20strip=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Cursor Composer 2.5 --- .../PublicDiskTemporaryImageStorage.php | 66 ++++++++++++++----- tests/Feature/PublicMediaControllerTest.php | 18 +++-- tests/Feature/PublicReportControllerTest.php | 11 +++- 3 files changed, 73 insertions(+), 22 deletions(-) diff --git a/app/Infrastructure/Storage/PublicDiskTemporaryImageStorage.php b/app/Infrastructure/Storage/PublicDiskTemporaryImageStorage.php index 3d620a8..bb5e456 100644 --- a/app/Infrastructure/Storage/PublicDiskTemporaryImageStorage.php +++ b/app/Infrastructure/Storage/PublicDiskTemporaryImageStorage.php @@ -23,44 +23,50 @@ public function upload(UploadedFile $file, string $directory, ?int $width = null throw new InvalidArgumentException('Crop width and height must both be set or both be null.'); } - $key = $this->cacheKey($file, $directory, $width, $height); + $directory = trim($directory, '/'); + $extension = $this->normalizedExtension($file); + $original = $file->getContent(); + $contentHash = md5($original); + $key = $this->cacheKey($directory, $width, $height, $contentHash); $cached = Cache::get($key); if (is_string($cached) && $this->storedFileExists($cached)) { return $cached; } - $filename = $file->hashName(); - $path = trim($directory, '/').'/'.$filename; - $original = $file->getContent(); + $stagingPath = $directory.'/'.Str::uuid()->toString().'.'.$extension; - if (Storage::disk('raw')->put($path, $original) === false) { + if (Storage::disk('raw')->put($stagingPath, $original) === false) { throw new RuntimeException('The image could not be stored.'); } if (! $this->antiNsfwAnalyzer->passes($original)) { - if (Storage::disk('quarantine')->put($path, $original) === false) { + $quarantinePath = $directory.'/'.$this->safeOriginalFilename($file); + + if (Storage::disk('quarantine')->put($quarantinePath, $original) === false) { throw new RuntimeException('The image could not be stored.'); } - Storage::disk('raw')->delete($path); + Storage::disk('raw')->delete($stagingPath); throw new ImageRejectedException; } try { - $contents = $width === null - ? $original - : (string) app(ImageManager::class) - ->decodePath($file->getPathname()) - ->cover($width, $height) - ->encodeUsingFileExtension(pathinfo($filename, PATHINFO_EXTENSION)); + $image = app(ImageManager::class)->decodeBinary($original); + + if ($width !== null) { + $image = $image->cover($width, $height); + } + + $contents = (string) $image->encodeUsingFileExtension($extension); + $path = $directory.'/'.$contentHash.'.'.$extension; if (Storage::disk('public')->put($path, $contents) === false) { throw new RuntimeException('The image could not be stored.'); } } finally { - Storage::disk('raw')->delete($path); + Storage::disk('raw')->delete($stagingPath); } $url = config('filesystems.disks.public.url').'/'.$path; @@ -110,11 +116,11 @@ public function deletePublished(string $relativePath): void Storage::disk('public')->delete($relativePath); } - private function cacheKey(UploadedFile $file, string $directory, ?int $width, ?int $height): string + private function cacheKey(string $directory, ?int $width, ?int $height, string $contentHash): string { $variant = $width === null ? 'original' : $width.'x'.$height; - return TemporaryImageStorageInterface::CACHE_KEY_PREFIX.md5(trim($directory, '/').':'.$variant.':'.$file->getContent()); + return TemporaryImageStorageInterface::CACHE_KEY_PREFIX.$directory.':'.$variant.':'.$contentHash; } private function storedFileExists(string $url): bool @@ -165,4 +171,32 @@ private function isDirectChild(string $relative, string $directory): bool return $name !== '' && ! str_contains($name, '/'); } + + private function normalizedExtension(UploadedFile $file): string + { + $extension = strtolower(pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION)); + + if (in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) { + return $extension; + } + + return match ($file->getMimeType()) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + default => throw new InvalidArgumentException('Unsupported image extension.'), + }; + } + + private function safeOriginalFilename(UploadedFile $file): string + { + $filename = basename($file->getClientOriginalName()); + $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename) ?? ''; + + if ($filename === '' || $filename === '.' || $filename === '..') { + return 'upload.'.$this->normalizedExtension($file); + } + + return $filename; + } } diff --git a/tests/Feature/PublicMediaControllerTest.php b/tests/Feature/PublicMediaControllerTest.php index 240477f..7dbf625 100644 --- a/tests/Feature/PublicMediaControllerTest.php +++ b/tests/Feature/PublicMediaControllerTest.php @@ -2,9 +2,11 @@ namespace Tests\Feature; +use App\Application\Media\TemporaryImageStorageInterface; use App\Infrastructure\Media\AntiNSFWAnalyzer; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; +use Tests\Support\WebpFixtures; use Tests\TestCase; class PublicMediaControllerTest extends TestCase @@ -18,11 +20,12 @@ protected function setUp(): void Storage::fake('quarantine'); } - public function test_stores_jpg_and_png_profile_pictures_for_scammers_and_organizations(): void + public function test_stores_jpg_png_and_webp_profile_pictures_for_scammers_and_organizations(): void { foreach ([ ['profile.jpg', self::jpegBytes()], ['profile.png', self::pngBytes()], + ['profile.webp', WebpFixtures::minimalBytes()], ] as [$filename, $contents]) { $response = $this->post('/api/public/media/profiles', [ 'image' => UploadedFile::fake()->createWithContent($filename, $contents), @@ -36,6 +39,10 @@ public function test_stores_jpg_and_png_profile_pictures_for_scammers_and_organi $this->assertStringStartsWith($prefix, $url); $this->assertStringEndsWith('.'.$extension, $url); + $this->assertMatchesRegularExpression( + '#^'.preg_quote($prefix, '#').'[a-f0-9]{32}\.'.preg_quote($extension, '#').'$#', + $url, + ); $relative = substr($url, strlen(config('filesystems.disks.public.url').'/')); $this->assertTrue(Storage::disk('public')->exists($relative)); @@ -73,7 +80,7 @@ public function passes(string $contents): bool $contents = self::jpegBytes(); $this->post('/api/public/media/profiles', [ - 'image' => UploadedFile::fake()->createWithContent('profile.jpg', $contents), + 'image' => UploadedFile::fake()->createWithContent('oscar-cedano.jpg', $contents), ]) ->assertStatus(422) ->assertJsonPath('error.code', 'image_rejected') @@ -82,7 +89,10 @@ public function passes(string $contents): bool $quarantined = Storage::disk('quarantine')->allFiles(); $this->assertCount(1, $quarantined); - $this->assertStringStartsWith('tmp/reports/profiles/', $quarantined[0]); + $this->assertSame( + TemporaryImageStorageInterface::PROFILE_PICTURE_DIRECTORY.'/oscar-cedano.jpg', + $quarantined[0], + ); $this->assertSame($contents, Storage::disk('quarantine')->get($quarantined[0])); $this->assertSame([], Storage::disk('public')->allFiles()); $this->assertSame([], Storage::disk('raw')->allFiles()); @@ -106,7 +116,7 @@ public function test_stores_the_profile_picture_again_when_the_cached_file_is_mi $second->assertCreated(); $secondRelative = substr($second->json('path'), strlen($prefix)); - $this->assertNotSame($firstRelative, $secondRelative); + $this->assertSame($firstRelative, $secondRelative); $this->assertTrue(Storage::disk('public')->exists($secondRelative)); $this->assertSame([], Storage::disk('raw')->allFiles()); } diff --git a/tests/Feature/PublicReportControllerTest.php b/tests/Feature/PublicReportControllerTest.php index 1949453..809744c 100644 --- a/tests/Feature/PublicReportControllerTest.php +++ b/tests/Feature/PublicReportControllerTest.php @@ -16,6 +16,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; +use Tests\Support\WebpFixtures; use Tests\TestCase; class PublicReportControllerTest extends TestCase @@ -29,12 +30,13 @@ protected function setUp(): void Storage::fake('quarantine'); } - public function test_stores_jpg_and_png_proofs_at_their_original_size(): void + public function test_stores_jpg_png_and_webp_proofs_at_their_original_size(): void { $response = $this->post('/api/public/reports/media/proofs', [ 'images' => [ UploadedFile::fake()->createWithContent('proof.jpg', self::jpegBytes()), UploadedFile::fake()->createWithContent('proof.png', self::pngBytes()), + UploadedFile::fake()->createWithContent('proof.webp', WebpFixtures::minimalBytes()), ], ]); @@ -45,15 +47,20 @@ public function test_stores_jpg_and_png_proofs_at_their_original_size(): void $expected = [ ['jpg', 1200, 800], ['png', 400, 900], + ['webp', 1, 1], ]; - $this->assertCount(2, $paths); + $this->assertCount(3, $paths); foreach ($paths as $index => $url) { [$extension, $width, $height] = $expected[$index]; $this->assertStringStartsWith($prefix, $url); $this->assertStringEndsWith('.'.$extension, $url); + $this->assertMatchesRegularExpression( + '#^'.preg_quote($prefix, '#').'[a-f0-9]{32}\.'.preg_quote($extension, '#').'$#', + $url, + ); $relative = substr($url, strlen(config('filesystems.disks.public.url').'/')); $this->assertTrue(Storage::disk('public')->exists($relative));