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