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