removeAttachments()   D
last analyzed

Complexity

Conditions 31
Paths 33

Size

Total Lines 183
Code Lines 88

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 44
CRAP Score 138.881

Importance

Changes 0
Metric Value
cc 31
eloc 88
c 0
b 0
f 0
nc 33
nop 4
dl 0
loc 183
rs 4.1666
ccs 44
cts 85
cp 0.5175
crap 138.881

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
 * This file handles the uploading and creation of attachments
5
 * as well as the auto management of the attachment directories.
6
 * Note to enhance documentation later:
7
 * attachment_type = 0 is a regular attachment
8
 * attachment_type = 1 is an avatar
9
 * attachment_type = 3 is a thumbnail
10
 *
11
 * @package   ElkArte Forum
12
 * @copyright ElkArte Forum contributors
13
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
14
 *
15
 * This file contains code covered by:
16
 * copyright: 2011 Simple Machines (http://www.simplemachines.org)
17
 *
18
 * @version 2.0 Beta 1
19
 *
20
 */
21
22
use BBC\ParserWrapper;
23
use ElkArte\Attachments\AttachmentsDirectory;
24
use ElkArte\Helper\FileFunctions;
25
use ElkArte\Helper\Util;
26
27
/**
28
 * Approve an attachment, or maybe even more - no permission check!
29
 *
30
 * @param int[] $attachment_ids
31
 *
32
 * @return int|null
33
 */
34
function approveAttachments($attachment_ids)
35
{
36
	$db = database();
37
38
	if (empty($attachment_ids))
39
	{
40
		return 0;
41
	}
42
43
	// For safety, check for thumbnails...
44
	$attachments = [];
45
	$db->fetchQuery('
46
		SELECT
47
			a.id_attach, a.id_member, COALESCE(thumb.id_attach, 0) AS id_thumb
48
		FROM {db_prefix}attachments AS a
49
			LEFT JOIN {db_prefix}attachments AS thumb ON (thumb.id_attach = a.id_thumb)
50
		WHERE a.id_attach IN ({array_int:attachments})
51
			AND a.attachment_type = {int:attachment_type}',
52
		[
53
			'attachments' => $attachment_ids,
54
			'attachment_type' => 0,
55
		]
56
	)->fetch_callback(
57
		function ($row) use (&$attachments) {
58
			// Update the thumbnail too...
59
			if (!empty($row['id_thumb']))
60
			{
61
				$attachments[] = (int) $row['id_thumb'];
62
			}
63
64
			$attachments[] = (int) $row['id_attach'];
65
		}
66
	);
67
68
	if (empty($attachments))
69
	{
70
		return 0;
71
	}
72
73
	// Approving an attachment is not hard - it's easy.
74
	$db->query('', '
75
		UPDATE {db_prefix}attachments
76
		SET 
77
			approved = {int:is_approved}
78
		WHERE id_attach IN ({array_int:attachments})',
79
		[
80
			'attachments' => $attachments,
81
			'is_approved' => 1,
82
		]
83
	);
84
85
	// To log the attachments, we really need their message and filename
86
	$db->fetchQuery('
87
		SELECT 
88
			m.id_msg, a.filename
89
		FROM {db_prefix}attachments AS a
90
			INNER JOIN {db_prefix}messages AS m ON (a.id_msg = m.id_msg)
91
		WHERE a.id_attach IN ({array_int:attachments})
92
			AND a.attachment_type = {int:attachment_type}',
93
		[
94
			'attachments' => $attachments,
95
			'attachment_type' => 0,
96
		]
97
	)->fetch_callback(
98
		function ($row) {
99
			logAction(
100
				'approve_attach',
101
				[
102
					'message' => (int) $row['id_msg'],
103
					'filename' => preg_replace('~&amp;#(\\d{1,7}|x[0-9a-fA-F]{1,6});~', '&#\\1;', Util::htmlspecialchars($row['filename'])),
104
				]
105
			);
106
		}
107
	);
108
109
	// Remove from the approval queue.
110
	$db->query('', '
111
		DELETE FROM {db_prefix}approval_queue
112
		WHERE id_attach IN ({array_int:attachments})',
113
		[
114
			'attachments' => $attachments,
115
		]
116
	);
117
118
	call_integration_hook('integrate_approve_attachments', [$attachments]);
119
}
120
121
/**
122
 * Removes attachments or avatars based on a given query condition.
123
 *
124
 * - Called by remove avatar/attachment functions.
125
 * - It removes attachments based that match the $condition.
126
 * - It allows query_types 'messages' and 'members', whichever is needed by the
127
 * $condition parameter.
128
 * - It does no permissions check.
129
 *
130
 * @param array $condition
131
 * @param string $query_type
132
 * @param bool $return_affected_messages = false
133
 * @param bool $autoThumbRemoval = true
134
 * @return int[]|bool returns affected messages if $return_affected_messages is set to true
135
 */
136
function removeAttachments($condition, $query_type = '', $return_affected_messages = false, $autoThumbRemoval = true)
137
{
138 12
	$db = database();
139
140 12
	// @todo This might need more work!
141
	$new_condition = [];
142
	$query_parameter = [
143 12
		'thumb_attachment_type' => 3,
144
	];
145 12
	$do_logging = [];
146
147 12
	if (is_array($condition))
0 ignored issues
show
introduced by
The condition is_array($condition) is always true.
Loading history...
148
	{
149 12
		foreach ($condition as $real_type => $restriction)
150
		{
151 12
			// Doing a NOT?
152
			$is_not = str_starts_with($real_type, 'not_');
153
			$type = $is_not ? substr($real_type, 4) : $real_type;
154 12
155 12
			switch ($type)
156
			{
157 6
				case 'id_member':
158
				case 'id_attach':
159 12
				case 'id_msg':
160 12
					// @todo the !empty($restriction) is a trick to override the checks on $_POST['attach_del'] in Post.controller
161 12
					// In theory it should not be necessary
162
					if (!empty($restriction))
163
					{
164
						$new_condition[] = 'a.' . $type . ($is_not ? ' NOT' : '') . ' IN (' . (is_array($restriction) ? '{array_int:' . $real_type . '}' : '{int:' . $real_type . '}') . ')';
165
					}
166
					break;
167
				case 'attachment_type':
168
					$new_condition[] = 'a.attachment_type = {int:' . $real_type . '}';
169 12
					break;
170 12
				case 'poster_time':
171 12
					$new_condition[] = 'm.poster_time < {int:' . $real_type . '}';
172 12
					break;
173
				case 'last_login':
174
					$new_condition[] = 'mem.last_login < {int:' . $real_type . '}';
175 12
					break;
176
				case 'size':
177
					$new_condition[] = 'a.size > {int:' . $real_type . '}';
178 12
					break;
179
				case 'id_topic':
180
					$new_condition[] = 'm.id_topic IN (' . (is_array($restriction) ? '{array_int:' . $real_type . '}' : '{int:' . $real_type . '}') . ')';
181 12
					break;
182 12
				case 'do_logging':
183 12
					$do_logging = $condition['id_attach'];
184
					break;
185
			}
186
187
			// Add the parameter!
188
			$query_parameter[$real_type] = $restriction;
189
		}
190 12
191
		if (empty($new_condition))
192
		{
193 12
			return false;
194
		}
195
196
		$condition = implode(' AND ', $new_condition);
197
	}
198 12
199
	// Delete it only if it exists...
200
	$msgs = [];
201
	$attach = [];
202 12
	$parents = [];
203 12
204 12
	require_once(SUBSDIR . '/Attachments.subs.php');
205
206 12
	// Get all the attachment names and id_msg's.
207
	$db->fetchQuery('
208
		SELECT
209 12
			a.id_folder, a.filename, a.file_hash, a.attachment_type, a.id_attach, a.id_member' . ($query_type === 'messages' ? ', m.id_msg' : ', a.id_msg') . ',
210
			thumb.id_folder AS thumb_folder, COALESCE(thumb.id_attach, 0) AS id_thumb, thumb.filename AS thumb_filename, thumb.file_hash AS thumb_file_hash, thumb_parent.id_attach AS id_parent
211 12
		FROM {db_prefix}attachments AS a' . ($query_type === 'members' ? '
212
			INNER JOIN {db_prefix}members AS mem ON (mem.id_member = a.id_member)' : ($query_type === 'messages' ? '
213 12
			INNER JOIN {db_prefix}messages AS m ON (m.id_msg = a.id_msg)' : '')) . '
214 12
			LEFT JOIN {db_prefix}attachments AS thumb ON (thumb.id_attach = a.id_thumb)
215 12
			LEFT JOIN {db_prefix}attachments AS thumb_parent ON (thumb.attachment_type = {int:thumb_attachment_type} AND thumb_parent.id_thumb = a.id_attach)
216
		WHERE ' . $condition,
217
		$query_parameter
218 12
	)->fetch_callback(
219 6
		function ($row) use (&$attach, &$parents, &$msgs, $return_affected_messages, $autoThumbRemoval) {
220 12
			global $modSettings;
221
222
			// Figure out the "encrypted" filename and unlink it ;).
223
			if ((int) $row['attachment_type'] === 1)
224
			{
225
				FileFunctions::instance()->delete($modSettings['custom_avatar_dir'] . '/' . $row['filename']);
226
			}
227
			else
228
			{
229
				$filename = getAttachmentFilename($row['filename'], $row['id_attach'], $row['id_folder'], false, $row['file_hash']);
230
				FileFunctions::instance()->delete($filename);
231
232
				// If this was a thumb, the parent attachment should know about it.
233
				if (!empty($row['id_parent']))
234
				{
235
					$parents[] = $row['id_parent'];
236
				}
237
238
				// If this attachment has a thumb, remove it as well, ouch baby.
239
				if (!empty($row['id_thumb']) && $autoThumbRemoval)
240
				{
241
					$thumb_filename = getAttachmentFilename($row['thumb_filename'], $row['id_thumb'], $row['thumb_folder'], false, $row['thumb_file_hash']);
242
					FileFunctions::instance()->delete($thumb_filename);
243
					$attach[] = $row['id_thumb'];
244
				}
245
			}
246
247
			// Make a list.
248
			if ($return_affected_messages && empty($row['attachment_type']))
249
			{
250
				$msgs[] = $row['id_msg'];
251
			}
252
253
			$attach[] = $row['id_attach'];
254
		}
255
	);
256
257 12
	// Removed attachments don't have to be updated anymore.
258
	$parents = array_diff($parents, $attach);
259
	if (!empty($parents))
260
	{
261 12
		$db->query('', '
262 12
			UPDATE {db_prefix}attachments
263
			SET 
264
				id_thumb = {int:no_thumb}
265
			WHERE id_attach IN ({array_int:parent_attachments})',
266
			[
267
				'parent_attachments' => $parents,
268
				'no_thumb' => 0,
269
			]
270
		);
271
	}
272
273
	if (!empty($do_logging))
274
	{
275
		// To log the attachments, we really need their message and filename
276 12
		$db->fetchQuery('
277
			SELECT 
278
				m.id_msg, a.filename
279
			FROM {db_prefix}attachments AS a
280
				INNER JOIN {db_prefix}messages AS m ON (a.id_msg = m.id_msg)
281
			WHERE a.id_attach IN ({array_int:attachments})
282
				AND a.attachment_type = {int:attachment_type}',
283
			[
284
				'attachments' => $do_logging,
285
				'attachment_type' => 0,
286
			]
287
		)->fetch_callback(
288
			function ($row) {
289
				logAction(
290
					'remove_attach',
291
					[
292
						'message' => $row['id_msg'],
293
						'filename' => preg_replace('~&amp;#(\\d{1,7}|x[0-9a-fA-F]{1,6});~', '&#\\1;', Util::htmlspecialchars($row['filename'])),
294
					]
295
				);
296
			}
297
		);
298
	}
299
300
	if (!empty($attach))
301
	{
302
		$db->query('', '
303 12
			DELETE FROM {db_prefix}attachments
304
			WHERE id_attach IN ({array_int:attachment_list})',
305
			[
306
				'attachment_list' => $attach,
307
			]
308
		);
309
	}
310
311
	call_integration_hook('integrate_remove_attachments', [$attach]);
312
313
	if ($return_affected_messages)
314 12
	{
315
		return array_unique($msgs);
316 12
	}
317
318
	return true;
319
}
320
321
/**
322 12
 * How many attachments the DB says we have in a certain folder.
323
 *
324
 * @param string $folder
325
 *
326
 * @return int
327
 */
328
function getFolderAttachmentCount($folder)
329
{
330
	$db = database();
331
332
	// Get the number of attachments....
333
	$request = $db->query('', '
334
		SELECT 
335
			COUNT(*)
336
		FROM {db_prefix}attachments
337
		WHERE id_folder = {int:folder_id}
338
			AND attachment_type != {int:attachment_type}',
339
		[
340
			'folder_id' => $folder,
341
			'attachment_type' => 1,
342
		]
343
	);
344
	list ($num_attachments) = $request->fetch_row();
345
	$request->free_result();
346
347
	return (int) $num_attachments;
348
}
349
350
/**
351
 * How many attachments of a given type we have in the DB
352
 *
353
 * @return int
354
 */
355
function getAttachmentCountByType($type = 'attachment')
356
{
357
	$db = database();
358
359
	$types = ['attachment' => 0, 'attachments' => 0, 'avatar' => 1, 'avatars' => 1, 'thumbnail' => 3, 'thumbnails' => 3];
360
	$attachmentType = $types[$type] ?? 0;
361
362
	// Guests can't have avatars
363
	$where = $attachmentType === 1 ? ' AND id_member != {int:guest_id_member}' : ' AND id_member = {int:guest_id_member}';
364
365
	// Get the number of attachments....
366
	$request = $db->query('', '
367
		SELECT 
368
			COUNT(*)
369
		FROM {db_prefix}attachments
370
		WHERE attachment_type = {int:attachment_type}' .
371
			$where,	[
372
			'attachment_type' => $attachmentType,
373
			'guest_id_member' => 0,
374
		]
375
	);
376
	list ($num_attachments) = $request->fetch_row();
377
	$request->free_result();
378
379
	return (int) $num_attachments;
380
}
381
382
/**
383
 * Function to remove the strictly needed of orphan attachments.
384
 *
385
 * - This is used from attachments maintenance.
386
 * - It assumes the files have no message, no member information.
387
 * - It only removes the attachments and thumbnails from the database.
388
 *
389
 * @param int[] $attach_ids
390
 */
391
function removeOrphanAttachments($attach_ids)
392
{
393
	$db = database();
394
395
	$db->query('', '
396
		DELETE FROM {db_prefix}attachments
397
		WHERE id_attach IN ({array_int:to_remove})',
398
		[
399
			'to_remove' => $attach_ids,
400
		]
401
	);
402
403
	$db->query('', '
404
		UPDATE {db_prefix}attachments
405
			SET 
406
				id_thumb = {int:no_thumb}
407
			WHERE id_thumb IN ({array_int:to_remove})',
408
		[
409
			'to_remove' => $attach_ids,
410
			'no_thumb' => 0,
411
		]
412
	);
413
}
414
415
/**
416
 * Set or retrieve the size of an attachment.
417
 *
418
 * @param int $attach_id
419
 * @param int|null $filesize = null
420
 *
421
 * @return bool|int
422
 */
423
function attachment_filesize($attach_id, $filesize = null)
424
{
425
	$db = database();
426
427
	if ($filesize === null)
428
	{
429
		$result = $db->query('', '
430
			SELECT 
431
				size
432
			FROM {db_prefix}attachments
433
			WHERE id_attach = {int:id_attach}',
434
			[
435
				'id_attach' => $attach_id,
436
			]
437
		);
438
		if ($result->hasResults())
439
		{
440
			list ($filesize) = $result->fetch_row();
441
			$result->free_result();
442
443
			return (int) $filesize;
444
		}
445
446
		return false;
447
	}
448
449
	$db->query('', '
450
		UPDATE {db_prefix}attachments
451
		SET 
452
			size = {int:filesize}
453
		WHERE id_attach = {int:id_attach}',
454
		[
455
			'filesize' => $filesize,
456
			'id_attach' => $attach_id,
457
		]
458
	);
459
460
	return true;
461
}
462
463
/**
464
 * Set or retrieve the ID of the folder where an attachment is stored on disk.
465
 *
466
 * @param int $attach_id
467
 * @param int|null $folder_id = null
468
 *
469
 * @return bool|int
470
 */
471
function attachment_folder($attach_id, $folder_id = null)
472
{
473
	$db = database();
474
475
	if ($folder_id === null)
476
	{
477
		$result = $db->query('', '
478
			SELECT 
479
				id_folder
480
			FROM {db_prefix}attachments
481
			WHERE id_attach = {int:id_attach}',
482
			[
483
				'id_attach' => $attach_id,
484
			]
485
		);
486
		if ($result->hasResults())
487
		{
488
			list ($folder_id) = $result->fetch_row();
489
			$result->free_result();
490
491
			return (int) $folder_id;
492
		}
493
494
		return false;
495
	}
496
497
	$db->query('', '
498
		UPDATE {db_prefix}attachments
499
		SET 
500
			id_folder = {int:new_folder}
501
		WHERE id_attach = {int:id_attach}',
502
		[
503
			'new_folder' => $folder_id,
504
			'id_attach' => $attach_id,
505
		]
506
	);
507
508
	return true;
509
}
510
511
/**
512
 * Get the last attachment ID without a thumbnail.
513
 */
514
function maxNoThumb()
515
{
516
	$db = database();
517
518
	$result = $db->query('', '
519
		SELECT 
520
			MAX(id_attach)
521
		FROM {db_prefix}attachments
522
		WHERE id_thumb != {int:no_thumb}',
523
		[
524
			'no_thumb' => 0,
525
		]
526
	);
527
	list ($thumbnails) = $result->fetch_row();
528
	$result->free_result();
529
530
	return (int) $thumbnails;
531
}
532
533
/**
534
 * Finds orphan thumbnails in the database
535
 *
536
 * - Checks in groups of 500
537
 * - Called by attachment maintenance
538
 * - If $fix_errors is set to true, it will attempt to remove the thumbnail from disk
539
 *
540
 * @param int $start
541
 * @param bool $fix_errors
542
 * @param string[] $to_fix
543
 *
544
 * @return array
545
 */
546
function findOrphanThumbnails($start, $fix_errors, $to_fix)
547
{
548
	$db = database();
549
550
	require_once(SUBSDIR . '/Attachments.subs.php');
551
552
	$result = $db->query('', '
553
		SELECT 
554
			thumb.id_attach, thumb.id_folder, thumb.filename, thumb.file_hash
555
		FROM {db_prefix}attachments AS thumb
556
			LEFT JOIN {db_prefix}attachments AS tparent ON (tparent.id_thumb = thumb.id_attach)
557
		WHERE thumb.id_attach BETWEEN {int:substep} AND {int:substep} + 499
558
			AND thumb.attachment_type = {int:thumbnail}
559
			AND tparent.id_attach IS NULL',
560
		[
561
			'thumbnail' => 3,
562
			'substep' => $start,
563
		]
564
	);
565
	$to_remove = [];
566
	if ($result->num_rows() !== 0)
567
	{
568
		$to_fix[] = 'missing_thumbnail_parent';
569
		while (($row = $result->fetch_assoc()))
570
		{
571
			// Only do anything once... just in case
572
			if (!isset($to_remove[$row['id_attach']]))
573
			{
574
				$to_remove[$row['id_attach']] = $row['id_attach'];
575
576
				// If we are repairing, remove the file from the disk now.
577
				if ($fix_errors && in_array('missing_thumbnail_parent', $to_fix))
578
				{
579
					$filename = getAttachmentFilename($row['filename'], $row['id_attach'], $row['id_folder'], false, $row['file_hash']);
580
					FileFunctions::instance()->delete($filename);
581
				}
582
			}
583
		}
584
	}
585
	$result->free_result();
586
587
	// Do we need to delete what we have?
588
	if ($fix_errors && !empty($to_remove) && in_array('missing_thumbnail_parent', $to_fix))
589
	{
590
		$db->query('', '
591
			DELETE FROM {db_prefix}attachments
592
			WHERE id_attach IN ({array_int:to_remove})
593
				AND attachment_type = {int:attachment_type}',
594
			[
595
				'to_remove' => $to_remove,
596
				'attachment_type' => 3,
597
			]
598
		);
599
	}
600
601
	return $to_remove;
602
}
603
604
/**
605
 * Finds parents who think they do have thumbnails, but don't
606
 *
607
 * - Checks in groups of 500
608
 * - Called by attachment maintenance
609
 * - If $fix_errors is set to true, it will attempt to remove the thumbnail from disk
610
 *
611
 * @param int $start
612
 * @param bool $fix_errors
613
 * @param string[] $to_fix
614
 *
615
 * @return array
616
 */
617
function findParentsOrphanThumbnails($start, $fix_errors, $to_fix)
618
{
619
	$db = database();
620
621
	$to_update = $db->fetchQuery('
622
		SELECT 
623
			a.id_attach
624
		FROM {db_prefix}attachments AS a
625
			LEFT JOIN {db_prefix}attachments AS thumb ON (thumb.id_attach = a.id_thumb)
626
		WHERE a.id_attach BETWEEN {int:substep} AND {int:substep} + 499
627
			AND a.id_thumb != {int:no_thumb}
628
			AND thumb.id_attach IS NULL',
629
		[
630
			'no_thumb' => 0,
631
			'substep' => $start,
632
		]
633
	)->fetch_callback(
634
		function ($row) {
635
			return $row['id_attach'];
636
		}
637
	);
638
639
	// Do we need to delete what we have?
640
	if ($fix_errors && !empty($to_update) && in_array('parent_missing_thumbnail', $to_fix))
641
	{
642
		$db->query('', '
643
			UPDATE {db_prefix}attachments
644
			SET 
645
				id_thumb = {int:no_thumb}
646
			WHERE id_attach IN ({array_int:to_update})',
647
			[
648
				'to_update' => $to_update,
649
				'no_thumb' => 0,
650
			]
651
		);
652
	}
653
654
	return $to_update;
655
}
656
657
/**
658
 * Goes thought all the attachments and checks that they exist
659
 *
660
 * - Goes in increments of 250
661
 * - If $fix_errors is true will remove empty files, update wrong file sizes in the DB, and
662
 * remove DB entries if the file cannot be found.
663
 *
664
 * @param int $start
665
 * @param bool $fix_errors
666
 * @param string[] $to_fix
667
 *
668
 * @return array
669
 */
670
function repairAttachmentData($start, $fix_errors, $to_fix)
671
{
672
	global $modSettings;
673
674
	$db = database();
675
	$attachmentDirectory = new AttachmentsDirectory($modSettings, $db);
676
677
	require_once(SUBSDIR . '/Attachments.subs.php');
678
679
	$repair_errors = [
680
		'wrong_folder' => 0,
681
		'missing_extension' => 0,
682
		'file_missing_on_disk' => 0,
683
		'file_size_of_zero' => 0,
684
		'file_wrong_size' => 0
685
	];
686
687
	$request = $db->query('', '
688
		SELECT 
689
			id_attach, id_folder, filename, file_hash, size, attachment_type
690
		FROM {db_prefix}attachments
691
		WHERE id_attach BETWEEN {int:substep} AND {int:substep} + 249',
692
		[
693
			'substep' => $start,
694
		]
695
	);
696
	$to_remove = [];
697
	while (($row = $request->fetch_assoc()))
698
	{
699
		// Get the filename.
700
		if ((int) $row['attachment_type'] === 1)
701
		{
702
			$filename = $modSettings['custom_avatar_dir'] . '/' . $row['filename'];
703
		}
704
		else
705
		{
706
			$filename = getAttachmentFilename($row['filename'], $row['id_attach'], $row['id_folder'], false, $row['file_hash']);
707
		}
708
709
		// File doesn't exist?
710
		if (!file_exists($filename))
711
		{
712
			// If we're lucky, it might just be in a different folder.
713
			if ($attachmentDirectory->hasMultiPaths())
714
			{
715
				// Get the attachment name without the folder.
716
				$attachment_name = !empty($row['file_hash']) ? $row['id_attach'] . '_' . $row['file_hash'] . '.elk' : getLegacyAttachmentFilename($row['filename'], $row['id_attach'], null, true);
717
718
				// Loop through the other folders looking for this file
719
				$attachmentDirs = $attachmentDirectory->getPaths();
720
				foreach ($attachmentDirs as $id => $dir)
721
				{
722
					if (file_exists($dir . '/' . $attachment_name))
0 ignored issues
show
Bug introduced by
Are you sure $attachment_name of type null|string|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 ignore-type  annotation

722
					if (file_exists($dir . '/' . /** @scrutinizer ignore-type */ $attachment_name))
Loading history...
723
					{
724
						$repair_errors['wrong_folder']++;
725
726
						// Are we going to fix this now?
727
						if ($fix_errors && in_array('wrong_folder', $to_fix))
728
						{
729
							attachment_folder($row['id_attach'], $id);
730
						}
731
732
						// Found it, on to the next attachment
733
						continue 2;
734
					}
735
				}
736
737
				if (!empty($row['file_hash']))
738
				{
739
					// It may be without the elk extension (something wrong during upgrade/conversion)
740
					$attachment_name = $row['id_attach'] . '_' . $row['file_hash'];
741
742
					// Loop through the other folders looking for this file
743
					foreach ($attachmentDirectory->getPaths() as $id => $dir)
744
					{
745
						if (file_exists($dir . '/' . $attachment_name))
746
						{
747
							$repair_errors['missing_extension']++;
748
749
							// Are we going to fix this now?
750
							if ($fix_errors && in_array('missing_extension', $to_fix))
751
							{
752
								rename($dir . '/' . $attachment_name, $dir . '/' . $attachment_name . '.elk');
753
								attachment_folder($row['id_attach'], $id);
754
							}
755
756
							// Found it, on to the next attachment
757
							continue 2;
758
						}
759
					}
760
				}
761
			}
762
763
			// Could not find it anywhere
764
			$to_remove[] = $row['id_attach'];
765
			$repair_errors['file_missing_on_disk']++;
766
		}
767
		// An empty file on the disk?
768
		elseif (FileFunctions::instance()->fileSize($filename) === 0)
769
		{
770
			$repair_errors['file_size_of_zero']++;
771
772
			// Fixing?
773
			if ($fix_errors && in_array('file_size_of_zero', $to_fix))
774
			{
775
				$to_remove[] = $row['id_attach'];
776
				FileFunctions::instance()->delete($filename);
777
			}
778
		}
779
		// Size listed and actual size are different?
780
		elseif (FileFunctions::instance()->fileSize($filename) !== (int) $row['size'])
781
		{
782
			$repair_errors['file_wrong_size']++;
783
784
			// Fix it here?
785
			if ($fix_errors && in_array('file_wrong_size', $to_fix))
786
			{
787
				attachment_filesize($row['id_attach'], FileFunctions::instance()->fileSize($filename));
0 ignored issues
show
Bug introduced by
It seems like ElkArte\Helper\FileFunct...()->fileSize($filename) can also be of type false; however, parameter $filesize of attachment_filesize() does only seem to accept integer|null, maybe add an additional type check? ( Ignorable by Annotation )

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

787
				attachment_filesize($row['id_attach'], /** @scrutinizer ignore-type */ FileFunctions::instance()->fileSize($filename));
Loading history...
788
			}
789
		}
790
	}
791
	$request->free_result();
792
793
	// Do we need to delete what we have?
794
	if ($fix_errors && !empty($to_remove) && in_array('file_missing_on_disk', $to_fix))
795
	{
796
		removeOrphanAttachments($to_remove);
797
	}
798
799
	return $repair_errors;
800
}
801
802
/**
803
 * Function used to find orphan avatars and optionally fix them.
804
 *
805
 * What it does:
806
 *
807
 * - Retrieves the attachments that are avatar types and do not belong to any member.
808
 * - If $fix_errors is set to true and 'avatar_no_member' is in the $to_fix array, it deletes
809
 *   the file from disk and from the DB and returns the id_attachment of the deleted files.
810
 *
811
 * @param int $start The starting point for retrieving attachments.
812
 * @param bool $fix_errors Whether to fix errors or not.
813
 * @param array $to_fix The list of errors to fix.
814
 *
815
 * @return array The id_attachments of the deleted files (if $fix_errors is true).
816
 */
817
function findOrphanAvatars($start, $fix_errors, $to_fix)
818
{
819
	global $modSettings;
820
821
	$db = database();
822
823
	require_once(SUBSDIR . '/Attachments.subs.php');
824
825
	$to_remove = $db->fetchQuery('
826
		SELECT 
827
			a.id_attach, a.id_folder, a.filename, a.file_hash, a.attachment_type
828
		FROM {db_prefix}attachments AS a
829
			LEFT JOIN {db_prefix}members AS mem ON (mem.id_member = a.id_member)
830
		WHERE a.id_attach BETWEEN {int:substep} AND {int:substep} + 499
831
			AND a.id_member != {int:no_member}
832
			AND a.id_msg = {int:no_msg}
833
			AND mem.id_member IS NULL',
834
		[
835
			'no_member' => 0,
836
			'no_msg' => 0,
837
			'substep' => $start,
838
		]
839
	)->fetch_callback(
840
		function ($row) use ($fix_errors, $to_fix, $modSettings) {
841
			// If we are repairing, remove the file from the disk now.
842
			if ($fix_errors && in_array('avatar_no_member', $to_fix))
843
			{
844
				if ((int) $row['attachment_type'] === 1)
845
				{
846
					$filename = $modSettings['custom_avatar_dir'] . '/' . $row['filename'];
847
				}
848
				else
849
				{
850
					$filename = getAttachmentFilename($row['filename'], $row['id_attach'], $row['id_folder'], false, $row['file_hash']);
851
				}
852
853
				FileFunctions::instance()->delete($filename);
854
			}
855
856
			return $row['id_attach'];
857
		}
858
	);
859
860
	// Do we need to delete what we have?
861
	if ($fix_errors && !empty($to_remove) && in_array('avatar_no_member', $to_fix, true))
862
	{
863
		$db->query('', '
864
			DELETE FROM {db_prefix}attachments
865
			WHERE id_attach IN ({array_int:to_remove})
866
				AND id_member != {int:no_member}
867
				AND id_msg = {int:no_msg}',
868
			[
869
				'to_remove' => $to_remove,
870
				'no_member' => 0,
871
				'no_msg' => 0,
872
			]
873
		);
874
	}
875
876
	return $to_remove;
877
}
878
879
/**
880
 * Finds attachments that are not used in any message
881
 *
882
 * @param int $start
883
 * @param bool $fix_errors
884
 * @param string[] $to_fix
885
 *
886
 * @return array
887
 */
888
function findOrphanAttachments($start, $fix_errors, $to_fix)
889
{
890
	$db = database();
891
892
	require_once(SUBSDIR . '/Attachments.subs.php');
893
894
	$to_remove = $db->fetchQuery('
895
		SELECT 
896
			a.id_attach, a.id_folder, a.filename, a.file_hash
897
		FROM {db_prefix}attachments AS a
898
			LEFT JOIN {db_prefix}messages AS m ON (m.id_msg = a.id_msg)
899
		WHERE a.id_attach BETWEEN {int:substep} AND {int:substep} + 499
900
			AND a.id_member = {int:no_member}
901
			AND a.id_msg != {int:no_msg}
902
			AND m.id_msg IS NULL',
903
		[
904
			'no_member' => 0,
905
			'no_msg' => 0,
906
			'substep' => $start,
907
		]
908
	)->fetch_callback(
909
		function ($row) use ($fix_errors, $to_fix) {
910
			// If we are repairing, remove the file from the disk now.
911
			if ($fix_errors && in_array('attachment_no_msg', $to_fix))
912
			{
913
				$filename = getAttachmentFilename($row['filename'], $row['id_attach'], $row['id_folder'], false, $row['file_hash']);
914
				FileFunctions::instance()->delete($filename);
915
			}
916
917
			return $row['id_attach'];
918
		}
919
	);
920
921
	// Do we need to delete what we have?
922
	if ($fix_errors && !empty($to_remove) && in_array('attachment_no_msg', $to_fix))
923
	{
924
		$db->query('', '
925
			DELETE FROM {db_prefix}attachments
926
			WHERE id_attach IN ({array_int:to_remove})
927
				AND id_member = {int:no_member}
928
				AND id_msg != {int:no_msg}',
929
			[
930
				'to_remove' => $to_remove,
931
				'no_member' => 0,
932
				'no_msg' => 0,
933
			]
934
		);
935
	}
936
937
	return $to_remove;
938
}
939
940
/**
941
 * Get the max attachment ID which is a thumbnail.
942
 */
943
function getMaxThumbnail()
944
{
945
	$db = database();
946
947
	$result = $db->query('', '
948
		SELECT 
949
			MAX(id_attach)
950
		FROM {db_prefix}attachments
951
		WHERE attachment_type = {int:thumbnail}',
952
		[
953
			'thumbnail' => 3,
954
		]
955
	);
956
	list ($thumbnail) = $result->fetch_row();
957
	$result->free_result();
958
959
	return $thumbnail;
960
}
961
962
/**
963
 * Get the max attachment ID.
964
 */
965
function maxAttachment()
966
{
967
	$db = database();
968
969
	$result = $db->query('', '
970
		SELECT 
971
			MAX(id_attach)
972
		FROM {db_prefix}attachments',
973
		[]
974
	);
975
	list ($attachment) = $result->fetch_row();
976
	$result->free_result();
977
978
	return $attachment;
979
}
980
981
/**
982
 * Check multiple attachments IDs against the database.
983
 *
984
 * @param int[] $attachments
985
 * @param string $approve_query
986
 *
987
 * @return array
988
 */
989
function validateAttachments($attachments, $approve_query)
990
{
991
	$db = database();
992
993
	// double-check the attachments array, pick only what is returned from the database
994
	return $db->fetchQuery('
995
		SELECT 
996
			a.id_attach, m.id_board, m.id_msg, m.id_topic
997
		FROM {db_prefix}attachments AS a
998
			INNER JOIN {db_prefix}messages AS m ON (m.id_msg = a.id_msg)
999
			LEFT JOIN {db_prefix}boards AS b ON (m.id_board = b.id_board)
1000
		WHERE a.id_attach IN ({array_int:attachments})
1001
			AND a.approved = {int:not_approved}
1002
			AND a.attachment_type = {int:attachment_type}
1003
			AND {query_see_board}
1004
			' . $approve_query,
1005
		[
1006
			'attachments' => $attachments,
1007
			'not_approved' => 0,
1008
			'attachment_type' => 0,
1009
		]
1010
	)->fetch_callback(
1011
		function ($row) {
1012
			return $row['id_attach'];
1013
		}
1014
	);
1015
}
1016
1017
/**
1018
 * Finds an attachments parent topic/message and returns the values in an array
1019
 *
1020
 * @param int $attachment
1021
 *
1022
 * @return array
1023
 */
1024
function attachmentBelongsTo($attachment)
1025
{
1026
	$db = database();
1027
1028
	$attachment = $db->fetchQuery('
1029
		SELECT 
1030
			a.id_attach, m.id_board, m.id_msg, m.id_topic
1031
		FROM {db_prefix}attachments AS a
1032
			INNER JOIN {db_prefix}messages AS m ON (m.id_msg = a.id_msg)
1033
			LEFT JOIN {db_prefix}boards AS b ON (m.id_board = b.id_board)
1034
		WHERE a.id_attach = ({int:attachment})
1035
			AND a.attachment_type = {int:attachment_type}
1036
			AND {query_see_board}
1037
		LIMIT 1',
1038
		[
1039
			'attachment' => $attachment,
1040
			'attachment_type' => 0,
1041
		]
1042
	)->fetch_all();
1043
1044
	return $attachment[0] ?? $attachment;
1045
}
1046
1047
/**
1048
 * Checks an attachments id
1049
 *
1050
 * @param int $id_attach
1051
 * @return bool
1052
 */
1053
function validateAttachID($id_attach)
1054
{
1055
	$db = database();
1056
1057
	$request = $db->query('', '
1058
		SELECT 
1059
			id_attach
1060
		FROM {db_prefix}attachments
1061
		WHERE id_attach = {int:attachment_id}
1062
		LIMIT 1',
1063
		[
1064
			'attachment_id' => $id_attach,
1065
		]
1066
	);
1067
	$count = $request->num_rows();
1068
	$request->free_result();
1069
1070
	return $count != 0;
1071
}
1072
1073
/**
1074
 * Callback function for action_unapproved_attachments
1075
 *
1076
 * - Retrieve all the attachments waiting for approval the user can approve
1077
 *
1078
 * @param int $start The item to start with (for pagination purposes)
1079
 * @param int $items_per_page The number of items to show per page
1080
 * @param string $sort A string indicating how to sort the results
1081
 * @param string $approve_query additional restrictions based on the boards the approver can see
1082
 * @return array an array of unapproved attachments
1083
 */
1084
function list_getUnapprovedAttachments($start, $items_per_page, $sort, $approve_query)
1085
{
1086
	global $scripturl;
1087
1088
	$db = database();
1089
1090
	$bbc_parser = ParserWrapper::instance();
1091
1092
	// Get all unapproved attachments.
1093
	return $db->fetchQuery('
1094
		SELECT 
1095
			a.id_attach, a.filename, a.size, m.id_msg, m.id_topic, m.id_board, m.subject, m.body, m.id_member,
1096
			COALESCE(mem.real_name, m.poster_name) AS poster_name, m.poster_time,
1097
			t.id_member_started, t.id_first_msg, b.name AS board_name, c.id_cat, c.name AS cat_name
1098
		FROM {db_prefix}attachments AS a
1099
			INNER JOIN {db_prefix}messages AS m ON (m.id_msg = a.id_msg)
1100
			INNER JOIN {db_prefix}topics AS t ON (t.id_topic = m.id_topic)
1101
			INNER JOIN {db_prefix}boards AS b ON (b.id_board = m.id_board)
1102
			LEFT JOIN {db_prefix}members AS mem ON (mem.id_member = m.id_member)
1103
			LEFT JOIN {db_prefix}categories AS c ON (c.id_cat = b.id_cat)
1104
		WHERE a.approved = {int:not_approved}
1105
			AND a.attachment_type = {int:attachment_type}
1106
			AND {query_see_board}
1107
			{raw:approve_query}
1108
		ORDER BY {raw:sort}
1109
		LIMIT {int:start}, {int:items_per_page}',
1110
		[
1111
			'not_approved' => 0,
1112
			'attachment_type' => 0,
1113
			'start' => $start,
1114
			'sort' => $sort,
1115
			'items_per_page' => $items_per_page,
1116
			'approve_query' => $approve_query,
1117
		]
1118
	)->fetch_callback(
1119
		function ($row) use ($scripturl, $bbc_parser) {
1120
			return [
1121
				'id' => $row['id_attach'],
1122
				'filename' => $row['filename'],
1123
				'size' => round($row['size'] / 1024, 2),
1124
				'time' => standardTime($row['poster_time']),
1125
				'html_time' => htmlTime($row['poster_time']),
1126
				'timestamp' => forum_time(true, $row['poster_time']),
1127
				'poster' => [
1128
					'id' => $row['id_member'],
1129
					'name' => $row['poster_name'],
1130
					'link' => $row['id_member'] ? '<a href="' . $scripturl . '?action=profile;u=' . $row['id_member'] . '">' . $row['poster_name'] . '</a>' : $row['poster_name'],
1131
					'href' => $scripturl . '?action=profile;u=' . $row['id_member'],
1132
				],
1133
				'message' => [
1134
					'id' => $row['id_msg'],
1135
					'subject' => $row['subject'],
1136
					'body' => $bbc_parser->parseMessage($row['body'], false),
1137
					'time' => standardTime($row['poster_time']),
1138
					'html_time' => htmlTime($row['poster_time']),
1139
					'timestamp' => forum_time(true, $row['poster_time']),
1140
					'href' => $scripturl . '?topic=' . $row['id_topic'] . '.msg' . $row['id_msg'] . '#msg' . $row['id_msg'],
1141
				],
1142
				'topic' => [
1143
					'id' => $row['id_topic'],
1144
				],
1145
				'board' => [
1146
					'id' => $row['id_board'],
1147
					'name' => $row['board_name'],
1148
				],
1149
				'category' => [
1150
					'id' => $row['id_cat'],
1151
					'name' => $row['cat_name'],
1152
				],
1153
			];
1154
		}
1155
	);
1156
}
1157
1158
/**
1159
 * Callback function for action_unapproved_attachments
1160
 *
1161
 * - Count all the attachments waiting for approval that this user can approve
1162
 *
1163
 * @param string $approve_query additional restrictions based on the boards the user can see
1164
 * @return int the number of unapproved attachments
1165
 */
1166
function list_getNumUnapprovedAttachments($approve_query)
1167
{
1168
	$db = database();
1169
1170
	// How many unapproved attachments in total?
1171
	$request = $db->query('', '
1172
		SELECT 
1173
			COUNT(*)
1174
		FROM {db_prefix}attachments AS a
1175
			INNER JOIN {db_prefix}messages AS m ON (m.id_msg = a.id_msg)
1176
			INNER JOIN {db_prefix}boards AS b ON (b.id_board = m.id_board)
1177
		WHERE a.approved = {int:not_approved}
1178
			AND a.attachment_type = {int:attachment_type}
1179
			AND {query_see_board}
1180
			' . $approve_query,
1181
		[
1182
			'not_approved' => 0,
1183
			'attachment_type' => 0,
1184
		]
1185
	);
1186
	list ($total_unapproved_attachments) = $request->fetch_row();
1187
	$request->free_result();
1188
1189
	return $total_unapproved_attachments;
1190
}
1191
1192
/**
1193
 * Prepare the actual attachment directories to be displayed in the list.
1194
 *
1195
 * - Callback function for createList().
1196
 *
1197
 */
1198
function list_getAttachDirs()
1199
{
1200
	global $modSettings, $context, $txt, $scripturl;
1201
1202
	$db = database();
1203
1204
	$attachmentsDir = new AttachmentsDirectory($modSettings, $db);
1205
	$expected_files = [];
1206
	$expected_size = [];
1207
1208
	$db->fetchQuery('
1209
		SELECT 
1210
			id_folder, COUNT(id_attach) AS num_attach, SUM(size) AS size_attach
1211
		FROM {db_prefix}attachments
1212
		WHERE attachment_type != {int:type}
1213
		GROUP BY id_folder',
1214
		[
1215
			'type' => 1,
1216
		]
1217
	)->fetch_callback(
1218
		function ($row) use (&$expected_files, &$expected_size) {
1219
			$expected_files[$row['id_folder']] = $row['num_attach'];
1220
			$expected_size[$row['id_folder']] = $row['size_attach'];
1221
		}
1222
	);
1223
	$attachdirs = [];
1224
	foreach ($attachmentsDir->getPaths() as $id => $dir)
1225
	{
1226
		// If there aren't any attachments in this directory, this won't exist.
1227
		if (!isset($expected_files[$id]))
1228
		{
1229
			$expected_files[$id] = 0;
1230
		}
1231
1232
		// Check if the directory is doing okay.
1233
		list ($status, $error, $files) = attachDirStatus($dir, $expected_files[$id]);
1234
1235
		// If it is one, let's show that it's a base directory.
1236
		$sub_dirs = 0;
1237
		$is_base_dir = false;
1238
		if ($attachmentsDir->hasBaseDir())
1239
		{
1240
			$is_base_dir = $attachmentsDir->isBaseDir($dir);
1241
1242
			// Count any subfolders.
1243
			$sub_dirs = $attachmentsDir->countSubdirs($dir);
1244
			$expected_files[$id] += $sub_dirs;
1245
		}
1246
1247
		$attachdirs[] = [
1248
			'id' => $id,
1249
			'current' => $attachmentsDir->isCurrentDirectoryId($id),
1250
			'disable_current' => $attachmentsDir->autoManageEnabled(AttachmentsDirectory::AUTO_SEQUENCE),
1251
			'disable_base_dir' => $is_base_dir && $sub_dirs > 0 && !empty($files) && empty($error),
1252
			'path' => $dir,
1253
			'current_size' => !empty($expected_size[$id]) ? byte_format($expected_size[$id]) : 0,
1254
			'num_files' => comma_format($expected_files[$id] - $sub_dirs, 0) . ($sub_dirs > 0 ? ' (' . $sub_dirs . ')' : ''),
1255
			'status' => ($is_base_dir ? $txt['attach_dir_basedir'] . '<br />' : '') . ($error ? '<div class="error">' : '') . str_replace('{repair_url}', $scripturl . '?action=admin;area=manageattachments;sa=repair;' . $context['session_var'] . '=' . $context['session_id'], $txt['attach_dir_' . $status]) . ($error ? '</div>' : ''),
1256
		];
1257
	}
1258
1259
	// Just stick a new directory on at the bottom.
1260
	if (isset($_REQUEST['new_path']))
1261
	{
1262
		$attachdirs[] = [
1263
			'id' => max(array_merge(array_keys($expected_files), array_keys($attachmentsDir->getPaths()))) + 1,
1264
			'current' => false,
1265
			'path' => '',
1266
			'current_size' => '',
1267
			'num_files' => '',
1268
			'status' => '',
1269
		];
1270
	}
1271
1272
	return $attachdirs;
1273
}
1274
1275
/**
1276
 * Checks the status of an attachment directory and returns an array
1277
 * of the status key, if that status key signifies an error, and the file count.
1278
 *
1279
 * @param string $dir
1280
 * @param int $expected_files
1281
 *
1282
 * @return array
1283
 *
1284
 */
1285
function attachDirStatus($dir, $expected_files)
1286
{
1287
	$expected_files = (int) $expected_files;
1288
	if (!FileFunctions::instance()->isDir($dir))
1289
	{
1290
		return ['does_not_exist', true, ''];
1291
	}
1292
1293
	if (!FileFunctions::instance()->isWritable($dir))
1294
	{
1295
		return ['not_writable', true, ''];
1296
	}
1297
1298
	// Count the files with a glob, easier and less time-consuming
1299
	$glob = new GlobIterator($dir . '/*.elk', FilesystemIterator::SKIP_DOTS);
1300
	try
1301
	{
1302
		$num_files = $glob->count();
1303
	}
1304
	catch (LogicException $e)
1305
	{
1306
		$num_files = count(iterator_to_array($glob));
1307
	}
1308
1309
	if ($num_files < $expected_files)
1310
	{
1311
		return ['files_missing', true, $num_files];
1312
	}
1313
1314
	// Empty?
1315
	if ($expected_files === 0)
1316
	{
1317
		return ['unused', false, $num_files];
1318
	}
1319
1320
	// All good!
1321
	return ['ok', false, $num_files];
1322
}
1323
1324
/**
1325
 * Prepare the base directories to be displayed in a list.
1326
 *
1327
 * - Callback function for createList().
1328
 *
1329
 */
1330
function list_getBaseDirs()
1331
{
1332
	global $modSettings, $txt;
1333
1334
	$attachmentsDir = new AttachmentsDirectory($modSettings, database());
1335
1336
	if ($attachmentsDir->hasBaseDir() === false)
1337
	{
1338
		return false;
1339
	}
1340
1341
	// Get a list of the base directories.
1342
	$basedirs = [];
1343
	foreach ($attachmentsDir->getBaseDirs() as $id => $dir)
1344
	{
1345
		$expected_dirs = $attachmentsDir->countSubdirs($dir);
1346
1347
		$status = 'ok';
1348
		if (!FileFunctions::instance()->isDir($dir))
1349
		{
1350
			$status = 'does_not_exist';
1351
		}
1352
		elseif (!FileFunctions::instance()->isWritable($dir))
1353
		{
1354
			$status = 'not_writable';
1355
		}
1356
1357
		$basedirs[] = [
1358
			'id' => $id,
1359
			'current' => $attachmentsDir->isCurrentBaseDir($dir),
1360
			'path' => $expected_dirs > 0 ? $dir : ('<input type="text" name="base_dir[' . $id . ']" value="' . $dir . '" size="40" class="input_text" />'),
1361
			'num_dirs' => $expected_dirs,
1362
			'status' => $status === 'ok' ? $txt['attach_dir_ok'] : ('<span class="error">' . $txt['attach_dir_' . $status] . '</span>'),
1363
		];
1364
	}
1365
1366
	if (isset($_REQUEST['new_base_path']))
1367
	{
1368
		$basedirs[] = [
1369
			'id' => '',
1370
			'current' => false,
1371
			'path' => '<input type="text" name="new_base_dir" value="" size="40" class="input_text" />',
1372
			'num_dirs' => '',
1373
			'status' => '',
1374
		];
1375
	}
1376
1377
	return $basedirs;
1378
}
1379
1380
/**
1381
 * Return the number of files of the specified type recorded in the database.
1382
 *
1383
 * - (the specified type being attachments or avatars).
1384
 * - Callback function for createList()
1385
 *
1386
 * @param string $browse_type can be one of 'avatars' or not. (in which case they're attachments)
1387
 *
1388
 * @return int
1389
 */
1390
function list_getNumFiles($browse_type)
1391
{
1392
	$db = database();
1393
1394
	// Depending on the type of file, different queries are used.
1395
	if ($browse_type === 'avatars')
1396
	{
1397
		$request = $db->query('', '
1398
			SELECT 
1399
				COUNT(*)
1400
			FROM {db_prefix}attachments
1401
			WHERE id_member != {int:guest_id_member}',
1402
			[
1403
				'guest_id_member' => 0,
1404
			]
1405
		);
1406
	}
1407
	else
1408
	{
1409
		$request = $db->query('', '
1410
			SELECT 
1411
				COUNT(*) AS num_attach
1412
			FROM {db_prefix}attachments AS a
1413
				INNER JOIN {db_prefix}messages AS m ON (m.id_msg = a.id_msg)
1414
				INNER JOIN {db_prefix}topics AS t ON (t.id_topic = m.id_topic)
1415
				INNER JOIN {db_prefix}messages AS mf ON (mf.id_msg = t.id_first_msg)
1416
			WHERE a.attachment_type = {int:attachment_type}
1417
				AND a.id_member = {int:guest_id_member}',
1418
			[
1419
				'attachment_type' => $browse_type === 'thumbs' ? '3' : '0',
1420
				'guest_id_member' => 0,
1421
			]
1422
		);
1423
	}
1424
1425
	list ($num_files) = $request->fetch_row();
1426
	$request->free_result();
1427
1428
	return (int) $num_files;
1429
}
1430
1431
/**
1432
 * Returns the list of attachments files (avatars or not), recorded
1433
 * in the database, per the parameters received.
1434
 *
1435
 * - Callback function for createList()
1436
 *
1437
 * @param int $start The item to start with (for pagination purposes)
1438
 * @param int $items_per_page The number of items to show per page
1439
 * @param string $sort A string indicating how to sort the results
1440
 * @param string $browse_type can be on eof 'avatars' or ... not. :P
1441
 *
1442
 * @return array
1443
 */
1444
function list_getFiles($start, $items_per_page, $sort, $browse_type)
1445
{
1446
	global $txt;
1447
1448
	$db = database();
1449
1450
	// Choose a query depending on what we are viewing.
1451
	if ($browse_type === 'avatars')
1452
	{
1453
		return $db->fetchQuery('
1454
			SELECT
1455
				{string:blank_text} AS id_msg, COALESCE(mem.real_name, {string:not_applicable_text}) AS poster_name,
1456
				mem.last_login AS poster_time, 0 AS id_topic, a.id_member, a.id_attach, a.filename, a.file_hash, a.attachment_type,
1457
				a.size, a.width, a.height, a.downloads, {string:blank_text} AS subject, 0 AS id_board
1458
			FROM {db_prefix}attachments AS a
1459
				LEFT JOIN {db_prefix}members AS mem ON (mem.id_member = a.id_member)
1460
			WHERE a.id_member != {int:guest_id}
1461
			ORDER BY {raw:sort}
1462
			LIMIT {int:per_page} OFFSET {int:start} ',
1463
			[
1464
				'guest_id' => 0,
1465
				'blank_text' => '',
1466
				'not_applicable_text' => $txt['not_applicable'],
1467
				'sort' => $sort,
1468
				'start' => $start,
1469
				'per_page' => $items_per_page,
1470
			]
1471
		)->fetch_all();
1472
	}
1473
1474
	return $db->fetchQuery('
1475
		SELECT
1476
			m.id_msg, COALESCE(mem.real_name, m.poster_name) AS poster_name, m.poster_time, m.id_topic, m.id_member,
1477
			a.id_attach, a.filename, a.file_hash, a.attachment_type, a.size, a.width, a.height, a.downloads, mf.subject, t.id_board
1478
		FROM {db_prefix}attachments AS a
1479
			INNER JOIN {db_prefix}messages AS m ON (m.id_msg = a.id_msg)
1480
			INNER JOIN {db_prefix}topics AS t ON (t.id_topic = m.id_topic)
1481
			INNER JOIN {db_prefix}messages AS mf ON (mf.id_msg = t.id_first_msg)
1482
			LEFT JOIN {db_prefix}members AS mem ON (mem.id_member = m.id_member)
1483
		WHERE a.attachment_type = {int:attachment_type}
1484
		ORDER BY {raw:sort}
1485
		LIMIT {int:per_page} OFFSET {int:start} ',
1486
		[
1487
			'attachment_type' => $browse_type === 'thumbs' ? '3' : '0',
1488
			'sort' => $sort,
1489
			'start' => $start,
1490
			'per_page' => $items_per_page,
1491
		]
1492
	)->fetch_all();
1493
}
1494
1495
/**
1496
 * Calculates the overall size of all attachments, excluding avatars and thumbnails
1497
 *
1498
 * What it does:
1499
 * - Retrieves the size of all attachments in the database.
1500
 * - Excludes avatars from the calculation.
1501
 *
1502
 * @return string The overall size of all attachments in a human-readable format.
1503
 */
1504
function overallAttachmentsSize()
1505
{
1506
	$db = database();
1507
1508
	// Check the size of all the directories.
1509
	$request = $db->query('', '
1510
		SELECT 
1511
			SUM(size)
1512
		FROM {db_prefix}attachments
1513
		WHERE attachment_type != {int:type}',
1514
		[
1515
			'type' => 1,
1516
		]
1517
	);
1518
	list ($attachmentDirSize) = $request->fetch_row();
1519
	$request->free_result();
1520
1521
	return byte_format($attachmentDirSize);
1522
}
1523
1524
/**
1525
 * Retrieves the properties of the current attachments directory.
1526
 *
1527
 *  What it does:
1528
 *
1529
 *  - Calls the AttachmentsDirectory constructor to create an instance of the attachments directory class
1530
 *  - Retrieves the current directory ID from the attachments directory object
1531
 *  - Calls the attachDirProperties function to retrieve the properties of the current directory
1532
 *
1533
 * @return array The properties of the current attachments directory.
1534
 */
1535
function currentAttachDirProperties()
1536
{
1537
	global $modSettings;
1538
1539
	$attachmentsDir = new AttachmentsDirectory($modSettings, database());
1540
1541
	return attachDirProperties($attachmentsDir->currentDirectoryId());
1542
}
1543
1544
/**
1545
 * Retrieves the properties of attachments in a specific directory.
1546
 *
1547
 * What it does:
1548
 *
1549
 * - Retrieves the number of files and the total size of the attachments.
1550
 * - Excludes attachments of type 1 (avatars).
1551
 *
1552
 * @param int $dir The directory ID.
1553
 *
1554
 * @return array An array containing the number of files and the total size of attachments in the directory.
1555
 */
1556
function attachDirProperties($dir)
1557
{
1558
	$db = database();
1559
1560
	$current_dir = [];
1561
	$request = $db->query('', '
1562
		SELECT 
1563
			COUNT(*), SUM(size)
1564
		FROM {db_prefix}attachments
1565
		WHERE id_folder = {int:folder_id}
1566
			AND attachment_type != {int:type}',
1567
		[
1568
			'folder_id' => $dir,
1569
			'type' => 1,
1570
		]
1571
	);
1572
	list ($current_dir['files'], $current_dir['size']) = $request->fetch_row();
1573
	$request->free_result();
1574
1575
	return array_map('intval', $current_dir);
1576
}
1577
1578
/**
1579
 * Select a group of attachments to move to a new destination
1580
 *
1581
 * Used by maintenance transfer attachments
1582
 * Returns number found and array of details
1583
 *
1584
 * @param string $from source location
1585
 * @param int $start
1586
 * @param int $limit
1587
 *
1588
 * @return array
1589
 */
1590
function findAttachmentsToMove($from, $start, $limit)
1591
{
1592
	$db = database();
1593
1594
	// Find some attachments to move
1595
	$attachments = $db->fetchQuery('
1596
		SELECT 
1597
			id_attach, filename, id_folder, file_hash, size
1598
		FROM {db_prefix}attachments
1599
		WHERE id_folder = {int:folder}
1600
			AND attachment_type != {int:attachment_type}
1601
		LIMIT {int:limit} OFFSET {int:start} ',
1602
		[
1603
			'folder' => $from,
1604
			'attachment_type' => 1,
1605
			'start' => $start,
1606
			'limit' => $limit,
1607
		]
1608
	)->fetch_all();
1609
	$number = count($attachments);
1610
1611
	return [$number, $attachments];
1612
}
1613
1614
/**
1615
 * Update the database to reflect the new directory of an array of attachments
1616
 *
1617
 * @param int[] $moved integer array of attachment ids
1618
 * @param string $new_dir new directory string
1619
 */
1620
function moveAttachments($moved, $new_dir)
1621
{
1622
	$db = database();
1623
1624
	// Update the database
1625
	$db->query('', '
1626
		UPDATE {db_prefix}attachments
1627
		SET 
1628
			id_folder = {int:new}
1629
		WHERE id_attach IN ({array_int:attachments})',
1630
		[
1631
			'attachments' => $moved,
1632
			'new' => $new_dir,
1633
		]
1634
	);
1635
}
1636
1637
/**
1638
 * Extend the message body with a removal message.
1639
 *
1640
 * @param int[] $messages array of message id's to update
1641
 * @param string $notice notice to add
1642
 */
1643
function setRemovalNotice($messages, $notice)
1644
{
1645
	$db = database();
1646
1647
	$db->query('', '
1648
		UPDATE {db_prefix}messages
1649
		SET 
1650
			body = CONCAT(body, {string:notice})
1651
		WHERE id_msg IN ({array_int:messages})',
1652
		[
1653
			'messages' => $messages,
1654
			'notice' => '<br /><br />' . $notice,
1655
		]
1656
	);
1657
}
1658
1659
/**
1660
 * Retrieves the attachment IDs associated with a specific message.
1661
 *
1662
 *  What it does:
1663
 *
1664
 *  - Returns an array of attachment IDs.
1665
 *
1666
 * @param int $id_msg The ID of the message.
1667
 * @param bool $unapproved Whether to include unapproved attachments or not. Default is false.
1668
 *
1669
 * @return int[] An array of attachment IDs.
1670
 */
1671
function attachmentsOfMessage($id_msg, $unapproved = false)
1672
{
1673
	$db = database();
1674
1675
	return $db->fetchQuery('
1676
		SELECT 
1677
			id_attach
1678
		FROM {db_prefix}attachments
1679
		WHERE id_msg = {int:id_msg}' . ($unapproved ? '' : '
1680
			AND approved = {int:is_approved}') . '
1681
			AND attachment_type = {int:attachment_type}',
1682
		[
1683
			'id_msg' => $id_msg,
1684
			'is_approved' => 0,
1685
			'attachment_type' => 0,
1686
		]
1687
	)->fetch_callback(
1688
		function ($row) {
1689
			return $row['id_attach'];
1690
		}
1691
	);
1692
}
1693
1694
/**
1695
 * Counts the number of attachments in a given folder.
1696
 *
1697
 * What it does:
1698
 *
1699
 * - Retrieves the total number of attachments in the specified folder.
1700
 *
1701
 * @param int $id_folder The ID of the folder to count attachments in.
1702
 *
1703
 * @return int The number of attachments in the folder.
1704
 */
1705
function countAttachmentsInFolders($id_folder)
1706
{
1707
	$db = database();
1708
1709
	$request = $db->query('', '
1710
		SELECT 
1711
			COUNT(id_attach) AS num_attach
1712
		FROM {db_prefix}attachments
1713
		WHERE id_folder = {int:id_folder}',
1714
		[
1715
			'id_folder' => $id_folder,
1716
		]
1717
	);
1718
	list ($num_attach) = $request->fetch_row();
1719
	$request->free_result();
1720
1721
	return (int) $num_attach;
1722
}
1723
1724
/**
1725
 * Changes the folder id of all the attachments in a certain folder
1726
 *
1727
 * @param int $from - the folder the attachments are in
1728
 * @param int $to - the folder the attachments should be moved to
1729
 */
1730
function updateAttachmentIdFolder($from, $to)
1731
{
1732
	$db = database();
1733
1734
	$db->query('', '
1735
		UPDATE {db_prefix}attachments
1736
		SET 
1737
			id_folder = {int:folder_to}
1738
		WHERE id_folder = {int:folder_from}',
1739
		[
1740
			'folder_from' => $from,
1741
			'folder_to' => $to,
1742
		]
1743
	);
1744
}
1745
1746
/**
1747
 * Validates the current user can remove a specified attachment
1748
 *
1749
 *  What it does:
1750
 *
1751
 * - Has moderator / admin manage_attachments permission
1752
 * - Message is not locked, they have attach permissions and meets one of the following:
1753
 *    - Has modify any permission
1754
 *    - Is the owner of the message and within edit_disable_time
1755
 *    - Is allowed to edit messages in a thread they started
1756
 *
1757
 * @param int $id_attach
1758
 * @param int $id_member_requesting
1759
 *
1760
 * @return bool
1761
 */
1762
function canRemoveAttachment($id_attach, $id_member_requesting)
1763
{
1764
	if (allowedTo('manage_attachments'))
1765
	{
1766
		return true;
1767
	}
1768
1769
	$canRemove = false;
1770
1771
	$db = database();
1772
	$db->fetchQuery('
1773
		SELECT 
1774
			m.id_board, m.id_member, m.approved, m.poster_time,
1775
			t.locked, t.id_member_started
1776
		FROM {db_prefix}attachments as a
1777
			LEFT JOIN {db_prefix}messages AS m ON m.id_msg = a.id_msg
1778
			LEFT JOIN {db_prefix}topics AS t ON t.id_topic = m.id_topic
1779
		WHERE a.id_attach = {int:id_attach}',
1780
		[
1781
			'id_attach' => $id_attach,
1782
		]
1783
	)->fetch_callback(function($row) use ($id_member_requesting, &$canRemove) {
1784
		global $modSettings;
1785
1786
		if (!empty($row))
1787
		{
1788
			$is_owner = $id_member_requesting === (int) $row['id_member'];
1789
			$is_starter = $id_member_requesting === (int) $row['id_member_started'];
1790
			$can_attach = allowedTo('post_attachment', $row['id_board']) || ($modSettings['postmod_active'] && allowedTo('post_unapproved_attachments', $row['id_board']));
1791
			$can_modify = (!$row['locked'] || allowedTo('moderate_board', $row['id_board']))
1792
				&& (
1793
					allowedTo('modify_any', $row['id_board'])
1794
					|| (allowedTo('modify_replies', $row['id_board']) && $is_starter)
1795
					|| (allowedTo('modify_own', $row['id_board']) && $is_owner && (empty($modSettings['edit_disable_time']) || !$row['approved'] || $row['poster_time'] + $modSettings['edit_disable_time'] * 60 > time()))
1796
				);
1797
1798
			$canRemove = $can_attach && $can_modify;
1799
		}
1800
	});
1801
1802
	return $canRemove;
1803
}
1804
1805
/**
1806
 * Retrieves the total number of attachments on disk.
1807
 *
1808
 *  What it does:
1809
 *
1810
 *  - Iterates through each attachment directory specified in $modSettings['attachmentUploadDir'].
1811
 *  - Counts the number of files in each directory, excluding dotfiles.
1812
 *  - Returns the total of all files found.
1813
 *
1814
 * @return int The total number of attachments found on disk.
1815
 */
1816
function getAttachmentCountFromDisk()
1817
{
1818
	global $modSettings;
1819
1820
	$attach_dirs = $modSettings['attachmentUploadDir'];
1821
1822
	$fileCount = 0;
1823
	foreach ($attach_dirs as $attach_dir)
1824
	{
1825
		try
1826
		{
1827
			$dir_iterator = new FilesystemIterator($attach_dir, FilesystemIterator::SKIP_DOTS);
1828
			$filter_iterator = new CallbackFilterIterator($dir_iterator, function ($file, $key, $iterator) {
1829
				return $file->getFilename()[0] !== '.';
1830
			});
1831
			$fileCount += iterator_count($filter_iterator);
1832
		}
1833
		catch (\Exception)
1834
		{
1835
			$fileCount += 0;
1836
		}
1837
	}
1838
1839
	return $fileCount;
1840
}
1841
1842
/**
1843
 * Function called in-between each round of attachments and avatar repairs to pause the
1844
 * attachment maintenance process.
1845
 *
1846
 *  What it does:
1847
 *
1848
 *  - Called by repairAttachments().
1849
 *  - If repairAttachments() has more steps added, this function needs to be updated!
1850
 *
1851
 * @param array $to_fix The attachments to fix.
1852
 * @param int $max_substep The maximum number of items in teh substep
1853
 * @param int $starting_substep The starting substep.
1854
 * @param int $substep The current substep value (current completed of $max_substep)
1855
 * @param int $step The current step.
1856
 * @param bool $fixErrors Whether to fix errors or not.
1857
 *
1858
 * @return void
1859
 */
1860
function pauseAttachmentMaintenance($to_fix, $max_substep = 0, $starting_substep = 0, $substep = 0, $step = 0, $fixErrors = false)
1861
{
1862
	global $context, $txt, $time_start;
1863
1864
	// Try to get more time...
1865
	detectServer()->setTimeLimit(600);
1866
1867
	// Have we already used our maximum time?
1868
	if ($starting_substep === $substep || microtime(true) - $time_start < 3)
1869
	{
1870
		return;
1871
	}
1872
1873
	$context['continue_get_data'] = '?action=admin;area=manageattachments;sa=repair' . ($fixErrors ? ';fixErrors' : '') . ';step=' . $step . ';substep=' . $substep . ';' . $context['session_var'] . '=' . $context['session_id'];
1874
	$context['page_title'] = $txt['not_done_title'];
1875
	$context['continue_post_data'] = '';
1876
	$context['continue_countdown'] = '3';
1877
	$context['sub_template'] = 'not_done';
1878
1879
	// Specific stuff to not break this template!
1880
	$context[$context['admin_menu_name']]['current_subsection'] = 'maintenance';
1881
1882
	// Change these two if more steps are added!
1883
	if ($max_substep === 0)
1884
	{
1885
		$context['continue_percent'] = round($step * 20);
1886
	}
1887
	else
1888
	{
1889
		$context['continue_percent'] = round($step * 20 + (($substep / $max_substep) * 20));
1890
	}
1891
1892
	// Never more than 100%!
1893
	$context['continue_percent'] = min($context['continue_percent'], 100);
1894
1895
	// Save the information for the next loop
1896
	$_SESSION['attachments_to_fix'] = $to_fix;
1897
	$_SESSION['attachments_to_fix2'] = $context['repair_errors'];
1898
1899
	obExit();
1900
}
1901