Passed
Push — master ( b00cb0...46b036 )
by Greg
05:27
created

MediaFileService::replacementImage()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 8
rs 10
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2020 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\Services;
21
22
use Fig\Http\Message\StatusCodeInterface;
23
use Fisharebest\Flysystem\Adapter\ChrootAdapter;
24
use Fisharebest\Webtrees\FlashMessages;
25
use Fisharebest\Webtrees\GedcomTag;
26
use Fisharebest\Webtrees\I18N;
27
use Fisharebest\Webtrees\Mime;
28
use Fisharebest\Webtrees\Tree;
29
use Illuminate\Database\Capsule\Manager as DB;
30
use Illuminate\Database\Query\Expression;
31
use Illuminate\Support\Collection;
32
use InvalidArgumentException;
33
use League\Flysystem\Adapter\Local;
34
use League\Flysystem\Filesystem;
35
use League\Flysystem\FilesystemInterface;
36
use League\Glide\Filesystem\FileNotFoundException;
37
use League\Glide\ServerFactory;
38
use Psr\Http\Message\ResponseInterface;
39
use Psr\Http\Message\ServerRequestInterface;
40
use Psr\Http\Message\UploadedFileInterface;
41
42
use RuntimeException;
43
use Throwable;
44
45
use function array_combine;
46
use function array_diff;
47
use function array_filter;
48
use function array_map;
49
use function assert;
50
use function dirname;
51
use function explode;
52
use function extension_loaded;
53
use function implode;
54
use function ini_get;
55
use function intdiv;
56
use function min;
57
use function pathinfo;
58
use function preg_replace;
59
use function response;
60
use function sha1;
61
use function sort;
62
use function str_contains;
63
use function strlen;
64
use function strtolower;
65
use function strtr;
66
use function substr;
67
use function trim;
68
69
use function view;
70
71
use const PATHINFO_EXTENSION;
72
use const UPLOAD_ERR_OK;
73
74
/**
75
 * Managing media files.
76
 */
