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