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

TemporaryAttachmentChunk   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 512
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 150
c 1
b 0
f 0
dl 0
loc 512
rs 4.08
wmc 59

20 Methods

Rating   Name   Duplication   Size   Complexity  
A validateReceivedFile() 0 20 4
A action_async() 0 10 2
A validateInitialChunk() 0 17 6
A extractPostData() 0 13 2
A saveAsyncFile() 0 23 4
A validatePostData() 0 13 5
A generateLocalFileName() 0 6 1
A errorAsyncFile() 0 16 2
A __construct() 0 11 2
A getUserIdentifier() 0 3 2
A getSmallerNonZero() 0 18 4
A checkTotalSize() 0 21 4
A returnResults() 0 17 2
A getPathWithChunks() 0 3 1
A verifyChunkExistence() 0 5 2
A action_combineChunks() 0 27 3
A build_fileArray() 0 14 2
A writeChunkToFile() 0 32 6
A getCombinedFilePath() 0 5 1
A combineFileFragments() 0 28 4

How to fix   Complexity   

Complex Class

Complex classes like TemporaryAttachmentChunk often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use TemporaryAttachmentChunk, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * Handles the job of attachment-chunked upload management.
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\Helper\FileFunctions;
17
use ElkArte\Helper\HttpReq;
18
use ElkArte\Helper\TokenHash;
19
use ElkArte\User;
20
use FilesystemIterator;
21
use GlobIterator;
22
use Throwable;
23
24
/**
25
 * Class TemporaryAttachmentChunk
26
 *
27
 * Handles the job of chunked upload management.
28
 */
