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