Passed
Push — development ( 319958...42896e )
by Spuds
01:19 queued 20s
created

TemporaryAttachment::setErrors()   D

Complexity

Conditions 19
Paths 4

Size

Total Lines 51
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 19
eloc 19
c 1
b 0
f 0
nc 4
nop 1
dl 0
loc 51
rs 4.5166

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * Handles the preparing of attachments from the post form.
5
 *
6
 * @package   ElkArte Forum
7
 * @copyright ElkArte Forum contributors
8
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
9
 *
10
 * @version 2.0 dev
11
 *
12
 */
13
14
namespace ElkArte\Attachments;
15
16
use ElkArte\Exceptions\Exception as ElkException;
17
use ElkArte\Graphics\Image;
18
use ElkArte\Graphics\ImageUploadResize;
19
use ElkArte\Helper\FileFunctions;
20
use ElkArte\Helper\Util;
21
use ElkArte\Helper\ValuesContainer;
22
23
/**
24
 * TemporaryAttachment value bag for attachments
25
 */
26
class TemporaryAttachment extends ValuesContainer
27
{
28
	/**
29
	 * {@inheritDoc}
30
	 */
31
	public function __construct($data = null)
32
	{
33
		$data['errors'] = [];
34
		$data['name'] = Util::clean_4byte_chars(htmlspecialchars($data['name'], ENT_COMPAT, 'UTF-8'));
35
36
		ValuesContainer::__construct($data);
37
	}
38
39
	/**
40
	 * Deletes a temporary attachment from the filesystem
41
	 *
42
	 * @param bool $fatal
43
	 * @return bool
44
	 * @throws ElkException thrown if fatal is true
45
	 */
46
	public function remove(bool $fatal = true): bool
47
	{
48
		$this->data['size'] = 0;
49
		$this->data['type'] = '';
50
51
		if ($fatal && !$this->fileWritable())
52
		{
53
			throw new ElkException('attachment_not_found');
54
		}
55
56
		return $this->unlinkFile();
57
	}
58
59
	/**
60
	 * Checks if the file (not a directory) exists, and is editable, in the file system.
61
	 */
62
	public function fileWritable(): bool
63
	{
64
		$path = $this->data['tmp_name'] ?? '';
65
		if ($path === '')
66
		{
67
			return false;
68
		}
69
70
		$fs = FileFunctions::instance();
71
		return $fs->fileExists($path) && $fs->isWritable($path);
72
	}
73
74
	/**
75
	 * Returns an array of names of temporary attachments.
76
	 *
77
	 * @return string
78
	 */
79
	public function getName(): string
80
	{
81
		return $this->data['name'];
82
	}
83
84
	/**
85
	 * Error setter, adds errors to the stack
86
	 *
87
	 * @param $error
88
	 */
89
	public function setErrors($error): void
90
	{
91
		// Normalize input to a flat list of error items where each item is an array:
92
		// [code] or [code, [args]]
93
		if ($error === null || $error === '')
94
		{
95
			return;
96
		}
97
98
		$append = function ($item) {
99
			if ($item === null || $item === '')
100
			{
101
				return;
102
			}
103
104
			// If a string, wrap as [code]
105
			if (!is_array($item))
106
			{
107
				$this->data['errors'][] = [$item];
108
				return;
109
			}
110
111
			// If it looks like a single error tuple [code, [args]] or [code]
112
			// keep as-is; otherwise, best-effort wrap
113
			if (isset($item[0]) && (is_string($item[0]) || is_scalar($item[0])))
114
			{
115
				// If args present but not an array, wrap it
116
				if (isset($item[1]) && !is_array($item[1]))
117
				{
118
					$item[1] = [$item[1]];
119
				}
120
121
				$this->data['errors'][] = $item;
122
				return;
123
			}
124
125
			// Fallback: wrap whole structure as a single error payload
126
			$this->data['errors'][] = [$item];
127
		};
128
129
		// If we received a list of errors (mixed strings and arrays), append each
130
		if (is_array($error) && !(isset($error[0]) && is_string($error[0]) && (count($error) === 1 || (count($error) === 2 && isset($error[1]) && is_array($error[1])))))
131
		{
132
			foreach ($error as $e)
133
			{
134
				$append($e);
135
			}
136
		}
137
		else
138
		{
139
			$append($error);
140
		}
141
	}
142
143
	/**
144
	 * Return if errors were found for this attachment attempt
145
	 *
146
	 * @return bool
147
	 */
148
	public function hasErrors(): bool
149
	{
150
		return !empty($this->data['errors']);
151
	}
152
153
	/**
154
	 * Error getter
155
	 *
156
	 * @return array
157
	 */
158
	public function getErrors(): mixed
159
	{
160
		return $this->data['errors'];
161
	}
162
163
	/**
164
	 * Return the attachment filesize
165
	 *
166
	 * @return int
167
	 */
168
	public function getSize(): int
169
	{
170
		return $this->data['size'];
171
	}
172
173
	/**
174
	 * Return the mime type of the file, if available
175
	 *
176
	 * @return string
177
	 */
178
	public function getMime(): string
179
	{
180
		return $this->data['mime'] ?? '';
181
	}
182
183
	/**
184
	 * Checks if the file exists, and is editable, in the file system.
185
	 */
186
	public function fileExists(): bool
187
	{
188
		$path = $this->data['tmp_name'] ?? '';
189
		return $path !== '' && FileFunctions::instance()->fileExists($path);
190
	}
191
192
	/**
193
	 * Renaming and moving
194
	 *
195
	 * @param $file_path
196
	 */
197
	public function moveTo($file_path): void
198
	{
199
		$destination = $file_path . '/' . $this->data['attachid'];
200
		rename($this->data['tmp_name'], $destination);
201
		$this->data['tmp_name'] = $destination;
202
	}
203
204
	/**
205
	 * Moves the uploaded file to a specified destination folder.
206
	 *
207
	 * @param string $file_path The destination folder path.
208
	 * @param bool $strict Determines whether to use strict file moving or not.
209
	 * @return bool Returns true if the file is moved successfully, false otherwise.
210
	 */
211
	public function moveUploaded(string $file_path, bool $strict = true): bool
212
	{
213
		$destName = $file_path . '/' . $this->data['attachid'];
214
215
		if (!$strict)
216
		{
217
			$result = rename($this->data['tmp_name'], $destName);
218
		}
219
		else
220
		{
221
			// Move the file to the attachment folder with a temp name for now.
222
			set_error_handler(static function () { /* ignore warnings */ });
223
			try
224
			{
225
				$result = move_uploaded_file($this->data['tmp_name'], $destName);
226
			}
227
			catch (\Throwable)
228
			{
229
				$result = false;
230
			}
231
			finally
232
			{
233
				restore_error_handler();
234
			}
235
		}
236
237
		if ($result === true)
238
		{
239
			$this->data['tmp_name'] = $destName;
240
			FileFunctions::instance()->chmod($destName);
241
242
			return true;
243
		}
244
245
		$this->setErrors('attach_timeout');
246
		$this->unlinkFile();
247
248
		return false;
249
	}
250
251
	/**
252
	 * Sets the folder ID value in the object's data.
253
	 *
254
	 * @param int $id The ID to be assigned to the folder.
255
	 * @return void
256
	 */
257
	public function setIdFolder(int $id): void
258
	{
259
		$this->data['id_folder'] = $id;
260
	}
261
262
	/**
263
	 * Performs various checks on an uploaded file.
264
	 *
265
	 * @param AttachmentsDirectory $attachmentDirectory
266
	 * @return bool
267
	 * @throws ElkException attach_check_nag
268
	 */
269
	public function doElkarteUploadChecks(AttachmentsDirectory $attachmentDirectory): bool
270
	{
271
		global $context;
272
273
		// If there were already errors at this point, no need to check further
274
		if (!empty($this->data['errors']))
275
		{
276
			return false;
277
		}
278
279
		// Apply some additional checks
280
		if (empty($this->data['attachid']))
281
		{
282
			$error = 'attachid';
283
		}
284
		// @TODO this needs to go away, not sure where though.
285
		elseif (empty($context['attachments']))
286
		{
287
			$error = '$context[\'attachments\']';
288
		}
289
290
		// Let's get their attention.
291
		if (!empty($error))
292
		{
293
			throw new ElkException('attach_check_nag', 'debug', [$error]);
294
		}
295
296
		// Just in case this slipped by the first checks, we stop it here and now
297
		if ($this->data['size'] === 0)
298
		{
299
			$this->setErrors('attach_0_byte_file');
300
301
			return false;
302
		}
303
304
		// Allow addons to make their own pre checks / adjustments
305
		call_integration_hook('integrate_attachment_checks', [$this->data['attachid']]);
306
307
		// Did you pack this bag yourself?
308
		$this->checkImageContents();
309
310
		// WebP may require special processing that will affect size/type
311
		$this->convertFromWebp();
312
313
		// We may allow resizing uploaded images, so they take less room
314
		$this->adjustImageSizeType();
315
316
		// We may want to correct rotated images
317
		$this->autoRotate();
318
319
		// Run our batch of tests, set any errors along the way
320
		$this->checkDirectorySpace($attachmentDirectory);
321
		$this->checkFileSize();
322
		$this->checkTotalUploadSize();
323
		$this->checkTotalUploadCount();
324
		$this->checkFileExtensions();
325
326
		// Undo the math if there's an error
327
		if ($this->hasErrors())
328
		{
329
			if (isset($context['dir_size']))
330
			{
331
				$context['dir_size'] -= $this->data['size'];
332
			}
333
334
			if (isset($context['dir_files']))
335
			{
336
				$context['dir_files']--;
337
			}
338
339
			$context['attachments']['total_size'] -= $this->data['size'];
340
			$context['attachments']['quantity']--;
341
342
			return false;
343
		}
344
345
		return true;
346
	}
347
348
	/**
349
	 * If we have a valid image type, inspect to see if there is any
350
	 * injected code fragments.  If found re encode to remove those fragments
351
	 */
352
	public function checkImageContents(): void
353
	{
354
		global $modSettings;
355
356
		// First, the dreaded security check. Sorry folks, but this should't be avoided
357
		$image = new Image($this->data['tmp_name']);
358
		if ($image->isImageLoaded())
359
		{
360
			$this->data['imagesize'] = $image->getImageDimensions();
361
			$this->data['size'] = $image->getFilesize();
362
			try
363
			{
364
				if (!$image->checkImageContents())
365
				{
366
					// It's bad. Last chance, maybe we can re-encode it?
367
					if (empty($modSettings['attachment_image_reencode']) || (!$image->reEncodeImage()))
368
					{
369
						// Nothing to do: not allowed or not successful re-encoding it.
370
						$this->setErrors('bad_attachment');
371
						$this->data['imagesize'] = [];
372
					}
373
					else
374
					{
375
						$this->data['size'] = $image->getFilesize();
376
					}
377
				}
378
			}
379
			catch (\Exception)
380
			{
381
				$this->setErrors('bad_attachment');
382
			}
383
		}
384
385
		unset($image);
386
	}
387
388
	/**
389
	 * If enabled, call the attachment image resizing functions.  These reduce the image WxH
390
	 * and potentially change the format in order to reduce size.
391
	 */
392
	public function adjustImageSizeType(): void
393
	{
394
		global $modSettings;
395
396
		// Auto resize enabled, then do sizing manipulations up front
397
		if (!empty($modSettings['attachmentSizeLimit']) && !empty($modSettings['attachment_image_resize_enabled']))
398
		{
399
			$autoSizer = new ImageUploadResize();
400
			$autoSizer->autoResize($this->data);
401
		}
402
	}
403
404
	/**
405
	 * If the admin does not want to save webP (attachment_webp_enable is off) but they accept
406
	 * webp extensions and the server has webp capabilities, then webP -> PNG or -> JPG (best choice)
407
	 * based on the input image
408
	 *
409
	 * @return void
410
	 */
411
	public function convertFromWebp(): void
412
	{
413
		global $modSettings;
414
415
		// We may have to adjust for webp based on ACP settings
416
		if (empty($this->data['imagesize'][2])
417
			|| $this->data['imagesize'][2] !== IMAGETYPE_WEBP
418
			|| !empty($modSettings['attachment_webp_enable'])
419
			|| (!empty($modSettings['attachmentCheckExtensions']) && stripos($modSettings['attachmentExtensions'], ',webp') === false))
420
		{
421
			return;
422
		}
423
424
		// Is a webp image and manipulation is possible?
425
		$image = new Image($this->data['tmp_name']);
426
		if ($image->hasWebpSupport())
427
		{
428
			$format = $image->getDefaultFormat();
429
			if ($image->isImageLoaded() && $image->saveImage($this->data['tmp_name'], $format))
430
			{
431
				$valid_mime = getValidMimeImageType($format);
432
				$ext = str_replace('jpeg', 'jpg', substr($valid_mime, strpos($valid_mime, '/') + 1));
433
434
				// Update to what it now is (webp to png or jpg)
435
				$update = [
436
					'size' => $image->getFilesize(),
437
					'imagesize' => $image->getImageDimensions(),
438
					'type' => $valid_mime,
439
					'mime' => $valid_mime,
440
					'name' => $this->data['name'] . '.' . $ext
441
				];
442
443
				$this->data = array_merge($this->data, $update);
444
			}
445
		}
446
	}
447
448
	/**
449
	 * Is there room in the directory for this file
450
	 *
451
	 * @param AttachmentsDirectory $attachmentDirectory
452
	 */
453
	public function checkDirectorySpace(AttachmentsDirectory $attachmentDirectory): void
454
	{
455
		try
456
		{
457
			$attachmentDirectory->checkDirSpace($this);
458
		}
459
		catch (\Exception $exception)
460
		{
461
			$this->setErrors($exception->getMessage());
462
		}
463
	}
464
465
	/**
466
	 * Is the file larger than we accept
467
	 */
468
	public function checkFileSize(): void
469
	{
470
		global $modSettings;
471
472
		// Is the file too big?
473
		if (empty($modSettings['attachmentSizeLimit']))
474
		{
475
			return;
476
		}
477
478
		if ($this->data['size'] <= $modSettings['attachmentSizeLimit'] * 1024)
479
		{
480
			return;
481
		}
482
483
		$this->setErrors([
484
			'file_too_big', [
485
				comma_format($modSettings['attachmentSizeLimit'], 0)
486
			]
487
		]);
488
	}
489
490
	/**
491
	 * Check if they are sending too much data in a single post
492
	 */
493
	public function checkTotalUploadSize(): void
494
	{
495
		global $context, $modSettings;
496
497
		// Check the total upload size for this post...
498
		$context['attachments']['total_size'] += $this->data['size'];
499
		if (empty($modSettings['attachmentPostLimit']))
500
		{
501
			return;
502
		}
503
504
		if ($context['attachments']['total_size'] <= $modSettings['attachmentPostLimit'] * 1024)
505
		{
506
			return;
507
		}
508
509
		$this->setErrors([
510
			'attach_max_total_file_size', [
511
				comma_format($modSettings['attachmentPostLimit'], 0),
512
				comma_format($modSettings['attachmentPostLimit'] - (($context['attachments']['total_size'] - $this->data['size']) / 1024), 0)
513
			]
514
		]);
515
	}
516
517
	/**
518
	 * Check if they are sending too many files at once
519
	 */
520
	public function checkTotalUploadCount(): void
521
	{
522
		global $context, $modSettings;
523
524
		// Have we reached the maximum number of files we are allowed?
525
		$context['attachments']['quantity']++;
526
527
		// Set a max limit if none exists
528
		if (empty($modSettings['attachmentNumPerPostLimit']) && $context['attachments']['quantity'] >= 15)
529
		{
530
			$modSettings['attachmentNumPerPostLimit'] = 15;
531
		}
532
533
		if (empty($modSettings['attachmentNumPerPostLimit']))
534
		{
535
			return;
536
		}
537
538
		if ($context['attachments']['quantity'] <= $modSettings['attachmentNumPerPostLimit'])
539
		{
540
			return;
541
		}
542
543
		$this->setErrors([
544
			'attachments_limit_per_post', [
545
				$modSettings['attachmentNumPerPostLimit']
546
			]
547
		]);
548
	}
549
550
	/**
551
	 * If enabled, check if this is a filetype we accept (by extension)
552
	 */
553
	public function checkFileExtensions(): void
554
	{
555
		global $modSettings;
556
557
		// File extension check
558
		if (!empty($modSettings['attachmentCheckExtensions']))
559
		{
560
			$allowed = explode(',', strtolower($modSettings['attachmentExtensions']));
561
			$allowed = array_map('trim', $allowed);
562
563
			if (!in_array(strtolower(substr(strrchr($this->data['name'], '.'), 1)), $allowed, true))
564
			{
565
				$allowed_extensions = strtr(strtolower($modSettings['attachmentExtensions']), [',' => ', ']);
566
				$this->setErrors([
567
					'cant_upload_type', [
568
						$allowed_extensions
569
					]
570
				]);
571
			}
572
		}
573
	}
574
575
	/**
576
	 * Rotate an image top side up based on its EXIF data
577
	 */
578
	public function autoRotate(): void
579
	{
580
		global $modSettings;
581
582
		// Want to correct for phone rotated photos, hell yeah ya do!
583
		if (!empty($modSettings['attachment_autorotate'])
584
			&& $this->hasErrors() === false && strpos($this->data['type'], 'image') === 0)
585
		{
586
			$image = new Image($this->data['tmp_name']);
587
			if ($image->isImageLoaded() && $image->autoRotate())
588
			{
589
				$image->saveImage($this->data['tmp_name'], IMAGETYPE_JPEG, 95);
590
				$this->data['size'] = filesize($this->data['tmp_name']);
591
			}
592
		}
593
	}
594
595
	/**
596
	 * Checks if a file existence/permission and if granted will attempt
597
	 * to remove/unlink the file.
598
	 *
599
	 * @return bool
600
	 */
601
	private function unlinkFile(): bool
602
	{
603
		try
604
		{
605
			if (!$this->fileWritable())
606
			{
607
				throw new \Exception('attachment_not_found');
608
			}
609
610
			$fs = FileFunctions::instance();
611
			$path = $this->data['tmp_name'];
612
613
			// Best-effort deletes; ignore missing thumb
614
			$fs->delete($path);
615
			$thumb = $path . '_thumb';
616
			if ($fs->fileExists($thumb))
617
			{
618
				$fs->delete($thumb);
619
			}
620
		}
621
		catch (\Exception)
622
		{
623
			return false;
624
		}
625
626
		return true;
627
	}
628
}
629