Completed
Push — openstreetmap ( c02ae5...190f01 )
by Greg
17:40 queued 08:16
created

AdminMediaController::allDiskFiles()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 4
dl 0
loc 2
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * webtrees: online genealogy
4
 * Copyright (C) 2018 webtrees development team
5
 * This program is free software: you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation, either version 3 of the License, or
8
 * (at your option) any later version.
9
 * This program is distributed in the hope that it will be useful,
10
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
 * GNU General Public License for more details.
13
 * You should have received a copy of the GNU General Public License
14
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15
 */
16
declare(strict_types=1);
17
18
namespace Fisharebest\Webtrees\Http\Controllers;
19
20
use Fisharebest\Webtrees\Database;
21
use Fisharebest\Webtrees\DebugBar;
22
use Fisharebest\Webtrees\File;
23
use Fisharebest\Webtrees\FlashMessages;
24
use Fisharebest\Webtrees\Functions\Functions;
25
use Fisharebest\Webtrees\GedcomTag;
26
use Fisharebest\Webtrees\Html;
27
use Fisharebest\Webtrees\I18N;
28
use Fisharebest\Webtrees\Log;
29
use Fisharebest\Webtrees\Media;
30
use Fisharebest\Webtrees\MediaFile;
31
use Fisharebest\Webtrees\Tree;
32
use Symfony\Component\HttpFoundation\JsonResponse;
33
use Symfony\Component\HttpFoundation\RedirectResponse;
34
use Symfony\Component\HttpFoundation\Request;
35
use Symfony\Component\HttpFoundation\Response;
36
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
37
use Throwable;
38
39
/**
40
 * Controller for media administration.
41
 */
