Passed
Push — develop ( e50e9c...6c809f )
by Greg
20:25 queued 05:22
created

MediaFileService::maxUploadFilesize()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 5
nc 1
nop 0
dl 0
loc 9
rs 10
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2022 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\Exceptions\FileUploadException;
23
use Fisharebest\Webtrees\FlashMessages;
24
use Fisharebest\Webtrees\I18N;
25
use Fisharebest\Webtrees\Registry;
26
use Fisharebest\Webtrees\Tree;
27
use Fisharebest\Webtrees\Validator;
28
use Illuminate\Database\Capsule\Manager as DB;
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
    public const EXTENSION_TO_FORM = [
68
        'JPEG' => 'JPG',
69
        'TIFF' => 'TIF',
70
    ];
71
72
    private const IGNORE_FOLDERS = [
73
        // Old versions of webtrees
74
        'thumbs',
75
        'watermarks',
76
        // Windows
77
        'Thumbs.db',
78
        // Synology
79
        '@eaDir',
80
        // QNAP,
81
        '.@__thumb',
82
        // WebDAV,
83
        '_DAV',
84
    ];
85
86
    /**
87
     * What is the largest file a user may upload?
88
     */
89
    public function maxUploadFilesize(): string
90
    {
91
        $sizePostMax   = $this->parseIniFileSize((string) ini_get('post_max_size'));
92
        $sizeUploadMax = $this->parseIniFileSize((string) ini_get('upload_max_filesize'));
93
94
        $bytes = min($sizePostMax, $sizeUploadMax);
95
        $kb    = intdiv($bytes + 1023, 1024);
96
97
        return I18N::translate('%s KB', I18N::number($kb));
98
    }
99
100
    /**
101
     * Returns the given size from an ini value in bytes.
102
     *
103
     * @param string $size
104
     *
105
     * @return int
106
     */
107
    private function parseIniFileSize(string $size): int
108
    {
109
        $number = (int) $size;
110
111
        $units = [
112
            'g' => 1073741824,
113
            'G' => 1073741824,
114
            'm' => 1048576,
115
            'M' => 1048576,
116
            'k' => 1024,
117
            'K' => 1024,
118
        ];
119
120
        $number *= $units[substr($size, -1)] ?? 1;
121
122
        if (is_float($number)) {
0 ignored issues
show
introduced by
The condition is_float($number) is always false.
Loading history...
123
            // Probably a 32bit version of PHP, with an INI setting >= 2GB
124
            return PHP_INT_MAX;
125
        }
126
127
        return $number;
128
    }
129
130
    /**
131
     * A list of media files not already linked to a media object.
132
     *
133
     * @param Tree               $tree
134
     * @param FilesystemOperator $data_filesystem
135
     *
136
     * @return array<string>
137
     */
138
    public function unusedFiles(Tree $tree, FilesystemOperator $data_filesystem): array
139
    {
140
        $used_files = DB::table('media_file')
141
            ->where('m_file', '=', $tree->id())
142
            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
143
            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
144
            ->pluck('multimedia_file_refn')
145
            ->all();
146
147
        $media_filesystem = $tree->mediaFilesystem($data_filesystem);
148
        $disk_files       = $this->allFilesOnDisk($media_filesystem, '', FilesystemReader::LIST_DEEP)->all();
149
        $unused_files     = array_diff($disk_files, $used_files);
150
151
        sort($unused_files);
152
153
        return array_combine($unused_files, $unused_files);
154
    }
155
156
    /**
157
     * Store an uploaded file (or URL), either to be added to a media object
158
     * or to create a media object.
159
     *
160
     * @param ServerRequestInterface $request
161
     *
162
     * @return string The value to be stored in the 'FILE' field of the media object.
163
     * @throws FilesystemException
164
     */
165
    public function uploadFile(ServerRequestInterface $request): string
