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