Issues (1698)

sources/ElkArte/Graphics/Image.php (1 issue)

1
<?php
2
3
/**
4
 * This file deals with low-level graphics operations performed on images,
5
 * specifically as needed for avatars (uploaded avatars), attachments, or
6
 * visual verification images.
7
 *
8
 * TrueType fonts supplied by www.LarabieFonts.com
9
 *
10
 * @package   ElkArte Forum
11
 * @copyright ElkArte Forum contributors
12
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
13
 *
14
 * @version 2.0 Beta 1
15
 *
16
 */
17
18
namespace ElkArte\Graphics;
19
20
use ElkArte\Exceptions\Exception;
21
use ElkArte\Graphics\Manipulators\Gd2;
22
use ElkArte\Graphics\Manipulators\ImageMagick;
23
use ElkArte\Helper\FileFunctions;
24
25
/**
26
 * Class Image
27
 *
28
 * Base class for image function and interaction with the various graphic engines (GD/IMagick)
29
 *
30
 * @package ElkArte\Graphics
31
 */
32
class Image
33
{
34
	/** @var array The image formats we will work with */
35
	public const DEFAULT_FORMATS = [
36
		IMAGETYPE_GIF => 'gif',
37
		IMAGETYPE_JPEG => 'jpeg',
38
		IMAGETYPE_PNG => 'png',
39
		IMAGETYPE_BMP => 'bmp',
40
		IMAGETYPE_WBMP => 'wbmp',
41
		IMAGETYPE_WEBP => 'webp'
42
	];
43
44
	/** @var ImageMagick|Gd2 */
45
	protected $_manipulator;
46
47
	/** @var string filename we are working with */
48
	protected $_fileName = '';
49
50
	/** @var bool if the image has been loaded into the manipulator */
51
	protected $_image_loaded = false;
52
53
	/** @var string what manipulator (GD, ImageMagick, etc.) is in use */
54
	protected $_current_manipulator = '';
55
56
	/**
57
	 * Image constructor.
58
	 *
59
	 * @param string $fileName
60 2
	 * @param bool $_force_gd
61
	 */
62 2
	public function __construct($fileName, protected $_force_gd = false)
63 2
	{
64
		$this->setFileName($fileName);
65 2
66 2
		$this->loadImage();
67
	}
68
69
	/**
70
	 * Sets the filename / path in use
71
	 */
72
	public function setFileName($fileName): void
73 4
	{
74
		$this->_fileName = $fileName;
75
	}
76 4
77
	/**
78
	 * Check if the current manipulator supports webP
79
	 *
80 4
	 * @return bool
81
	 */
82 4
	public function hasWebpSupport(): bool
83
	{
84
		if (!$this->_force_gd && ImageMagick::canUse())
85
		{
86
			$check = \Imagick::queryformats();
87
			if (!in_array('WEBP', $check, true))
88 4
			{
89
				return false;
90
			}
91
		}
92
93
		if (Gd2::canUse())
94 2
		{
95
			$check = gd_info();
96
			if (empty($check['WebP Support']))
97
			{
98
				return false;
99 2
			}
100
		}
101
102
		return true;
103
	}
104
105
	/**
106 2
	 * Returns if the acp allows saving webP images
107
	 *
108 2
	 * @return bool
109
	 */
110
	public function canUseWebp(): bool
111
	{
112 2
		global $modSettings;
113
114
		// Enabled?
115 2
		if (empty($modSettings['attachment_webp_enable']))
116
		{
117 2
			return false;
118
		}
119 2
120
		return !(!empty($modSettings['attachmentCheckExtensions']) && stripos($modSettings['attachmentExtensions'], ',webp') === false);
121
	}
122
123
	/**
124
	 * Load an image from a file or web address into the active graphics library
125
	 */
126 2
	protected function loadImage()
127
	{
128 2
		// Determine and set what image library we will use
129
		try
130
		{
131
			$this->setManipulator();
132
		}
133
		catch (\Exception)
134
		{
135
			// Nothing to do
136
		}
137
138
		if ($this->isWebAddress())
139
		{
140
			$success = $this->_manipulator->createImageFromWeb();
141
		}
142
		else
143
		{
144
			$success = $this->_manipulator->createImageFromFile();
145
		}
146
147
		if ($success === true)
148
		{
149
			$this->_image_loaded = true;
150
		}
151
	}
152
153
	/**
154
	 * Returns if the loading of the image was successful
155
	 *
156
	 * @return bool
157
	 */
158
	public function isImageLoaded(): bool
159
	{
160
		return $this->_image_loaded && $this->isImage();
161
	}
162
163
	/**
164
	 * Determine and set what image library we will use
165
	 *
166 2
	 * @throws \Exception
167
	 */
168 2
	protected function setManipulator(): void
169
	{
170
		// Later this could become an array of "manipulators" (or not) and remove the hard-coded IM/GD requirements
171 2
		if (!$this->_force_gd && ImageMagick::canUse())
172 2
		{
173 2
			$this->_manipulator = new ImageMagick($this->_fileName);
174 2
			$this->_current_manipulator = 'ImageMagick';
175
		}
176
		elseif (Gd2::canUse())
177 2
		{
178
			$this->_manipulator = new Gd2($this->_fileName);
179
			$this->_current_manipulator = 'GD';
180 2
		}
181
		else
182 2
		{
183 2
			throw new \Exception('No image manipulators available');
184
		}
185
	}
186
187
	/**
188
	 * Returns the current image library in use.
189
	 *
190 2
	 * @return string
191
	 */
192
	public function getManipulator(): string
193
	{
194
		return $this->_current_manipulator ?? '';
195
	}
196
197
	/**
198
	 * Return if the source is actually a web address vs. local file
199
	 *
200
	 * @return bool
201
	 */
202
	protected function isWebAddress()
203
	{
204
		return str_starts_with($this->_fileName, 'http://') || str_starts_with($this->_fileName, 'https://');
205
	}
206
207
	/**
208
	 * It's how big?
209
	 *
210
	 * @return int
211
	 */
212
	public function getFilesize()
213
	{
214
		clearstatcache(false, $this->_fileName);
215
216
		$size = FileFunctions::instance()->fileSize($this->_fileName);
217
218
		return $size === false ? 0 : $size;
219
	}
220
221
	/**
222
	 * Best determination of the mime type.
223
	 *
224
	 * @return string
225
	 */
226
	public function getMimeType(): string
227
	{
228
		// Try Exif, which reads the file headers, most accurate for images
229
		if (function_exists('exif_imagetype'))
230
		{
231
			return image_type_to_mime_type(exif_imagetype($this->_fileName));
232
		}
233
234
		return getMimeType($this->_fileName);
235
	}
236
237
	/**
238
	 * If the file is an image or not
239
	 *
240
	 * @return bool
241
	 */
242
	public function isImage()
243
	{
244
		return str_starts_with($this->getMimeType(), 'image');
245
	}
246
247
	/**
248
	 * Creates a thumbnail from an image.
249
	 *
250
	 * - "Recipe" function to create, rotate, and save a thumbnail of a given image
251
	 * - Thumbnail will be proportional to the original image
252
	 * - Saves the thumbnail file
253
	 *
254
	 * @param int $max_width allowed width
255 2
	 * @param int $max_height allowed height
256
	 * @param string $dstName name to save
257
	 * @param null|int $format image format image constant value to save the thumbnail
258 2
	 * @param null|bool $force if forcing the image resize to scale up, the default action
259
	 * @return bool|Image On success returns an image class loaded with new image
260
	 */
261
	public function createThumbnail($max_width, $max_height, $dstName = '', $format = null, $force = null)
262
	{
263
		// The particulars
264
		$dstName = $dstName === '' ? $this->_fileName . '_thumb' : $dstName;
265 2
		$default_format = $this->getDefaultFormat();
266
		$format = empty($format) || !is_int($format) ? $default_format : $format;
267
		$max_width = max(16, $max_width);
268
		$max_height = max(16, $max_height);
269
270
		// Do the actual resize, thumbnails by default strip EXIF data to save space
271
		$success = $this->resizeImage($max_width, $max_height, true, $force ?? true, true);
272
273
		// Save our work
274
		if ($success)
275
		{
276
			$success = false;
277
			if ($this->saveImage($dstName, $format))
278
			{
279
				FileFunctions::instance()->chmod($dstName);
280
				$success = new Image($dstName);
281
			}
282 2
		}
283
		else
284 2
		{
285
			@touch($dstName);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for touch(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

285
			/** @scrutinizer ignore-unhandled */ @touch($dstName);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
286 2
		}
287
288
		return $success;
289
	}
290
291
	/**
292
	 * Sets the best output format for a given image's thumbnail
293
	 *
294
	 * - If webP is available, use that as it gives the smallest size
295
	 * - No webP then, if the image has alpha, we preserve it
296
	 * - Finally good ol' jpeg
297
	 *
298
	 * @return int
299
	 */
300
	public function getDefaultFormat(): int
301
	{
302
		global $modSettings;
303
304
		// Webp is the best choice if server supports
305
		if (!empty($modSettings['attachment_webp_enable']) && $this->hasWebpSupport())
306
		{
307
			return IMAGETYPE_WEBP;
308
		}
309
310
		// They uploaded a webp image, but ACP does not allow saving webp images, then
311
		// if the server supports and its alpha save it as a png
312
		if ($this->getMimeType() === 'image/webp' && $this->hasWebpSupport() && $this->getTransparency(false))
313
		{
314
			return IMAGETYPE_PNG;
315
		}
316
317
		// If you have alpha channels, best keep them with PNG
318
		if ($this->getMimeType() !== 'image/png')
319
		{
320
			// The default, JPG
321
			return IMAGETYPE_JPEG;
322
		}
323
324
		if (!$this->getTransparency())
325
		{
326
			// The default, JPG
327
			return IMAGETYPE_JPEG;
328
		}
329
330
		return IMAGETYPE_PNG;
331
	}
332
333
	/**
334
	 * Calls functions to correct an images orientation based on the EXIF orientation flag
335
	 *
336
	 * @return bool
337
	 */
338
	public function autoRotate()
339
	{
340
		$this->getOrientation();
341
342
		// We only process jpeg images, so check that we have one
343
		if (!isset($this->_manipulator->imageDimensions[2]))
344
		{
345
			$this->_manipulator->getImageDimensions();
346
		}
347
348
		// Not a jpeg or not rotated, done!
349
		if ($this->_manipulator->imageDimensions[2] !== 2 || $this->_manipulator->orientation <= 1)
350
		{
351
			return false;
352
		}
353
354
		try
355
		{
356
			$this->_manipulator->autoRotate();
357
		}
358
		catch (\Exception)
359
		{
360
			return false;
361
		}
362
363
		return true;
364
	}
365
366
	/**
367
	 * Finds the orientation flag of the image as defined by its EXIF data
368
	 *
369
	 * @return int
370
	 */
371
	public function getOrientation(): int
372
	{
373
		return $this->_manipulator->getOrientation();
374
	}
375
376
	/**
377
	 * Resize an image from a remote location or a local file.
378
	 *
379
	 * What it does:
380
	 *
381
	 * - Resize an image, proportionally, to fit withing WxH limits
382
	 * - The file would have the format preferred_format if possible,
383
	 * otherwise the default format is jpeg.
384
	 * - Optionally removes exif header data to make a smaller image
385
	 *
386
	 * @param int $max_width The maximum allowed width
387
	 * @param int $max_height The maximum allowed height
388
	 * @param bool $strip Allow IM to remove exif data as GD always will
389
	 * @param bool $force_resize Always resize the image (force scale up)
390
	 * @param bool $thumbnail If the image is a small thumbnail version
391
	 *
392
	 * @return bool Whether the thumbnail creation was successful.
393
	 */
394
	public function resizeImage($max_width, $max_height, $strip = false, $force_resize = true, $thumbnail = false)
395
	{
396
		// Nothing to do without GD or IM or an Image
397
		if ($this->_manipulator === null)
398
		{
399
			return false;
400
		}
401
402
		try
403
		{
404
			return $this->_manipulator->resizeImage($max_width, $max_height, $strip, $force_resize, $thumbnail);
405
		}
406
		catch (\Exception)
407
		{
408
			return false;
409
		}
410
	}
411
412
	/**
413
	 * Save the image object to a file.
414
	 *
415
	 * @param string $file_name name to save the image to
416
	 * @param int $preferred_format what format to save the image
417
	 * @param int $quality some formats require we provide a compression quality
418
	 *
419
	 * @return bool
420
	 */
421
	public function saveImage($file_name = '', $preferred_format = IMAGETYPE_JPEG, $quality = 85)
422
	{
423
		return $this->_manipulator->output($file_name, $preferred_format, $quality);
424
	}
425
426
	/**
427
	 * Used to re-encode an image to a specified image format
428
	 *
429
	 * What it does:
430
	 *
431
	 * - Creates a copy of the file at the same location.
432
	 * - The file would have the format preferred_format if possible, otherwise the default format is jpeg.
433
	 * - Strips the exif data
434
	 * - The function makes sure that all non-essential image contents are disposed of.
435
	 *
436
	 * @return bool
437
	 */
438
	public function reEncodeImage()
439
	{
440
		// The image should already be loaded
441
		if (!$this->isImage())
442
		{
443
			return false;
444
		}
445
446
		// re-encode the image at the same size it is now, strip exif data.
447
		$sizes = $this->getImageDimensions();
448
		$success = $this->resizeImage($sizes[0], $sizes[1], true);
449
450
		// if all went well, and it's valid, save it back in place
451
		if ($success && !empty(self::DEFAULT_FORMATS[$sizes[2]]))
452
		{
453
			// Write over the original file
454
			$success = $this->saveImage($this->_fileName, $sizes[2]);
455
			$this->loadImage();
456
		}
457
458
		return $success;
459
	}
460
461
	/**
462
	 * Return or set (via getimagesize or getimagesizefromstring) some image details such
463
	 * as size and mime type
464
	 *
465
	 * @return array
466
	 */
467
	public function getImageDimensions(): array
468
	{
469
		return $this->_manipulator->imageDimensions;
470
	}
471
472
	/**
473
	 * Checks for transparency in a PNG image
474
	 *
475
	 *  - Checks file header for saved with Alpha space flag
476
	 *  - 8 Bit (256 color) PNGs are not handled.
477
	 *  - If png is false, will instead check webp headers for transparency flag
478
	 *  - If the alpha flag is set, will go pixel by pixel to validate true alpha pixels exist
479
	 *
480
	 * @param bool $png
481
	 *
482
	 * @return bool
483
	 */
484
	public function getTransparency($png = true): bool
485
	{
486
		// If it claims transparency, we do pixel inspection
487
		$header = file_get_contents($this->_fileName, false, null, 0, 26);
488
489
		// Does it even claim to have been saved with transparency?
490
		if ($png && ord($header[25]) & 4)
491
		{
492
			return $this->_manipulator->getTransparency();
493
		}
494
495
		// Webp has its own unique headers
496
		if (!$png)
497
		{
498
			if (($header[15] === 'L' && (ord($header[24]) & 16)) || ($header[15] === 'X' && (ord($header[20]) & 16)))
499
			{
500
				return $this->_manipulator->getTransparency();
501
			}
502
		}
503
504
		return false;
505
	}
506
507
	/**
508
	 * Searches through the file to see if there's potentially harmful content.
509
	 *
510
	 * What it does:
511
	 *
512
	 * - Basic search of an image file for potential web (php/script) infections
513
	 *
514
	 * @return bool
515
	 * @throws Exception
516
	 */
517
	public function checkImageContents()
518
	{
519
		$fp = fopen($this->_fileName, 'rb');
520
521
		// If we can't open it to scan, go no further
522
		if ($fp === false)
523
		{
524
			throw new Exception('Post.attach_timeout');
525
		}
526
527
		$prev_chunk = '';
528
		while (!feof($fp))
529
		{
530
			$cur_chunk = fread($fp, 256000);
531
			$test_chunk = $prev_chunk . $cur_chunk;
532
533
			// Though not exhaustive lists, better safe than sorry.
534
			if (preg_match('~<\\?php|<script\s+language\s*=\s*(?:php|"php"|\'php\')\s*>~i', $test_chunk) === 1)
535
			{
536
				fclose($fp);
537
538
				return false;
539
			}
540
541
			$prev_chunk = $cur_chunk;
542
		}
543
544
		fclose($fp);
545
546
		return true;
547
	}
548
}
549