166
    {
167
        $tree = Validator::attributes($request)->tree();
168
169
        $data_filesystem = Registry::filesystem()->data();
170
171
        $params        = (array) $request->getParsedBody();
172
        $file_location = $params['file_location'];
173
174
        switch ($file_location) {
175
            case 'url':
176
                $remote = $params['remote'];
177
178
                if (str_contains($remote, '://')) {
179
                    return $remote;
180
                }
181
182
                return '';
183
184
            case 'unused':
185
                $unused = $params['unused'];
186
187
                if ($tree->mediaFilesystem($data_filesystem)->fileExists($unused)) {
188
                    return $unused;
189
                }
190
191
                return '';
192
193
            case 'upload':
194
            default:
195
                $folder   = $params['folder'];
196
                $auto     = $params['auto'];
197
                $new_file = $params['new_file'];
198
199
                $uploaded_file = $request->getUploadedFiles()['file'] ?? null;
200
201
                if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) {
202
                    throw new FileUploadException($uploaded_file);
203
                }
204
205
                // The filename
206
                $new_file = strtr($new_file, ['\\' => '/']);
207
                if ($new_file !== '' && !str_contains($new_file, '/')) {
208
                    $file = $new_file;
209
                } else {
210
                    $file = $uploaded_file->getClientFilename();
211
                }
212
213
                // The folder
214
                $folder = strtr($folder, ['\\' => '/']);
215
                $folder = trim($folder, '/');
216
                if ($folder !== '') {
217
                    $folder .= '/';
218
                }
219
220
                // Generate a unique name for the file?
221
                if ($auto === '1' || $tree->mediaFilesystem($data_filesystem)->fileExists($folder . $file)) {
222
                    $folder    = '';
223
                    $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION);
224
                    $file      = sha1((string) $uploaded_file->getStream()) . '.' . $extension;
225
                }
226
227
                try {
228
                    $tree->mediaFilesystem($data_filesystem)->writeStream($folder . $file, $uploaded_file->getStream()->detach());
229
230
                    return $folder . $file;
231
                } catch (RuntimeException | InvalidArgumentException $ex) {
232
                    FlashMessages::addMessage(I18N::translate('There was an error uploading your file.'));
233
234
                    return '';
235
                }
236
        }
237
    }
238
239
    /**
240
     * Convert the media file attributes into GEDCOM format.
241
     *
242
     * @param string $file
243
     * @param string $type
244
     * @param string $title
245
     * @param string $note
246
     *
247
     * @return string
248
     */
249
    public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string
250
    {
251
        $gedcom = '1 FILE ' . $file;
252
253
        $format = strtoupper(pathinfo($file, PATHINFO_EXTENSION));
0 ignored issues
show
Bug introduced by
It seems like pathinfo($file, PATHINFO_EXTENSION) can also be of type array; however, parameter $string of strtoupper() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

253
        $format = strtoupper(/** @scrutinizer ignore-type */ pathinfo($file, PATHINFO_EXTENSION));
Loading history...
254
        $format = self::EXTENSION_TO_FORM[$format] ?? $format;
255
256
        if ($format !== '') {
257
            $gedcom .= "\n2 FORM " . $format;
258
        } elseif ($type !== '') {
259
            $gedcom .= "\n2 FORM";
260
        }
261
262
        if ($type !== '') {
263
            $gedcom .= "\n3 TYPE " . $type;
264
        }
265
266
        if ($title !== '') {
267
            $gedcom .= "\n2 TITL " . $title;
268
        }
269
270
        if ($note !== '') {
271
            // Convert HTML line endings to GEDCOM continuations
272
            $gedcom .= "\n1 NOTE " . strtr($note, ["\r\n" => "\n2 CONT "]);
273
        }
274
275
        return $gedcom;
276
    }
277
278
    /**
279
     * Fetch a list of all files on disk (in folders used by any tree).
280
     *
281
     * @param FilesystemOperator $filesystem $filesystem to search
282
     * @param string             $folder     Root folder
283
     * @param bool               $subfolders Include subfolders
284
     *
285
     * @return Collection<int,string>
286
     */
287
    public function allFilesOnDisk(FilesystemOperator $filesystem, string $folder, bool $subfolders): Collection