29
class TemporaryAttachmentChunk
30
{
31
	/** @var HttpReq */
32
	public HttpReq $req;
33
34
	/** @var AttachmentsDirectory */
35
	public AttachmentsDirectory $attachmentDirectory;
36
37
	/** @var string Active attachment directory */
38
	public string $attach_current_dir;
39
40
	/** @var int Maximum chunk size allowed */
41
	public mixed $chunkSize;
42
43
	/** @var string the combined file temporary path and name */
44
	private string $combinedFilePath;
45
46
	/**
47
	 * Class constructor.
48
	 *
49
	 * Initializes the object and sets the necessary properties.
50
	 */
51
	public function __construct()
52
	{
53
		global $modSettings;
54
55
		$this->req = HttpReq::instance();
56
		$this->attachmentDirectory = new AttachmentsDirectory($modSettings, database());
57
		$this->attachmentDirectory->automanageCheckDirectory();
58
		$this->attach_current_dir = $this->attachmentDirectory->getCurrent();
59
		$this->chunkSize = empty($modSettings['attachmentChunkSize']) ? 250000 : $modSettings['attachmentChunkSize'];
60
61
		require_once(SUBSDIR . '/Attachments.subs.php');
62
	}
63
64
	/**
65
	 * Handles an asynchronous action by validating the session and saving the file.
66
	 *
67
	 * @return array The result of the action, including status and related data.
68
	 */
69
	public function action_async(): array
70
	{
71
		if (checkSession('post', '', false) !== '')
72
		{
73
			return ['result' => false, 'data' => 'session_timeout'];
74
		}
75
76
		$result = $this->saveAsyncFile();
77
78
		return $this->returnResults($result);
79
	}
80
81
	/**
82
	 * Process and save an asynchronously uploaded file chunk.
83
	 *
84
	 * This method handles the extraction and validation of post data,
85
	 * validation of the received file, writing the file chunk to a local temporary file,
86
	 * and managing errors during these operations.
87
	 *
88
	 * @return array|string Returns an array containing the unique identifier and status
89
	 * code on successful processing, or a string describing the error on failure.
90
	 */
91
	public function saveAsyncFile(): array|string
92
	{
93
		[$uuid, $chunkIndex, $totalChunkCount] = $this->extractPostData();
94
		$postValidationError = $this->validatePostData($uuid, $chunkIndex, $totalChunkCount);
95
		if (is_string($postValidationError))
96
		{
97
			return $this->errorAsyncFile($postValidationError, $uuid);
98
		}
99
100
		$validationError = $this->validateReceivedFile();
101
		if (is_string($validationError))
102
		{
103
			return $this->errorAsyncFile($validationError, $uuid);
104
		}
105
106
		$local_file = $this->generateLocalFileName($uuid, $chunkIndex);
107
		$chunkWritingError = $this->writeChunkToFile($local_file);
108
		if ($chunkWritingError !== true)
109
		{
110
			return $this->errorAsyncFile($chunkWritingError, $uuid);
111
		}
112
113
		return ['id' => $uuid, 'code' => ''];
114
	}
115
116
	/**
117
	 * Extract post data parameters from the request.
118
	 *
119
	 * @return array An array containing the UUID, chunk index, and total chunk count.
120
	 * Defaults to [null, 0, 0] if values are not provided.
121
	 */
122
	private function extractPostData(): array
123
	{
124
		$chunkIndex = $this->req->getPost('elkchunkindex', 'intval', 0);
125
		$totalChunkCount = $this->req->getPost('elktotalchunkcount', 'intval', 0);
126
		$uuid = $this->req->getPost('elkuuid', 'intval', 0);
127
128
		if (!isset($chunkIndex, $totalChunkCount))
129
		{
130
			$chunkIndex = 0;
131
			$totalChunkCount = 0;
132
		}
133
134
		return [$uuid, $chunkIndex, $totalChunkCount];
135
	}
136
137
	/**
138
	 * Validates the post data is complete and within the known bounds.
139
	 *
140
	 * @param string $uuid The UUID of the data.
141
	 * @param int $chunkIndex The index of the current chunk.
142
	 * @param int $totalChunkCount The total number of chunks.
143
	 *
144
	 * @return string|bool Returns 'invalid_chunk' if the chunk index is invalid or 'invalid_uuid' if the UUID is not set.
145
	 * If the chunk and UUID are valid, it delegates the validation to the validateInitialChunk method and returns its result.
146
	 */
147
	private function validatePostData(string $uuid, int $chunkIndex, int $totalChunkCount): bool|string
148
	{
149
		if ($chunkIndex < 0 || $totalChunkCount < 1 || $chunkIndex >= $totalChunkCount)
150
		{
151
			return 'invalid_chunk';
152
		}
153
154
		if (!isset($uuid))
155
		{
156
			return 'invalid_uuid';
157
		}
158
159
		return $this->validateInitialChunk($totalChunkCount, $chunkIndex);
160
	}
161
162
	/**
163
	 * Validates
164
	 * - the total number of chunks will fit within the maximum post size
165
	 * - that the output directory is writable
166
	 * - only does this on the first chuck
167
	 *
168
	 * @param int $totalChunkCount The total number of chunks.
169
	 * @param int $chunkIndex The index of the current chunk.
170
	 *
171
	 * @return string|bool Returns 'chunk_quota' if the chunk quota check fails, 'not_writable' if the attachment directory is not writable.
172
	 * If the checks passed, it returns true.
173
	 */
174
	private function validateInitialChunk(int $totalChunkCount, int $chunkIndex): bool|string
175
	{
176
		// Make sure this (when completed) file size will not exceed what we are willing to accept
177
		if ($totalChunkCount === 1 || ($totalChunkCount > 1 && $chunkIndex === 0))
178
		{
179
			if ($this->checkTotalSize($totalChunkCount) !== true)
180
			{
181
				return 'chunk_quota';
182
			}
183
184
			if (!FileFunctions::instance()->isWritable($this->attach_current_dir))
185
			{
186
				return 'not_writable';
187
			}
188
		}
189
190
		return true;
191
	}
192
193
	/**
194
	 * Check if the total size of the chunks is within the allowed upload limits.
195
	 *
196
	 * @param int $totalChunks The total number of chunks.
197
	 * @param int $chunkSize The size of each chunk, in bytes. Default is 250,000.
198
	 *
199
	 * @return bool True if the total size does not exceed the allowed limits, false otherwise.
200
	 */
201
	public function checkTotalSize(int $totalChunks, int $chunkSize = 250000): bool
202
	{
203
		global $modSettings;
204
205
		$expectedSize = $totalChunks * $chunkSize;
206
207
		// What upload max sizes are defined?
208
		$post_max_size = ini_get('post_max_size');
209
		$testPM = memoryReturnBytes($post_max_size);
210
		$acpPM = isset($modSettings['attachmentPostLimit']) ? $modSettings['attachmentPostLimit'] * 1024 : 0;
211
212
		// Limitless ?
213
		if ($testPM === 0 && $acpPM === 0)
214
		{
215
			return true;
216
		}
217
218
		// Which is creating the limit?
219
		$limit = $this->getSmallerNonZero($testPM, $acpPM);
220
221
		return ($expectedSize <= $limit);
222
	}
223
224
	/**
225
	 * Returns the smaller non-zero number between two given numbers.
226
	 *
227
	 * @param float|int $num1 The first number to compare.
228
	 * @param float|int $num2 The second number to compare.
229
	 *
230
	 * @return int|float Returns the smaller non-zero number between $num1 and $num2.
231
	 * If $num1 is equal to $num2 or if both numbers are zero, returns zero.
232
	 */
233
	public function getSmallerNonZero(float|int $num1, float|int $num2): float|int
234
	{
235
		if (empty($num1))
236
		{
237
			return $num2;
238
		}
239
240
		if (empty($num2))
241
		{
242
			return $num1;
243
		}
244
245
		if ($num1 < $num2)
246
		{
247
			return $num1;
248
		}
249
250
		return $num2;
251
	}
252
253
	/**
254
	 * Retrieves the error message for the given code and cleans up any related async files.
255
	 *
256
	 * @param int|string $code The error code.
257
	 * @param string $fileID The ID of the file. Default is an empty string.
258
	 * @return array An associative array containing the error message, code, and file ID.
259
	 */
260
	public function errorAsyncFile(int|string $code, string $fileID = ''): array
261
	{
262
		global $txt;
263
264
		$error = $txt['attachment_' . $code] ?? $code;
265
266
		// Clean up
267
		$user_ident = $this->getUserIdentifier();
268
		$in = $this->attach_current_dir . '/post_tmp_async_' . $user_ident . '_' . $fileID . '*.dat';
269
		$iterator = new GlobIterator($in, FilesystemIterator::SKIP_DOTS | FilesystemIterator::KEY_AS_FILENAME);
270
		foreach ($iterator as $file)
271
		{
272
			@unlink($file->getPathname());
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). 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

272
			/** @scrutinizer ignore-unhandled */ @unlink($file->getPathname());

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...
273
		}
274
275
		return ['error' => $error, 'code' => $code, 'id' => $fileID];
276
	}
