Completed
Push — develop ( 90766b...23de6e )
by Greg
15:33 queued 06:18
created

MediaFileService::allMediaFolders()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 38
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 27
nc 2
nop 1
dl 0
loc 38
rs 9.488
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2021 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\FlashMessages;
23
use Fisharebest\Webtrees\I18N;
24
use Fisharebest\Webtrees\Registry;
25
use Fisharebest\Webtrees\Tree;
26
use Illuminate\Database\Capsule\Manager as DB;
27
use Illuminate\Database\Query\Expression;
28
use Illuminate\Support\Collection;
29
use InvalidArgumentException;
30
use League\Flysystem\Filesystem;
31
use League\Flysystem\FilesystemException;
32
use League\Flysystem\FilesystemOperator;
33
use League\Flysystem\StorageAttributes;
34
use Psr\Http\Message\ServerRequestInterface;
35
use Psr\Http\Message\UploadedFileInterface;
36
use RuntimeException;
37
38
use function array_combine;
39
use function array_diff;
40
use function assert;
41
use function dirname;
42
use function ini_get;
43
use function intdiv;
44
use function min;
45
use function pathinfo;
46
use function preg_replace;
47
use function sha1;
48
use function sort;
49
use function str_contains;
50
use function str_ends_with;
51
use function str_starts_with;
52
use function strtolower;
53
use function strtr;
54
use function substr;
55
use function trim;
56
57
use const PATHINFO_EXTENSION;
58
use const UPLOAD_ERR_OK;
59
60
/**
61
 * Managing media files.
62
 */
63
class 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
    public const EXTENSION_TO_FORM = [
76
        'jpg' => 'jpeg',
77
        'tif' => 'tiff',
78
    ];
79
80
    /**
81
     * What is the largest file a user may upload?
82
     */
83
    public function maxUploadFilesize(): string
84
    {
85
        $sizePostMax = $this->parseIniFileSize(ini_get('post_max_size'));
86
        $sizeUploadMax = $this->parseIniFileSize(ini_get('upload_max_filesize'));
87
88
        $bytes =  min($sizePostMax, $sizeUploadMax);
89
        $kb    = intdiv($bytes + 1023, 1024);
90
91
        return I18N::translate('%s KB', I18N::number($kb));
92
    }
93
94
    /**
95
     * Returns the given size from an ini value in bytes.
96
     *
97
     * @param string $size
98
     *
99
     * @return int
100
     */
101
    private function parseIniFileSize(string $size): int
102
    {
103
        $number = (int) $size;
104
105
        switch (substr($size, -1)) {
106
            case 'g':
107
            case 'G':
108
                return $number * 1073741824;
109
            case 'm':
110
            case 'M':
111
                return $number * 1048576;
112
            case 'k':
113
            case 'K':
114
                return $number * 1024;
115
            default:
116
                return $number;
117
        }
118
    }
119
120
    /**
121
     * A list of media files not already linked to a media object.
122
     *
123
     * @param Tree               $tree
124
     * @param FilesystemOperator $data_filesystem
125
     *
126
     * @return array<string>
127
     * @throws FilesystemException
128
     */
129
    public function unusedFiles(Tree $tree, FilesystemOperator $data_filesystem): array
130
    {
131
        $used_files = DB::table('media_file')
132
            ->where('m_file', '=', $tree->id())
133
            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
134
            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
135
            ->pluck('multimedia_file_refn')
136
            ->all();
137
138
        $media_filesystem = $disk_files = $tree->mediaFilesystem($data_filesystem);
0 ignored issues
show
Unused Code introduced by
The assignment to $disk_files is dead and can be removed.
Loading history...
139
        $disk_files       = $this->allFilesOnDisk($media_filesystem, '', Filesystem::LIST_DEEP)->all();
140
        $unused_files     = array_diff($disk_files, $used_files);
141
142
        sort($unused_files);
143
144
        return array_combine($unused_files, $unused_files);
145
    }
146
147
    /**
148
     * Store an uploaded file (or URL), either to be added to a media object
149
     * or to create a media object.
150
     *
151
     * @param ServerRequestInterface $request
152
     *
153
     * @return string The value to be stored in the 'FILE' field of the media object.
154
     * @throws FilesystemException
155
     */
