Passed
Push — master ( 896a57...289ad7 )
by Greg
07:24
created

MediaController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 2
dl 0
loc 4
rs 10
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\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 Illuminate\Database\Capsule\Manager as DB;
33
use Illuminate\Database\Query\Builder;
34
use Illuminate\Database\Query\Expression;
35
use Illuminate\Database\Query\JoinClause;
36
use Illuminate\Support\Str;
37
use League\Flysystem\FilesystemInterface;
38
use Psr\Http\Message\ResponseInterface;
39
use Psr\Http\Message\ServerRequestInterface;
40
use Psr\Http\Message\UploadedFileInterface;
41
use stdClass;
42
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
43
use Throwable;
44
45
use function assert;
46
use function e;
47
use function filesize;
48
use function getimagesize;
49
use function ini_get;
50
use function intdiv;
51
use function is_string;
52
use function preg_match;
53
use function redirect;
54
use function route;
55
use function str_replace;
56
use function strlen;
57
use function substr;
58
use function trim;
59
60
use const UPLOAD_ERR_OK;
61
62
/**
63
 * Controller for media administration.
64
 */
65
class MediaController extends AbstractAdminController
66
{
67
    // How many files to upload on one form.
68
    private const MAX_UPLOAD_FILES = 10;
69
70
    /** @var DatatablesService */
71
    private $datatables_service;
72
73
    /** @var MediaFileService */
74
    private $media_file_service;
75
76
    /**
77
     * MediaController constructor.
78
     *
79
     * @param DatatablesService $datatables_service
80
     * @param MediaFileService  $media_file_service
81
     */
82
    public function __construct(DatatablesService $datatables_service, MediaFileService $media_file_service)
83
    {
84
        $this->datatables_service = $datatables_service;
85
        $this->media_file_service = $media_file_service;
86
    }
87
88
    /**
89
     * @param ServerRequestInterface $request
90
     *
91
     * @return ResponseInterface
92
     */
93
    public function index(ServerRequestInterface $request): ResponseInterface
94
    {
95
        $data_filesystem = $request->getAttribute('filesystem.data');
96
        assert($data_filesystem instanceof FilesystemInterface);
97
98
        $data_filesystem_name = $request->getAttribute('filesystem.data.name');
99
        assert(is_string($data_filesystem_name));
100
101
        $files         = $request->getQueryParams()['files'] ?? 'local'; // local|unused|external
102
        $media_folder  = $request->getQueryParams()['media_folder'] ?? '';
103
        $subfolders    = $request->getQueryParams()['subfolders'] ?? 'include'; // include|exclude
104
        $media_folders = $this->media_file_service->allMediaFolders($data_filesystem);
105
106
        $title = I18N::translate('Manage media');
107
108
        return $this->viewResponse('admin/media', [
109
            'data_folder'   => $data_filesystem_name,
110
            'files'         => $files,
111
            'media_folder'  => $media_folder,
112
            'media_folders' => $media_folders,
113
            'subfolders'    => $subfolders,
114
            'title'         => $title,
115
        ]);
116
    }
117
118
    /**
119
     * @param ServerRequestInterface $request
120
     *
121
     * @return ResponseInterface
122
     */
123
    public function select(ServerRequestInterface $request): ResponseInterface
124
    {
125
        return redirect(route('admin-media', [
126
            'files'        => $request->getParsedBody()['files'],
127
            'media_folder' => $request->getParsedBody()['media_folder'] ?? '',
128
            'subfolders'   => $request->getParsedBody()['subfolders'] ?? 'include',
129
        ]));
130
    }
131
132
    /**
133
     * @param ServerRequestInterface $request
134
     *
135
     * @return ResponseInterface
136
     */
137
    public function data(ServerRequestInterface $request): ResponseInterface
138
    {
139
        $data_filesystem = $request->getAttribute('filesystem.data');
140
        assert($data_filesystem instanceof FilesystemInterface);
141
142
        $files  = $request->getQueryParams()['files']; // local|external|unused
143
144
        // Files within this folder
145
        $media_folder = $request->getQueryParams()['media_folder'];
146
147
        // Show sub-folders within $media_folder
148
        $subfolders = $request->getQueryParams()['subfolders']; // include|exclude
149
150
        $search_columns = ['multimedia_file_refn', 'descriptive_title'];
151
152
        $sort_columns = [
153
            0 => 'multimedia_file_refn',
154
            2 => new Expression('descriptive_title || multimedia_file_refn'),
155
        ];
156
157
        // Convert a row from the database into a row for datatables
158
        $callback = function (stdClass $row): array {
159
            /** @var Media $media */
160
            $media = Media::rowMapper()($row);
161
162
            $media_files = $media->mediaFiles()
163
                ->filter(static function (MediaFile $media_file) use ($row): bool {
164
                    return $media_file->filename() === $row->multimedia_file_refn;
165
                })
166
                ->map(static function (MediaFile $media_file): string {
167
                    return $media_file->displayImage(150, 150, '', []);
168
                })
169
                ->first();
170
171
            return [
172
                $row->multimedia_file_refn,
173
                $media_files,
174
                $this->mediaObjectInfo($media),
175
            ];
176
        };
177
178
        switch ($files) {
179
            case 'local':
180
                $query = DB::table('media_file')
181
                    ->join('media', static function (JoinClause $join): void {
182
                        $join
183
                            ->on('media.m_file', '=', 'media_file.m_file')
184
                            ->on('media.m_id', '=', 'media_file.m_id');
185
                    })
186
                    ->join('gedcom_setting', 'gedcom_id', '=', 'media.m_file')
187
                    ->where('setting_name', '=', 'MEDIA_DIRECTORY')
188
                    ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
189
                    ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
190
                    ->select(['media.*', 'multimedia_file_refn', 'descriptive_title']);
191
192
                $query->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%');
193
194
                if ($subfolders === 'exclude') {
195
                    $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
196
                }
197
198
                return $this->datatables_service->handleQuery($request, $query, $search_columns, $sort_columns, $callback);
199
200
            case 'external':
201
                $query = DB::table('media_file')
202
                    ->join('media', static function (JoinClause $join): void {
203
                        $join
204
                            ->on('media.m_file', '=', 'media_file.m_file')
205
                            ->on('media.m_id', '=', 'media_file.m_id');
206
                    })
207
                    ->where(static function (Builder $query): void {
208
                        $query
209
                            ->where('multimedia_file_refn', 'LIKE', 'http://%')
210
                            ->orWhere('multimedia_file_refn', 'LIKE', 'https://%');
211
                    })
212
                    ->select(['media.*', 'multimedia_file_refn', 'descriptive_title']);
213
214
                return $this->datatables_service->handleQuery($request, $query, $search_columns, $sort_columns, $callback);
215
216
            case 'unused':
217
                // Which trees use which media folder?
218
                $media_trees = DB::table('gedcom')
219
                    ->join('gedcom_setting', 'gedcom_setting.gedcom_id', '=', 'gedcom.gedcom_id')
220
                    ->where('setting_name', '=', 'MEDIA_DIRECTORY')
221
                    ->where('gedcom.gedcom_id', '>', 0)
222
                    ->pluck('setting_value', 'gedcom_name');
223
224
                $disk_files = $this->media_file_service->allFilesOnDisk($data_filesystem, $media_folder, $subfolders === 'include');
225
                $db_files   = $this->media_file_service->allFilesInDatabase($media_folder, $subfolders === 'include');
226
227
                // All unused files
228
                $unused_files = $disk_files->diff($db_files)
229
                    ->map(static function (string $file): array {
230
                        return (array) $file;
231
                    });
232
233
                $search_columns = [0];
234
235
                $sort_columns = [0 => 0];
236
237
                $callback = function (array $row) use ($media_folder, $media_trees): array {
238
                    $imgsize = getimagesize(WT_DATA_DIR . $row[0]);
239
                    // We can’t create a URL (not in public_html) or use the media firewall (no such object)
240
                    if ($imgsize === false) {
241
                        $mime_type = mime_content_type(WT_DATA_DIR . $row[0]) ?? 'application/octet-stream';
242
                        $img       = view('icons/mime', ['type' => $mime_type]);
243
                    } else {
244
                        $url = route('unused-media-thumbnail', [
245
                            'path' => $row[0],
246
                            'w'    => 100,
247
                            'h'    => 100,
248
                        ]);
249
                        $img = '<img src="' . e($url) . '">';
250
                    }
251
252
                    // Form to create new media object in each tree
253
                    $create_form = '';
254
                    foreach ($media_trees as $media_tree => $media_directory) {
255
                        if (Str::startsWith($row[0], $media_directory)) {
256
                            $tmp         = substr($row[0], strlen($media_directory));
257
                            $create_form .=
258
                                '<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>';
259
                        }
260
                    }
261
262
                    $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, [
263
                            'path'   => $row[0],
264
                        ])) . '" href="#">' . I18N::translate('Delete') . '</a></p>';
265
266
                    return [
267
                        $this->mediaFileInfo($media_folder, $row[0]) . $delete_link,
268
                        $img,
269
                        $create_form,
270
                    ];
271
                };
272
273
                return $this->datatables_service->handleCollection($request, $unused_files, $search_columns, $sort_columns, $callback);
274
275
            default:
276
                throw new BadRequestHttpException();
277
        }
