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