Passed
Push — master ( f5be59...b5f5af )
by Greg
06:01
created

MediaController::index()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 22
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 16
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 22
rs 9.7333
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\Http\RequestHandlers\DeletePath;
26
use Fisharebest\Webtrees\I18N;
27
use Fisharebest\Webtrees\Log;
28
use Fisharebest\Webtrees\Media;
29
use Fisharebest\Webtrees\MediaFile;
30
use Fisharebest\Webtrees\Services\DatatablesService;
31
use Fisharebest\Webtrees\Services\MediaFileService;
32
use Fisharebest\Webtrees\Services\TreeService;
33
use Illuminate\Database\Capsule\Manager as DB;
34
use Illuminate\Database\Query\Builder;
35
use Illuminate\Database\Query\Expression;
36
use Illuminate\Database\Query\JoinClause;
37
use Illuminate\Support\Str;
38
use League\Flysystem\FilesystemInterface;
39
use Psr\Http\Message\ResponseInterface;
40
use Psr\Http\Message\ServerRequestInterface;
41
use Psr\Http\Message\UploadedFileInterface;
42
use stdClass;
43
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
44
use Throwable;
45
46
use function assert;
47
use function e;
48
use function explode;
49
use function filesize;
50
use function getimagesize;
51
use function ini_get;
52
use function intdiv;
53
use function is_string;
54
use function preg_match;
55
use function redirect;
56
use function route;
57
use function str_replace;
58
use function strlen;
59
use function substr;
60
use function trim;
61
62
use const UPLOAD_ERR_OK;
63
64
/**
65
 * Controller for media administration.
66
 */