278
    }
279
280
    /**
281
     * Generate some useful information and links about a media object.
282
     *
283
     * @param Media $media
284
     *
285
     * @return string HTML
286
     */
287
    private function mediaObjectInfo(Media $media): string
288
    {
289
        $html = '<b><a href="' . e($media->url()) . '">' . $media->fullName() . '</a></b>' . '<br><i>' . e($media->getNote()) . '</i></br><br>';
290
291
        $linked = [];
292
        foreach ($media->linkedIndividuals('OBJE') as $link) {
293
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
294
        }
295
        foreach ($media->linkedFamilies('OBJE') as $link) {
296
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
297
        }
298
        foreach ($media->linkedSources('OBJE') as $link) {
299
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
300
        }
301
        foreach ($media->linkedNotes('OBJE') as $link) {
302
            // Invalid GEDCOM - you cannot link a NOTE to an OBJE
303
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
304
        }
305
        foreach ($media->linkedRepositories('OBJE') as $link) {
306
            // Invalid GEDCOM - you cannot link a REPO to an OBJE
307
            $linked[] = '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
308
        }
309
        if ($linked !== []) {
310
            $html .= '<ul>';
311
            foreach ($linked as $link) {
312
                $html .= '<li>' . $link . '</li>';
313
            }
314
            $html .= '</ul>';
315
        } else {
316
            $html .= '<div class="alert alert-danger">' . I18N::translate('There are no links to this media object.') . '</div>';
317
        }
318
319
        return $html;
320
    }
