From f99c2a38d98dac23e07a54b13f16c3f3e8254a2c Mon Sep 17 00:00:00 2001 From: h8d13 Date: Mon, 26 Oct 2026 21:30:22 +0100 Subject: [PATCH 1/3] fix: `log` section hover twitching Signed-off-by: h8d13 --- resources/css/app.css | 7 ++++--- resources/js/pages/log/show.tsx | 9 ++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/resources/css/app.css b/resources/css/app.css index b21124a20..27465290c 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -703,14 +703,15 @@ html body[data-scroll-locked] { } } -/* Commits that just arrived on their own, until the reader moves on. */ -[data-commit] { +/* Commits that just arrived on their own, until the reader moves on. Only they fade slowly, as this unlayered rule + would otherwise override the hover transition of every commit. */ +[data-commit][data-fresh] { transition: background-color 1.4s ease, box-shadow 1.4s ease; } -[data-commit][data-fresh] { +[data-commit][data-fresh=''] { background-color: color-mix(in oklab, var(--primary) 7%, transparent); box-shadow: inset 3px 0 0 color-mix(in oklab, var(--primary) 70%, transparent); diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 4e3f9aecf..4114bcfa8 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -277,7 +277,14 @@ export default function Show({ const visible = new Set(); const settle = () => { - rows.forEach((row) => row.removeAttribute('data-fresh')); + rows.forEach((row) => { + row.dataset.fresh = 'fading'; + setTimeout(() => { + if (row.dataset.fresh === 'fading') { + row.removeAttribute('data-fresh'); + } + }, 1400); + }); freshIds.current = new Set(); cleanup(); }; From 8526a59b49dc320d56eda57cd70906af24dd1e50 Mon Sep 17 00:00:00 2001 From: h8d13 Date: Mon, 26 Oct 2026 23:05:37 +0100 Subject: [PATCH 2/3] feat(log): add `gif` support only upload * doesn't not touch any link preview logic or docs/comments Signed-off-by: h8d13 --- app/Actions/CreateImageFiles.php | 26 ++++++++++--- app/Actions/CreateUserFeed.php | 2 +- app/Actions/UpdateCommitAiDecision.php | 5 ++- .../Api/MessageImageController.php | 4 +- .../Controllers/CommitImageController.php | 4 +- .../Controllers/MessageImageController.php | 4 +- .../Requests/CreateCommitReplyRequest.php | 2 +- app/Http/Requests/CreateCommitRequest.php | 2 +- app/Http/Resources/MessageResource.php | 1 + app/Models/CommitImage.php | 3 ++ app/Models/Concerns/IsImageFile.php | 22 +++++++++++ app/Models/MessageImage.php | 3 ++ app/Services/PageMetadata.php | 2 +- composer.json | 1 + composer.lock | 2 +- resources/js/components/commit-images.tsx | 2 +- resources/js/components/image-viewer.tsx | 2 +- resources/js/lib/read-picked-images.ts | 9 ++++- resources/js/types/commit.ts | 1 + resources/js/types/conversation.ts | 1 + .../Controllers/Api/CommitControllerTest.php | 8 ++-- .../Controllers/Api/LogControllerTest.php | 1 + .../Controllers/CommitControllerTest.php | 3 +- .../Controllers/CommitImageControllerTest.php | 11 ++++++ .../Controllers/CommitReplyControllerTest.php | 2 +- tests/Unit/Actions/CreateCommitImagesTest.php | 37 ++++++++++++++++++- tests/Unit/Models/CommitImageTest.php | 1 + tests/Unit/ReadPickedImages.test.ts | 2 +- 28 files changed, 131 insertions(+), 32 deletions(-) create mode 100644 app/Models/Concerns/IsImageFile.php diff --git a/app/Actions/CreateImageFiles.php b/app/Actions/CreateImageFiles.php index e70b04714..da49bc871 100644 --- a/app/Actions/CreateImageFiles.php +++ b/app/Actions/CreateImageFiles.php @@ -13,6 +13,8 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use Intervention\Gif\Decoder as GifDecoder; +use Throwable; final readonly class CreateImageFiles { @@ -20,7 +22,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 +48,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 +80,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 && $info[0] * $info[1] * $this->frames($file) > self::MAX_GIF_PIXELS)) { throw $this->invalid(); } @@ -84,7 +89,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(); @@ -95,10 +100,19 @@ private function optimize(UploadedFile $file): ProcessedImage return $image; } + private function frames(UploadedFile $file): int + { + try { + return count(GifDecoder::decode($file->getRealPath())->frames()); + } catch (Throwable) { + throw $this->invalid(); + } + } + 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/); From 49bae85406bf41ec3abc4a60c1224a97e7643b8e Mon Sep 17 00:00:00 2001 From: h8d13 Date: Mon, 26 Oct 2026 23:38:56 +0100 Subject: [PATCH 3/3] sniff the frames in raw bytes Signed-off-by: h8d13 --- app/Actions/CreateImageFiles.php | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/app/Actions/CreateImageFiles.php b/app/Actions/CreateImageFiles.php index da49bc871..af24e8356 100644 --- a/app/Actions/CreateImageFiles.php +++ b/app/Actions/CreateImageFiles.php @@ -13,8 +13,6 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; -use Intervention\Gif\Decoder as GifDecoder; -use Throwable; final readonly class CreateImageFiles { @@ -81,7 +79,7 @@ private function optimize(UploadedFile $file): ProcessedImage || ! in_array($info[2], self::ALLOWED_TYPES, true) || $info[0] > CreateCommitImages::MAX_SOURCE_DIMENSION || $info[1] > CreateCommitImages::MAX_SOURCE_DIMENSION - || ($info[2] === IMAGETYPE_GIF && $info[0] * $info[1] * $this->frames($file) > self::MAX_GIF_PIXELS)) { + || ($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(); } @@ -100,15 +98,6 @@ private function optimize(UploadedFile $file): ProcessedImage return $image; } - private function frames(UploadedFile $file): int - { - try { - return count(GifDecoder::decode($file->getRealPath())->frames()); - } catch (Throwable) { - throw $this->invalid(); - } - } - private function invalid(): ValidationException { return ValidationException::withMessages([