Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .cursor/rules/reports.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion app/Http/Requests/Public/StoreProfilePictureRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
];
}
}
2 changes: 1 addition & 1 deletion app/Http/Requests/Public/StoreProofRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
];
}
}
66 changes: 50 additions & 16 deletions app/Infrastructure/Storage/PublicDiskTemporaryImageStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
10 changes: 5 additions & 5 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
18 changes: 14 additions & 4 deletions tests/Feature/PublicMediaControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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));
Expand Down Expand Up @@ -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')
Expand All @@ -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());
Expand All @@ -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());
}
Expand Down
11 changes: 9 additions & 2 deletions tests/Feature/PublicReportControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()),
],
]);

Expand All @@ -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));
Expand Down
18 changes: 18 additions & 0 deletions tests/Support/WebpFixtures.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Tests\Support;

final class WebpFixtures
{
/** 1×1 lossy WebP (valid image/webp without GD imagewebp). */
public static function minimalBytes(): string
{
$decoded = base64_decode('UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=', true);

if (! is_string($decoded) || $decoded === '') {
throw new \RuntimeException('The WebP fixture could not be decoded.');
}

return $decoded;
}
}
Loading