xref: /webtrees/app/Services/MediaFileService.php (revision 4c8fd9a21a2f83b2d6f6ff708f3d81a9938d02f3)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2023 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\DB;
23use Fisharebest\Webtrees\Exceptions\FileUploadException;
24use Fisharebest\Webtrees\FlashMessages;
25use Fisharebest\Webtrees\I18N;
26use Fisharebest\Webtrees\Registry;
27use Fisharebest\Webtrees\Tree;
28use Fisharebest\Webtrees\Validator;
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 is_float;
48use function min;
49use function pathinfo;
50use function sha1;
51use function sort;
52use function str_contains;
53use function strtoupper;
54use function strtr;
55use function substr;
56use function trim;
57
58use const PATHINFO_EXTENSION;
59use const PHP_INT_MAX;
60use const UPLOAD_ERR_OK;
61
62/**
63 * Managing media files.
64 */
65class MediaFileService
66{
67    private const IGNORE_FOLDERS = [
68        // Old versions of webtrees
69        'thumbs',
70        'watermarks',
71        // Windows
72        'Thumbs.db',
73        // Synology
74        '@eaDir',
75        // QNAP,
76        '.@__thumb',
77        // WebDAV,
78        '_DAV',
79    ];
80
81    /**
82     * What is the largest file a user may upload?
83     */
84    public function maxUploadFilesize(): string
85    {
86        $sizePostMax   = $this->parseIniFileSize((string) ini_get('post_max_size'));
87        $sizeUploadMax = $this->parseIniFileSize((string) 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        $units = [
107            'g' => 1073741824,
108            'G' => 1073741824,
109            'm' => 1048576,
110            'M' => 1048576,
111            'k' => 1024,
112            'K' => 1024,
113        ];
114
115        $number *= $units[substr($size, -1)] ?? 1;
116
117        if (is_float($number)) {
118            // Probably a 32bit version of PHP, with an INI setting >= 2GB
119            return PHP_INT_MAX;
120        }
121
122        return $number;
123    }
124
125    /**
126     * A list of media files not already linked to a media object.
127     *
128     * @param Tree $tree
129     *
130     * @return array<string>
131     */
132    public function unusedFiles(Tree $tree): 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();
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        $file_location = Validator::parsedBody($request)->string('file_location');
163
164        switch ($file_location) {
165            case 'url':
166                $remote = Validator::parsedBody($request)->string('remote');
167
168                if (str_contains($remote, '://')) {
169                    return $remote;
170                }
171
172                return '';
173
174            case 'unused':
175                $unused = Validator::parsedBody($request)->string('unused');
176
177                if ($tree->mediaFilesystem()->fileExists($unused)) {
178                    return $unused;
179                }
180
181                return '';
182
183            case 'upload':
184                $folder   = Validator::parsedBody($request)->string('folder');
185                $auto     = Validator::parsedBody($request)->string('auto');
186                $new_file = Validator::parsedBody($request)->string('new_file');
187
188                $uploaded_file = $request->getUploadedFiles()['file'] ?? null;
189
190                if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) {
191                    throw new FileUploadException($uploaded_file);
192                }
193
194                // The filename
195                $new_file = strtr($new_file, ['\\' => '/']);
196                if ($new_file !== '' && !str_contains($new_file, '/')) {
197                    $file = $new_file;
198                } else {
199                    $file = $uploaded_file->getClientFilename();
200                }
201
202                // The folder
203                $folder = strtr($folder, ['\\' => '/']);
204                $folder = trim($folder, '/');
205                if ($folder !== '') {
206                    $folder .= '/';
207                }
208
209                // Generate a unique name for the file?
210                if ($auto === '1' || $tree->mediaFilesystem()->fileExists($folder . $file)) {
211                    $folder    = '';
212                    $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION);
213                    $file      = sha1((string) $uploaded_file->getStream()) . '.' . $extension;
214                }
215
216                try {
217                    $tree->mediaFilesystem()->writeStream($folder . $file, $uploaded_file->getStream()->detach());
218
219                    return $folder . $file;
220                } catch (RuntimeException | InvalidArgumentException) {
221                    FlashMessages::addMessage(I18N::translate('There was an error uploading your file.'));
222
223                    return '';
224                }
225        }
226
227        return '';
228    }
229
230    /**
231     * Convert the media file attributes into GEDCOM format.
232     *
233     * @param string $file
234     * @param string $type
235     * @param string $title
236     * @param string $note
237     *
238     * @return string
239     */
240    public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string
241    {
242        $gedcom = '1 FILE ' . $file;
243
244        if (str_contains($file, '://')) {
245            $format = '';
246        } else {
247            $format = strtoupper(pathinfo($file, PATHINFO_EXTENSION));
248            $format = Registry::elementFactory()->make('OBJE:FILE:FORM')->canonical($format);
249        }
250
251        if ($format !== '') {
252            $gedcom .= "\n2 FORM " . strtr($format, ["\n" => "\n3 CONT "]);
253        } elseif ($type !== '') {
254            $gedcom .= "\n2 FORM";
255        }
256
257        if ($type !== '') {
258            $gedcom .= "\n3 TYPE " . strtr($type, ["\n" => "\n4 CONT "]);
259        }
260
261        if ($title !== '') {
262            $gedcom .= "\n2 TITL " . strtr($title, ["\n" => "\n3 CONT "]);
263        }
264
265        if ($note !== '') {
266            // Convert HTML line endings to GEDCOM continuations
267            $gedcom .= "\n1 NOTE " . strtr($note, ["\n" => "\n2 CONT "]);
268        }
269
270        return $gedcom;
271    }
272
273    /**
274     * Fetch a list of all files on disk (in folders used by any tree).
275     *
276     * @param FilesystemOperator $filesystem $filesystem to search
277     * @param string             $folder     Root folder
278     * @param bool               $subfolders Include subfolders
279     *
280     * @return Collection<int,string>
281     */
282    public function allFilesOnDisk(FilesystemOperator $filesystem, string $folder, bool $subfolders): Collection
283    {
284        try {
285            $files = $filesystem
286                ->listContents($folder, $subfolders)
287                ->filter(fn (StorageAttributes $attributes): bool => $attributes->isFile())
288                ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
289                ->map(fn (StorageAttributes $attributes): string => $attributes->path())
290                ->toArray();
291        } catch (FilesystemException) {
292            $files = [];
293        }
294
295        return new Collection($files);
296    }
297
298    /**
299     * Fetch a list of all files on in the database.
300     *
301     * @param string $media_folder Root folder
302     * @param bool   $subfolders   Include subfolders
303     *
304     * @return Collection<int,string>
305     */
306    public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection
307    {
308        $query = DB::table('media_file')
309            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
310            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
311            //->where('multimedia_file_refn', 'LIKE', '%/%')
312            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
313            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
314            ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%');
315
316        if (!$subfolders) {
317            $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
318        }
319
320        return $query
321            ->orderBy(new Expression('setting_value || multimedia_file_refn'))
322            ->pluck(new Expression('setting_value || multimedia_file_refn AS path'));
323    }
324
325    /**
326     * Generate a list of all folders used by a tree.
327     *
328     * @param Tree $tree
329     *
330     * @return Collection<int,string>
331     * @throws FilesystemException
332     */
333    public function mediaFolders(Tree $tree): Collection
334    {
335        $folders = $tree->mediaFilesystem()
336            ->listContents('', FilesystemReader::LIST_DEEP)
337            ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir())
338            ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
339            ->map(fn (StorageAttributes $attributes): string => $attributes->path())
340            ->toArray();
341
342        return new Collection($folders);
343    }
344
345    /**
346     * Generate a list of all folders in either the database or the filesystem.
347     *
348     * @param FilesystemOperator $data_filesystem
349     *
350     * @return Collection<array-key,string>
351     * @throws FilesystemException
352     */
353    public function allMediaFolders(FilesystemOperator $data_filesystem): Collection
354    {
355        $db_folders = DB::table('media_file')
356            ->leftJoin('gedcom_setting', static function (JoinClause $join): void {
357                $join
358                    ->on('gedcom_id', '=', 'm_file')
359                    ->where('setting_name', '=', 'MEDIA_DIRECTORY');
360            })
361            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
362            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
363            ->pluck(new Expression("COALESCE(setting_value, 'media/') || multimedia_file_refn AS 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/') AS path"))
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            ->sort(I18N::comparator())
394            ->mapWithKeys(static function (string $folder): array {
395                return [$folder => $folder];
396            });
397    }
398
399    /**
400     * Ignore special media folders.
401     *
402     * @param string $path
403     *
404     * @return bool
405     */
406    private function ignorePath(string $path): bool
407    {
408        return array_intersect(self::IGNORE_FOLDERS, explode('/', $path)) !== [];
409    }
410}
411