Skip to content
Open
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
15 changes: 9 additions & 6 deletions app/Actions/CreateImageFiles.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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));
}

Expand Down Expand Up @@ -76,15 +78,16 @@ 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();
}

$image = Image::fromUpload($file)
->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();
Expand All @@ -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.',
]);
}
}
2 changes: 1 addition & 1 deletion app/Actions/CreateUserFeed.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
5 changes: 4 additions & 1 deletion app/Actions/UpdateCommitAiDecision.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions app/Http/Controllers/Api/MessageImageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions app/Http/Controllers/CommitImageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions app/Http/Controllers/MessageImageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Requests/CreateCommitReplyRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Requests/CreateCommitRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions app/Http/Resources/MessageResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions app/Models/CommitImage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -37,6 +38,7 @@ final class CommitImage extends Model
use HasFactory;

use HasUuids;
use IsImageFile;

/**
* @return array<string, string>
Expand Down Expand Up @@ -71,6 +73,7 @@ public function toArray(): array
{
return [
...parent::toArray(),
'type' => $this->mimeType(),
'url' => $this->url(),
];
}
Expand Down
22 changes: 22 additions & 0 deletions app/Models/Concerns/IsImageFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace App\Models\Concerns;

/**
* @property-read string $id
* @property-read string $path
*/
trait IsImageFile
{
public function mimeType(): string
{
return str_ends_with($this->path, '.gif') ? 'image/gif' : 'image/webp';
}

public function filename(): string
{
return $this->id.($this->mimeType() === 'image/gif' ? '.gif' : '.webp');
}
}
3 changes: 3 additions & 0 deletions app/Models/MessageImage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,6 +37,7 @@ final class MessageImage extends Model
use HasFactory;

use HasUuids;
use IsImageFile;

/**
* @return array<string, string>
Expand Down Expand Up @@ -70,6 +72,7 @@ public function toArray(): array
{
return [
...parent::toArray(),
'type' => $this->mimeType(),
'url' => route('message-image.show', $this),
];
}
Expand Down
2 changes: 1 addition & 1 deletion app/Services/PageMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()}",
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion resources/js/components/commit-images.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function commitImageAlt(
}

export type ShownImage = Pick<CommitImage, 'id' | 'url' | 'description'> &
Partial<Pick<CommitImage, 'width' | 'height'>>;
Partial<Pick<CommitImage, 'width' | 'height' | 'type'>>;

/**
* Tall images in a grid cell stay contained so they aren't blown up to fill the width.
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/image-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`,
},
}),
})),
Expand Down
9 changes: 7 additions & 2 deletions resources/js/lib/read-picked-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions resources/js/types/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type CommitImage = {
width: number;
height: number;
description: string | null;
type?: 'image/webp' | 'image/gif';
};

export type BodySegment =
Expand Down
1 change: 1 addition & 0 deletions resources/js/types/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type MessageImage = {
width: number;
height: number;
description: string | null;
type?: 'image/webp' | 'image/gif';
};

/**
Expand Down
8 changes: 4 additions & 4 deletions tests/Feature/Controllers/Api/CommitControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
]);
});

Expand Down Expand Up @@ -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' => [
Expand Down Expand Up @@ -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.'],
Expand Down
1 change: 1 addition & 0 deletions tests/Feature/Controllers/Api/LogControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 1 addition & 2 deletions tests/Feature/Controllers/CommitControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -1455,7 +1455,6 @@
'svg' => fn (): UploadedFile => UploadedFile::fake()->createWithContent('image.svg', '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'),
'html disguised as png' => fn (): UploadedFile => UploadedFile::fake()->createWithContent('image.png', '<html><script>alert(1)</script></html>'),
'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'),
]);

Expand Down
11 changes: 11 additions & 0 deletions tests/Feature/Controllers/CommitImageControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
2 changes: 1 addition & 1 deletion tests/Feature/Controllers/CommitReplyControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading