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