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