156
    public function uploadFile(ServerRequestInterface $request): string
157
    {
158
        $tree = $request->getAttribute('tree');
159
        assert($tree instanceof Tree);
160
161
        $data_filesystem = Registry::filesystem()->data();
162
163
        $params        = (array) $request->getParsedBody();
164
        $file_location = $params['file_location'];
165
166
        switch ($file_location) {
167
            case 'url':
168
                $remote = $params['remote'];
169
170
                if (str_contains($remote, '://')) {
171
                    return $remote;
172
                }
173
174
                return '';
175
176
            case 'unused':
177
                $unused = $params['unused'];
178
179
                if ($tree->mediaFilesystem($data_filesystem)->fileExists($unused)) {
180
                    return $unused;
181
                }
182
183
                return '';
184
185
            case 'upload':
186
            default:
187
                $folder   = $params['folder'];
188
                $auto     = $params['auto'];
189
                $new_file = $params['new_file'];
190
191
                /** @var UploadedFileInterface|null $uploaded_file */
192
                $uploaded_file = $request->getUploadedFiles()['file'];
193
                if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) {
194
                    return '';
195
                }
196
197
                // The filename
198
                $new_file = strtr($new_file, ['\\' => '/']);
199
                if ($new_file !== '' && !str_contains($new_file, '/')) {
200
                    $file = $new_file;
201
                } else {
202
                    $file = $uploaded_file->getClientFilename();
203
                }
204
205
                // The folder
206
                $folder = strtr($folder, ['\\' => '/']);
207
                $folder = trim($folder, '/');
208
                if ($folder !== '') {
209
                    $folder .= '/';
210
                }
211
212
                // Generate a unique name for the file?
213
                if ($auto === '1' || $tree->mediaFilesystem($data_filesystem)->fileExists($folder . $file)) {
214
                    $folder    = '';
215
                    $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION);
0 ignored issues
show
Bug introduced by
It seems like $uploaded_file->getClientFilename() can also be of type null; however, parameter $path of pathinfo() 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

215
                    $extension = pathinfo(/** @scrutinizer ignore-type */ $uploaded_file->getClientFilename(), PATHINFO_EXTENSION);
Loading history...
216
                    $file      = sha1((string) $uploaded_file->getStream()) . '.' . $extension;
1 ignored issue
show
Bug introduced by
Are you sure $extension of type array|string can be used in concatenation? ( Ignorable by Annotation )

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

216
                    $file      = sha1((string) $uploaded_file->getStream()) . '.' . /** @scrutinizer ignore-type */ $extension;
Loading history...
217
                }
218
219
                try {
220
                    $tree->mediaFilesystem($data_filesystem)->writeStream($folder . $file, $uploaded_file->getStream()->detach());
221
222
                    return $folder . $file;
223
                } catch (RuntimeException | InvalidArgumentException $ex) {
224
                    FlashMessages::addMessage(I18N::translate('There was an error uploading your file.'));
225
226
                    return '';
227
                }
228
        }
229
    }
230
231
    /**
232
     * Convert the media file attributes into GEDCOM format.
233
     *
234
     * @param string $file
235
     * @param string $type
236
     * @param string $title
237
     * @param string $note
238
     *
239
     * @return string
240
     */
241
    public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string
242
    {
243
        // Tidy non-printing characters
244
        $type  = trim(preg_replace('/\s+/', ' ', $type));
245
        $title = trim(preg_replace('/\s+/', ' ', $title));
246
247
        $gedcom = '1 FILE ' . $file;
248
249
        $format = strtolower(pathinfo($file, PATHINFO_EXTENSION));
1 ignored issue
show
Bug introduced by
It seems like pathinfo($file, PATHINFO_EXTENSION) can also be of type array; however, parameter $string of strtolower() 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

249
        $format = strtolower(/** @scrutinizer ignore-type */ pathinfo($file, PATHINFO_EXTENSION));
Loading history...
250
        $format = self::EXTENSION_TO_FORM[$format] ?? $format;
251
252
        if ($format !== '') {
253
            $gedcom .= "\n2 FORM " . $format;
254
        } elseif ($type !== '') {
255
            $gedcom .= "\n2 FORM";
256
        }
257
258
        if ($type !== '') {
259
            $gedcom .= "\n3 TYPE " . $type;
260
        }
261
262
        if ($title !== '') {
263
            $gedcom .= "\n2 TITL " . $title;
264
        }
265
266
        if ($note !== '') {
267
            // Convert HTML line endings to GEDCOM continuations
268
            $gedcom .= "\n1 NOTE " . strtr($note, ["\r\n" => "\n2 CONT "]);
269
        }
270
271
        return $gedcom;
272
    }
