xref: /webtrees/app/Services/MediaFileService.php (revision 728c8c2714cc197d45bad7363eb52e0bff3297bb)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Services;
21
22use Fisharebest\Webtrees\FlashMessages;
23use Fisharebest\Webtrees\I18N;
24use Fisharebest\Webtrees\Registry;
25use Fisharebest\Webtrees\Tree;
26use Illuminate\Database\Capsule\Manager as DB;
27use Illuminate\Database\Query\Expression;
28use Illuminate\Database\Query\JoinClause;
29use Illuminate\Support\Collection;
30use InvalidArgumentException;
31use League\Flysystem\Filesystem;
32use League\Flysystem\FilesystemException;
33use League\Flysystem\FilesystemOperator;
34use League\Flysystem\FilesystemReader;
35use League\Flysystem\StorageAttributes;
36use Psr\Http\Message\ServerRequestInterface;
37use Psr\Http\Message\UploadedFileInterface;
38use RuntimeException;
39
40use function array_combine;
41use function array_diff;
42use function array_intersect;
43use function assert;
44use function dirname;
45use function explode;
46use function ini_get;
47use function intdiv;
48use function min;
49use function pathinfo;
50use function preg_replace;
51use function sha1;
52use function sort;
53use function str_contains;
54use function strtolower;
55use function strtr;
56use function substr;
57use function trim;
58
59use const PATHINFO_EXTENSION;
60use const UPLOAD_ERR_OK;
61
62/**
63 * Managing media files.
64 */
65class MediaFileService
66{
67    public const EDIT_RESTRICTIONS = [
68        'locked',
69    ];
70
71    public const PRIVACY_RESTRICTIONS = [
72        'none',
73        'privacy',
74        'confidential',
75    ];
76
77    public const EXTENSION_TO_FORM = [
78        'jpeg' => 'jpg',
79        'tiff' => 'tif',
80    ];
81
82    private const IGNORE_FOLDERS = [
83        // Old versions of webtrees
84        'thumbs',
85        'watermarks',
86        // Windows
87        'Thumbs.db',
88        // Synology
89        '@eaDir',
90        // QNAP,
91        '.@__thumb',
92    ];
93
94    /**
95     * What is the largest file a user may upload?
96     */
97    public function maxUploadFilesize(): string
98    {
99        $sizePostMax = $this->parseIniFileSize(ini_get('post_max_size'));
100        $sizeUploadMax = $this->parseIniFileSize(ini_get('upload_max_filesize'));
101
102        $bytes =  min($sizePostMax, $sizeUploadMax);
103        $kb    = intdiv($bytes + 1023, 1024);
104
105        return I18N::translate('%s KB', I18N::number($kb));
106    }
107
108    /**
109     * Returns the given size from an ini value in bytes.
110     *
111     * @param string $size
112     *
113     * @return int
114     */
115    private function parseIniFileSize(string $size): int
116    {
117        $number = (int) $size;
118
119        switch (substr($size, -1)) {
120            case 'g':
121            case 'G':
122                return $number * 1073741824;
123            case 'm':
124            case 'M':
125                return $number * 1048576;
126            case 'k':
127            case 'K':
128                return $number * 1024;
129            default:
130                return $number;
131        }
132    }
133
134    /**
135     * A list of media files not already linked to a media object.
136     *
137     * @param Tree               $tree
138     * @param FilesystemOperator $data_filesystem
139     *
140     * @return array<string>
141     */
142    public function unusedFiles(Tree $tree, FilesystemOperator $data_filesystem): array
143    {
144        $used_files = DB::table('media_file')
145            ->where('m_file', '=', $tree->id())
146            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
147            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
148            ->pluck('multimedia_file_refn')
149            ->all();
150
151        $media_filesystem = $tree->mediaFilesystem($data_filesystem);
152        $disk_files       = $this->allFilesOnDisk($media_filesystem, '', FilesystemReader::LIST_DEEP)->all();
153        $unused_files     = array_diff($disk_files, $used_files);
154
155        sort($unused_files);
156
157        return array_combine($unused_files, $unused_files);
158    }
159
160    /**
161     * Store an uploaded file (or URL), either to be added to a media object
162     * or to create a media object.
163     *
164     * @param ServerRequestInterface $request
165     *
166     * @return string The value to be stored in the 'FILE' field of the media object.
167     * @throws FilesystemException
168     */
169    public function uploadFile(ServerRequestInterface $request): string
170    {
171        $tree = $request->getAttribute('tree');
172        assert($tree instanceof Tree);
173
174        $data_filesystem = Registry::filesystem()->data();
175
176        $params        = (array) $request->getParsedBody();
177        $file_location = $params['file_location'];
178
179        switch ($file_location) {
180            case 'url':
181                $remote = $params['remote'];
182
183                if (str_contains($remote, '://')) {
184                    return $remote;
185                }
186
187                return '';
188
189            case 'unused':
190                $unused = $params['unused'];
191
192                if ($tree->mediaFilesystem($data_filesystem)->fileExists($unused)) {
193                    return $unused;
194                }
195
196                return '';
197
198            case 'upload':
199            default:
200                $folder   = $params['folder'];
201                $auto     = $params['auto'];
202                $new_file = $params['new_file'];
203
204                /** @var UploadedFileInterface|null $uploaded_file */
205                $uploaded_file = $request->getUploadedFiles()['file'];
206                if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) {
207                    return '';
208                }
209
210                // The filename
211                $new_file = strtr($new_file, ['\\' => '/']);
212                if ($new_file !== '' && !str_contains($new_file, '/')) {
213                    $file = $new_file;
214                } else {
215                    $file = $uploaded_file->getClientFilename();
216                }
217
218                // The folder
219                $folder = strtr($folder, ['\\' => '/']);
220                $folder = trim($folder, '/');
221                if ($folder !== '') {
222                    $folder .= '/';
223                }
224
225                // Generate a unique name for the file?
226                if ($auto === '1' || $tree->mediaFilesystem($data_filesystem)->fileExists($folder . $file)) {
227                    $folder    = '';
228                    $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION);
229                    $file      = sha1((string) $uploaded_file->getStream()) . '.' . $extension;
230                }
231
232                try {
233                    $tree->mediaFilesystem($data_filesystem)->writeStream($folder . $file, $uploaded_file->getStream()->detach());
234
235                    return $folder . $file;
236                } catch (RuntimeException | InvalidArgumentException $ex) {
237                    FlashMessages::addMessage(I18N::translate('There was an error uploading your file.'));
238
239                    return '';
240                }
241        }
242    }
243
244    /**
245     * Convert the media file attributes into GEDCOM format.
246     *
247     * @param string $file
248     * @param string $type
249     * @param string $title
250     * @param string $note
251     *
252     * @return string
253     */
254    public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string
255    {
256        // Tidy non-printing characters
257        $type  = trim(preg_replace('/\s+/', ' ', $type));
258        $title = trim(preg_replace('/\s+/', ' ', $title));
259
260        $gedcom = '1 FILE ' . $file;
261
262        $format = strtolower(pathinfo($file, PATHINFO_EXTENSION));
263        $format = self::EXTENSION_TO_FORM[$format] ?? $format;
264
265        if ($format !== '') {
266            $gedcom .= "\n2 FORM " . $format;
267        } elseif ($type !== '') {
268            $gedcom .= "\n2 FORM";
269        }
270
271        if ($type !== '') {
272            $gedcom .= "\n3 TYPE " . $type;
273        }
274
275        if ($title !== '') {
276            $gedcom .= "\n2 TITL " . $title;
277        }
278
279        if ($note !== '') {
280            // Convert HTML line endings to GEDCOM continuations
281            $gedcom .= "\n1 NOTE " . strtr($note, ["\r\n" => "\n2 CONT "]);
282        }
283
284        return $gedcom;
285    }
286
287    /**
288     * Fetch a list of all files on disk (in folders used by any tree).
289     *
290     * @param FilesystemOperator $filesystem $filesystem to search
291     * @param string             $folder     Root folder
292     * @param bool               $subfolders Include subfolders
293     *
294     * @return Collection<string>
295     */
296    public function allFilesOnDisk(FilesystemOperator $filesystem, string $folder, bool $subfolders): Collection
297    {
298        try {
299            $files = $filesystem
300                ->listContents($folder, $subfolders)
301                ->filter(fn (StorageAttributes $attributes): bool => $attributes->isFile())
302                ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
303                ->map(fn (StorageAttributes $attributes): string => $attributes->path())
304                ->toArray();
305        } catch (FilesystemException $ex) {
306            $files = [];
307        }
308
309        return new Collection($files);
310    }
311
312    /**
313     * Fetch a list of all files on in the database.
314     *
315     * @param string $media_folder Root folder
316     * @param bool   $subfolders   Include subfolders
317     *
318     * @return Collection<string>
319     */
320    public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection
321    {
322        $query = DB::table('media_file')
323            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
324            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
325            //->where('multimedia_file_refn', 'LIKE', '%/%')
326            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
327            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
328            ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%')
329            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
330            ->orderBy(new Expression('setting_value || multimedia_file_refn'));
331
332        if (!$subfolders) {
333            $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
334        }
335
336        return $query->pluck('path');
337    }
338
339    /**
340     * Generate a list of all folders used by a tree.
341     *
342     * @param Tree $tree
343     *
344     * @return Collection<string>
345     * @throws FilesystemException
346     */
347    public function mediaFolders(Tree $tree): Collection
348    {
349        $folders = Registry::filesystem()->media($tree)
350            ->listContents('', FilesystemReader::LIST_DEEP)
351            ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir())
352            ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
353            ->map(fn (StorageAttributes $attributes): string => $attributes->path())
354            ->toArray();
355
356        return new Collection($folders);
357    }
358
359    /**
360     * Generate a list of all folders in either the database or the filesystem.
361     *
362     * @param FilesystemOperator $data_filesystem
363     *
364     * @return Collection<string,string>
365     * @throws FilesystemException
366     */
367    public function allMediaFolders(FilesystemOperator $data_filesystem): Collection
368    {
369        $db_folders = DB::table('media_file')
370            ->leftJoin('gedcom_setting', static function (JoinClause $join): void {
371                $join
372                    ->on('gedcom_id', '=', 'm_file')
373                    ->where('setting_name', '=', 'MEDIA_DIRECTORY');
374            })
375            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
376            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
377            ->select(new Expression("COALESCE(setting_value, 'media/') || multimedia_file_refn AS path"))
378            ->pluck('path')
379            ->map(static function (string $path): string {
380                return dirname($path) . '/';
381            });
382
383        $media_roots = DB::table('gedcom')
384            ->leftJoin('gedcom_setting', static function (JoinClause $join): void {
385                $join
386                    ->on('gedcom.gedcom_id', '=', 'gedcom_setting.gedcom_id')
387                    ->where('setting_name', '=', 'MEDIA_DIRECTORY');
388            })
389            ->where('gedcom.gedcom_id', '>', '0')
390            ->pluck(new Expression("COALESCE(setting_value, 'media/')"))
391            ->uniqueStrict();
392
393        $disk_folders = new Collection($media_roots);
394
395        foreach ($media_roots as $media_folder) {
396            $tmp = $data_filesystem
397                ->listContents($media_folder, FilesystemReader::LIST_DEEP)
398                ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir())
399                ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
400                ->map(fn (StorageAttributes $attributes): string => $attributes->path() . '/')
401                ->toArray();
402
403            $disk_folders = $disk_folders->concat($tmp);
404        }
405
406        return $disk_folders->concat($db_folders)
407            ->uniqueStrict()
408            ->mapWithKeys(static function (string $folder): array {
409                return [$folder => $folder];
410            });
411    }
412
413    /**
414     * Ignore special media folders.
415     *
416     * @param string $path
417     *
418     * @return bool
419     */
420    private function ignorePath(string $path): bool
421    {
422        return array_intersect(static::IGNORE_FOLDERS, explode('/', $path)) !== [];
423    }
424}
425