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