77
class MediaFileService
78
{
79
    public const EDIT_RESTRICTIONS = [
80
        'locked',
81
    ];
82
83
    public const PRIVACY_RESTRICTIONS = [
84
        'none',
85
        'privacy',
86
        'confidential',
87
    ];
88
89
    public const EXTENSION_TO_FORM = [
90
        'jpg' => 'jpeg',
91
        'tif' => 'tiff',
92
    ];
93
94
    public const SUPPORTED_LIBRARIES = ['imagick', 'gd'];
95
96
    /**
97
     * What is the largest file a user may upload?
98
     */
99
    public function maxUploadFilesize(): string
100
    {
101
        $sizePostMax = $this->parseIniFileSize(ini_get('post_max_size'));
102
        $sizeUploadMax = $this->parseIniFileSize(ini_get('upload_max_filesize'));
103
104
        $bytes =  min($sizePostMax, $sizeUploadMax);
105
        $kb    = intdiv($bytes + 1023, 1024);
106
107
        return I18N::translate('%s KB', I18N::number($kb));
108
    }
109
110
    /**
111
     * Returns the given size from an ini value in bytes.
112
     *
113
     * @param string $size
114
     *
115
     * @return int
116
     */
117
    private function parseIniFileSize(string $size): int
118
    {
119
        $number = (int) $size;
120
121
        switch (substr($size, -1)) {
122
            case 'g':
123
            case 'G':
124
                return $number * 1073741824;
125
            case 'm':
126
            case 'M':
127
                return $number * 1048576;
128
            case 'k':
129
            case 'K':
130
                return $number * 1024;
131
            default:
132
                return $number;
133
        }
134
    }
135
136
    /**
137
     * A list of key/value options for media types.
138
     *
139
     * @param string $current
140
     *
141
     * @return array<string,string>
142
     */
143
    public function mediaTypes($current = ''): array
144
    {
145
        $media_types = GedcomTag::getFileFormTypes();
146
147
        $media_types = ['' => ''] + [$current => $current] + $media_types;
148
149
        return $media_types;
150
    }
151
152
    /**
153
     * A list of media files not already linked to a media object.
154
     *
155
     * @param Tree                $tree
156
     * @param FilesystemInterface $data_filesystem
157
     *
158
     * @return array<string>
159
     */
160
    public function unusedFiles(Tree $tree, FilesystemInterface $data_filesystem): array
161
    {
162
        $used_files = DB::table('media_file')
163
            ->where('m_file', '=', $tree->id())
164
            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
165
            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
166
            ->pluck('multimedia_file_refn')
167
            ->all();
168
169
        $disk_files = $tree->mediaFilesystem($data_filesystem)->listContents('', true);
170
171
        $disk_files = array_filter($disk_files, static function (array $item) {
172
            // Older versions of webtrees used a couple of special folders.
173
            return
174
                $item['type'] === 'file' &&
175
                !str_contains($item['path'], '/thumbs/') &&
0 ignored issues
show
Deprecated Code introduced by
The function str_contains() has been deprecated: Str::contains() should be used directly instead. Will be removed in Laravel 6.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

175
                !/** @scrutinizer ignore-deprecated */ str_contains($item['path'], '/thumbs/') &&

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
176
                !str_contains($item['path'], '/watermarks/');
0 ignored issues
show
Deprecated Code introduced by
The function str_contains() has been deprecated: Str::contains() should be used directly instead. Will be removed in Laravel 6.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

176
                !/** @scrutinizer ignore-deprecated */ str_contains($item['path'], '/watermarks/');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
177
        });
178
179
        $disk_files = array_map(static function (array $item): string {
180
            return $item['path'];
181
        }, $disk_files);
182
183
        $unused_files = array_diff($disk_files, $used_files);
184
185
        sort($unused_files);
186
187
        return array_combine($unused_files, $unused_files);
188
    }
189
190
    /**
191
     * Store an uploaded file (or URL), either to be added to a media object
192
     * or to create a media object.
193
     *
194
     * @param ServerRequestInterface $request
195
     *
196
     * @return string The value to be stored in the 'FILE' field of the media object.
197
     */
198
    public function uploadFile(ServerRequestInterface $request): string
199
    {
200
        $tree = $request->getAttribute('tree');
201
        assert($tree instanceof Tree);
202
203
        $data_filesystem = $request->getAttribute('filesystem.data');
204
        assert($data_filesystem instanceof FilesystemInterface);
205
206
        $params        = (array) $request->getParsedBody();
207
        $file_location = $params['file_location'];
208
209
        switch ($file_location) {
210
            case 'url':
211
                $remote = $params['remote'];
212
213
                if (str_contains($remote, '://')) {
0 ignored issues
show
Deprecated Code introduced by
The function str_contains() has been deprecated: Str::contains() should be used directly instead. Will be removed in Laravel 6.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

213
                if (/** @scrutinizer ignore-deprecated */ str_contains($remote, '://')) {

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
214
                    return $remote;
215
                }
216
217
                return '';
218
219
            case 'unused':
220
                $unused = $params['unused'];
221
222
                if ($tree->mediaFilesystem($data_filesystem)->has($unused)) {
223
                    return $unused;
224
                }
225
226
                return '';
227
228
            case 'upload':
229
            default:
230
                $folder   = $params['folder'];
231
                $auto     = $params['auto'];
232
                $new_file = $params['new_file'];
233
234
                /** @var UploadedFileInterface|null $uploaded_file */
235
                $uploaded_file = $request->getUploadedFiles()['file'];
236
                if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) {
237
                    return '';
238
                }
239
240
                // The filename
241
                $new_file = strtr($new_file, ['\\' => '/']);
242
                if ($new_file !== '' && !str_contains($new_file, '/')) {
0 ignored issues
show
Deprecated Code introduced by
The function str_contains() has been deprecated: Str::contains() should be used directly instead. Will be removed in Laravel 6.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

242
                if ($new_file !== '' && !/** @scrutinizer ignore-deprecated */ str_contains($new_file, '/')) {

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
243
                    $file = $new_file;
244
                } else {
245
                    $file = $uploaded_file->getClientFilename();
246
                }
247
248
                // The folder
249
                $folder = strtr($folder, ['\\' => '/']);
250
                $folder = trim($folder, '/');
251
                if ($folder !== '') {
252
                    $folder .= '/';
253
                }
254
255
                // Generate a unique name for the file?
256
                if ($auto === '1' || $tree->mediaFilesystem($data_filesystem)->has($folder . $file)) {
257
                    $folder    = '';
258
                    $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION);
259
                    $file      = sha1((string) $uploaded_file->getStream()) . '.' . $extension;
260
                }
261
262
                try {
263
                    $tree->mediaFilesystem($data_filesystem)->putStream($folder . $file, $uploaded_file->getStream()->detach());
264
265
                    return $folder . $file;
266
                } catch (RuntimeException | InvalidArgumentException $ex) {
267
                    FlashMessages::addMessage(I18N::translate('There was an error uploading your file.'));
268
269
                    return '';
270
                }
271
        }
272
    }
273
274
    /**
275
     * Convert the media file attributes into GEDCOM format.
276
     *
277
     * @param string $file
278
     * @param string $type
279
     * @param string $title
280
     * @param string $note
281
     *
282
     * @return string
283
     */
284
    public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string
285
    {
286
        // Tidy non-printing characters
287
        $type  = trim(preg_replace('/\s+/', ' ', $type));
288
        $title = trim(preg_replace('/\s+/', ' ', $title));
289
290
        $gedcom = '1 FILE ' . $file;
291
292
        $format = strtolower(pathinfo($file, PATHINFO_EXTENSION));
293
        $format = self::EXTENSION_TO_FORM[$format] ?? $format;
294
295
        if ($format !== '') {
296
            $gedcom .= "\n2 FORM " . $format;
297
        } elseif ($type !== '') {
298
            $gedcom .= "\n2 FORM";
299
        }
300
301
        if ($type !== '') {
302
            $gedcom .= "\n3 TYPE " . $type;
303
        }
304
305
        if ($title !== '') {
306
            $gedcom .= "\n2 TITL " . $title;
307
        }
308
309
        if ($note !== '') {
310
            // Convert HTML line endings to GEDCOM continuations
311
            $gedcom .= "\n1 NOTE " . strtr($note, ["\r\n" => "\n2 CONT "]);
312
        }
313
314
        return $gedcom;
315
    }
316
317
    /**
318
     * Fetch a list of all files on disk (in folders used by any tree).
319
     *
320
     * @param FilesystemInterface $data_filesystem Fileystem to search
321
     * @param string              $media_folder    Root folder
322
     * @param bool                $subfolders      Include subfolders
323
     *
324
     * @return Collection<string>
325
     */
326
    public function allFilesOnDisk(FilesystemInterface $data_filesystem, string $media_folder, bool $subfolders): Collection
327
    {
328
        $array = $data_filesystem->listContents($media_folder, $subfolders);
329
330
        return Collection::make($array)
331
            ->filter(static function (array $metadata): bool {
332
                return
333
                    $metadata['type'] === 'file' &&
334
                    !str_contains($metadata['path'], '/thumbs/') &&
0 ignored issues
show
Deprecated Code introduced by
The function str_contains() has been deprecated: Str::contains() should be used directly instead. Will be removed in Laravel 6.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

334
                    !/** @scrutinizer ignore-deprecated */ str_contains($metadata['path'], '/thumbs/') &&

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
335
                    !str_contains($metadata['path'], '/watermark/');
0 ignored issues
show
Deprecated Code introduced by
The function str_contains() has been deprecated: Str::contains() should be used directly instead. Will be removed in Laravel 6.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

335
                    !/** @scrutinizer ignore-deprecated */ str_contains($metadata['path'], '/watermark/');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
336
            })
337
            ->map(static function (array $metadata): string {
338
                return $metadata['path'];
339
            });
340
    }
341
342
    /**
343
     * Fetch a list of all files on in the database.
344
     *
345
     * @param string $media_folder Root folder
346
     * @param bool   $subfolders   Include subfolders
347
     *
348
     * @return Collection<string>
349
     */
350
    public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection
351
    {
352
        $query = DB::table('media_file')
353
            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
354
            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
355
            //->where('multimedia_file_refn', 'LIKE', '%/%')
356
            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
357
            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
358
            ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%')
359
            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
360
            ->orderBy(new Expression('setting_value || multimedia_file_refn'));
361
362
        if (!$subfolders) {
363
            $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
364
        }
365
366
        return $query->pluck('path');
367
    }
368
369
    /**
370
     * Generate a list of all folders in either the database or the filesystem.
371
     *
372
     * @param FilesystemInterface $data_filesystem
373
     *
374
     * @return Collection<string,string>
375
     */
376
    public function allMediaFolders(FilesystemInterface $data_filesystem): Collection
377
    {
378
        $db_folders = DB::table('media_file')
379
            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
380
            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
381
            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
382
            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
383
            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
384
            ->pluck('path')
385
            ->map(static function (string $path): string {
386
                return dirname($path) . '/';
387
            });
388
389
        $media_roots = DB::table('gedcom_setting')
390
            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
391
            ->where('gedcom_id', '>', '0')
392
            ->pluck('setting_value')
393
            ->uniqueStrict();
394
395
        $disk_folders = new Collection($media_roots);
396
397
        foreach ($media_roots as $media_folder) {
398
            $tmp = Collection::make($data_filesystem->listContents($media_folder, true))
399
                ->filter(static function (array $metadata) {
400
                    return $metadata['type'] === 'dir';
401
                })
402
                ->map(static function (array $metadata): string {
403
                    return $metadata['path'] . '/';
404
                })
405
                ->filter(static function (string $dir): bool {
406
                    return !str_contains($dir, '/thumbs/') && !str_contains($dir, 'watermarks');
0 ignored issues
show
Deprecated Code introduced by
The function str_contains() has been deprecated: Str::contains() should be used directly instead. Will be removed in Laravel 6.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

406
                    return !/** @scrutinizer ignore-deprecated */ str_contains($dir, '/thumbs/') && !str_contains($dir, 'watermarks');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
407
                });
408
409
            $disk_folders = $disk_folders->concat($tmp);
410
        }
411
412
        return $disk_folders->concat($db_folders)
413
            ->uniqueStrict()
414
            ->mapWithKeys(static function (string $folder): array {
415
                return [$folder => $folder];
416
            });
417
    }
418
419
    /**
420
     * Send a replacement image, to replace one that could not be found or created.
421
     *
422
     * @param string $status HTTP status code or file extension
423
     *
424
     * @return ResponseInterface
425
     */
426
    public function replacementImage(string $status): ResponseInterface
427
    {
428
        $svg = view('errors/image-svg', ['status' => $status]);
429
430
        // We can't use the actual status code, as browsers won't show images with 4xx/5xx
431
        return response($svg, StatusCodeInterface::STATUS_OK, [
432
            'Content-Type'   => 'image/svg+xml',
433
            'Content-Length' => (string) strlen($svg),
434
        ]);
435
    }
436
437
    /**
438
     * Generate a thumbnail image for a file.
439
     *
440
     * @param string              $folder
441
     * @param string              $file
442
     * @param FilesystemInterface $filesystem
443
     * @param array<string>       $params
444
     *
445
     * @return ResponseInterface
446
     */
447
    public function generateImage(string $folder, string $file, FilesystemInterface $filesystem, array $params): ResponseInterface
448
    {
449
        // Automatic rotation only works when the php-exif library is loaded.
450
        if (!extension_loaded('exif')) {
451
            $params['or'] = '0';
452
        }
453
454
        try {
455
            $cache_path           = 'thumbnail-cache/' . $folder;
456
            $cache_filesystem     = new Filesystem(new ChrootAdapter($filesystem, $cache_path));
457
            $source_filesystem    = new Filesystem(new ChrootAdapter($filesystem, $folder));
458
            $watermark_filesystem = new Filesystem(new Local('resources/img'));
459
460
            $server = ServerFactory::create([
461
                'cache'      => $cache_filesystem,
462
                'driver'     => $this->graphicsDriver(),
463
                'source'     => $source_filesystem,
464
                'watermarks' => $watermark_filesystem,
465
            ]);
466
467
            // Workaround for https://github.com/thephpleague/glide/issues/227
468
            $file = implode('/', array_map('rawurlencode', explode('/', $file)));
469
470
            $thumbnail = $server->makeImage($file, $params);
471
            $cache     = $server->getCache();
472
473
            return response($cache->read($thumbnail), StatusCodeInterface::STATUS_OK, [
474
                'Content-Type'   => $cache->getMimetype($thumbnail) ?: Mime::DEFAULT_TYPE,
475
                'Content-Length' => (string) $cache->getSize($thumbnail),
476
                'Cache-Control'  => 'public,max-age=31536000',
477
            ]);
478
        } catch (FileNotFoundException $ex) {
479
            return $this->replacementImage((string) StatusCodeInterface::STATUS_NOT_FOUND);
480
        } catch (Throwable $ex) {
481
            return $this->replacementImage((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
482
                ->withHeader('X-Thumbnail-Exception', $ex->getMessage());
483
        }
484
    }
485
486
    /**
487
     * Which graphics driver should we use for glide/intervention?
488
     * Prefer ImageMagick
489
     *
490
     * @return string
491
     */
492
    private function graphicsDriver(): string
493
    {
494
        foreach (self::SUPPORTED_LIBRARIES as $library) {
495
            if (extension_loaded($library)) {
496
                return $library;
497
            }
498
        }
499
500
        throw new RuntimeException('No PHP graphics library is installed.');
501
    }
502
}
503