xref: /webtrees/app/Services/MediaFileService.php (revision 9f9acdbc09170c04c1e150c36ee57d49027e314a)
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\Factory;
25use Fisharebest\Webtrees\FlashMessages;
26use Fisharebest\Webtrees\GedcomTag;
27use Fisharebest\Webtrees\I18N;
28use Fisharebest\Webtrees\Mime;
29use Fisharebest\Webtrees\Tree;
30use Illuminate\Database\Capsule\Manager as DB;
31use Illuminate\Database\Query\Expression;
32use Illuminate\Support\Collection;
33use InvalidArgumentException;
34use League\Flysystem\Adapter\Local;
35use League\Flysystem\Filesystem;
36use League\Flysystem\FilesystemInterface;
37use League\Glide\Filesystem\FileNotFoundException;
38use League\Glide\ServerFactory;
39use Psr\Http\Message\ResponseInterface;
40use Psr\Http\Message\ServerRequestInterface;
41use Psr\Http\Message\UploadedFileInterface;
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;
68use function view;
69
70use const PATHINFO_EXTENSION;
71use const UPLOAD_ERR_OK;
72
73/**
74 * Managing media files.
75 */
76class MediaFileService
77{
78    public const EDIT_RESTRICTIONS = [
79        'locked',
80    ];
81
82    public const PRIVACY_RESTRICTIONS = [
83        'none',
84        'privacy',
85        'confidential',
86    ];
87
88    public const EXTENSION_TO_FORM = [
89        'jpg' => 'jpeg',
90        'tif' => 'tiff',
91    ];
92
93    public const SUPPORTED_LIBRARIES = ['imagick', 'gd'];
94
95    /**
96     * What is the largest file a user may upload?
97     */
98    public function maxUploadFilesize(): string
99    {
100        $sizePostMax = $this->parseIniFileSize(ini_get('post_max_size'));
101        $sizeUploadMax = $this->parseIniFileSize(ini_get('upload_max_filesize'));
102
103        $bytes =  min($sizePostMax, $sizeUploadMax);
104        $kb    = intdiv($bytes + 1023, 1024);
105
106        return I18N::translate('%s KB', I18N::number($kb));
107    }
108
109    /**
110     * Returns the given size from an ini value in bytes.
111     *
112     * @param string $size
113     *
114     * @return int
115     */
116    private function parseIniFileSize(string $size): int
117    {
118        $number = (int) $size;
119
120        switch (substr($size, -1)) {
121            case 'g':
122            case 'G':
123                return $number * 1073741824;
124            case 'm':
125            case 'M':
126                return $number * 1048576;
127            case 'k':
128            case 'K':
129                return $number * 1024;
130            default:
131                return $number;
132        }
133    }
134
135    /**
136     * A list of key/value options for media types.
137     *
138     * @param string $current
139     *
140     * @return array<string,string>
141     */
142    public function mediaTypes($current = ''): array
143    {
144        $media_types = GedcomTag::getFileFormTypes();
145
146        $media_types = ['' => ''] + [$current => $current] + $media_types;
147
148        return $media_types;
149    }
150
151    /**
152     * A list of media files not already linked to a media object.
153     *
154     * @param Tree                $tree
155     * @param FilesystemInterface $data_filesystem
156     *
157     * @return array<string>
158     */
159    public function unusedFiles(Tree $tree, FilesystemInterface $data_filesystem): array
160    {
161        $used_files = DB::table('media_file')
162            ->where('m_file', '=', $tree->id())
163            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
164            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
165            ->pluck('multimedia_file_refn')
166            ->all();
167
168        $disk_files = $tree->mediaFilesystem($data_filesystem)->listContents('', true);
169
170        $disk_files = array_filter($disk_files, static function (array $item) {
171            // Older versions of webtrees used a couple of special folders.
172            return
173                $item['type'] === 'file' &&
174                !str_contains($item['path'], '/thumbs/') &&
175                !str_contains($item['path'], '/watermarks/');
176        });
177
178        $disk_files = array_map(static function (array $item): string {
179            return $item['path'];
180        }, $disk_files);
181
182        $unused_files = array_diff($disk_files, $used_files);
183
184        sort($unused_files);
185
186        return array_combine($unused_files, $unused_files);
187    }
188
189    /**
190     * Store an uploaded file (or URL), either to be added to a media object
191     * or to create a media object.
192     *
193     * @param ServerRequestInterface $request
194     *
195     * @return string The value to be stored in the 'FILE' field of the media object.
196     */
197    public function uploadFile(ServerRequestInterface $request): string
198    {
199        $tree = $request->getAttribute('tree');
200        assert($tree instanceof Tree);
201
202        $data_filesystem = Factory::filesystem()->data();
203
204        $params        = (array) $request->getParsedBody();
205        $file_location = $params['file_location'];
206
207        switch ($file_location) {
208            case 'url':
209                $remote = $params['remote'];
210
211                if (str_contains($remote, '://')) {
212                    return $remote;
213                }
214
215                return '';
216
217            case 'unused':
218                $unused = $params['unused'];
219
220                if ($tree->mediaFilesystem($data_filesystem)->has($unused)) {
221                    return $unused;
222                }
223
224                return '';
225
226            case 'upload':
227            default:
228                $folder   = $params['folder'];
229                $auto     = $params['auto'];
230                $new_file = $params['new_file'];
231
232                /** @var UploadedFileInterface|null $uploaded_file */
233                $uploaded_file = $request->getUploadedFiles()['file'];
234                if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) {
235                    return '';
236                }
237
238                // The filename
239                $new_file = strtr($new_file, ['\\' => '/']);
240                if ($new_file !== '' && !str_contains($new_file, '/')) {
241                    $file = $new_file;
242                } else {
243                    $file = $uploaded_file->getClientFilename();
244                }
245
246                // The folder
247                $folder = strtr($folder, ['\\' => '/']);
248                $folder = trim($folder, '/');
249                if ($folder !== '') {
250                    $folder .= '/';
251                }
252
253                // Generate a unique name for the file?
254                if ($auto === '1' || $tree->mediaFilesystem($data_filesystem)->has($folder . $file)) {
255                    $folder    = '';
256                    $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION);
257                    $file      = sha1((string) $uploaded_file->getStream()) . '.' . $extension;
258                }
259
260                try {
261                    $tree->mediaFilesystem($data_filesystem)->putStream($folder . $file, $uploaded_file->getStream()->detach());
262
263                    return $folder . $file;
264                } catch (RuntimeException | InvalidArgumentException $ex) {
265                    FlashMessages::addMessage(I18N::translate('There was an error uploading your file.'));
266
267                    return '';
268                }
269        }
270    }
271
272    /**
273     * Convert the media file attributes into GEDCOM format.
274     *
275     * @param string $file
276     * @param string $type
277     * @param string $title
278     * @param string $note
279     *
280     * @return string
281     */
282    public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string
283    {
284        // Tidy non-printing characters
285        $type  = trim(preg_replace('/\s+/', ' ', $type));
286        $title = trim(preg_replace('/\s+/', ' ', $title));
287
288        $gedcom = '1 FILE ' . $file;
289
290        $format = strtolower(pathinfo($file, PATHINFO_EXTENSION));
291        $format = self::EXTENSION_TO_FORM[$format] ?? $format;
292
293        if ($format !== '') {
294            $gedcom .= "\n2 FORM " . $format;
295        } elseif ($type !== '') {
296            $gedcom .= "\n2 FORM";
297        }
298
299        if ($type !== '') {
300            $gedcom .= "\n3 TYPE " . $type;
301        }
302
303        if ($title !== '') {
304            $gedcom .= "\n2 TITL " . $title;
305        }
306
307        if ($note !== '') {
308            // Convert HTML line endings to GEDCOM continuations
309            $gedcom .= "\n1 NOTE " . strtr($note, ["\r\n" => "\n2 CONT "]);
310        }
311
312        return $gedcom;
313    }
314
315    /**
316     * Fetch a list of all files on disk (in folders used by any tree).
317     *
318     * @param FilesystemInterface $data_filesystem Fileystem to search
319     * @param string              $media_folder    Root folder
320     * @param bool                $subfolders      Include subfolders
321     *
322     * @return Collection<string>
323     */
324    public function allFilesOnDisk(FilesystemInterface $data_filesystem, string $media_folder, bool $subfolders): Collection
325    {
326        $array = $data_filesystem->listContents($media_folder, $subfolders);
327
328        return Collection::make($array)
329            ->filter(static function (array $metadata): bool {
330                return
331                    $metadata['type'] === 'file' &&
332                    !str_contains($metadata['path'], '/thumbs/') &&
333                    !str_contains($metadata['path'], '/watermark/');
334            })
335            ->map(static function (array $metadata): string {
336                return $metadata['path'];
337            });
338    }
339
340    /**
341     * Fetch a list of all files on in the database.
342     *
343     * @param string $media_folder Root folder
344     * @param bool   $subfolders   Include subfolders
345     *
346     * @return Collection<string>
347     */
348    public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection
349    {
350        $query = DB::table('media_file')
351            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
352            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
353            //->where('multimedia_file_refn', 'LIKE', '%/%')
354            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
355            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
356            ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%')
357            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
358            ->orderBy(new Expression('setting_value || multimedia_file_refn'));
359
360        if (!$subfolders) {
361            $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
362        }
363
364        return $query->pluck('path');
365    }
366
367    /**
368     * Generate a list of all folders in either the database or the filesystem.
369     *
370     * @param FilesystemInterface $data_filesystem
371     *
372     * @return Collection<string,string>
373     */
374    public function allMediaFolders(FilesystemInterface $data_filesystem): Collection
375    {
376        $db_folders = DB::table('media_file')
377            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
378            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
379            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
380            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
381            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
382            ->pluck('path')
383            ->map(static function (string $path): string {
384                return dirname($path) . '/';
385            });
386
387        $media_roots = DB::table('gedcom_setting')
388            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
389            ->where('gedcom_id', '>', '0')
390            ->pluck('setting_value')
391            ->uniqueStrict();
392
393        $disk_folders = new Collection($media_roots);
394
395        foreach ($media_roots as $media_folder) {
396            $tmp = Collection::make($data_filesystem->listContents($media_folder, true))
397                ->filter(static function (array $metadata) {
398                    return $metadata['type'] === 'dir';
399                })
400                ->map(static function (array $metadata): string {
401                    return $metadata['path'] . '/';
402                })
403                ->filter(static function (string $dir): bool {
404                    return !str_contains($dir, '/thumbs/') && !str_contains($dir, 'watermarks');
405                });
406
407            $disk_folders = $disk_folders->concat($tmp);
408        }
409
410        return $disk_folders->concat($db_folders)
411            ->uniqueStrict()
412            ->mapWithKeys(static function (string $folder): array {
413                return [$folder => $folder];
414            });
415    }
416
417    /**
418     * Send a replacement image, to replace one that could not be found or created.
419     *
420     * @param string $status HTTP status code or file extension
421     *
422     * @return ResponseInterface
423     */
424    public function replacementImage(string $status): ResponseInterface
425    {
426        $svg = view('errors/image-svg', ['status' => $status]);
427
428        // We can't use the actual status code, as browsers won't show images with 4xx/5xx
429        return response($svg, StatusCodeInterface::STATUS_OK, [
430            'Content-Type'   => 'image/svg+xml',
431            'Content-Length' => (string) strlen($svg),
432        ]);
433    }
434
435    /**
436     * Generate a thumbnail image for a file.
437     *
438     * @param string              $folder
439     * @param string              $file
440     * @param FilesystemInterface $filesystem
441     * @param array<string>       $params
442     *
443     * @return ResponseInterface
444     */
445    public function generateImage(string $folder, string $file, FilesystemInterface $filesystem, array $params): ResponseInterface
446    {
447        // Automatic rotation only works when the php-exif library is loaded.
448        if (!extension_loaded('exif')) {
449            $params['or'] = '0';
450        }
451
452        try {
453            $cache_path           = 'thumbnail-cache/' . $folder;
454            $cache_filesystem     = new Filesystem(new ChrootAdapter($filesystem, $cache_path));
455            $source_filesystem    = new Filesystem(new ChrootAdapter($filesystem, $folder));
456            $watermark_filesystem = new Filesystem(new Local('resources/img'));
457
458            $server = ServerFactory::create([
459                'cache'      => $cache_filesystem,
460                'driver'     => $this->graphicsDriver(),
461                'source'     => $source_filesystem,
462                'watermarks' => $watermark_filesystem,
463            ]);
464
465            // Workaround for https://github.com/thephpleague/glide/issues/227
466            $file = implode('/', array_map('rawurlencode', explode('/', $file)));
467
468            $thumbnail = $server->makeImage($file, $params);
469            $cache     = $server->getCache();
470
471            return response($cache->read($thumbnail), StatusCodeInterface::STATUS_OK, [
472                'Content-Type'   => $cache->getMimetype($thumbnail) ?: Mime::DEFAULT_TYPE,
473                'Content-Length' => (string) $cache->getSize($thumbnail),
474                'Cache-Control'  => 'public,max-age=31536000',
475            ]);
476        } catch (FileNotFoundException $ex) {
477            return $this->replacementImage((string) StatusCodeInterface::STATUS_NOT_FOUND);
478        } catch (Throwable $ex) {
479            return $this->replacementImage((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
480                ->withHeader('X-Thumbnail-Exception', $ex->getMessage());
481        }
482    }
483
484    /**
485     * Which graphics driver should we use for glide/intervention?
486     * Prefer ImageMagick
487     *
488     * @return string
489     */
490    private function graphicsDriver(): string
491    {
492        foreach (self::SUPPORTED_LIBRARIES as $library) {
493            if (extension_loaded($library)) {
494                return $library;
495            }
496        }
497
498        throw new RuntimeException('No PHP graphics library is installed.');
499    }
500}
501