Passed
Push — master ( 143a11...3d180b )
by Greg
06:13
created

MediaController::allMediaFolders()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 22
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 18
nc 1
nop 0
dl 0
loc 22
rs 9.6666
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2019 webtrees development team
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU General Public License as published by
8
 * the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 * You should have received a copy of the GNU General Public License
15
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Http\Controllers\Admin;
21
22
use Fisharebest\Webtrees\FlashMessages;
23
use Fisharebest\Webtrees\Functions\Functions;
24
use Fisharebest\Webtrees\Html;
25
use Fisharebest\Webtrees\I18N;
26
use Fisharebest\Webtrees\Log;
27
use Fisharebest\Webtrees\Media;
28
use Fisharebest\Webtrees\MediaFile;
29
use Fisharebest\Webtrees\Services\DatatablesService;
30
use Illuminate\Database\Capsule\Manager as DB;
31
use Illuminate\Database\Query\Builder;
32
use Illuminate\Database\Query\Expression;
33
use Illuminate\Database\Query\JoinClause;
34
use Illuminate\Support\Collection;
35
use Illuminate\Support\Str;
36
use League\Flysystem\FilesystemInterface;
37
use Psr\Http\Message\ResponseInterface;
38
use Psr\Http\Message\ServerRequestInterface;
39
use stdClass;
40
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
41
use Throwable;
42
43
use function dirname;
44
use function is_dir;
45
use function preg_match;
46
use function redirect;
47
use function route;
48
use function str_replace;
49
use function strpos;
50
use function trim;
51
52
use const UPLOAD_ERR_OK;
53
54
/**
55
 * Controller for media administration.
56
 */
