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