288
    {
289
        try {
290
            $files = $filesystem
291
                ->listContents($folder, $subfolders)
292
                ->filter(fn (StorageAttributes $attributes): bool => $attributes->isFile())
293
                ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
294
                ->map(fn (StorageAttributes $attributes): string => $attributes->path())
295
                ->toArray();
296
        } catch (FilesystemException $ex) {
297
            $files = [];
298
        }
299
300
        return new Collection($files);
0 ignored issues
show
Bug introduced by
$files of type array is incompatible with the type Illuminate\Contracts\Support\Arrayable expected by parameter $items of Illuminate\Support\Collection::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

300
        return new Collection(/** @scrutinizer ignore-type */ $files);
Loading history...
301
    }
302
303
    /**
304
     * Fetch a list of all files on in the database.
305
     *
306
     * @param string $media_folder Root folder
307
     * @param bool   $subfolders   Include subfolders
308
     *
309
     * @return Collection<int,string>
310
     */
311
    public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection
312
    {
313
        $query = DB::table('media_file')
314
            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
315
            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
316
            //->where('multimedia_file_refn', 'LIKE', '%/%')
317
            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
318
            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
319
            ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%')
320
            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
321
            ->orderBy(new Expression('setting_value || multimedia_file_refn'));
322
323
        if (!$subfolders) {
324
            $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
325
        }
326
327
        return $query->pluck('path');
328
    }
329
330
    /**
331
     * Generate a list of all folders used by a tree.
332
     *
333
     * @param Tree $tree
334
     *
335
     * @return Collection<int,string>
336
     * @throws FilesystemException
337
     */
338
    public function mediaFolders(Tree $tree): Collection
339
    {
340
        $folders = Registry::filesystem()->media($tree)
341
            ->listContents('', FilesystemReader::LIST_DEEP)
342
            ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir())
343
            ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
344
            ->map(fn (StorageAttributes $attributes): string => $attributes->path())
345
            ->toArray();
346
347
        return new Collection($folders);
0 ignored issues
show
Bug introduced by
$folders of type array is incompatible with the type Illuminate\Contracts\Support\Arrayable expected by parameter $items of Illuminate\Support\Collection::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

347
        return new Collection(/** @scrutinizer ignore-type */ $folders);
Loading history...
348
    }
349
350
    /**
351
     * Generate a list of all folders in either the database or the filesystem.
352
     *
353
     * @param FilesystemOperator $data_filesystem
354
     *
355
     * @return Collection<array-key,string>
356
     * @throws FilesystemException
357
     */
358
    public function allMediaFolders(FilesystemOperator $data_filesystem): Collection
359
    {
360
        $db_folders = DB::table('media_file')
361
            ->leftJoin('gedcom_setting', static function (JoinClause $join): void {
362
                $join
363
                    ->on('gedcom_id', '=', 'm_file')
364
                    ->where('setting_name', '=', 'MEDIA_DIRECTORY');
365
            })
366
            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
367
            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
368
            ->select(new Expression("COALESCE(setting_value, 'media/') || 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')
375
            ->leftJoin('gedcom_setting', static function (JoinClause $join): void {
376
                $join
377
                    ->on('gedcom.gedcom_id', '=', 'gedcom_setting.gedcom_id')
378
                    ->where('setting_name', '=', 'MEDIA_DIRECTORY');
379
            })
380
            ->where('gedcom.gedcom_id', '>', '0')
381
            ->pluck(new Expression("COALESCE(setting_value, 'media/')"))
382
            ->uniqueStrict();
383
384
        $disk_folders = new Collection($media_roots);
385
386
        foreach ($media_roots as $media_folder) {
387
            $tmp = $data_filesystem
388
                ->listContents($media_folder, FilesystemReader::LIST_DEEP)
389
                ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir())
390
                ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
391
                ->map(fn (StorageAttributes $attributes): string => $attributes->path() . '/')
392
                ->toArray();
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
    /**
405
     * Ignore special media folders.
406
     *
407
     * @param string $path
408
     *
409
     * @return bool
410
     */
411
    private function ignorePath(string $path): bool
412
    {
413
        return array_intersect(self::IGNORE_FOLDERS, explode('/', $path)) !== [];
414
    }
415
}
416