57
class MediaController extends AbstractAdminController
58
{
59
    // How many files to upload on one form.
60
    private const MAX_UPLOAD_FILES = 10;
61
62
    /** @var DatatablesService */
63
    private $datatables_service;
64
65
    /** @var FilesystemInterface */
66
    private $filesystem;
67
68
    /**
69
     * MediaController constructor.
70
     *
71
     * @param DatatablesService   $datatables_service
72
     * @param FilesystemInterface $filesystem
73
     */
74
    public function __construct(DatatablesService $datatables_service, FilesystemInterface $filesystem)
75
    {
76
        $this->datatables_service = $datatables_service;
77
        $this->filesystem         = $filesystem;
78
    }
79
80
    /**
81
     * @param ServerRequestInterface $request
82
     *
83
     * @return ResponseInterface
84
     */
85
    public function index(ServerRequestInterface $request): ResponseInterface
86
    {
87
        $files        = $request->getQueryParams()['files'] ?? 'local'; // local|unused|external
88
        $media_folder = $request->getQueryParams()['media_folder'] ?? '';
89
        $subfolders   = $request->getQueryParams()['subfolders'] ?? 'include'; // include/exclude
90
91
        $media_folders = $this->allMediaFolders();
92
93
        // Preserve the pagination/filtering/sorting between requests, so that the
94
        // browser’s back button works. Pagination is dependent on the currently
95
        // selected folder.
96
        $table_id = md5($files . $media_folder . $subfolders);
97
98
        $title = I18N::translate('Manage media');
99
100
        return $this->viewResponse('admin/media', [
101
            'data_folder'   => WT_DATA_DIR,
102
            'files'         => $files,
103
            'media_folder'  => $media_folder,
104
            'media_folders' => $media_folders,
105
            'subfolders'    => $subfolders,
106
            'table_id'      => $table_id,
107
            'title'         => $title,
108
        ]);
109
    }
110
111
    /**
112
     * @param ServerRequestInterface $request
113
     *
114
     * @return ResponseInterface
115
     */
116
    public function select(ServerRequestInterface $request): ResponseInterface
117
    {
118
        return redirect(route('admin-media', [
119
            'files'        => $request->getParsedBody()['files'],
120
            'media_folder' => $request->getParsedBody()['media_folder'] ?? '',
121
            'subfolders'   => $request->getParsedBody()['subfolders'] ?? 'include',
122
        ]));
123
    }
124
125
    /**
126
     * Generate a list of all folders from all the trees.
127
     *
128
     * @return Collection
129
     */
130
    private function allMediaFolders(): Collection
131
    {
132
        $base_folders = DB::table('gedcom_setting')
133
            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
134
            ->select(new Expression("setting_value || 'dummy.jpeg' AS path"));
135
136
        return DB::table('media_file')
137
            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
138
            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
139
            ->where('multimedia_file_refn', 'LIKE', '%/%')
140
            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
141
            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
142
            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
143
            ->union($base_folders)
144
            ->pluck('path')
145
            ->map(static function (string $path): string {
146
                return dirname($path) . '/';
147
            })
148
            ->unique()
149
            ->sort()
150
            ->mapWithKeys(static function (string $path): array {
151
                return [$path => $path];
152
            });
153
    }
154
155
    /**
156
     * @param ServerRequestInterface $request
157
     *
158
     * @return ResponseInterface
159
     */
160
    public function delete(ServerRequestInterface $request): ResponseInterface
161
    {
162
        $delete_file  = $request->getQueryParams()['file'];
163
        $media_folder = $request->getQueryParams()['folder'];
164
165
        // Only delete valid (i.e. unused) media files
166
        $disk_files = $this->allDiskFiles($media_folder, 'include');
167
168
        // Check file exists? Maybe it was already deleted or renamed.
169
        if (in_array($delete_file, $disk_files, true)) {
170
            $path = $media_folder . $delete_file;
171
            try {
172
                if ($this->filesystem->has($path)) {
173
                    $this->filesystem->delete($path);
174
                }
175
                FlashMessages::addMessage(I18N::translate('The file %s has been deleted.', e($path)), 'info');
176
            } catch (Throwable $ex) {
177
                FlashMessages::addMessage(I18N::translate('The file %s could not be deleted.', e($path)) . '<hr><samp dir="ltr">' . $ex->getMessage() . '</samp>', 'danger');
178
            }
179
        }
180
181
        return response();
182
    }
183
184
    /**
185
     * Fetch a list of all files on disk
186
     *
187
     * @param string $media_folder Location of root folder
188
     * @param string $subfolders   Include or exclude subfolders
189
     *
190
     * @return string[]
191
     */
192
    private function allDiskFiles(string $media_folder, string $subfolders): array
193
    {
194
        return $this->scanFolders(WT_DATA_DIR . $media_folder, $subfolders === 'include');
195
    }
196
197
    /**
198
     * Search a folder (and optional subfolders) for filenames that match a search pattern.
199
     *
200
     * @param string $dir
201
     * @param bool   $recursive
202
     *
203
     * @return string[]
204
     */
205
    private function scanFolders(string $dir, bool $recursive): array
206
    {
207
        $files = [];
208
209
        // $dir comes from the database. The actual folder may not exist.
210
        if (is_dir($dir)) {
211
            foreach (scandir($dir, SCANDIR_SORT_NONE) as $path) {
212
                if (is_dir($dir . $path)) {
213
                    // What if there are user-defined subfolders “thumbs” or “watermarks”?
214
                    if ($path !== '.' && $path !== '..' && $path !== 'thumbs' && $path !== 'watermark' && $recursive) {
215
                        foreach ($this->scanFolders($dir . $path . '/', $recursive) as $subpath) {
216
                            $files[] = $path . '/' . $subpath;
217
                        }
218
                    }
219
                } else {
220
                    $files[] = $path;
221
                }
222
            }
223
        }
224
225
        return $files;
226
    }
227
228
    /**
229
     * @param ServerRequestInterface $request
230
     *
231
     * @return ResponseInterface
232
     */
233
    public function data(ServerRequestInterface $request): ResponseInterface
234
    {
235
        $files  = $request->getQueryParams()['files']; // local|external|unused
236
        $search = $request->getQueryParams()['search'];
237
        $search = $search['value'];
238
        $start  = (int) $request->getQueryParams()['start'];
239
        $length = (int) $request->getQueryParams()['length'];
240
241
        // Files within this folder
242
        $media_folder = $request->getQueryParams()['media_folder'];
243
244
        // subfolders within $media_path
245
        $subfolders = $request->getQueryParams()['subfolders']; // include|exclude
246
247
        $search_columns = ['multimedia_file_refn', 'descriptive_title'];
248
249
        $sort_columns = [
250
            0 => 'multimedia_file_refn',
251
            2 => new Expression('descriptive_title || multimedia_file_refn'),
252
        ];
253
254
        switch ($files) {
255
            case 'local':
256
                $query = DB::table('media_file')
257
                    ->join('media', static function (JoinClause $join): void {
258
                        $join
259
                            ->on('media.m_file', '=', 'media_file.m_file')
260
                            ->on('media.m_id', '=', 'media_file.m_id');
261
                    })
262
                    ->join('gedcom_setting', 'gedcom_id', '=', 'media.m_file')
263
                    ->where('setting_name', '=', 'MEDIA_DIRECTORY')
264
                    ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
265
                    ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
266
                    ->select(['media.*', 'multimedia_file_refn', 'descriptive_title']);
267
268
                $query->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%');
269
270
                if ($subfolders === 'exclude') {
271
                    $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
272
                }
273
274
                return $this->datatables_service->handle($request, $query, $search_columns, $sort_columns, function (stdClass $row): array {
275
                    /** @var Media $media */
276
                    $media = Media::rowMapper()($row);
277
278
                    $media_files = $media->mediaFiles()
279
                        ->filter(static function (MediaFile $media_file) use ($row): bool {
280
                            return $media_file->filename() == $row->multimedia_file_refn;
281
                        })
282
                        ->map(static function (MediaFile $media_file): string {
283
                            return $media_file->displayImage(150, 150, '', []);
284
                        })
285
                        ->implode('');
286
287
                    return [
288
                        $row->multimedia_file_refn,
289
                        $media_files,
290
                        $this->mediaObjectInfo($media),
291
                    ];
292
                });
293
294
            case 'external':
295
                $query = DB::table('media_file')
296
                    ->join('media', static function (JoinClause $join): void {
297
                        $join
298
                            ->on('media.m_file', '=', 'media_file.m_file')
299
                            ->on('media.m_id', '=', 'media_file.m_id');
300
                    })
301
                    ->where(static function (Builder $query): void {
302
                        $query
303
                            ->where('multimedia_file_refn', 'LIKE', 'http://%')
304
                            ->orWhere('multimedia_file_refn', 'LIKE', 'https://%');
305
                    })
306
                    ->select(['media.*', 'multimedia_file_refn', 'descriptive_title']);
307
308
                return $this->datatables_service->handle($request, $query, $search_columns, $sort_columns, function (stdClass $row): array {
309
                    /** @var Media $media */
310
                    $media = Media::rowMapper()($row);
311
312
                    $media_files = $media->mediaFiles()
313
                        ->filter(static function (MediaFile $media_file) use ($row): bool {
314
                            return $media_file->filename() === $row->multimedia_file_refn;
315
                        })
316
                        ->map(static function (MediaFile $media_file): string {
317
                            return $media_file->displayImage(150, 150, '', []);
318
                        })
319
                        ->implode('');
320
321
                    return [
322
                        $row->multimedia_file_refn,
323
                        $media_files,
324
                        $this->mediaObjectInfo($media),
325
                    ];
326
                });
327
328
            case 'unused':
329
                // Which trees use which media folder?
330
                $media_trees = DB::table('gedcom')
331
                    ->join('gedcom_setting', 'gedcom_setting.gedcom_id', '=', 'gedcom.gedcom_id')
332
                    ->where('setting_name', '=', 'MEDIA_DIRECTORY')
333
                    ->where('gedcom.gedcom_id', '>', 0)
334
                    ->pluck('setting_value', 'gedcom_name');
335
336
                $disk_files = $this->allDiskFiles($media_folder, $subfolders);
337
                $db_files   = $this->allMediaFiles($media_folder, $subfolders);
338
339
                // All unused files
340
                $unused_files = array_diff($disk_files, $db_files);
341
                $recordsTotal = count($unused_files);
342
343
                // Filter unused files
344
                if ($search) {
345
                    $unused_files = array_filter($unused_files, static function (string $x) use ($search): bool {
346
                        return strpos($x, $search) !== false;
347
                    });
348
                }
349
                $recordsFiltered = count($unused_files);
350
351
                // Sort files - only option is column 0
352
                sort($unused_files);
353
                $order = $request->getQueryParams()['order'];
354
                if ($order[0]['dir'] === 'desc') {
355
                    $unused_files = array_reverse($unused_files);
356
                }
357
358
                // Paginate unused files
359
                $unused_files = array_slice($unused_files, $start, $length);
360
361
                $data = [];
362
                foreach ($unused_files as $unused_file) {
363
                    $imgsize = getimagesize(WT_DATA_DIR . $media_folder . $unused_file);
364
                    // We can’t create a URL (not in public_html) or use the media firewall (no such object)
365
                    if ($imgsize === false) {
366
                        $img = '-';
367
                    } else {
368
                        $url = route('unused-media-thumbnail', [
369
                            'folder' => $media_folder,
370
                            'file'   => $unused_file,
371
                            'w'      => 100,
372
                            'h'      => 100,
373
                        ]);
374
                        $img = '<img src="' . e($url) . '">';
375
                    }
376
377
                    // Form to create new media object in each tree
378
                    $create_form = '';
379
                    foreach ($media_trees as $media_tree => $media_directory) {
380
                        if (Str::startsWith($media_folder . $unused_file, $media_directory)) {
381
                            $tmp         = substr($media_folder . $unused_file, strlen($media_directory));
382
                            $create_form .=
383
                                '<p><a href="#" data-toggle="modal" data-target="#modal-create-media-from-file" data-file="' . e($tmp) . '" 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>';
384
                        }
385
                    }
386
387
                    $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', [
388
                            'file'   => $unused_file,
389
                            'folder' => $media_folder,
390
                        ])) . '" onclick="if (confirm(this.dataset.confirm)) jQuery.post(this.dataset.url, function (){document.location.reload();})" href="#">' . I18N::translate('Delete') . '</a></p>';
391
392
                    $data[] = [
393
                        $this->mediaFileInfo($media_folder, $unused_file) . $delete_link,
394
                        $img,
395
                        $create_form,
396
                    ];
397
                }