42
class AdminMediaController extends AbstractBaseController {
43
	// How many files to upload on one form.
44
	const MAX_UPLOAD_FILES = 10;
45
46
	protected $layout = 'layouts/administration';
47
48
	/**
49
	 * @param Request $request
50
	 *
51
	 * @return Response
52
	 */
53
	public function index(Request $request): Response {
54
		$files        = $request->get('files', 'local'); // local|unused|external
55
		$media_folder = $request->get('media_folder', '');
56
		$media_path   = $request->get('media_path', '');
57
		$subfolders   = $request->get('subfolders', 'include'); // include/exclude
58
59
		$media_folders = $this->allMediaFolders();
60
		$media_paths   = $this->mediaPaths($media_folder);
61
62
		// Preserve the pagination/filtering/sorting between requests, so that the
63
		// browser’s back button works. Pagination is dependent on the currently
64
		// selected folder.
65
		$table_id = md5($files . $media_folder . $media_path . $subfolders);
66
67
		$title = I18N::translate('Manage media');
68
69
		return $this->viewResponse('admin/media', [
70
			'files'         => $files,
71
			'media_folder'  => $media_folder,
72
			'media_folders' => $media_folders,
73
			'media_path'    => $media_path,
74
			'media_paths'   => $media_paths,
75
			'subfolders'    => $subfolders,
76
			'table_id'      => $table_id,
77
			'title'         => $title,
78
		]);
79
	}
80
81
	/**
82
	 * @param Request $request
83
	 *
84
	 * @return Response
85
	 */
86
	public function delete(Request $request): Response {
87
		$delete_file  = $request->get('file', '');
88
		$media_folder = $request->get('folder', '');
89
90
		// Only delete valid (i.e. unused) media files
91
		$disk_files = $this->allDiskFiles($media_folder, '', 'include', '');
92
93
		// Check file exists? Maybe it was already deleted or renamed.
94
		if (in_array($delete_file, $disk_files)) {
95
			$tmp = WT_DATA_DIR . $media_folder . $delete_file;
96
			try {
97
				unlink($tmp);
98
				FlashMessages::addMessage(I18N::translate('The file %s has been deleted.', Html::filename($tmp)), 'info');
99
			} catch (Throwable $ex) {
100
				DebugBar::addThrowable($ex);
101
102
				FlashMessages::addMessage(I18N::translate('The file %s could not be deleted.', Html::filename($tmp)) . '<hr><samp dir="ltr">' . $ex->getMessage() . '</samp>', 'danger');
103
			}
104
		}
105
106
		return new Response;
107
	}
108
109
	/**
110
	 * @param Request $request
111
	 *
112
	 * @return JsonResponse
113
	 */
114
	public function data(Request $request): JsonResponse {
115
		$files  = $request->get('files'); // local|external|unused
116
		$search = $request->get('search');
117
		$search = $search['value'];
118
		$start  = (int) $request->get('start');
119
		$length = (int) $request->get('length');
120
121
		// family tree setting MEDIA_DIRECTORY
122
		$media_folders = $this->allMediaFolders();
123
		$media_folder  = $request->get('media_folder', '');
124
		// User folders may contain special characters. Restrict to actual folders.
125
		if (!array_key_exists($media_folder, $media_folders)) {
126
			$media_folder = reset($media_folders);
127
		}
128
129
		// prefix to filename
130
		$media_paths = $this->mediaPaths($media_folder);
131
		$media_path  = $request->get('media_path', '');
132
		// User paths may contain special characters. Restrict to actual paths.
133
		if (!array_key_exists($media_path, $media_paths)) {
134
			$media_path = reset($media_paths);
135
		}
136
137
		// subfolders within $media_path
138
		$subfolders = $request->get('subfolders'); // include|exclude
139
140
		switch ($files) {
141
			case 'local':
142
				// Filtered rows
143
				$SELECT1 =
144
					"SELECT SQL_CACHE SQL_CALC_FOUND_ROWS TRIM(LEADING :media_path_1 FROM multimedia_file_refn) AS media_path, m_id AS xref, descriptive_title, m_file AS gedcom_id, m_gedcom AS gedcom" .
145
					" FROM  `##media`" .
146
					" JOIN  `##media_file` USING (m_file, m_id)" .
147
					" JOIN  `##gedcom_setting` ON (m_file = gedcom_id AND setting_name = 'MEDIA_DIRECTORY')" .
148
					" JOIN  `##gedcom` USING (gedcom_id)" .
149
					" WHERE setting_value = :media_folder" .
150
					" AND   multimedia_file_refn LIKE CONCAT(:media_path_2, '%')" .
151
					" AND   (SUBSTRING_INDEX(multimedia_file_refn, '/', -1) LIKE CONCAT('%', :search_1, '%')" .
152
					"  OR   descriptive_title LIKE CONCAT('%', :search_2, '%'))" .
153
					" AND   multimedia_file_refn NOT LIKE 'http://%'" .
154
					" AND   multimedia_file_refn NOT LIKE 'https://%'";
155
				$ARGS1   = [
156
					'media_path_1' => $media_path,
157
					'media_folder' => $media_folder,
158
					'media_path_2' => Database::escapeLike($media_path),
159
					'search_1'     => Database::escapeLike($search),
160
					'search_2'     => Database::escapeLike($search),
161
				];
162
				// Unfiltered rows
163
				$SELECT2 =
164
					"SELECT SQL_CACHE COUNT(*)" .
165
					" FROM  `##media`" .
166
					" JOIN  `##media_file` USING (m_file, m_id)" .
167
					" JOIN  `##gedcom_setting` ON (m_file = gedcom_id AND setting_name = 'MEDIA_DIRECTORY')" .
168
					" WHERE setting_value = :media_folder" .
169
					" AND   multimedia_file_refn LIKE CONCAT(:media_path_3, '%')" .
170
					" AND   multimedia_file_refn NOT LIKE 'http://%'" .
171
					" AND   multimedia_file_refn NOT LIKE 'https://%'";
172
				$ARGS2   = [
173
					'media_folder' => $media_folder,
174
					'media_path_3' => $media_path,
175
				];
176
177
				if ($subfolders == 'exclude') {
178
					$SELECT1               .= " AND multimedia_file_refn NOT LIKE CONCAT(:media_path_4, '%/%')";
179
					$ARGS1['media_path_4'] = Database::escapeLike($media_path);
180
					$SELECT2               .= " AND multimedia_file_refn NOT LIKE CONCAT(:media_path_4, '%/%')";
181
					$ARGS2['media_path_4'] = Database::escapeLike($media_path);
182
				}
183
184
				$order   = $request->get('order', []);
185
				$SELECT1 .= " ORDER BY ";
186
				if ($order) {
187
					foreach ($order as $key => $value) {
188
						if ($key > 0) {
189
							$SELECT1 .= ',';
190
						}
191
						// Columns in datatables are numbered from zero.
192
						// Columns in MySQL are numbered starting with one.
193
						switch ($value['dir']) {
194
							case 'asc':
195
								$SELECT1 .= ":col_" . $key . " ASC";
196
								break;
197
							case 'desc':
198
								$SELECT1 .= ":col_" . $key . " DESC";
199
								break;
200
						}
201
						$ARGS1['col_' . $key] = 1 + $value['column'];
202
					}
203
				} else {
204
					$SELECT1 = " 1 ASC";
205
				}
206
207
				if ($length > 0) {
208
					$SELECT1         .= " LIMIT :length OFFSET :start";
209
					$ARGS1['length'] = $length;
210
					$ARGS1['start']  = $start;
211
				}
212
213
				$rows = Database::prepare($SELECT1)->execute($ARGS1)->fetchAll();
214
				// Total filtered/unfiltered rows
215
				$recordsFiltered = Database::prepare("SELECT FOUND_ROWS()")->fetchOne();
216
				$recordsTotal    = Database::prepare($SELECT2)->execute($ARGS2)->fetchOne();
217
218
				$data = [];
219
				foreach ($rows as $row) {
220
					$media       = Media::getInstance($row->xref, Tree::findById($row->gedcom_id), $row->gedcom);
221
					$media_files = $media->mediaFiles();
222
					$media_files = array_map(function (MediaFile $media_file) {
223
						return $media_file->displayImage(150, 150, '', []);
224
					}, $media_files);
225
					$data[]      = [
226
						$this->mediaFileInfo($media_folder, $media_path, $row->media_path),
227
						implode('', $media_files),
228
						$this->mediaObjectInfo($media),
229
					];
230
				}
231
				break;
232
233
			case 'external':
234
				// Filtered rows
235
				$SELECT1 =
236
					"SELECT SQL_CACHE SQL_CALC_FOUND_ROWS multimedia_file_refn, m_id AS xref, descriptive_title, m_file AS gedcom_id, m_gedcom AS gedcom" .
237
					" FROM  `##media`" .
238
					" JOIN  `##media_file` USING (m_id, m_file)" .
239
					" WHERE (multimedia_file_refn LIKE 'http://%' OR multimedia_file_refn LIKE 'https://%')" .
240
					" AND   (multimedia_file_refn LIKE CONCAT('%', :search_1, '%') OR descriptive_title LIKE CONCAT('%', :search_2, '%'))";
241
				$ARGS1   = [
242
					'search_1' => Database::escapeLike($search),
243
					'search_2' => Database::escapeLike($search),
244
				];
245
				// Unfiltered rows
246
				$SELECT2 =
247
					"SELECT SQL_CACHE COUNT(*)" .
248
					" FROM  `##media`" .
249
					" JOIN  `##media_file` USING (m_id, m_file)" .
250
					" WHERE (multimedia_file_refn LIKE 'http://%' OR multimedia_file_refn LIKE 'https://%')";
251
				$ARGS2   = [];
252
253
				$order   = $request->get('order', []);
254
				$SELECT1 .= " ORDER BY ";
255
				if ($order) {
256
					foreach ($order as $key => $value) {
257
						if ($key > 0) {
258
							$SELECT1 .= ',';
259
						}
260
						// Columns in datatables are numbered from zero.
261
						// Columns in MySQL are numbered starting with one.
262
						switch ($value['dir']) {
263
							case 'asc':
264
								$SELECT1 .= ":col_" . $key . " ASC";
265
								break;
266
							case 'desc':
267
								$SELECT1 .= ":col_" . $key . " DESC";
268
								break;
269
						}
270
						$ARGS1['col_' . $key] = 1 + $value['column'];
271
					}
272
				} else {
273
					$SELECT1 = " 1 ASC";
274
				}
275
276
				if ($length > 0) {
277
					$SELECT1         .= " LIMIT :length OFFSET :start";
278
					$ARGS1['length'] = $length;
279
					$ARGS1['start']  = $start;
280
				}
281
282
				$rows = Database::prepare($SELECT1)->execute($ARGS1)->fetchAll();
283
284
				// Total filtered/unfiltered rows
285
				$recordsFiltered = Database::prepare("SELECT FOUND_ROWS()")->fetchOne();
286
				$recordsTotal    = Database::prepare($SELECT2)->execute($ARGS2)->fetchOne();
287
288
				$data = [];
289
				foreach ($rows as $row) {
290
					$media  = Media::getInstance($row->xref, Tree::findById($row->gedcom_id), $row->gedcom);
291
					$data[] = [
292
						GedcomTag::getLabelValue('URL', $row->multimedia_file_refn),
293
						$media->displayImage(150, 150, '', []),
294
						$this->mediaObjectInfo($media),
295
					];
296
				}
297
				break;
298
299
			case 'unused':
300
				// Which trees use this media folder?
301
				$media_trees = Database::prepare(
302
					"SELECT gedcom_name, gedcom_name" .
303
					" FROM `##gedcom`" .
304
					" JOIN `##gedcom_setting` USING (gedcom_id)" .
305
					" WHERE setting_name='MEDIA_DIRECTORY' AND setting_value = :media_folder AND gedcom_id > 0"
306
				)->execute([
307
					'media_folder' => $media_folder,
308
				])->fetchAssoc();
309
310
				$disk_files = $this->allDiskFiles($media_folder, $media_path, $subfolders, $search);
311
				$db_files   = $this->allMediaFiles($media_folder, $media_path, $search);
312
313
				// All unused files
314
				$unused_files = array_diff($disk_files, $db_files);
315
				$recordsTotal = count($unused_files);
316
317
				// Filter unused files
318
				if ($search) {
319
					$unused_files = array_filter($unused_files, function ($x) use ($search) {
320
						return strpos($x, $search) !== false;
321
					});
322
				}
323
				$recordsFiltered = count($unused_files);
324
325
				// Sort files - only option is column 0
326
				sort($unused_files);
327
				$order = $request->get('order', []);
328
				if ($order && $order[0]['dir'] === 'desc') {
329
					$unused_files = array_reverse($unused_files);
330
				}
331
332
				// Paginate unused files
333
				$unused_files = array_slice($unused_files, $start, $length);
334
335
				$data = [];
336
				foreach ($unused_files as $unused_file) {
337
					$imgsize = getimagesize(WT_DATA_DIR . $media_folder . $media_path . $unused_file);
338
					// We can’t create a URL (not in public_html) or use the media firewall (no such object)
339
					if ($imgsize === false) {
340
						$img = '-';
341
					} else {
342
						$url = route('unused-media-thumbnail', ['folder' => $media_folder, 'file' => $media_path . $unused_file, 'w' => 100, 'h' => 100]);
343
						$img = '<img src="' . e($url) . '">';
344
					}
345
346
					// Is there a pending record for this file?
347
					$exists_pending = Database::prepare(
348
						"SELECT 1 FROM `##change` WHERE status='pending' AND new_gedcom LIKE CONCAT('%\n1 FILE ', :unused_file, '\n%')"
349
					)->execute([
350
						'unused_file' => Database::escapeLike($unused_file),
351
					])->fetchOne();
352
353
					// Form to create new media object in each tree
354
					$create_form = '';
355
					if (!$exists_pending) {
356
						foreach ($media_trees as $media_tree) {
357
							$create_form .=
358
								'<p><a href="#" data-toggle="modal" data-target="#modal-create-media-from-file" data-file="' . e($unused_file) . '" data-tree="' . e($media_tree) . '" onclick="document.getElementById(\'file\').value=this.dataset.file; document.getElementById(\'ged\').value=this.dataset.tree;">' . I18N::translate('Create') . '</a> — ' . e($media_tree) . '<p>';
359
						}
360
					}
361
362
					$delete_link = '<p><a data-confirm="' . I18N::translate('Are you sure you want to delete “%s”?', e($unused_file)) . '" data-url="' . e(route('admin-media-delete', ['file' => $media_path . $unused_file, 'folder' => $media_folder])) . '" onclick="if (confirm(this.dataset.confirm)) jQuery.post(this.dataset.url, function (){location.reload();})" href="#">' . I18N::translate('Delete') . '</a></p>';
363
364
					$data[] = [
365
						$this->mediaFileInfo($media_folder, $media_path, $unused_file) . $delete_link,
366
						$img,
367
						$create_form,
368
					];
369
				}
370
				break;
371
372
			default:
373
				throw new BadRequestHttpException;
374
		}
375
376
		// See http://www.datatables.net/usage/server-side
377
		return new JsonResponse([
378
			'draw'            => $request->get('draw'),
379
			'recordsTotal'    => $recordsTotal,
380
			'recordsFiltered' => $recordsFiltered,
381
			'data'            => $data,
382
		]);
383
	}
384
385
	/**
386
	 * @param Request $request
387
	 *
388
	 * @return Response
389
	 */
390
	public function upload(Request $request): Response {
391
		$media_folders = $this->folderListAll();
392
393
		$filesize = ini_get('upload_max_filesize');
394
		if (empty($filesize)) {
395
			$filesize = '2M';
396
		}
397
398
		$title = I18N::translate('Upload media files');
399
400
		return $this->viewResponse('admin/media-upload', [
401
			'max_upload_files' => self::MAX_UPLOAD_FILES,
402
			'filesize'         => $filesize,
403
			'media_folders'    => $media_folders,
404
			'title'            => $title,
405
		]);
406
	}
407
408
	/**
409
	 * @param Request $request
410
	 *
411
	 * @return RedirectResponse
412
	 */
413
	public function uploadAction(Request $request): RedirectResponse {
414
		$all_folders = $this->folderListAll();
415
416
		for ($i = 1; $i < self::MAX_UPLOAD_FILES; $i++) {
417
			if (!empty($_FILES['mediafile' . $i]['name'])) {
418
				$folder   = $request->get('folder' . $i, '');
419
				$filename = $request->get('filename' . $i, '');
420
421
				// If no filename specified, use the original filename.
422
				if ($filename === '') {
423
					$filename = $_FILES['mediafile' . $i]['name'];
424
				}
425
426
				// Validate the folder
427
				if (!in_array($folder, $all_folders)) {
428
					break;
429
				}
430
431
				// Validate the filename.
432
				$filename = str_replace('\\', '/', $filename);
433
				$filename = trim($filename, '/');
434
435
				if (strpos('/' . $filename, '/../') !== false) {
436
					FlashMessages::addMessage('Folder names are not allowed to include “../”');
437
					continue;
438
				} elseif (preg_match('/([:])/', $filename, $match)) {
439
					// Local media files cannot contain certain special characters, especially on MS Windows
440
					FlashMessages::addMessage(I18N::translate('Filenames are not allowed to contain the character “%s”.', $match[1]));
441
					continue;
442
				} elseif (preg_match('/(\.(php|pl|cgi|bash|sh|bat|exe|com|htm|html|shtml))$/i', $filename, $match)) {
443
					// Do not allow obvious script files.
444
					FlashMessages::addMessage(I18N::translate('Filenames are not allowed to have the extension “%s”.', $match[1]));
445
					continue;
446
				}
447
448
				// The new filename may have created a new sub-folder.
449
				$full_path = WT_DATA_DIR . $folder . $filename;
450
				$folder    = dirname($full_path);
451
452
				// Make sure the media folder exists
453
				if (!is_dir($folder)) {
454
					if (File::mkdir($folder)) {
455
						FlashMessages::addMessage(I18N::translate('The folder %s has been created.', Html::filename($folder)), 'info');
456
					} else {
457
						FlashMessages::addMessage(I18N::translate('The folder %s does not exist, and it could not be created.', Html::filename($folder)), 'danger');
458
						continue;
459
					}
460
				}
461
462
				if (file_exists($full_path)) {
463
					FlashMessages::addMessage(I18N::translate('The file %s already exists. Use another filename.', $full_path, 'error'));
464
					continue;
465
				}
466
467
				// Now copy the file to the correct location.
468
				if (move_uploaded_file($_FILES['mediafile' . $i]['tmp_name'], $full_path)) {
469
					FlashMessages::addMessage(I18N::translate('The file %s has been uploaded.', Html::filename($full_path)), 'success');
470
					Log::addMediaLog('Media file ' . $full_path . ' uploaded');
471
				} else {
472
					FlashMessages::addMessage(I18N::translate('There was an error uploading your file.') . '<br>' . Functions::fileUploadErrorText($_FILES['mediafile' . $i]['error']), 'danger');
473
				}
474
			}
475
		}
476
477
		$url = route('admin-media-upload');
478
479
		return new RedirectResponse($url);
480
	}
481
482
	/**
483
	 * Generate a list of all folders from all the trees.
484
	 *
485
	 * @return string[]
486
	 */
487
	private function folderListAll(): array {
488
		$folders = Database::prepare(
489
			"SELECT SQL_CACHE CONCAT(setting_value, LEFT(multimedia_file_refn, CHAR_LENGTH(multimedia_file_refn) - CHAR_LENGTH(SUBSTRING_INDEX(multimedia_file_refn, '/', -1))))" .
490
			" FROM  `##gedcom_setting` AS gs" .
491
			" JOIN  `##media_file` AS m ON m.m_file = gs.gedcom_id AND gs.setting_name = 'MEDIA_DIRECTORY'" .
492
			" WHERE multimedia_file_refn NOT LIKE 'http://%'" .
493
			" AND   multimedia_file_refn NOT LIKE 'https://%'" .
494
			" AND   gs.gedcom_id > 0" .
495
			" GROUP BY 1" .
496
			" UNION" .
497
			" SELECT setting_value FROM `##gedcom_setting` WHERE setting_name = 'MEDIA_DIRECTORY'" .
498
			" ORDER BY 1"
499
		)->execute()->fetchOneColumn();
500
501
		return $folders;
502
	}
503
504
	/**
505
	 * A unique list of media folders, from all trees.
506
	 *
507
	 * @return string[]
508
	 */
509
	private function allMediaFolders(): array {
510
		return Database::prepare(
511
			"SELECT SQL_CACHE setting_value, setting_value" .
512
			" FROM `##gedcom_setting`" .
513
			" WHERE setting_name='MEDIA_DIRECTORY' AND gedcom_id > 0" .
514
			" GROUP BY 1" .
515
			" ORDER BY 1"
516
		)->execute([])->fetchAssoc();
517
	}
518
519
	/**
520
	 * Generate a list of media paths (within a media folder) used by all media objects.
521
	 *
522
	 * @param string $media_folder
523
	 *
524
	 * @return string[]
525
	 */
526
	private function mediaPaths(string $media_folder): array {
527
		$media_paths = Database::prepare(
528
			"SELECT SQL_CACHE LEFT(multimedia_file_refn, CHAR_LENGTH(multimedia_file_refn) - CHAR_LENGTH(SUBSTRING_INDEX(multimedia_file_refn, '/', -1))) AS media_path" .
529
			" FROM  `##media`" .
530
			" JOIN  `##media_file` USING (m_file, m_id)" .
531
			" JOIN  `##gedcom_setting` ON (m_file = gedcom_id AND setting_name = 'MEDIA_DIRECTORY')" .
532
			" WHERE setting_value = :media_folder" .
533
			" AND   multimedia_file_refn NOT LIKE 'http://%'" .
534
			" AND   multimedia_file_refn NOT LIKE 'https://%'" .
535
			" GROUP BY 1" .
536
			" ORDER BY 1"
537
		)->execute([
538
			'media_folder' => $media_folder,
539
		])->fetchOneColumn();
540
541
		if (empty($media_paths) || $media_paths[0] !== '') {
542
			// Always include a (possibly empty) top-level folder
543
			array_unshift($media_paths, '');
544
		}
545
546
		return array_combine($media_paths, $media_paths);
547
	}
548
549
	/**
550
	 * Search a folder (and optional subfolders) for filenames that match a search pattern.
551
	 *
552
	 * @param string $dir
553
	 * @param bool   $recursive
554
	 * @param string $filter
555
	 *
556
	 * @return string[]
557
	 */
558
	private function scanFolders(string $dir, bool $recursive, string $filter): array {
559
		$files = [];
560
561
		// $dir comes from the database. The actual folder may not exist.
562
		if (is_dir($dir)) {
563
			foreach (scandir($dir) as $path) {
564
				if (is_dir($dir . $path)) {
565
					// What if there are user-defined subfolders “thumbs” or “watermarks”?
566
					if ($path != '.' && $path != '..' && $path != 'thumbs' && $path != 'watermark' && $recursive) {
567
						foreach ($this->scanFolders($dir . $path . '/', $recursive, $filter) as $subpath) {
568
							$files[] = $path . '/' . $subpath;
569
						}
570
					}
571
				} elseif (!$filter || stripos($path, $filter) !== false) {
572
					$files[] = $path;
573
				}
574
			}
575
		}
576
577
		return $files;
578
	}
579
580
	/**
581
	 * Fetch a list of all files on disk
582
	 *
583
	 * @param string $media_folder Location of root folder
584
	 * @param string $media_path   Any subfolder
585
	 * @param string $subfolders   Include or exclude subfolders
586
	 * @param string $filter       Filter files whose name contains this test
587
	 *
588
	 * @return string[]
589
	 */
590
	private function allDiskFiles(string $media_folder, string $media_path, string $subfolders, string $filter): array {
591
		return $this->scanFolders(WT_DATA_DIR . $media_folder . $media_path, $subfolders == 'include', $filter);
592
	}
593
594
	/**
595
	 * Fetch a list of all files on in the database.
596
	 *
597
	 * @param string $media_folder
598
	 * @param string $media_path
599
	 * @param string $filter
600
	 *
601
	 * @return string[]
602
	 */
603
	private function allMediaFiles(string $media_folder, string $media_path, string $filter): array {
604
		return Database::prepare(
605
			"SELECT SQL_CACHE SQL_CALC_FOUND_ROWS TRIM(LEADING :media_path_1 FROM multimedia_file_refn) AS media_path, 'OBJE' AS type, descriptive_title, m_id AS xref, m_file AS ged_id, m_gedcom AS gedrec, multimedia_file_refn" .
606
			" FROM  `##media`" .
607
			" JOIN  `##media_file` USING (m_file, m_id)" .
608
			" JOIN  `##gedcom_setting` ON (m_file = gedcom_id AND setting_name = 'MEDIA_DIRECTORY')" .
609
			" JOIN  `##gedcom`         USING (gedcom_id)" .
610
			" WHERE setting_value = :media_folder" .
611
			" AND   multimedia_file_refn LIKE CONCAT(:media_path_2, '%')" .
612
			" AND   (SUBSTRING_INDEX(multimedia_file_refn, '/', -1) LIKE CONCAT('%', :filter_1, '%')" .
613
			"  OR   descriptive_title LIKE CONCAT('%', :filter_2, '%'))" .
614
			" AND   multimedia_file_refn NOT LIKE 'http://%'" .
615
			" AND   multimedia_file_refn NOT LIKE 'https://%'"
616
		)->execute([
617
			'media_path_1' => $media_path,
618
			'media_folder' => $media_folder,
619
			'media_path_2' => Database::escapeLike($media_path),
620
			'filter_1'     => Database::escapeLike($filter),
621
			'filter_2'     => Database::escapeLike($filter),
622
		])->fetchOneColumn();
623
	}
624
625
	/**
626
	 * Generate some useful information and links about a media file.
627
	 *
628
	 * @param string $media_folder
629
	 * @param string $media_path
630
	 * @param string $file
631
	 *
632
	 * @return string
633
	 */
634
	private function mediaFileInfo(string $media_folder, string $media_path, string $file): string {
635
		$html = '<dl>';
636
		$html .= '<dt>' . I18N::translate('Filename') . '</dt>';
637
		$html .= '<dd>' . e($file) . '</dd>';
638
639
		$full_path = WT_DATA_DIR . $media_folder . $media_path . $file;
640
		try {
641
			$size = filesize($full_path);
642
			$size = (int) (($size + 1023) / 1024); // Round up to next KB
643
			$size = /* I18N: size of file in KB */
644
				I18N::translate('%s KB', I18N::number($size));
645
			$html .= '<dt>' . I18N::translate('File size') . '</dt>';
646
			$html .= '<dd>' . $size . '</dd>';
647
648
			try {
649
				$imgsize = getimagesize($full_path);
650
				$html    .= '<dt>' . I18N::translate('Image dimensions') . '</dt>';
651
				$html    .= '<dd>' . /* I18N: image dimensions, width × height */
652
					I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1'])) . '</dd>';
653
			} catch (Throwable $ex) {
654
				DebugBar::addThrowable($ex);
655
656
				// Not an image, or not a valid image?
657
			}
658
659
			$html .= '</dl>';
660
		} catch (Throwable $ex) {
661
			DebugBar::addThrowable($ex);
662
663
			// Not a file?  Not an image?
664
		}
665
666
		return $html;
667
	}
668
669
	/**
670
	 * Generate some useful information and links about a media object.
671
	 *
672
	 * @param Media $media
673
	 *
674
	 * @return string HTML
675
	 */
676
	private function mediaObjectInfo(Media $media) {
677
		$html = '<b><a href="' . e($media->url()) . '">' . $media->getFullName() . '</a></b>' . '<br><i>' . e($media->getNote()) . '</i></br><br>';
678
679
		$linked = [];
680
		foreach ($media->linkedIndividuals('OBJE') as $link) {
681
			$linked[] = '<a href="' . e($link->url()) . '">' . $link->getFullName() . '</a>';
682
		}
683
		foreach ($media->linkedFamilies('OBJE') as $link) {
684
			$linked[] = '<a href="' . e($link->url()) . '">' . $link->getFullName() . '</a>';
685
		}
686
		foreach ($media->linkedSources('OBJE') as $link) {
687
			$linked[] = '<a href="' . e($link->url()) . '">' . $link->getFullName() . '</a>';
688
		}
689
		foreach ($media->linkedNotes('OBJE') as $link) {
690
			// Invalid GEDCOM - you cannot link a NOTE to an OBJE
691
			$linked[] = '<a href="' . e($link->url()) . '">' . $link->getFullName() . '</a>';
692
		}
693
		foreach ($media->linkedRepositories('OBJE') as $link) {
694
			// Invalid GEDCOM - you cannot link a REPO to an OBJE
695
			$linked[] = '<a href="' . e($link->url()) . '">' . $link->getFullName() . '</a>';
696
		}
697
		if (!empty($linked)) {
698
			$html .= '<ul>';
699
			foreach ($linked as $link) {
700
				$html .= '<li>' . $link . '</li>';
701
			}
702
			$html .= '</ul>';
703
		} else {
704
			$html .= '<div class="alert alert-danger">' . I18N::translate('There are no links to this media object.') . '</div>';
705
		}
706
707
		return $html;
708
	}
709
}
710