Issues (1691)

sources/subs/ManageAttachments.subs.php (3 issues)

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
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
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
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