398
                break;
399
400
            default:
401
                throw new BadRequestHttpException();
402
        }
403
404
        // See http://www.datatables.net/usage/server-side
405
        return response([
406
            'draw'            => (int) $request->getQueryParams()['draw'],
407
            'recordsTotal'    => $recordsTotal,
408
            'recordsFiltered' => $recordsFiltered,
409
            'data'            => $data,
410
        ]);
411
    }
412
413
    /**
414
     * Generate some useful information and links about a media object.
415
     *
416
     * @param Media $media
417
     *
418
     * @return string HTML
419
     */
420
    private function mediaObjectInfo(Media $media): string
421
    {
422
        $html = '<b><a href="' . e($media->url()) . '">' . $media->fullName() . '</a></b>' . '<br><i>' . e($media->getNote()) . '</i></br><br>';
423
424
        $linked = [];
425
        foreach ($media->linkedIndividuals('OBJE') as $link) {
426
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
427
        }
428
        foreach ($media->linkedFamilies('OBJE') as $link) {
429
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
430
        }
431
        foreach ($media->linkedSources('OBJE') as $link) {
432
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
433
        }
434
        foreach ($media->linkedNotes('OBJE') as $link) {
435
            // Invalid GEDCOM - you cannot link a NOTE to an OBJE
436
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
437
        }
438
        foreach ($media->linkedRepositories('OBJE') as $link) {
439
            // Invalid GEDCOM - you cannot link a REPO to an OBJE
440
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
441
        }
442
        if ($linked !== []) {
443
            $html .= '<ul>';
444
            foreach ($linked as $link) {
445
                $html .= '<li>' . $link . '</li>';
446
            }
447
            $html .= '</ul>';
448
        } else {
449
            $html .= '<div class="alert alert-danger">' . I18N::translate('There are no links to this media object.') . '</div>';
450
        }
451
452
        return $html;
453
    }
