1 | <?php |
||||
2 | |||||
3 | /** |
||||
4 | * webtrees: online genealogy |
||||
5 | * Copyright (C) 2023 webtrees development team |
||||
6 | * This program is free software: you can redistribute it and/or modify |
||||
7 | * it under the terms of the GNU General Public License as published by |
||||
8 | * the Free Software Foundation, either version 3 of the License, or |
||||
9 | * (at your option) any later version. |
||||
10 | * This program is distributed in the hope that it will be useful, |
||||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
13 | * GNU General Public License for more details. |
||||
14 | * You should have received a copy of the GNU General Public License |
||||
15 | * along with this program. If not, see <https://www.gnu.org/licenses/>. |
||||
16 | */ |
||||
17 | |||||
18 | declare(strict_types=1); |
||||
19 | |||||
20 | namespace Fisharebest\Webtrees\Factories; |
||||
21 | |||||
22 | use Fig\Http\Message\StatusCodeInterface; |
||||
23 | use Fisharebest\Webtrees\Auth; |
||||
24 | use Fisharebest\Webtrees\Contracts\ImageFactoryInterface; |
||||
25 | use Fisharebest\Webtrees\Contracts\UserInterface; |
||||
26 | use Fisharebest\Webtrees\MediaFile; |
||||
27 | use Fisharebest\Webtrees\Mime; |
||||
28 | use Fisharebest\Webtrees\Registry; |
||||
29 | use Fisharebest\Webtrees\Webtrees; |
||||
30 | use Imagick; |
||||
31 | use Intervention\Gif\Exceptions\NotReadableException; |
||||
32 | use Intervention\Image\Drivers\Gd\Driver as GdDriver; |
||||
33 | use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver; |
||||
34 | use Intervention\Image\ImageManager; |
||||
35 | use Intervention\Image\Interfaces\ImageInterface; |
||||
36 | use InvalidArgumentException; |
||||
37 | use League\Flysystem\FilesystemException; |
||||
38 | use League\Flysystem\FilesystemOperator; |
||||
39 | use League\Flysystem\UnableToReadFile; |
||||
40 | use League\Flysystem\UnableToRetrieveMetadata; |
||||
41 | use Psr\Http\Message\ResponseInterface; |
||||
42 | use RuntimeException; |
||||
43 | use Throwable; |
||||
44 | |||||
45 | use function addcslashes; |
||||
46 | use function basename; |
||||
47 | use function extension_loaded; |
||||
48 | use function get_class; |
||||
49 | use function implode; |
||||
50 | use function pathinfo; |
||||
51 | use function response; |
||||
52 | use function str_contains; |
||||
53 | use function view; |
||||
54 | |||||
55 | use const PATHINFO_EXTENSION; |
||||
56 | |||||
57 | /** |
||||
58 | * Make an image (from another image). |
||||
59 | */ |
||||
60 | class ImageFactory implements ImageFactoryInterface |
||||
61 | { |
||||
62 | // Imagick can detect the quality setting for images. GD cannot. |
||||
63 | protected const GD_DEFAULT_IMAGE_QUALITY = 90; |
||||
64 | protected const GD_DEFAULT_THUMBNAIL_QUALITY = 70; |
||||
65 | |||||
66 | protected const WATERMARK_FILE = 'resources/img/watermark.png'; |
||||
67 | |||||
68 | protected const THUMBNAIL_CACHE_TTL = 8640000; |
||||
69 | |||||
70 | public const SUPPORTED_FORMATS = [ |
||||
71 | 'image/jpeg' => 'jpg', |
||||
72 | 'image/png' => 'png', |
||||
73 | 'image/gif' => 'gif', |
||||
74 | 'image/tiff' => 'tif', |
||||
75 | 'image/bmp' => 'bmp', |
||||
76 | 'image/webp' => 'webp', |
||||
77 | ]; |
||||
78 | |||||
79 | /** |
||||
80 | * Send the original file - either inline or as a download. |
||||
81 | */ |
||||
82 | public function fileResponse(FilesystemOperator $filesystem, string $path, bool $download): ResponseInterface |
||||
83 | { |
||||
84 | try { |
||||
85 | try { |
||||
86 | $mime_type = $filesystem->mimeType(path: $path); |
||||
87 | } catch (UnableToRetrieveMetadata) { |
||||
88 | $mime_type = Mime::DEFAULT_TYPE; |
||||
89 | } |
||||
90 | |||||
91 | $filename = $download ? addcslashes(string: basename(path: $path), characters: '"') : ''; |
||||
92 | |||||
93 | return $this->imageResponse(data: $filesystem->read(location: $path), mime_type: $mime_type, filename: $filename); |
||||
94 | } catch (UnableToReadFile | FilesystemException $ex) { |
||||
95 | return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) |
||||
96 | ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); |
||||
97 | } |
||||
98 | } |
||||
99 | |||||
100 | /** |
||||
101 | * Send a thumbnail. |
||||
102 | */ |
||||
103 | public function thumbnailResponse( |
||||
104 | FilesystemOperator $filesystem, |
||||
105 | string $path, |
||||
106 | int $width, |
||||
107 | int $height, |
||||
108 | string $fit |
||||
109 | ): ResponseInterface { |
||||
110 | try { |
||||
111 | $mime_type = $filesystem->mimeType(path: $path); |
||||
112 | $image = $this->imageManager()->read(input: $filesystem->readStream($path)); |
||||
113 | $image = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit); |
||||
114 | $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_THUMBNAIL_QUALITY); |
||||
115 | $data = $image->encodeByMediaType(type: $mime_type, quality: $quality)->toString(); |
||||
116 | |||||
117 | return $this->imageResponse(data: $data, mime_type: $mime_type, filename: ''); |
||||
118 | } catch (FilesystemException | UnableToReadFile $ex) { |
||||
119 | return $this |
||||
120 | ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) |
||||
121 | ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); |
||||
122 | } catch (RuntimeException $ex) { |
||||
123 | return $this |
||||
124 | ->replacementImageResponse(text: '.' . pathinfo(path: $path, flags: PATHINFO_EXTENSION)) |
||||
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||||
125 | ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); |
||||
126 | } catch (Throwable $ex) { |
||||
127 | return $this |
||||
128 | ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) |
||||
129 | ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); |
||||
130 | } |
||||
131 | } |
||||
132 | |||||
133 | /** |
||||
134 | * Create a full-size version of an image. |
||||
135 | */ |
||||
136 | public function mediaFileResponse(MediaFile $media_file, bool $add_watermark, bool $download): ResponseInterface |
||||
137 | { |
||||
138 | $filesystem = $media_file->media()->tree()->mediaFilesystem(); |
||||
139 | $path = $media_file->filename(); |
||||
140 | |||||
141 | if (!$add_watermark || !$media_file->isImage()) { |
||||
142 | return $this->fileResponse(filesystem: $filesystem, path: $path, download: $download); |
||||
143 | } |
||||
144 | |||||
145 | try { |
||||
146 | $mime_type = $media_file->mimeType(); |
||||
147 | $image = $this->imageManager()->read(input: $filesystem->readStream($path)); |
||||
148 | $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file); |
||||
149 | $image = $this->addWatermark(image: $image, watermark: $watermark); |
||||
150 | $filename = $download ? basename(path: $path) : ''; |
||||
151 | $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_IMAGE_QUALITY); |
||||
152 | $data = $image->encodeByMediaType(type: $mime_type, quality: $quality)->toString(); |
||||
153 | |||||
154 | return $this->imageResponse(data: $data, mime_type: $mime_type, filename: $filename); |
||||
155 | } catch (NotReadableException $ex) { |
||||
156 | return $this->replacementImageResponse(text: pathinfo(path: $path, flags: PATHINFO_EXTENSION)) |
||||
157 | ->withHeader('x-image-exception', $ex->getMessage()); |
||||
158 | } catch (FilesystemException | UnableToReadFile $ex) { |
||||
159 | return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) |
||||
160 | ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); |
||||
161 | } catch (Throwable $ex) { |
||||
162 | return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) |
||||
163 | ->withHeader('x-image-exception', $ex->getMessage()); |
||||
164 | } |
||||
165 | } |
||||
166 | |||||
167 | /** |
||||
168 | * Create a smaller version of an image. |
||||
169 | */ |
||||
170 | public function mediaFileThumbnailResponse( |
||||
171 | MediaFile $media_file, |
||||
172 | int $width, |
||||
173 | int $height, |
||||
174 | string $fit, |
||||
175 | bool $add_watermark |
||||
176 | ): ResponseInterface { |
||||
177 | // Where are the images stored. |
||||
178 | $filesystem = $media_file->media()->tree()->mediaFilesystem(); |
||||
179 | |||||
180 | // Where is the image stored in the filesystem. |
||||
181 | $path = $media_file->filename(); |
||||
182 | |||||
183 | try { |
||||
184 | $mime_type = $filesystem->mimeType(path: $path); |
||||
185 | |||||
186 | $key = implode(separator: ':', array: [ |
||||
187 | $media_file->media()->tree()->name(), |
||||
188 | $path, |
||||
189 | $filesystem->lastModified(path: $path), |
||||
190 | (string) $width, |
||||
191 | (string) $height, |
||||
192 | $fit, |
||||
193 | (string) $add_watermark, |
||||
194 | ]); |
||||
195 | |||||
196 | $closure = function () use ($filesystem, $path, $width, $height, $fit, $add_watermark, $media_file): string { |
||||
197 | $image = $this->imageManager()->read(input: $filesystem->readStream($path)); |
||||
198 | $image = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit); |
||||
199 | |||||
200 | if ($add_watermark) { |
||||
201 | $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file); |
||||
202 | $image = $this->addWatermark(image: $image, watermark: $watermark); |
||||
203 | } |
||||
204 | |||||
205 | $quality = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_THUMBNAIL_QUALITY); |
||||
206 | |||||
207 | return $image->encodeByMediaType(type: $media_file->mimeType(), quality: $quality)->toString(); |
||||
208 | }; |
||||
209 | |||||
210 | // Images and Responses both contain resources - which cannot be serialized. |
||||
211 | // So cache the raw image data. |
||||
212 | $data = Registry::cache()->file()->remember(key: $key, closure: $closure, ttl: static::THUMBNAIL_CACHE_TTL); |
||||
213 | |||||
214 | return $this->imageResponse(data: $data, mime_type: $mime_type, filename: ''); |
||||
215 | } catch (NotReadableException $ex) { |
||||
216 | return $this |
||||
217 | ->replacementImageResponse(text: '.' . pathinfo(path: $path, flags: PATHINFO_EXTENSION)) |
||||
0 ignored issues
–
show
Are you sure
pathinfo($path, PATHINFO_EXTENSION) of type array|string can be used in concatenation ?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
Loading history...
|
|||||
218 | ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); |
||||
219 | } catch (FilesystemException | UnableToReadFile $ex) { |
||||
220 | return $this |
||||
221 | ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND) |
||||
222 | ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); |
||||
223 | } catch (Throwable $ex) { |
||||
224 | return $this |
||||
225 | ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) |
||||
226 | ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage()); |
||||
227 | } |
||||
228 | } |
||||
229 | |||||
230 | /** |
||||
231 | * Does a full-sized image need a watermark? |
||||
232 | */ |
||||
233 | public function fileNeedsWatermark(MediaFile $media_file, UserInterface $user): bool |
||||
234 | { |
||||
235 | $tree = $media_file->media()->tree(); |
||||
236 | |||||
237 | return Auth::accessLevel(tree: $tree, user: $user) > (int) $tree->getPreference(setting_name: 'SHOW_NO_WATERMARK'); |
||||
238 | } |
||||
239 | |||||
240 | /** |
||||
241 | * Does a thumbnail image need a watermark? |
||||
242 | */ |
||||
243 | public function thumbnailNeedsWatermark(MediaFile $media_file, UserInterface $user): bool |
||||
244 | { |
||||
245 | return $this->fileNeedsWatermark(media_file: $media_file, user: $user); |
||||
246 | } |
||||
247 | |||||
248 | /** |
||||
249 | * Create a watermark image, perhaps specific to a media-file. |
||||
250 | */ |
||||
251 | public function createWatermark(int $width, int $height, MediaFile $media_file): ImageInterface |
||||
252 | { |
||||
253 | return $this->imageManager() |
||||
254 | ->read(input: Webtrees::ROOT_DIR . static::WATERMARK_FILE) |
||||
255 | ->contain(width: $width, height: $height); |
||||
256 | } |
||||
257 | |||||
258 | /** |
||||
259 | * Add a watermark to an image. |
||||
260 | */ |
||||
261 | public function addWatermark(ImageInterface $image, ImageInterface $watermark): ImageInterface |
||||
262 | { |
||||
263 | return $image->place(element: $watermark, position: 'center'); |
||||
264 | } |
||||
265 | |||||
266 | /** |
||||
267 | * Send a replacement image, to replace one that could not be found or created. |
||||
268 | */ |
||||
269 | public function replacementImageResponse(string $text): ResponseInterface |
||||
270 | { |
||||
271 | // We can't create a PNG/BMP/JPEG image, as the GD/IMAGICK libraries may be missing. |
||||
272 | $svg = view(name: 'errors/image-svg', data: ['status' => $text]); |
||||
273 | |||||
274 | // We can't send the actual status code, as browsers won't show images with 4xx/5xx. |
||||
275 | return response(content: $svg, code: StatusCodeInterface::STATUS_OK, headers: [ |
||||
276 | 'content-type' => 'image/svg+xml', |
||||
277 | ]); |
||||
278 | } |
||||
279 | |||||
280 | /** |
||||
281 | * Create a response from image data. |
||||
282 | */ |
||||
283 | protected function imageResponse(string $data, string $mime_type, string $filename): ResponseInterface |
||||
284 | { |
||||
285 | if ($mime_type === 'image/svg+xml' && str_contains(haystack: $data, needle: '<script')) { |
||||
286 | return $this->replacementImageResponse(text: 'XSS') |
||||
287 | ->withHeader('x-image-exception', 'SVG image blocked due to XSS.'); |
||||
288 | } |
||||
289 | |||||
290 | // HTML files may contain javascript and iframes, so use content-security-policy to disable them. |
||||
291 | $response = response($data) |
||||
292 | ->withHeader('content-type', $mime_type) |
||||
293 | ->withHeader('content-security-policy', 'script-src none;frame-src none'); |
||||
294 | |||||
295 | if ($filename === '') { |
||||
296 | return $response; |
||||
297 | } |
||||
298 | |||||
299 | return $response |
||||
300 | ->withHeader('content-disposition', 'attachment; filename="' . addcslashes(string: basename(path: $filename), characters: '"')); |
||||
301 | } |
||||
302 | |||||
303 | /** |
||||
304 | * Choose an image library, based on what is installed. |
||||
305 | */ |
||||
306 | protected function imageManager(): ImageManager |
||||
307 | { |
||||
308 | if (extension_loaded(extension: 'imagick')) { |
||||
309 | return new ImageManager(driver: new ImagickDriver()); |
||||
310 | } |
||||
311 | |||||
312 | if (extension_loaded(extension: 'gd')) { |
||||
313 | return new ImageManager(driver: new GdDriver()); |
||||
314 | } |
||||
315 | |||||
316 | throw new RuntimeException(message: 'No PHP graphics library is installed. Need Imagick or GD'); |
||||
317 | } |
||||
318 | |||||
319 | /** |
||||
320 | * Resize an image. |
||||
321 | */ |
||||
322 | protected function resizeImage(ImageInterface $image, int $width, int $height, string $fit): ImageInterface |
||||
323 | { |
||||
324 | return match ($fit) { |
||||
325 | 'crop' => $image->cover(width: $width, height: $height), |
||||
326 | 'contain' => $image->scale(width: $width, height: $height), |
||||
327 | default => throw new InvalidArgumentException(message: 'Unknown fit type: ' . $fit), |
||||
328 | }; |
||||
329 | } |
||||
330 | |||||
331 | /** |
||||
332 | * Extract the quality/compression parameter from an image. |
||||
333 | */ |
||||
334 | protected function extractImageQuality(ImageInterface $image, int $default): int |
||||
335 | { |
||||
336 | $native = $image->core()->native(); |
||||
337 | |||||
338 | if ($native instanceof Imagick) { |
||||
339 | return $native->getImageCompressionQuality(); |
||||
340 | } |
||||
341 | |||||
342 | return $default; |
||||
343 | } |
||||
344 | } |
||||
345 |