67
class MediaController extends AbstractAdminController
68
{
69
    // How many files to upload on one form.
70
    private const MAX_UPLOAD_FILES = 10;
71
72
    /** @var DatatablesService */
73
    private $datatables_service;
74
75
    /** @var MediaFileService */
76
    private $media_file_service;
77
78
    /** @var TreeService */
79
    private $tree_service;
80
81
    /**
82
     * MediaController constructor.
83
     *
84
     * @param DatatablesService $datatables_service
85
     * @param MediaFileService  $media_file_service
86
     * @param TreeService       $tree_service
87
     */
88
    public function __construct(
89
        DatatablesService $datatables_service,
90
        MediaFileService $media_file_service,
91
        TreeService $tree_service
92
    ) {
93
        $this->datatables_service = $datatables_service;
94
        $this->media_file_service = $media_file_service;
95
        $this->tree_service       = $tree_service;
96
    }
97
98
    /**
99
     * @param ServerRequestInterface $request
100
     *
101
     * @return ResponseInterface
102
     */
103
    public function index(ServerRequestInterface $request): ResponseInterface
104
    {
105
        $data_filesystem = $request->getAttribute('filesystem.data');
106
        assert($data_filesystem instanceof FilesystemInterface);
107
108
        $data_filesystem_name = $request->getAttribute('filesystem.data.name');
109
        assert(is_string($data_filesystem_name));
110
111
        $files         = $request->getQueryParams()['files'] ?? 'local'; // local|unused|external
112
        $media_folder  = $request->getQueryParams()['media_folder'] ?? '';
113
        $subfolders    = $request->getQueryParams()['subfolders'] ?? 'include'; // include|exclude
114
        $media_folders = $this->media_file_service->allMediaFolders($data_filesystem);
115
116
        $title = I18N::translate('Manage media');
117
118
        return $this->viewResponse('admin/media', [
119
            'data_folder'   => $data_filesystem_name,
120
            'files'         => $files,
121
            'media_folder'  => $media_folder,
122
            'media_folders' => $media_folders,
123
            'subfolders'    => $subfolders,
124
            'title'         => $title,
125
        ]);
126
    }
127
128
    /**
129
     * @param ServerRequestInterface $request
130
     *
131
     * @return ResponseInterface
132
     */
133
    public function select(ServerRequestInterface $request): ResponseInterface
134
    {
135
        return redirect(route('admin-media', [
136
            'files'        => $request->getParsedBody()['files'],
137
            'media_folder' => $request->getParsedBody()['media_folder'] ?? '',
138
            'subfolders'   => $request->getParsedBody()['subfolders'] ?? 'include',
139
        ]));
140
    }
141
142
    /**
143
     * @param ServerRequestInterface $request
144
     *
145
     * @return ResponseInterface
146
     */
147
    public function data(ServerRequestInterface $request): ResponseInterface
148
    {
149
        $data_filesystem = $request->getAttribute('filesystem.data');
150
        assert($data_filesystem instanceof FilesystemInterface);
151
152
        $files  = $request->getQueryParams()['files']; // local|external|unused
153
154
        // Files within this folder
155
        $media_folder = $request->getQueryParams()['media_folder'];
156
157
        // Show sub-folders within $media_folder
158
        $subfolders = $request->getQueryParams()['subfolders']; // include|exclude
159
160
        $search_columns = ['multimedia_file_refn', 'descriptive_title'];
161
162
        $sort_columns = [
163
            0 => 'multimedia_file_refn',
164
            2 => new Expression('descriptive_title || multimedia_file_refn'),
165
        ];
166
167
        // Convert a row from the database into a row for datatables
168
        $callback = function (stdClass $row): array {
169
            /** @var Media $media */
170
            $tree = $this->tree_service->find((int) $row->m_file);
171
            $media = Media::getInstance($row->m_id, $tree, $row->m_gedcom);
172
            assert($media instanceof Media);
173
174
            $media_files = $media->mediaFiles()
175
                ->filter(static function (MediaFile $media_file) use ($row): bool {
176
                    return $media_file->filename() === $row->multimedia_file_refn;
177
                })
178
                ->map(static function (MediaFile $media_file): string {
179
                    return $media_file->displayImage(150, 150, '', []);
180
                })
181
                ->first();
182
183
            return [
184
                $row->multimedia_file_refn,
185
                $media_files,
186
                $this->mediaObjectInfo($media),
187
            ];
188
        };
189
190
        switch ($files) {
191
            case 'local':
192
                $query = DB::table('media_file')
193
                    ->join('media', static function (JoinClause $join): void {
194
                        $join
195
                            ->on('media.m_file', '=', 'media_file.m_file')
196
                            ->on('media.m_id', '=', 'media_file.m_id');
197
                    })
198
                    ->join('gedcom_setting', 'gedcom_id', '=', 'media.m_file')
199
                    ->where('setting_name', '=', 'MEDIA_DIRECTORY')
200
                    ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
201
                    ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
202
                    ->select(['media.*', 'multimedia_file_refn', 'descriptive_title']);
203
204
                $query->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%');
205
206
                if ($subfolders === 'exclude') {
207
                    $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
208
                }
209
210
                return $this->datatables_service->handleQuery($request, $query, $search_columns, $sort_columns, $callback);
211
212
            case 'external':
213
                $query = DB::table('media_file')
214
                    ->join('media', static function (JoinClause $join): void {
215
                        $join
216
                            ->on('media.m_file', '=', 'media_file.m_file')
217
                            ->on('media.m_id', '=', 'media_file.m_id');
218
                    })
219
                    ->where(static function (Builder $query): void {
220
                        $query
221
                            ->where('multimedia_file_refn', 'LIKE', 'http://%')
222
                            ->orWhere('multimedia_file_refn', 'LIKE', 'https://%');
223
                    })
224
                    ->select(['media.*', 'multimedia_file_refn', 'descriptive_title']);
225
226
                return $this->datatables_service->handleQuery($request, $query, $search_columns, $sort_columns, $callback);
227
228
            case 'unused':
229
                // Which trees use which media folder?
230
                $media_trees = DB::table('gedcom')
231
                    ->join('gedcom_setting', 'gedcom_setting.gedcom_id', '=', 'gedcom.gedcom_id')
232
                    ->where('setting_name', '=', 'MEDIA_DIRECTORY')
233
                    ->where('gedcom.gedcom_id', '>', 0)
234
                    ->pluck('setting_value', 'gedcom_name');
235
236
                $disk_files = $this->media_file_service->allFilesOnDisk($data_filesystem, $media_folder, $subfolders === 'include');
237
                $db_files   = $this->media_file_service->allFilesInDatabase($media_folder, $subfolders === 'include');
238
239
                // All unused files
240
                $unused_files = $disk_files->diff($db_files)
241
                    ->map(static function (string $file): array {
242
                        return (array) $file;
243
                    });
244
245
                $search_columns = [0];
246
247
                $sort_columns = [0 => 0];
248
249
                $callback = function (array $row) use ($data_filesystem, $media_folder, $media_trees): array {
250
                    $mime_type = $data_filesystem->getMimeType($row[0]);
251
252
                    if (explode('/', $mime_type)[0] === 'image') {
0 ignored issues
show
Bug introduced by
It seems like $mime_type can also be of type false; however, parameter $string of explode() does only seem to accept string, 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

252
                    if (explode('/', /** @scrutinizer ignore-type */ $mime_type)[0] === 'image') {
Loading history...
253
                        $url = route('unused-media-thumbnail', [
254
                            'path' => $row[0],
255
                            'w'    => 100,
256
                            'h'    => 100,
257
                        ]);
258
                        $img = '<img src="' . e($url) . '">';
259
                    } else {
260
                        $img = view('icons/mime', ['type' => $mime_type]);
261
                    }
262
263
                    // Form to create new media object in each tree
264
                    $create_form = '';
265
                    foreach ($media_trees as $media_tree => $media_directory) {
266
                        if (Str::startsWith($row[0], $media_directory)) {
267
                            $tmp         = substr($row[0], strlen($media_directory));
268
                            $create_form .=
269
                                '<p><a href="#" data-toggle="modal" data-target="#modal-create-media-from-file" data-file="' . e($tmp) . '" data-url="' . e(route('create-media-from-file', ['tree' => $media_tree])) . '" onclick="document.getElementById(\'modal-create-media-from-file-form\').action=this.dataset.url; document.getElementById(\'file\').value=this.dataset.file;">' . I18N::translate('Create') . '</a> — ' . e($media_tree) . '<p>';
270
                        }
271
                    }
272
273
                    $delete_link = '<p><a data-confirm="' . I18N::translate('Are you sure you want to delete “%s”?', e($row[0])) . '" data-post-url="' . e(route(DeletePath::class, [
274
                            'path'   => $row[0],
275
                        ])) . '" href="#">' . I18N::translate('Delete') . '</a></p>';
276
277
                    return [
278
                        $this->mediaFileInfo($media_folder, $row[0]) . $delete_link,
279
                        $img,
280
                        $create_form,
281
                    ];
282
                };
283
284
                return $this->datatables_service->handleCollection($request, $unused_files, $search_columns, $sort_columns, $callback);
285
286
            default:
287
                throw new BadRequestHttpException();
288
        }
289
    }
290
291
    /**
292
     * Generate some useful information and links about a media object.
293
     *
294
     * @param Media $media
295
     *
296
     * @return string HTML
297
     */
298
    private function mediaObjectInfo(Media $media): string
299
    {
300
        $html = '<b><a href="' . e($media->url()) . '">' . $media->fullName() . '</a></b>' . '<br><i>' . e($media->getNote()) . '</i></br><br>';
301
302
        $linked = [];
303
        foreach ($media->linkedIndividuals('OBJE') as $link) {
304
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
305
        }
306
        foreach ($media->linkedFamilies('OBJE') as $link) {
307
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
308
        }
309
        foreach ($media->linkedSources('OBJE') as $link) {
310
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
311
        }
312
        foreach ($media->linkedNotes('OBJE') as $link) {
313
            // Invalid GEDCOM - you cannot link a NOTE to an OBJE
314
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
315
        }
316
        foreach ($media->linkedRepositories('OBJE') as $link) {
317
            // Invalid GEDCOM - you cannot link a REPO to an OBJE
318
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
319
        }
320
        if ($linked !== []) {
321
            $html .= '<ul>';
322
            foreach ($linked as $link) {
323
                $html .= '<li>' . $link . '</li>';
324
            }
325
            $html .= '</ul>';
326
        } else {
327
            $html .= '<div class="alert alert-danger">' . I18N::translate('There are no links to this media object.') . '</div>';
328
        }
329
330
        return $html;
331
    }
332
333
    /**
334
     * Generate some useful information and links about a media file.
335
     *
336
     * @param string $media_folder
337
     * @param string $file
338
     *
339
     * @return string
340
     */
341
    private function mediaFileInfo(string $media_folder, string $file): string
342
    {
343
        $html = '<dl>';
344
        $html .= '<dt>' . I18N::translate('Filename') . '</dt>';
345
        $html .= '<dd>' . e($file) . '</dd>';
346
347
        $full_path = WT_DATA_DIR . $media_folder . $file;
348
        try {
349
            $size = filesize($full_path);
350
            $size = intdiv($size + 1023, 1024); // Round up to next KB
351
            /* I18N: size of file in KB */
352
            $size = I18N::translate('%s KB', I18N::number($size));
353
            $html .= '<dt>' . I18N::translate('File size') . '</dt>';
354
            $html .= '<dd>' . $size . '</dd>';
355
356
            try {
357
                $imgsize = getimagesize($full_path);
358
                $html    .= '<dt>' . I18N::translate('Image dimensions') . '</dt>';
359
                /* I18N: image dimensions, width × height */
360
                $html .= '<dd>' . I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1'])) . '</dd>';
361
            } catch (Throwable $ex) {
362
                // Not an image, or not a valid image?
363
            }
364
365
            $html .= '</dl>';
366
        } catch (Throwable $ex) {
367
            // Not a file?  Not an image?
368
        }
369
370
        return $html;
371
    }
372
373
    /**
374
     * @param ServerRequestInterface $request
375
     *
376
     * @return ResponseInterface
377
     */
378
    public function upload(ServerRequestInterface $request): ResponseInterface
379
    {
380
        $data_filesystem = $request->getAttribute('filesystem.data');
381
        assert($data_filesystem instanceof FilesystemInterface);
382
383
        $media_folders = $this->media_file_service->allMediaFolders($data_filesystem);
384
385
        $filesize = ini_get('upload_max_filesize') ?: '2M';
386
387
        $title = I18N::translate('Upload media files');
388
389
        return $this->viewResponse('admin/media-upload', [
390
            'max_upload_files' => self::MAX_UPLOAD_FILES,
391
            'filesize'         => $filesize,
392
            'media_folders'    => $media_folders,
393
            'title'            => $title,
394
        ]);
395
    }
396
397
    /**
398
     * @param ServerRequestInterface $request
399
     *
400
     * @return ResponseInterface
401
     */
402
    public function uploadAction(ServerRequestInterface $request): ResponseInterface
403
    {
404
        $data_filesystem = $request->getAttribute('filesystem.data');
405
        assert($data_filesystem instanceof FilesystemInterface);
406
407
        $all_folders = $this->media_file_service->allMediaFolders($data_filesystem);
408
409
        foreach ($request->getUploadedFiles() as $key => $uploaded_file) {
410
            assert($uploaded_file instanceof UploadedFileInterface);
411
            if ($uploaded_file->getClientFilename() === '') {
412
                continue;
413
            }
414
            if ($uploaded_file->getError() !== UPLOAD_ERR_OK) {
415
                FlashMessages::addMessage(Functions::fileUploadErrorText($uploaded_file->getError()), 'danger');
416
                continue;
417
            }
418
            $key = substr($key, 9);
419
420
            $folder   = $request->getParsedBody()['folder' . $key];
421
            $filename = $request->getParsedBody()['filename' . $key];
422
423
            // If no filename specified, use the original filename.
424
            if ($filename === '') {
425
                $filename = $uploaded_file->getClientFilename();
426
            }
427
428
            // Validate the folder
429
            if (!$all_folders->contains($folder)) {
430
                break;
431
            }
432
433
            // Validate the filename.
434
            $filename = str_replace('\\', '/', $filename);
435
            $filename = trim($filename, '/');
436
437
            if (preg_match('/([:])/', $filename, $match)) {
438
                // Local media files cannot contain certain special characters, especially on MS Windows
439
                FlashMessages::addMessage(I18N::translate('Filenames are not allowed to contain the character “%s”.', $match[1]));
440
                continue;
441
            }
442
443
            if (preg_match('/(\.(php|pl|cgi|bash|sh|bat|exe|com|htm|html|shtml))$/i', $filename, $match)) {
444
                // Do not allow obvious script files.
445
                FlashMessages::addMessage(I18N::translate('Filenames are not allowed to have the extension “%s”.', $match[1]));
446
                continue;
447
            }
448
449
            $path = $folder . $filename;
450
451
            if ($data_filesystem->has($path)) {
452
                FlashMessages::addMessage(I18N::translate('The file %s already exists. Use another filename.', $path, 'error'));
453
                continue;
454
            }
455
456
            // Now copy the file to the correct location.
457
            try {
458
                $data_filesystem->writeStream($path, $uploaded_file->getStream()->detach());
459
                FlashMessages::addMessage(I18N::translate('The file %s has been uploaded.', Html::filename($path)), 'success');
460
                Log::addMediaLog('Media file ' . $path . ' uploaded');
461
            } catch (Throwable $ex) {
462
                FlashMessages::addMessage(I18N::translate('There was an error uploading your file.') . '<br>' . e($ex->getMessage()), 'danger');
463
            }
464
        }
465
466
        $url = route('admin-media-upload');
467
468
        return redirect($url);
469
    }
470
}
471