454
455
    /**
456
     * Fetch a list of all files on in the database.
457
     *
458
     * @param string $media_folder
459
     * @param string $subfolders
460
     *
461
     * @return string[]
462
     */
463
    private function allMediaFiles(string $media_folder, string $subfolders): array
464
    {
465
        $query = DB::table('media_file')
466
            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
467
            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
468
            ->where('multimedia_file_refn', 'LIKE', '%/%')
469
            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
470
            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
471
            ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%')
472
            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
473
            ->orderBy(new Expression('setting_value || multimedia_file_refn'));
474
475
        if ($subfolders === 'exclude') {
476
            $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
477
        }
478
479
        return $query->pluck('path')->all();
480
    }
481
482
    /**
483
     * Generate some useful information and links about a media file.
484
     *
485
     * @param string $media_folder
486
     * @param string $file
487
     *
488
     * @return string
489
     */
490
    private function mediaFileInfo(string $media_folder, string $file): string
491
    {
492
        $html = '<dl>';
493
        $html .= '<dt>' . I18N::translate('Filename') . '</dt>';
494
        $html .= '<dd>' . e($file) . '</dd>';
495
496
        $full_path = WT_DATA_DIR . $media_folder . $file;
497
        try {
498
            $size = filesize($full_path);
499
            $size = intdiv($size + 1023, 1024); // Round up to next KB
500
            /* I18N: size of file in KB */
501
            $size = I18N::translate('%s KB', I18N::number($size));
502
            $html .= '<dt>' . I18N::translate('File size') . '</dt>';
503
            $html .= '<dd>' . $size . '</dd>';
504
505
            try {
506
                $imgsize = getimagesize($full_path);
507
                $html    .= '<dt>' . I18N::translate('Image dimensions') . '</dt>';
508
                /* I18N: image dimensions, width × height */
509
                $html .= '<dd>' . I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1'])) . '</dd>';
510
            } catch (Throwable $ex) {
511
                // Not an image, or not a valid image?
512
            }
513
514
            $html .= '</dl>';
515
        } catch (Throwable $ex) {
516
            // Not a file?  Not an image?
517
        }
518
519
        return $html;
520
    }