321
322
    /**
323
     * Generate some useful information and links about a media file.
324
     *
325
     * @param string $media_folder
326
     * @param string $file
327
     *
328
     * @return string
329
     */
330
    private function mediaFileInfo(string $media_folder, string $file): string
331
    {
332
        $html = '<dl>';
333
        $html .= '<dt>' . I18N::translate('Filename') . '</dt>';
334
        $html .= '<dd>' . e($file) . '</dd>';
335
336
        $full_path = WT_DATA_DIR . $media_folder . $file;
337
        try {
338
            $size = filesize($full_path);
339
            $size = intdiv($size + 1023, 1024); // Round up to next KB
340
            /* I18N: size of file in KB */
341
            $size = I18N::translate('%s KB', I18N::number($size));
342
            $html .= '<dt>' . I18N::translate('File size') . '</dt>';
343
            $html .= '<dd>' . $size . '</dd>';
344
345
            try {
346
                $imgsize = getimagesize($full_path);
347
                $html    .= '<dt>' . I18N::translate('Image dimensions') . '</dt>';
348
                /* I18N: image dimensions, width × height */
349
                $html .= '<dd>' . I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1'])) . '</dd>';
350
            } catch (Throwable $ex) {
351
                // Not an image, or not a valid image?
352
            }
353
354
            $html .= '</dl>';
355
        } catch (Throwable $ex) {
356
            // Not a file?  Not an image?
357
        }
358
359
        return $html;
360
    }
