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