xref: /webtrees/app/Services/MediaFileService.php (revision ede12dd76e86462107502e1d66b4be09c45656d0)
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 Psr\Http\Message\UploadedFileInterface;
38use RuntimeException;
39
40use function array_combine;
41use function array_diff;
42use function array_intersect;
43use function assert;
44use function dirname;
45use function explode;
46use function ini_get;
47use function intdiv;
48use function min;
49use function pathinfo;
50use function preg_replace;
51use function sha1;
52use function sort;
53use function str_contains;
54use function strtolower;
55use function strtr;
56use function substr;
57use function trim;
58
59use const PATHINFO_EXTENSION;
60use const UPLOAD_ERR_OK;
61
62/**
63 * Managing media files.
64 */
65class MediaFileService
66{
67    public const EXTENSION_TO_FORM = [
68        'jpeg' => 'jpg',
69        'tiff' => 'tif',
70    ];
71
72    private const IGNORE_FOLDERS = [
73        // Old versions of webtrees
74        'thumbs',
75        'watermarks',
76        // Windows
77        'Thumbs.db',
78        // Synology
79        '@eaDir',
80        // QNAP,
81        '.@__thumb',
82    ];
83
84    /**
85     * What is the largest file a user may upload?
86     */
87    public function maxUploadFilesize(): string
88    {
89        $sizePostMax   = $this->parseIniFileSize(ini_get('post_max_size'));
90        $sizeUploadMax = $this->parseIniFileSize(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 = $request->getAttribute('tree');
162        assert($tree instanceof Tree);
163
164        $data_filesystem = Registry::filesystem()->data();
165
166        $params        = (array) $request->getParsedBody();
167        $file_location = $params['file_location'];
168
169        switch ($file_location) {
170            case 'url':
171                $remote = $params['remote'];
172
173                if (str_contains($remote, '://')) {
174                    return $remote;
175                }
176
177                return '';
178
179            case 'unused':
180                $unused = $params['unused'];
181
182                if ($tree->mediaFilesystem($data_filesystem)->fileExists($unused)) {
183                    return $unused;
184                }
185
186                return '';
187
188            case 'upload':
189            default:
190                $folder   = $params['folder'];
191                $auto     = $params['auto'];
192                $new_file = $params['new_file'];
193
194                $uploaded_file = $request->getUploadedFiles()['file'] ?? null;
195
196                if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) {
197                    throw new FileUploadException($uploaded_file);
198                }
199
200                // The filename
201                $new_file = strtr($new_file, ['\\' => '/']);
202                if ($new_file !== '' && !str_contains($new_file, '/')) {
203                    $file = $new_file;
204                } else {
205                    $file = $uploaded_file->getClientFilename();
206                }
207
208                // The folder
209                $folder = strtr($folder, ['\\' => '/']);
210                $folder = trim($folder, '/');
211                if ($folder !== '') {
212                    $folder .= '/';
213                }
214
215                // Generate a unique name for the file?
216                if ($auto === '1' || $tree->mediaFilesystem($data_filesystem)->fileExists($folder . $file)) {
217                    $folder    = '';
218                    $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION);
219                    $file      = sha1((string) $uploaded_file->getStream()) . '.' . $extension;
220                }
221
222                try {
223                    $tree->mediaFilesystem($data_filesystem)->writeStream($folder . $file, $uploaded_file->getStream()->detach());
224
225                    return $folder . $file;
226                } catch (RuntimeException | InvalidArgumentException $ex) {
227                    FlashMessages::addMessage(I18N::translate('There was an error uploading your file.'));
228
229                    return '';
230                }
231        }
232    }
233
234    /**
235     * Convert the media file attributes into GEDCOM format.
236     *
237     * @param string $file
238     * @param string $type
239     * @param string $title
240     * @param string $note
241     *
242     * @return string
243     */
244    public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string
245    {
246        // Tidy non-printing characters
247        $type  = trim(preg_replace('/\s+/', ' ', $type));
248        $title = trim(preg_replace('/\s+/', ' ', $title));
249
250        $gedcom = '1 FILE ' . $file;
251
252        $format = strtolower(pathinfo($file, PATHINFO_EXTENSION));
253        $format = self::EXTENSION_TO_FORM[$format] ?? $format;
254
255        if ($format !== '') {
256            $gedcom .= "\n2 FORM " . $format;
257        } elseif ($type !== '') {
258            $gedcom .= "\n2 FORM";
259        }
260
261        if ($type !== '') {
262            $gedcom .= "\n3 TYPE " . $type;
263        }
264
265        if ($title !== '') {
266            $gedcom .= "\n2 TITL " . $title;
267        }
268
269        if ($note !== '') {
270            // Convert HTML line endings to GEDCOM continuations
271            $gedcom .= "\n1 NOTE " . strtr($note, ["\r\n" => "\n2 CONT "]);
272        }
273
274        return $gedcom;
275    }
276
277    /**
278     * Fetch a list of all files on disk (in folders used by any tree).
279     *
280     * @param FilesystemOperator $filesystem $filesystem to search
281     * @param string             $folder     Root folder
282     * @param bool               $subfolders Include subfolders
283     *
284     * @return Collection<string>
285     */
286    public function allFilesOnDisk(FilesystemOperator $filesystem, string $folder, bool $subfolders): Collection
287    {
288        try {
289            $files = $filesystem
290                ->listContents($folder, $subfolders)
291                ->filter(fn (StorageAttributes $attributes): bool => $attributes->isFile())
292                ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
293                ->map(fn (StorageAttributes $attributes): string => $attributes->path())
294                ->toArray();
295        } catch (FilesystemException $ex) {
296            $files = [];
297        }
298
299        return new Collection($files);
300    }
301
302    /**
303     * Fetch a list of all files on in the database.
304     *
305     * @param string $media_folder Root folder
306     * @param bool   $subfolders   Include subfolders
307     *
308     * @return Collection<string>
309     */
310    public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection
311    {
312        $query = DB::table('media_file')
313            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
314            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
315            //->where('multimedia_file_refn', 'LIKE', '%/%')
316            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
317            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
318            ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%')
319            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
320            ->orderBy(new Expression('setting_value || multimedia_file_refn'));
321
322        if (!$subfolders) {
323            $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
324        }
325
326        return $query->pluck('path');
327    }
328
329    /**
330     * Generate a list of all folders used by a tree.
331     *
332     * @param Tree $tree
333     *
334     * @return Collection<string>
335     * @throws FilesystemException
336     */
337    public function mediaFolders(Tree $tree): Collection
338    {
339        $folders = Registry::filesystem()->media($tree)
340            ->listContents('', FilesystemReader::LIST_DEEP)
341            ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir())
342            ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
343            ->map(fn (StorageAttributes $attributes): string => $attributes->path())
344            ->toArray();
345
346        return new Collection($folders);
347    }
348
349    /**
350     * Generate a list of all folders in either the database or the filesystem.
351     *
352     * @param FilesystemOperator $data_filesystem
353     *
354     * @return Collection<string,string>
355     * @throws FilesystemException
356     */
357    public function allMediaFolders(FilesystemOperator $data_filesystem): Collection
358    {
359        $db_folders = DB::table('media_file')
360            ->leftJoin('gedcom_setting', static function (JoinClause $join): void {
361                $join
362                    ->on('gedcom_id', '=', 'm_file')
363                    ->where('setting_name', '=', 'MEDIA_DIRECTORY');
364            })
365            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
366            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
367            ->select(new Expression("COALESCE(setting_value, 'media/') || multimedia_file_refn AS path"))
368            ->pluck('path')
369            ->map(static function (string $path): string {
370                return dirname($path) . '/';
371            });
372
373        $media_roots = DB::table('gedcom')
374            ->leftJoin('gedcom_setting', static function (JoinClause $join): void {
375                $join
376                    ->on('gedcom.gedcom_id', '=', 'gedcom_setting.gedcom_id')
377                    ->where('setting_name', '=', 'MEDIA_DIRECTORY');
378            })
379            ->where('gedcom.gedcom_id', '>', '0')
380            ->pluck(new Expression("COALESCE(setting_value, 'media/')"))
381            ->uniqueStrict();
382
383        $disk_folders = new Collection($media_roots);
384
385        foreach ($media_roots as $media_folder) {
386            $tmp = $data_filesystem
387                ->listContents($media_folder, FilesystemReader::LIST_DEEP)
388                ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir())
389                ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
390                ->map(fn (StorageAttributes $attributes): string => $attributes->path() . '/')
391                ->toArray();
392
393            $disk_folders = $disk_folders->concat($tmp);
394        }
395
396        return $disk_folders->concat($db_folders)
397            ->uniqueStrict()
398            ->mapWithKeys(static function (string $folder): array {
399                return [$folder => $folder];
400            });
401    }
402
403    /**
404     * Ignore special media folders.
405     *
406     * @param string $path
407     *
408     * @return bool
409     */
410    private function ignorePath(string $path): bool
411    {
412        return array_intersect(static::IGNORE_FOLDERS, explode('/', $path)) !== [];
413    }
414}
415