277
278
	/**
279
	 * Retrieves the user identifier for the current user.
280
	 *
281
	 * @return string The user identifier.
282
	 * @global array $user_info The user information array.
283
	 *
284
	 */
285
	protected function getUserIdentifier(): string
286
	{
287
		return empty(User::$info->id) ? preg_replace('~[^0-9a-z]~i', '', $_SESSION['session_value']) : User::$info->id;
0 ignored issues
show
Bug Best Practice introduced by
The property id does not exist on ElkArte\Helper\ValuesContainer. Since you implemented __get, consider adding a @property annotation.
Loading history...
288
	}
289
290
	/**
291
	 * Validates that a file was properly received. Validates it is of the correct chunk size.
292
	 *
293
	 * @return string|bool Returns a string indicating the error type,
294
	 *                     or a boolean true if the file is valid.
295
	 */
296
	private function validateReceivedFile(): bool|string
297
	{
298
		if (!$this->attachmentDirectory->hasFileTmpAttachments())
299
		{
300
			return 'no_files';
301
		}
302
303
		if ($_FILES['attachment']['size'][0] > $this->chunkSize)
304
		{
305
			return 'chunk_quota';
306
		}
307
308
		// If we have an initial PHP upload error, then we are baked
309
		$errors = doPHPUploadChecks(0);
310
		if (!empty($errors))
311
		{
312
			return 'upload_error';
313
		}
314
315
		return true;
316
	}
