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