Skip to content

Commit 3f71901

Browse files
committed
Serves original size WEBP image as JPG if browser does not support webp
#9623 In case of a user uploading a WEBP image, we should be careful not to serve it blindly because not all browser support WEBP (yet). So in that case we convert the image on the fly to JPG, preserving its original size.
1 parent 86b6f97 commit 3f71901

File tree

4 files changed

+102
-12
lines changed

4 files changed

+102
-12
lines changed

src/Handler/ImageHandler.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,15 @@ public function handle(ServerRequestInterface $request): ResponseInterface
3535
return $this->createError("Image for image $id not found on disk, or not readable");
3636
}
3737

38+
$isWebp = $image->getMime() === 'image/webp';
39+
$accept = $request->getHeaderLine('accept');
40+
$acceptWebp = str_contains($accept, 'image/webp');
41+
3842
$maxHeight = (int) $request->getAttribute('maxHeight');
3943
if ($maxHeight) {
40-
$accept = $request->getHeaderLine('accept');
41-
$useWebp = str_contains($accept, 'image/webp');
42-
43-
$path = $this->imageResizer->resize($image, $maxHeight, $useWebp);
44+
$path = $this->imageResizer->resize($image, $maxHeight, $acceptWebp);
45+
} elseif ($isWebp && !$acceptWebp) {
46+
$path = $this->imageResizer->webpToJpg($image);
4447
}
4548

4649
$resource = fopen($path, 'rb');

src/Service/ImageResizer.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,8 @@ public function resize(Image $image, int $maxHeight, bool $useWebp): string
3030

3131
$maxHeight = min($maxHeight, $image->getHeight());
3232

33-
$basename = pathinfo($image->getFilename(), PATHINFO_FILENAME);
3433
$extension = $useWebp ? '.webp' : '.jpg';
35-
$path = realpath('.') . '/' . self::CACHE_IMAGE_PATH . $basename . '-' . $maxHeight . $extension;
34+
$path = $this->getCachePath($image, '-' . $maxHeight . $extension);
3635

3736
if (file_exists($path)) {
3837
return $path;
@@ -43,4 +42,28 @@ public function resize(Image $image, int $maxHeight, bool $useWebp): string
4342

4443
return $path;
4544
}
45+
46+
/**
47+
* Assumes the image is WEBP, converts it to JPG, and return path to JPG version.
48+
*/
49+
public function webpToJpg(Image $image): string
50+
{
51+
$path = $this->getCachePath($image, '.jpg');
52+
53+
if (file_exists($path)) {
54+
return $path;
55+
}
56+
57+
$image = $this->imagine->open($image->getPath());
58+
$image->save($path);
59+
60+
return $path;
61+
}
62+
63+
private function getCachePath(Image $image, string $suffix): string
64+
{
65+
$basename = pathinfo($image->getFilename(), PATHINFO_FILENAME);
66+
67+
return realpath('.') . '/' . self::CACHE_IMAGE_PATH . $basename . $suffix;
68+
}
4669
}

tests/Handler/ImageHandlerTest.php

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Ecodev\Felix\Handler\ImageHandler;
99
use Ecodev\Felix\Model\Image;
1010
use Ecodev\Felix\Service\ImageResizer;
11+
use Exception;
1112
use Laminas\Diactoros\ServerRequest;
1213
use org\bovigo\vfs\vfsStream;
1314
use PHPUnit\Framework\TestCase;
@@ -28,7 +29,7 @@ protected function setUp(): void
2829
vfsStream::setup('felix', null, $virtualFileSystem);
2930
}
3031

31-
public function testWillServeJpgByDefault(): void
32+
public function testWillServeThumbnailJpgByDefault(): void
3233
{
3334
$image = $this->createImageMock();
3435
$repository = $this->createRepositoryMock($image);
@@ -51,7 +52,7 @@ public function testWillServeJpgByDefault(): void
5152
self::assertSame('max-age=21600', $response->getHeaderLine('cache-control'));
5253
}
5354

54-
public function testWillServeWebpIfAccepted(): void
55+
public function testWillServeThumbnailWebpIfAccepted(): void
5556
{
5657
$image = $this->createImageMock();
5758
$repository = $this->createRepositoryMock($image);
@@ -75,6 +76,46 @@ public function testWillServeWebpIfAccepted(): void
7576
self::assertSame('max-age=21600', $response->getHeaderLine('cache-control'));
7677
}
7778