273
274
    /**
275
     * Fetch a list of all files on disk (in folders used by any tree).
276
     *
277
     * @param FilesystemOperator $filesystem $filesystem to search
278
     * @param string             $folder     Root folder
279
     * @param bool               $subfolders Include subfolders
280
     *
281
     * @return Collection<string>
282
     */
283
    public function allFilesOnDisk(FilesystemOperator $filesystem, string $folder, bool $subfolders): Collection
284
    {
285
        try {
286
            $files = $filesystem->listContents($folder, $subfolders)
287
                ->filter(function (StorageAttributes $attributes): bool {
288
                    return $attributes->isFile() && !$this->isLegacyFolder($attributes->path());
289
                })
290
                ->map(static function (StorageAttributes $attributes): string {
291
                    return $attributes->path();
292
                })
293
                ->toArray();
294
        } catch (FilesystemException $ex) {
295
            $files = [];
296
        }
297
298
        return new Collection($files);
299
    }
300
301
    /**
302
     * Fetch a list of all files on in the database.
303
     *
304
     * @param string $media_folder Root folder
305
     * @param bool   $subfolders   Include subfolders
306
     *
307
     * @return Collection<string>
308
     */
309
    public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection
310
    {
311
        $query = DB::table('media_file')
312
            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
313
            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
314
            //->where('multimedia_file_refn', 'LIKE', '%/%')
315
            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
316
            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
317
            ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%')
318
            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
319
            ->orderBy(new Expression('setting_value || multimedia_file_refn'));
320
321
        if (!$subfolders) {
322
            $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
323
        }
324
325
        return $query->pluck('path');
326
    }
327
328
    /**
329
     * Generate a list of all folders in either the database or the filesystem.
330
     *
331
     * @param FilesystemOperator $data_filesystem
332
     *
333
     * @return Collection<string,string>
334
     * @throws FilesystemException
335
     */
336
    public function allMediaFolders(FilesystemOperator $data_filesystem): Collection
337
    {
338
        $db_folders = DB::table('media_file')
339
            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
340
            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
341
            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
342
            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
343
            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
344
            ->pluck('path')
345
            ->map(static function (string $path): string {
346
                return dirname($path) . '/';
347
            });
348
349
        $media_roots = DB::table('gedcom_setting')
350
            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
351
            ->where('gedcom_id', '>', '0')
352
            ->pluck('setting_value')
353
            ->uniqueStrict();
354
355
        $disk_folders = new Collection($media_roots);
356
357
        foreach ($media_roots as $media_folder) {
358
            $tmp = $data_filesystem->listContents($media_folder, Filesystem::LIST_DEEP)
359
                ->filter(function (StorageAttributes $attributes): bool {
360
                    return $attributes->isDir() && !$this->isLegacyFolder($attributes->path());
361
                })
362
                ->map(static function (StorageAttributes $attributes): string {
363
                    return $attributes->path() . '/';
364
                })
365
                ->toArray();
366
367
            $disk_folders = $disk_folders->concat($tmp);
368
        }
369
370
        return $disk_folders->concat($db_folders)
371
            ->uniqueStrict()
372
            ->mapWithKeys(static function (string $folder): array {
373
                return [$folder => $folder];
374
            });
375
    }
376
377
    /**
378
     * Some special media folders were created by earlier versions of webtrees.
379
     *
380
     * @param string $path
381
     *
382
     * @return bool
383
     */
384
    private function isLegacyFolder(string $path): bool
385
    {
386
        return
387
            str_starts_with($path, 'thumbs/') ||
388
            str_contains($path, '/thumbs/') ||
389
            str_ends_with($path, '/thumbs') ||
390
            str_starts_with($path, 'watermarks/') ||
391
            str_contains($path, '/watermarks/') ||
392
            str_ends_with($path, '/watermarks');
393
    }
394
}
395