521
522
    /**
523
     * @param ServerRequestInterface $request
524
     *
525
     * @return ResponseInterface
526
     */
527
    public function upload(ServerRequestInterface $request): ResponseInterface
528
    {
529
        $media_folders = $this->allMediaFolders();
530
531
        $filesize = ini_get('upload_max_filesize') ?: '2M';
532
533
        $title = I18N::translate('Upload media files');
534
535
        return $this->viewResponse('admin/media-upload', [
536
            'max_upload_files' => self::MAX_UPLOAD_FILES,
537
            'filesize'         => $filesize,
538
            'media_folders'    => $media_folders,
539
            'title'            => $title,
540
        ]);
541
    }
542
543
    /**
544
     * @param ServerRequestInterface $request
545
     *
546
     * @return ResponseInterface
547
     */
548
    public function uploadAction(ServerRequestInterface $request): ResponseInterface
549
    {
550
        $all_folders = $this->allMediaFolders();
551
552
        foreach ($request->getUploadedFiles() as $key => $uploaded_file) {
553
            if ($uploaded_file->getClientFilename() === '') {
554
                continue;
555
            }
556
            if ($uploaded_file->getError() !== UPLOAD_ERR_OK) {
557
                FlashMessages::addMessage(Functions::fileUploadErrorText($uploaded_file->getError()), 'danger');
558
                continue;
559
            }
560
            $key = substr($key, 9);
561
562
            $folder   = $request->getParsedBody()['folder' . $key];
563
            $filename = $request->getParsedBody()['filename' . $key];
564
565
            // If no filename specified, use the original filename.
566
            if ($filename === '') {
567
                $filename = $uploaded_file->getClientFilename();
568
            }
569
570
            // Validate the folder
571
            if (!$all_folders->contains($folder)) {
572
                break;
573
            }
574
575
            // Validate the filename.
576
            $filename = str_replace('\\', '/', $filename);
577
            $filename = trim($filename, '/');
578
579
            if (preg_match('/([:])/', $filename, $match)) {
580
                // Local media files cannot contain certain special characters, especially on MS Windows
581
                FlashMessages::addMessage(I18N::translate('Filenames are not allowed to contain the character “%s”.', $match[1]));
582
                continue;
583
            }
584
585
            if (preg_match('/(\.(php|pl|cgi|bash|sh|bat|exe|com|htm|html|shtml))$/i', $filename, $match)) {
586
                // Do not allow obvious script files.
587
                FlashMessages::addMessage(I18N::translate('Filenames are not allowed to have the extension “%s”.', $match[1]));
588
                continue;
589
            }
590
591
            $path = $folder . $filename;
592
593
            if ($this->filesystem->has($path)) {
594
                FlashMessages::addMessage(I18N::translate('The file %s already exists. Use another filename.', $path, 'error'));
595
                continue;
596
            }
597
598
            // Now copy the file to the correct location.
599
            try {
600
                $this->filesystem->writeStream($path, $uploaded_file->getStream()->detach());
601
                FlashMessages::addMessage(I18N::translate('The file %s has been uploaded.', Html::filename($path)), 'success');
602
                Log::addMediaLog('Media file ' . $path . ' uploaded');
603
            } catch (Throwable $ex) {
604
                FlashMessages::addMessage(I18N::translate('There was an error uploading your file.') . '<br>' . e($ex->getMessage()), 'danger');
605
            }
606
        }
607
608
        $url = route('admin-media-upload');
609
610
        return redirect($url);
611
    }
612
}
613