361
362
    /**
363
     * @param ServerRequestInterface $request
364
     *
365
     * @return ResponseInterface
366
     */
367
    public function upload(ServerRequestInterface $request): ResponseInterface
368
    {
369
        $data_filesystem = $request->getAttribute('filesystem.data');
370
        assert($data_filesystem instanceof FilesystemInterface);
371
372
        $media_folders = $this->media_file_service->allMediaFolders($data_filesystem);
373
374
        $filesize = ini_get('upload_max_filesize') ?: '2M';
375
376
        $title = I18N::translate('Upload media files');
377
378
        return $this->viewResponse('admin/media-upload', [
379
            'max_upload_files' => self::MAX_UPLOAD_FILES,
380
            'filesize'         => $filesize,
381
            'media_folders'    => $media_folders,
382
            'title'            => $title,
383
        ]);
384
    }
385
386
    /**
387
     * @param ServerRequestInterface $request
388
     *
389
     * @return ResponseInterface
390
     */
391
    public function uploadAction(ServerRequestInterface $request): ResponseInterface
392
    {
393
        $data_filesystem = $request->getAttribute('filesystem.data');
394
        assert($data_filesystem instanceof FilesystemInterface);
395
396
        $all_folders = $this->media_file_service->allMediaFolders($data_filesystem);
397
398
        foreach ($request->getUploadedFiles() as $key => $uploaded_file) {
399
            assert($uploaded_file instanceof UploadedFileInterface);
400
            if ($uploaded_file->getClientFilename() === '') {
401
                continue;
402
            }
403
            if ($uploaded_file->getError() !== UPLOAD_ERR_OK) {
404
                FlashMessages::addMessage(Functions::fileUploadErrorText($uploaded_file->getError()), 'danger');
405
                continue;
406
            }
407
            $key = substr($key, 9);
408
409
            $folder   = $request->getParsedBody()['folder' . $key];
410
            $filename = $request->getParsedBody()['filename' . $key];
411
412
            // If no filename specified, use the original filename.
413
            if ($filename === '') {
414
                $filename = $uploaded_file->getClientFilename();
415
            }
416
417
            // Validate the folder
418
            if (!$all_folders->contains($folder)) {
419
                break;
420
            }
421
422
            // Validate the filename.
423
            $filename = str_replace('\\', '/', $filename);
424
            $filename = trim($filename, '/');
425
426
            if (preg_match('/([:])/', $filename, $match)) {
427
                // Local media files cannot contain certain special characters, especially on MS Windows
428
                FlashMessages::addMessage(I18N::translate('Filenames are not allowed to contain the character “%s”.', $match[1]));
429
                continue;
430
            }
431
432
            if (preg_match('/(\.(php|pl|cgi|bash|sh|bat|exe|com|htm|html|shtml))$/i', $filename, $match)) {
433
                // Do not allow obvious script files.
434
                FlashMessages::addMessage(I18N::translate('Filenames are not allowed to have the extension “%s”.', $match[1]));
435
                continue;
436
            }
437
438
            $path = $folder . $filename;
439
440
            if ($data_filesystem->has($path)) {
441
                FlashMessages::addMessage(I18N::translate('The file %s already exists. Use another filename.', $path, 'error'));
442
                continue;
443
            }
444
445
            // Now copy the file to the correct location.
446
            try {
447
                $data_filesystem->writeStream($path, $uploaded_file->getStream()->detach());
448
                FlashMessages::addMessage(I18N::translate('The file %s has been uploaded.', Html::filename($path)), 'success');
449
                Log::addMediaLog('Media file ' . $path . ' uploaded');
450
            } catch (Throwable $ex) {
451
                FlashMessages::addMessage(I18N::translate('There was an error uploading your file.') . '<br>' . e($ex->getMessage()), 'danger');
452
            }
453
        }
454
455
        $url = route('admin-media-upload');
456
457
        return redirect($url);
458
    }
459
}
460