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