Issues (165)

app/Services/MediaFileService.php (1 issue)

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