diff --git a/app/Actions/CreateImageFiles.php b/app/Actions/CreateImageFiles.php index e70b04714..af24e8356 100644 --- a/app/Actions/CreateImageFiles.php +++ b/app/Actions/CreateImageFiles.php @@ -20,7 +20,9 @@ private const int QUALITY = 60; - private const array ALLOWED_TYPES = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP]; + private const int MAX_GIF_PIXELS = 8_000_000; + + private const array ALLOWED_TYPES = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP, IMAGETYPE_GIF]; public function __construct(private EncryptedFiles $encryptedFiles) { @@ -44,11 +46,11 @@ public function handle(array $images, array $descriptions = [], string $director foreach ($optimized as $position => $image) { if ($encrypted) { - $path = $directory.'/'.Str::uuid7()->toString(); + $path = $directory.'/'.Str::uuid7()->toString().($image->extension() === 'gif' ? '.gif' : ''); $stored = Storage::disk()->put($path, $this->encryptedFiles->encrypt($image->toBytes())); assert($stored); } else { - $path = $image->storeAs($directory, Str::uuid7()->toString().'.webp'); + $path = $image->storeAs($directory, Str::uuid7()->toString().'.'.$image->extension()); assert(is_string($path)); } @@ -76,7 +78,8 @@ private function optimize(UploadedFile $file): ProcessedImage if ($info === false || ! in_array($info[2], self::ALLOWED_TYPES, true) || $info[0] > CreateCommitImages::MAX_SOURCE_DIMENSION - || $info[1] > CreateCommitImages::MAX_SOURCE_DIMENSION) { + || $info[1] > CreateCommitImages::MAX_SOURCE_DIMENSION + || ($info[2] === IMAGETYPE_GIF && max($info[0] * $info[1], 16_000) * mb_substr_count((string) file_get_contents($file->getRealPath()), "\x00\x2C") > self::MAX_GIF_PIXELS)) { throw $this->invalid(); } @@ -84,7 +87,7 @@ private function optimize(UploadedFile $file): ProcessedImage ->usingGd() ->orient() ->scale(self::MAX_STORED_DIMENSION, self::MAX_STORED_DIMENSION) - ->optimize('webp', self::QUALITY); + ->optimize($info[2] === IMAGETYPE_GIF ? 'gif' : 'webp', self::QUALITY); try { $image->toBytes(); @@ -98,7 +101,7 @@ private function optimize(UploadedFile $file): ProcessedImage private function invalid(): ValidationException { return ValidationException::withMessages([ - 'images' => 'Images must be JPEG, PNG, or WebP files of at most '.CreateCommitImages::MAX_SOURCE_DIMENSION.' pixels per side.', + 'images' => 'Images must be JPEG, PNG, WebP, or GIF files of at most '.CreateCommitImages::MAX_SOURCE_DIMENSION.' pixels per side, and GIFs not too long.', ]); } } diff --git a/app/Actions/CreateUserFeed.php b/app/Actions/CreateUserFeed.php index 145731b7a..4f1c6b8d3 100644 --- a/app/Actions/CreateUserFeed.php +++ b/app/Actions/CreateUserFeed.php @@ -68,7 +68,7 @@ private function entry(XMLWriter $xml, User $user, Commit $commit): void $this->link($xml, 'alternate', route('commit.show', [$user, $commit]), 'text/html'); foreach ($commit->images as $image) { - $this->link($xml, 'enclosure', $image->url(), 'image/webp'); + $this->link($xml, 'enclosure', $image->url(), $image->mimeType()); } $xml->writeElement('published', $commit->created_at->toAtomString()); diff --git a/app/Actions/UpdateCommitAiDecision.php b/app/Actions/UpdateCommitAiDecision.php index 57c98e791..c0046a9b7 100644 --- a/app/Actions/UpdateCommitAiDecision.php +++ b/app/Actions/UpdateCommitAiDecision.php @@ -8,6 +8,7 @@ use App\Models\Commit; use App\Models\CommitImage; use App\Services\OpenAI; +use Illuminate\Support\Facades\Image; use Illuminate\Support\Facades\Storage; final readonly class UpdateCommitAiDecision @@ -32,7 +33,9 @@ public function __construct( public function handle(Commit $commit): void { $images = array_values($commit->images->map( - fn (CommitImage $image): string => 'data:image/webp;base64,'.base64_encode((string) Storage::get($image->path)), + fn (CommitImage $image): string => $image->mimeType() === 'image/gif' + ? Image::fromStorage($image->path)->usingGd()->optimize('webp')->toDataUri() + : 'data:image/webp;base64,'.base64_encode((string) Storage::get($image->path)), )->all()); $scores = $this->openAI->moderate($commit->body, $images); diff --git a/app/Http/Controllers/Api/MessageImageController.php b/app/Http/Controllers/Api/MessageImageController.php index d2265aae3..f6f29cdd4 100644 --- a/app/Http/Controllers/Api/MessageImageController.php +++ b/app/Http/Controllers/Api/MessageImageController.php @@ -19,8 +19,8 @@ public function show(ShowApiMessageImageRequest $request, MessageImage $messageImage, EncryptedFiles $encryptedFiles): Response { return response($encryptedFiles->decrypt((string) Storage::disk()->get($messageImage->path)), headers: [ - 'Content-Type' => 'image/webp', - 'Content-Disposition' => HeaderUtils::makeDisposition('inline', $messageImage->id.'.webp'), + 'Content-Type' => $messageImage->mimeType(), + 'Content-Disposition' => HeaderUtils::makeDisposition('inline', $messageImage->filename()), 'Content-Security-Policy' => "default-src 'none'; sandbox", 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, max-age=3600', diff --git a/app/Http/Controllers/CommitImageController.php b/app/Http/Controllers/CommitImageController.php index 943b25213..d2f4e6599 100644 --- a/app/Http/Controllers/CommitImageController.php +++ b/app/Http/Controllers/CommitImageController.php @@ -13,8 +13,8 @@ { public function show(ShowCommitImageRequest $request, CommitImage $commitImage): StreamedResponse { - return Storage::disk()->response($commitImage->path, $commitImage->id.'.webp', headers: [ - 'Content-Type' => 'image/webp', + return Storage::disk()->response($commitImage->path, $commitImage->filename(), headers: [ + 'Content-Type' => $commitImage->mimeType(), 'Content-Security-Policy' => "default-src 'none'; sandbox", 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, max-age=3600', diff --git a/app/Http/Controllers/MessageImageController.php b/app/Http/Controllers/MessageImageController.php index 99f73ccec..b25ace23f 100644 --- a/app/Http/Controllers/MessageImageController.php +++ b/app/Http/Controllers/MessageImageController.php @@ -16,8 +16,8 @@ public function show(ShowMessageImageRequest $request, MessageImage $messageImage, EncryptedFiles $encryptedFiles): Response { return response($encryptedFiles->decrypt((string) Storage::disk()->get($messageImage->path)), headers: [ - 'Content-Type' => 'image/webp', - 'Content-Disposition' => HeaderUtils::makeDisposition($request->download() ? 'attachment' : 'inline', $messageImage->id.'.webp'), + 'Content-Type' => $messageImage->mimeType(), + 'Content-Disposition' => HeaderUtils::makeDisposition($request->download() ? 'attachment' : 'inline', $messageImage->filename()), 'Content-Security-Policy' => "default-src 'none'; sandbox", 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, max-age=3600', diff --git a/app/Http/Requests/CreateCommitReplyRequest.php b/app/Http/Requests/CreateCommitReplyRequest.php index da3487990..0b6a65e17 100644 --- a/app/Http/Requests/CreateCommitReplyRequest.php +++ b/app/Http/Requests/CreateCommitReplyRequest.php @@ -51,7 +51,7 @@ public function rules(): array 'images.*' => [ 'required', 'image', - File::types(['image/jpeg', 'image/png', 'image/webp'])->max(CreateCommitImages::MAX_UPLOAD_SIZE), + File::types(['image/jpeg', 'image/png', 'image/webp', 'image/gif'])->max(CreateCommitImages::MAX_UPLOAD_SIZE), Rule::dimensions() ->maxWidth(CreateCommitImages::MAX_SOURCE_DIMENSION) ->maxHeight(CreateCommitImages::MAX_SOURCE_DIMENSION), diff --git a/app/Http/Requests/CreateCommitRequest.php b/app/Http/Requests/CreateCommitRequest.php index 338db8c30..d4810c2fd 100644 --- a/app/Http/Requests/CreateCommitRequest.php +++ b/app/Http/Requests/CreateCommitRequest.php @@ -60,7 +60,7 @@ public static function attachmentRules(): array 'images.*' => [ 'required', 'image', - File::types(['image/jpeg', 'image/png', 'image/webp'])->max(CreateCommitImages::MAX_UPLOAD_SIZE), + File::types(['image/jpeg', 'image/png', 'image/webp', 'image/gif'])->max(CreateCommitImages::MAX_UPLOAD_SIZE), Rule::dimensions() ->maxWidth(CreateCommitImages::MAX_SOURCE_DIMENSION) ->maxHeight(CreateCommitImages::MAX_SOURCE_DIMENSION), diff --git a/app/Http/Resources/MessageResource.php b/app/Http/Resources/MessageResource.php index a893c3087..a5683dbb1 100644 --- a/app/Http/Resources/MessageResource.php +++ b/app/Http/Resources/MessageResource.php @@ -57,6 +57,7 @@ public function toArray(Request $request): array 'width' => $image->width, 'height' => $image->height, 'description' => $image->description, + 'type' => $image->mimeType(), 'url' => url(URL::temporarySignedRoute('api-message-image.show', today()->addDays(2), [ 'messageImage' => $image, 'user' => $request->user()?->id, diff --git a/app/Models/CommitImage.php b/app/Models/CommitImage.php index c432140ba..b52110ff3 100644 --- a/app/Models/CommitImage.php +++ b/app/Models/CommitImage.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Models\Concerns\IsImageFile; use Carbon\CarbonInterface; use Database\Factories\CommitImageFactory; use Illuminate\Database\Eloquent\Attributes\Visible; @@ -37,6 +38,7 @@ final class CommitImage extends Model use HasFactory; use HasUuids; + use IsImageFile; /** * @return array @@ -71,6 +73,7 @@ public function toArray(): array { return [ ...parent::toArray(), + 'type' => $this->mimeType(), 'url' => $this->url(), ]; } diff --git a/app/Models/Concerns/IsImageFile.php b/app/Models/Concerns/IsImageFile.php new file mode 100644 index 000000000..a560c044a --- /dev/null +++ b/app/Models/Concerns/IsImageFile.php @@ -0,0 +1,22 @@ +path, '.gif') ? 'image/gif' : 'image/webp'; + } + + public function filename(): string + { + return $this->id.($this->mimeType() === 'image/gif' ? '.gif' : '.webp'); + } +} diff --git a/app/Models/MessageImage.php b/app/Models/MessageImage.php index bfc6d769f..1839a047a 100644 --- a/app/Models/MessageImage.php +++ b/app/Models/MessageImage.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Models\Concerns\IsImageFile; use Carbon\CarbonInterface; use Database\Factories\MessageImageFactory; use Illuminate\Database\Eloquent\Attributes\Visible; @@ -36,6 +37,7 @@ final class MessageImage extends Model use HasFactory; use HasUuids; + use IsImageFile; /** * @return array @@ -70,6 +72,7 @@ public function toArray(): array { return [ ...parent::toArray(), + 'type' => $this->mimeType(), 'url' => route('message-image.show', $this), ]; } diff --git a/app/Services/PageMetadata.php b/app/Services/PageMetadata.php index f41c49e12..36a5f0abe 100644 --- a/app/Services/PageMetadata.php +++ b/app/Services/PageMetadata.php @@ -339,7 +339,7 @@ private function commitImages(Commit $commit, User $user): array 'url' => $image->url(), 'width' => $image->width, 'height' => $image->height, - 'type' => 'image/webp', + 'type' => $image->mimeType(), 'alt' => $count === 1 ? "Image attached to the commit of {$user->nameWithHandle()}" : 'Image '.($index + 1)." of {$count} attached to the commit of {$user->nameWithHandle()}", diff --git a/composer.json b/composer.json index a1b2c2a36..52638b757 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "php": "^8.5.0", "firebase/php-jwt": "^7.2.1", "inertiajs/inertia-laravel": "^3.5.2", + "intervention/gif": "^5", "intervention/image": "^4.4.0", "laravel/ai": "^1.3.0", "laravel/framework": "^13.38.0", diff --git a/composer.lock b/composer.lock index a81eded3d..d90783529 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0a28cc7d4973dbf476f6cd899ca3350a", + "content-hash": "effe0af4216025573050644c317c5d7d", "packages": [ { "name": "aws/aws-crt-php", diff --git a/resources/js/components/commit-images.tsx b/resources/js/components/commit-images.tsx index 9bc295d3d..170731a3d 100644 --- a/resources/js/components/commit-images.tsx +++ b/resources/js/components/commit-images.tsx @@ -20,7 +20,7 @@ export function commitImageAlt( } export type ShownImage = Pick & - Partial>; + Partial>; /** * Tall images in a grid cell stay contained so they aren't blown up to fill the width. diff --git a/resources/js/components/image-viewer.tsx b/resources/js/components/image-viewer.tsx index 796319b72..231755ff4 100644 --- a/resources/js/components/image-viewer.tsx +++ b/resources/js/components/image-viewer.tsx @@ -78,7 +78,7 @@ export function ImageViewer({ url: downloadUrl(image), filename: downloadFilename?.(image, imageIndex) ?? - `${image.id}.webp`, + `${image.id}.${image.type === 'image/gif' ? 'gif' : 'webp'}`, }, }), })), diff --git a/resources/js/lib/read-picked-images.ts b/resources/js/lib/read-picked-images.ts index d18db22b4..d307266f7 100644 --- a/resources/js/lib/read-picked-images.ts +++ b/resources/js/lib/read-picked-images.ts @@ -2,9 +2,14 @@ export class PickedImageError extends Error {} export const maxImages = 3; export const maxImageBytes = 10 * 1024 * 1024; -export const acceptedImageTypes = ['image/jpeg', 'image/png', 'image/webp']; +export const acceptedImageTypes = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', +]; export const invalidImageMessage = - 'Only JPEG, PNG, or WebP images up to 10 MB.'; + 'Only JPEG, PNG, WebP, or GIF images up to 10 MB.'; /** * Copy provider-backed files before reading metadata or creating preview URLs. diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 8af6a4e68..7599578bd 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -45,6 +45,7 @@ export type CommitImage = { width: number; height: number; description: string | null; + type?: 'image/webp' | 'image/gif'; }; export type BodySegment = diff --git a/resources/js/types/conversation.ts b/resources/js/types/conversation.ts index 5ee575ff3..6ac7aeb02 100644 --- a/resources/js/types/conversation.ts +++ b/resources/js/types/conversation.ts @@ -18,6 +18,7 @@ export type MessageImage = { width: number; height: number; description: string | null; + type?: 'image/webp' | 'image/gif'; }; /** diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index 1d9199f10..c1032d619 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -137,8 +137,8 @@ ->assertOk() ->assertDontSee($first->path) ->assertJsonPath('data.images', [ - ['id' => $first->id, 'width' => 1200, 'height' => 800, 'description' => 'A terminal showing a successful build.', 'url' => route('commit-image.show', $first)], - ['id' => $second->id, 'width' => 640, 'height' => 480, 'description' => null, 'url' => route('commit-image.show', $second)], + ['id' => $first->id, 'width' => 1200, 'height' => 800, 'description' => 'A terminal showing a successful build.', 'type' => 'image/webp', 'url' => route('commit-image.show', $first)], + ['id' => $second->id, 'width' => 640, 'height' => 480, 'description' => null, 'type' => 'image/webp', 'url' => route('commit-image.show', $second)], ]); }); @@ -247,7 +247,7 @@ 'replies_count' => 1, 'link_preview' => null, 'images' => [ - ['id' => $image->id, 'width' => 1200, 'height' => 800, 'description' => null, 'url' => route('commit-image.show', $image)], + ['id' => $image->id, 'width' => 1200, 'height' => 800, 'description' => null, 'type' => 'image/webp', 'url' => route('commit-image.show', $image)], ], 'author' => $author($user), 'body_segments' => [ @@ -848,7 +848,7 @@ })->with([ 'more than three' => [fn (): array => array_map(fn (int $index): UploadedFile => UploadedFile::fake()->image("{$index}.png", 10, 10), range(1, 4)), [], 'images', 'The images field must not have more than 3 items.'], 'not an image' => [fn (): array => [UploadedFile::fake()->create('notes.txt', 1, 'text/plain')], [], 'images.0', 'The images.0 field must be an image.'], - 'a gif' => [fn (): array => [UploadedFile::fake()->image('party.gif', 10, 10)], [], 'images.0', 'The images.0 field must be a file of type: image/jpeg, image/png, image/webp.'], + 'a bmp' => [fn (): array => [UploadedFile::fake()->image('party.bmp', 10, 10)], [], 'images.0', 'The images.0 field must be a file of type: image/jpeg, image/png, image/webp, image/gif.'], 'larger than 10 MB' => [fn (): array => [UploadedFile::fake()->image('huge.png', 10, 10)->size(10_001)], [], 'images.0', 'The images.0 field must not be greater than 10000 kilobytes.'], 'too many pixels' => [fn (): array => [UploadedFile::fake()->image('wide.png', 6001, 1)], [], 'images.0', 'The images.0 field has invalid image dimensions.'], 'too long a description' => [fn (): array => [UploadedFile::fake()->image('photo.jpg', 10, 10)], ['image_descriptions' => [str_repeat('a', 1001)]], 'image_descriptions.0', 'The image description field must not be greater than 1000 characters.'], diff --git a/tests/Feature/Controllers/Api/LogControllerTest.php b/tests/Feature/Controllers/Api/LogControllerTest.php index 1e64ae1b9..66e055375 100644 --- a/tests/Feature/Controllers/Api/LogControllerTest.php +++ b/tests/Feature/Controllers/Api/LogControllerTest.php @@ -133,6 +133,7 @@ 'width' => 640, 'height' => 480, 'description' => null, + 'type' => 'image/webp', 'url' => route('commit-image.show', $image), ]]) ->assertJsonPath('data.0.author.lane', 4); diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 5809dd842..6562d9993 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -1437,7 +1437,7 @@ expect($user->commits()->count())->toBe(0); }); -it('may only attach jpeg, png or webp images to a commit', function (UploadedFile $file): void { +it('may only attach jpeg, png, webp or gif images to a commit', function (UploadedFile $file): void { Storage::fake(); $user = User::factory()->create(); @@ -1455,7 +1455,6 @@ 'svg' => fn (): UploadedFile => UploadedFile::fake()->createWithContent('image.svg', ''), 'html disguised as png' => fn (): UploadedFile => UploadedFile::fake()->createWithContent('image.png', ''), 'bmp' => fn (): UploadedFile => UploadedFile::fake()->image('image.bmp', 10, 10), - 'gif' => fn (): UploadedFile => UploadedFile::fake()->image('image.gif', 10, 10), 'pdf' => fn (): UploadedFile => UploadedFile::fake()->create('document.pdf', 10, 'application/pdf'), ]); diff --git a/tests/Feature/Controllers/CommitImageControllerTest.php b/tests/Feature/Controllers/CommitImageControllerTest.php index 9314912ce..4d3f02de5 100644 --- a/tests/Feature/Controllers/CommitImageControllerTest.php +++ b/tests/Feature/Controllers/CommitImageControllerTest.php @@ -35,6 +35,17 @@ function storedCommitImage(Commit $commit): CommitImage ->and($response->streamedContent())->toBe(Storage::disk()->get($image->path)); })->with(['default' => [[]], 'download disabled' => [['download' => '0']]]); +it('serves a gif as a gif', function (): void { + $path = 'commit-images/'.fake()->uuid().'.gif'; + Storage::disk()->put($path, (string) UploadedFile::fake()->image('image.gif', 10, 10)->getContent()); + $image = CommitImage::factory()->for(Commit::factory())->create(['path' => $path]); + + $this->get(route('commit-image.show', ['commitImage' => $image, 'download' => 1])) + ->assertOk() + ->assertDownload($image->id.'.gif') + ->assertHeader('Content-Type', 'image/gif'); +}); + it('downloads the stored image with a webp filename', function (): void { $image = storedCommitImage(Commit::factory()->create()); diff --git a/tests/Feature/Controllers/CommitReplyControllerTest.php b/tests/Feature/Controllers/CommitReplyControllerTest.php index 476c098fa..3ea8b48a9 100644 --- a/tests/Feature/Controllers/CommitReplyControllerTest.php +++ b/tests/Feature/Controllers/CommitReplyControllerTest.php @@ -485,7 +485,7 @@ expect($user->commits()->count())->toBe(0); }); -it('may only attach jpeg, png or webp images to a reply', function (): void { +it('may only attach jpeg, png, webp or gif images to a reply', function (): void { Storage::fake(); $user = User::factory()->create(); diff --git a/tests/Unit/Actions/CreateCommitImagesTest.php b/tests/Unit/Actions/CreateCommitImagesTest.php index c393b0653..70b313efb 100644 --- a/tests/Unit/Actions/CreateCommitImagesTest.php +++ b/tests/Unit/Actions/CreateCommitImagesTest.php @@ -8,6 +8,9 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; +use Intervention\Image\AnimationFactory; +use Intervention\Image\Drivers\Gd\Driver as GdDriver; +use Intervention\Image\ImageManager; beforeEach(function (): void { Storage::fake(); @@ -36,6 +39,36 @@ }); }); +function animatedGif(int $width, int $height, int $frames): string +{ + $gd = ImageManager::usingDriver(GdDriver::class); + + return $gd->createImage($width, $height, function (AnimationFactory $animation) use ($gd, $width, $height, $frames): void { + foreach (range(1, $frames) as $frame) { + $animation->add($gd->createImage($width, $height)->fill($frame % 2 === 0 ? 'f00' : '00f'), 0.2); + } + })->encodeUsingFileExtension('gif')->toString(); +} + +it('stores a gif as a gif, scaled down and still animated', function (): void { + $commit = Commit::factory()->create(); + + resolve(CreateCommitImages::class)->handle($commit, [UploadedFile::fake()->createWithContent('party.gif', animatedGif(1700, 850, 3))]); + + $image = $commit->images->sole(); + $stored = (string) Storage::disk()->get($image->path); + + expect($image->path)->toEndWith('.gif') + ->and($image->mimeType())->toBe('image/gif') + ->and($image->only(['width', 'height']))->toBe(['width' => 1600, 'height' => 800]) + ->and(getimagesizefromstring($stored)['mime'] ?? null)->toBe('image/gif') + ->and(ImageManager::usingDriver(GdDriver::class)->decodeBinary($stored)->count())->toBe(3); +}); + +it('refuses gifs with too many pixels across their frames', function (): void { + resolve(CreateCommitImages::class)->handle(Commit::factory()->create(), [UploadedFile::fake()->createWithContent('long.gif', animatedGif(480, 270, 62))]); +})->throws(ValidationException::class); + it('drops anything smuggled alongside the pixels', function (): void { $file = UploadedFile::fake()->image('payload.png', 10, 10); file_put_contents($file->getRealPath(), '', FILE_APPEND); @@ -137,11 +170,11 @@ ]); })->throws(ValidationException::class); -it('refuses formats other than jpeg, png and webp', function (string $name): void { +it('refuses formats other than jpeg, png, webp and gif', function (string $name): void { $file = UploadedFile::fake()->image($name, 10, 10); resolve(CreateCommitImages::class)->handle(Commit::factory()->create(), [$file]); -})->throws(ValidationException::class)->with(['image.bmp', 'image.gif']); +})->throws(ValidationException::class)->with(['image.bmp']); it('refuses images too large to decode safely', function (): void { resolve(CreateCommitImages::class)->handle(Commit::factory()->create(), [UploadedFile::fake()->image('huge.png', 6001, 10)]); diff --git a/tests/Unit/Models/CommitImageTest.php b/tests/Unit/Models/CommitImageTest.php index ef41fddca..b508f0335 100644 --- a/tests/Unit/Models/CommitImageTest.php +++ b/tests/Unit/Models/CommitImageTest.php @@ -13,6 +13,7 @@ 'width' => 1200, 'height' => 800, 'description' => null, + 'type' => 'image/webp', 'url' => route('commit-image.show', $image), ]); }); diff --git a/tests/Unit/ReadPickedImages.test.ts b/tests/Unit/ReadPickedImages.test.ts index b38265492..47dcf9789 100644 --- a/tests/Unit/ReadPickedImages.test.ts +++ b/tests/Unit/ReadPickedImages.test.ts @@ -71,7 +71,7 @@ void test('bounds oversized reads and cancels the provider stream', async () => void test('rejects empty and unsupported images', async () => { const { images, errors } = await readPickedImages([ new File([], 'empty.png', { type: 'image/png' }), - new File(['gif'], 'photo.gif', { type: 'image/gif' }), + new File(['bmp'], 'photo.bmp', { type: 'image/bmp' }), ]); assert.deepEqual(images, []); assert.match((errors[0] as Error).message, /empty/);