79+
public function testWillServeOriginalWebpIfAccepted(): void
80+
{
81+
$image = $this->createImageMock('vfs://felix/image-100.webp');
82+
$repository = $this->createRepositoryMock($image);
83+
84+
$imageResizer = $this->createMock(ImageResizer::class);
85+
86+
// A request specifically accepting webp images
87+
$request = new ServerRequest();
88+
$request = $request
89+
->withHeader('accept', 'text/html, image/webp, */*;q=0.8');
90+
91+
$response = $this->handle($repository, $imageResizer, $request);
92+
93+
self::assertSame('image/webp', $response->getHeaderLine('content-type'));
94+
self::assertSame('15', $response->getHeaderLine('content-length'));
95+
self::assertSame('max-age=21600', $response->getHeaderLine('cache-control'));
96+
}
97+
98+
public function testWillServeOriginalJpgIfWebpNotAccepted(): void
99+
{
100+
$image = $this->createImageMock('vfs://felix/image-100.webp');
101+
$repository = $this->createRepositoryMock($image);
102+
103+
$imageResizer = $this->createMock(ImageResizer::class);
104+
$imageResizer->expects(self::once())
105+
->method('webpToJpg')
106+
->with($image)
107+
->willReturn('vfs://felix/image-100.jpg');
108+
109+
// A request specifically accepting webp images
110+
$request = new ServerRequest();
111+
112+
$response = $this->handle($repository, $imageResizer, $request);
113+
114+
self::assertSame('image/jpeg', $response->getHeaderLine('content-type'));
115+
self::assertSame('16', $response->getHeaderLine('content-length'));
116+
self::assertSame('max-age=21600', $response->getHeaderLine('cache-control'));
117+
}
118+
78119
public function testWillErrorIfImageNotFoundInDatabase(): void
79120
{
80121
$repository = $this->createRepositoryMock(null);
@@ -115,6 +156,13 @@ private function createImageMock(string $path = 'vfs://felix/image.png'): Image
115156
{
116157
$image = $this->createMock(Image::class);
117158
$image->expects(self::once())->method('getPath')->willReturn($path);
159+
$image->expects(self::atMost(1))->method('getMime')->willReturn(match ($path) {
160+
'vfs://felix/image.png' => 'image/png',
161+
'vfs://felix/image-100.jpg' => 'image/jpeg',
162+
'vfs://felix/image-100.webp' => 'image/webp',
163+
'vfs://felix/totally-non-existing-path' => '',
164+
default => throw new Exception('Unsupported :' . $path),
165+
});
118166

119167
return $image;
120168
}

tests/Service/ImageResizerTest.php

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,17 @@ public function testResize(string $extension, int $wantedHeight, bool $useWebp,
2626
};
2727

2828
$imagineImage = $this->createMock(ImageInterface::class);
29-
$imagineImage->expects(self::any())->method('thumbnail')->willReturnSelf();
29+
$imagineImage->expects(self::atMost(1))->method('thumbnail')->willReturnSelf();
3030

3131
$imagine = $this->createMock(ImagineInterface::class);
32-
$imagine->expects(self::any())->method('open')->willReturn($imagineImage);
32+
$imagine->expects(self::atMost(1))->method('open')->willReturn($imagineImage);
3333

3434
$resizer = new ImageResizer($imagine);
3535
$image = $this->createMock(Image::class);
3636
$image->expects(self::once())->method('getPath')->willReturn('/felix/image.' . $extension);
37-
$image->expects(self::any())->method('getFilename')->willReturn('image.' . $extension);
37+
$image->expects(self::atMost(1))->method('getFilename')->willReturn('image.' . $extension);
3838
$image->expects(self::atMost(1))->method('getHeight')->willReturn(200);
39-
$image->expects(self::any())->method('getMime')->willReturn($mime);
39+
$image->expects(self::atMost(1))->method('getMime')->willReturn($mime);
4040

4141
$actual = $resizer->resize($image, $wantedHeight, $useWebp);
4242
self::assertStringEndsWith($expected, $actual);
@@ -69,4 +69,20 @@ public function providerResize(): array
6969
'tiff bigger webp' => ['tiff', 300, true, 'data/cache/images/image-200.webp'],
7070
];
7171
}
72+
73+
public function testWebpToJpg(): void
74+
{
75+
$imagineImage = $this->createMock(ImageInterface::class);
76+
77+
$imagine = $this->createMock(ImagineInterface::class);
78+
$imagine->expects(self::once())->method('open')->willReturn($imagineImage);
79+
80+
$imageResizer = new ImageResizer($imagine);
81+
$image = $this->createMock(Image::class);
82+
$image->expects(self::once())->method('getPath')->willReturn('/felix/image.webp');
83+
$image->expects(self::once())->method('getFilename')->willReturn('image.webp');
84+
85+
$actual = $imageResizer->webpToJpg($image);
86+
self::assertStringEndsWith('data/cache/images/image.jpg', $actual);
87+
}
7288
}

0 commit comments

Comments
 (0)