xref: /webtrees/app/Services/MediaFileService.php (revision 46b03695d6e15198a900106a736438bdff9f42b0)
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
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Services;
21
22use Fig\Http\Message\StatusCodeInterface;
23use Fisharebest\Flysystem\Adapter\ChrootAdapter;
24use Fisharebest\Webtrees\FlashMessages;
25use Fisharebest\Webtrees\GedcomTag;
26use Fisharebest\Webtrees\I18N;
27use Fisharebest\Webtrees\Mime;
28use Fisharebest\Webtrees\Tree;
29use Illuminate\Database\Capsule\Manager as DB;
30use Illuminate\Database\Query\Expression;
31use Illuminate\Support\Collection;
32use InvalidArgumentException;
33use League\Flysystem\Adapter\Local;
34use League\Flysystem\Filesystem;
35use League\Flysystem\FilesystemInterface;
36use League\Glide\Filesystem\FileNotFoundException;
37use League\Glide\ServerFactory;
38use Psr\Http\Message\ResponseInterface;
39use Psr\Http\Message\ServerRequestInterface;
40use Psr\Http\Message\UploadedFileInterface;
41
42use RuntimeException;
43use Throwable;
44
45use function array_combine;
46use function array_diff;
47use function array_filter;
48use function array_map;
49use function assert;
50use function dirname;
51use function explode;
52use function extension_loaded;
53use function implode;
54use function ini_get;
55use function intdiv;
56use function min;
57use function pathinfo;
58use function preg_replace;
59use function response;
60use function sha1;
61use function sort;
62use function str_contains;
63use function strlen;
64use function strtolower;
65use function strtr;
66use function substr;
67use function trim;
68
69use function view;
70
71use const PATHINFO_EXTENSION;
72use const UPLOAD_ERR_OK;
73
74/**
75 * Managing media files.
76 */
77class 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/') &&
176                !str_contains($item['path'], '/watermarks/');
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, '://')) {
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, '/')) {
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/') &&
335                    !str_contains($metadata['path'], '/watermark/');
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');
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