Issues (1686)

sources/ElkArte/Graphics/Image.php (2 issues)

1
<?php
2
3
/**
4
 * This file deals with low-level graphics operations performed on images,
5
 * specially 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 dev
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)
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()
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()
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()
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()
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()
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 strpos($this->_fileName, 'http://') === 0 || strpos($this->_fileName, 'https://') === 0;
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
		return FileFunctions::instance()->fileSize($this->_fileName);
0 ignored issues
show
Bug Best Practice introduced by
The expression return ElkArte\Helper\Fi...eSize($this->_fileName) could also return false which is incompatible with the documented return type integer. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
217
	}
218
219
	/**
220
	 * Best determination of the mime type.
221
	 *
222
	 * @return string
223
	 */
224
	public function getMimeType()
225
	{
226
		// Try Exif which reads the file headers, most accurate for images
227
		if (function_exists('exif_imagetype'))
228
		{
229
			return image_type_to_mime_type(exif_imagetype($this->_fileName));
230
		}
231
232
		return getMimeType($this->_fileName);
233
	}
234
235
	/**
236
	 * If the file is an image or not
237
	 *
238
	 * @return bool
239
	 */
240
	public function isImage()
241
	{
242
		return strpos($this->getMimeType(), 'image') === 0;
243
	}
244
245
	/**
246
	 * Creates a thumbnail from an image.
247
	 *
248
	 * - "recipe" function to create, rotate and save a thumbnail of a given image
249
	 * - Thumbnail will be proportional to the original image
250
	 * - Saves the thumbnail file
251
	 *
252
	 * @param int $max_width allowed width
253
	 * @param int $max_height allowed height
254
	 * @param string $dstName name to save
255 2
	 * @param null|int $format image format image constant value to save the thumbnail
256
	 * @param null|bool $force if forcing the image resize to scale up, the default action
257
	 * @return bool|Image On success returns an image class loaded with new image
258 2
	 */
259
	public function createThumbnail($max_width, $max_height, $dstName = '', $format = null, $force = null)
260
	{
261
		// The particulars
262
		$dstName = $dstName === '' ? $this->_fileName . '_thumb' : $dstName;
263
		$default_format = $this->getDefaultFormat();
264
		$format = empty($format) || !is_int($format) ? $default_format : $format;
265 2
		$max_width = max(16, $max_width);
266
		$max_height = max(16, $max_height);
267
268
		// Do the actual resize, thumbnails by default strip EXIF data to save space
269
		$success = $this->resizeImage($max_width, $max_height, true, $force ?? true, true);
270
271
		// Save our work
272
		if ($success)
273
		{
274
			$success = false;
275
			if ($this->saveImage($dstName, $format))
276
			{
277
				FileFunctions::instance()->chmod($dstName);
278
				$success = new Image($dstName);
279
			}
280
		}
281
		else
282 2
		{
283
			@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

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