317
318
	/**
319
	 * Generate a local file name based on provided parameters.
320
	 *
321
	 * @param string $uuid The unique identifier.
322
	 * @param int $chunkIndex The index of the current chunk.
323
	 *
324
	 * @return string The generated local file name.
325
	 */
326
	private function generateLocalFileName(string $uuid, int $chunkIndex): string
327
	{
328
		$salt = basename($_FILES['attachment']['tmp_name'][0]);
329
		$user_ident = $this->getUserIdentifier();
330
331
		return 'post_tmp_async_' . $user_ident . '_' . $uuid . '_part_' . $chunkIndex . '_' . $salt . '.dat';
332
	}
333
334
	/**
335
	 * Write a chunk of a file to the specified location.
336
	 *
337
	 * @param string $local_file The local file name.
338
	 *
339
	 * @return bool|string Returns true if the chunk was written successfully, 'not_found' if the destination file was not found.
340
	 */
341
	private function writeChunkToFile(string $local_file): bool|string
342
	{
343
		$out = $this->attach_current_dir . '/' . $local_file;
344
		$in = $_FILES['attachment']['tmp_name'][0] ?? '';
345
346
		if ($in === '' || $local_file === '')
347
		{
348
			return 'not_found';
349
		}
350
351
		// Move the file to the attachment folder with a temp name for now.
352
		set_error_handler(static function () { /* ignore warnings */ });
353
		try
354
		{
355
			$result = move_uploaded_file($in, $out);
356
		}
357
		catch (Throwable)
358
		{
359
			$result = false;
360
		}
361
		finally
362
		{
363
			restore_error_handler();
364
		}
365
366
		$fs = FileFunctions::instance();
367
		if (!$result || !$fs->fileExists($out))
368
		{
369
			return 'not_found';
370
		}
371
372
		return true;
373
	}
374
375
	/**
376
	 * Return the results of a process.
377
	 *
378
	 * @param array $result The result of the process.
379
	 *
380
	 * @return array The result array
381
	 */
382
	public function returnResults(array $result): array
383
	{
384
		// Some error?
385
		if (!empty($result['code']))
386
		{
387
			return [
388
				'result' => false,
389
				'error' => $result['error'],
390
				'code' => $result['code'],
391
				'fatal' => true,
392
				'async' => $result['id']
393
			];
394
		}
395
396
		return [
397
			'result' => true,
398
			'async' => $result['id']
399
		];
400
	}
401
402
	/**
403
	 * Combine the file chunks into a single file.
404
	 *
405
	 * @return array The path of the combined file.
406
	 */
407
	public function action_combineChunks(): array
408
	{
409
		[$uuid, , $totalChunkCount] = $this->extractPostData();
410
		$user_ident = $this->getUserIdentifier();
411
		$in = $this->getPathWithChunks($user_ident, $uuid);
412
413
		// Check that all chunks do exist
414
		if (!$this->verifyChunkExistence($in, $totalChunkCount))
415
		{
416
			$result = $this->errorAsyncFile('not_found', $uuid);
417
			return $this->returnResults($result);
418
		}
419
420
		// Combine the fragments in the correct order
421
		$success = $this->combineFileFragments($user_ident, $uuid, $in);
422
423
		if ($success)
424
		{
425
			$this->build_fileArray();
426
			$result = ['id' => $this->combinedFilePath, 'code' => ''];
427
		}
428
		else
429
		{
430
			$result = $this->errorAsyncFile('not_found', $uuid);
431
		}
432
433
		return $this->returnResults($result);
434
	}
435
436
	/**
437
	 * Build the fileArray parameter for now combined file.
438
	 *
439
	 * This will then be used in action_ulattach as though it was uploaded as a single file,
440
	 * and now be subject to all the same tests and manipulations.  This will also be done as strict=false
441
	 * as we have already verified these were php uploaded files.
442
	 *
443
	 * @return void
444
	 */
445
	public function build_fileArray(): void
