Passed
Push — master ( 456d0d...61bf91 )
by Greg
06:39
created

MediaController::uploadAction()   B

Complexity

Conditions 10
Paths 5

Size

Total Lines 63
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 10
eloc 34
c 0
b 0
f 0
nc 5
nop 2
dl 0
loc 63
rs 7.6666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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