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