446
	{
447
		unset($_FILES['attachment']);
448
449
		// What was sent should match what was claimed
450
		$sizeOnDisk = FileFunctions::instance()->fileSize($this->combinedFilePath);
451
		$sizeOnForm = $this->req->getPost('filesize', 'intval');
452
		$error = ($sizeOnDisk !== $sizeOnForm) ? UPLOAD_ERR_PARTIAL : UPLOAD_ERR_OK;
453
454
		$_FILES['attachment']['name'][] = $this->req->getPost('filename', 'trim');
455
		$_FILES['attachment']['type'][] = $this->req->getPost('filetype', 'trim');
456
		$_FILES['attachment']['size'][] = $this->req->getPost('filesize', 'intval');
457
		$_FILES['attachment']['tmp_name'][] = $this->combinedFilePath;
458
		$_FILES['attachment']['error'][] = $error;
459
	}
460
461
	/**
462
	 * Generate the path to a file with chunks based on provided parameters.
463
	 *
464
	 * @param string $user_ident The user identifier.
465
	 * @param string $uuid The unique identifier.
466
	 *
467
	 * @return string The generated path to the file with chunks.
468
	 */
469
	private function getPathWithChunks(string $user_ident, string $uuid): string
470
	{
471
		return $this->attach_current_dir . '/post_tmp_async_' . $user_ident . '_' . $uuid . '_part_*.dat';
472
	}
473
474
	/**
475
	 * Get the combined file path and name based on the user identifier, UUID and some salt
476
	 *
477
	 * @param string $user_ident The user identifier.
478
	 * @param string $uuid The unique identifier.
479
	 *
480
	 * @return string The combined file path.
481
	 */
482
	private function getCombinedFilePath(string $user_ident, string $uuid): string
483
	{
484
		$tokenizer = new TokenHash();
485
486
		return $this->attach_current_dir . '/post_tmp_async_combined_' . $user_ident . '_' . $uuid . '_' . $tokenizer->generate_hash(8) . '.dat';
487
	}
488
489
	/**
490
	 * Verify the existence of all chunks based on the provided file pattern and total chunk count.
491
	 *
492
	 * @param string $in The file pattern to search for chunks.
493
	 * @param int $totalChunkCount The total count of chunks.
494
	 *
495
	 * @return bool True if all chunks exist, false otherwise.
496
	 */
497
	private function verifyChunkExistence(string $in, int $totalChunkCount): bool
498
	{
499
		$iterator = new GlobIterator($in, FilesystemIterator::SKIP_DOTS | FilesystemIterator::KEY_AS_FILENAME);
500
501
		return $iterator->count() && $iterator->count() === $totalChunkCount;
502
	}
503
504
	/**
505
	 * Combine file fragments into a single file.
506
	 *
507
	 * @param string $user_ident The user identifier.
508
	 * @param string $uuid The unique identifier.
509
	 * @param string $in The input directory containing file fragments.
510
	 *
511
	 * @return bool Returns true if the file fragments were successfully combined into a single file, false otherwise.
512
	 */
513
	private function combineFileFragments(string $user_ident, string $uuid, string $in): bool
514
	{
515
		$files = iterator_to_array(new GlobIterator($in, FilesystemIterator::SKIP_DOTS | FilesystemIterator::KEY_AS_FILENAME));
516
		natsort($files);
517
		$this->combinedFilePath = $this->getCombinedFilePath($user_ident, $uuid);
518
		$success = true;
519
520
		foreach ($files as $file)
521
		{
522
			$fileInputPath = $this->attach_current_dir . '/' . $file->getFilename();
523
			$data = @file_get_contents($fileInputPath);
524
			if ($data === false)
525
			{
526
				$success = false;
527
			}
528
			else
529
			{
530
				$writeResult = @file_put_contents($this->combinedFilePath, $data, LOCK_EX | FILE_APPEND);
531
				if ($writeResult === false)
532
				{
533
					$success = false;
534
				}
535
			}
536
537
			@unlink($fileInputPath);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). 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

537
			/** @scrutinizer ignore-unhandled */ @unlink($fileInputPath);

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...
538
		}
539
540
		return $success;
541
	}
542
}
543