Issues (83)

app/MediaFile.php (1 issue)

Labels
Severity
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;
21
22
use Fisharebest\Webtrees\Http\RequestHandlers\MediaFileDownload;
23
use Fisharebest\Webtrees\Http\RequestHandlers\MediaFileThumbnail;
24
use League\Flysystem\FilesystemException;
25
use League\Flysystem\UnableToCheckFileExistence;
26
use League\Flysystem\UnableToReadFile;
27
use League\Flysystem\UnableToRetrieveMetadata;
28
29
use function bin2hex;
30
use function getimagesizefromstring;
31
use function http_build_query;
32
use function in_array;
33
use function intdiv;
34
use function is_array;
35
use function ksort;
36
use function md5;
37
use function pathinfo;
38
use function random_bytes;
39
use function str_contains;
40
use function strtoupper;
41
42
use const PATHINFO_EXTENSION;
43
44
/**
45
 * A GEDCOM media file.  A media object can contain many media files,
46
 * such as scans of both sides of a document, the transcript of an audio
47
 * recording, etc.
48
 */
49
class MediaFile
50
{
51
    private const SUPPORTED_IMAGE_MIME_TYPES = [
52
        'image/gif',
53
        'image/jpeg',
54
        'image/png',
55
        'image/webp',
56
    ];
57
58
    private string $multimedia_file_refn = '';
59
60
    private string $multimedia_format = '';
61
62
    private string $source_media_type = '';
63
64
    private string $descriptive_title = '';
65
66
    private Media $media;
67
68
    private string $fact_id;
69
70
    /**
71
     * Create a MediaFile from raw GEDCOM data.
72
     *
73
     * @param string $gedcom
74
     * @param Media  $media
75
     */
76
    public function __construct(string $gedcom, Media $media)
77
    {
78
        $this->media   = $media;
79
        $this->fact_id = md5($gedcom);
80
81
        if (preg_match('/^\d FILE (.+)/m', $gedcom, $match)) {
82
            $this->multimedia_file_refn = $match[1];
83
        }
84
85
        if (preg_match('/^\d FORM (.+)/m', $gedcom, $match)) {
86
            $this->multimedia_format = $match[1];
87
        }
88
89
        if (preg_match('/^\d TYPE (.+)/m', $gedcom, $match)) {
90
            $this->source_media_type = $match[1];
91
        }
92
93
        if (preg_match('/^\d TITL (.+)/m', $gedcom, $match)) {
94
            $this->descriptive_title = $match[1];
95
        }
96
    }
97
98
    /**
99
     * Get the format.
100
     *
101
     * @return string
102
     */
103
    public function format(): string
104
    {
105
        return $this->multimedia_format;
106
    }
107
108
    /**
109
     * Get the type.
110
     *
111
     * @return string
112
     */
113
    public function type(): string
114
    {
115
        return $this->source_media_type;
116
    }
117
118
    /**
119
     * Get the title.
120
     *
121
     * @return string
122
     */
123
    public function title(): string
124
    {
125
        return $this->descriptive_title;
126
    }
127
128
    /**
129
     * Get the fact ID.
130
     *
131
     * @return string
132
     */
133
    public function factId(): string
134
    {
135
        return $this->fact_id;
136
    }
137
138
    /**
139
     * @return bool
140
     */
141
    public function isPendingAddition(): bool
142
    {
143
        foreach ($this->media->facts() as $fact) {
144
            if ($fact->id() === $this->fact_id) {
145
                return $fact->isPendingAddition();
146
            }
147
        }
148
149
        return false;
150
    }
151
152
    /**
153
     * @return bool
154
     */
155
    public function isPendingDeletion(): bool
156
    {
157
        foreach ($this->media->facts() as $fact) {
158
            if ($fact->id() === $this->fact_id) {
159
                return $fact->isPendingDeletion();
160
            }
161
        }
162
163
        return false;
164
    }
165
166
    /**
167
     * Display an image-thumbnail or a media-icon, and add markup for image viewers such as colorbox.
168
     *
169
     * @param int                  $width            Pixels
170
     * @param int                  $height           Pixels
171
     * @param string               $fit              "crop" or "contain"
172
     * @param array<string,string> $image_attributes Additional HTML attributes
173
     *
174
     * @return string
175
     */
176
    public function displayImage(int $width, int $height, string $fit, array $image_attributes = []): string
177
    {
178
        if ($this->isExternal()) {
179
            $src    = $this->multimedia_file_refn;
180
            $srcset = [];
181
        } else {
182
            // Generate multiple images for displays with higher pixel densities.
183
            $src    = $this->imageUrl($width, $height, $fit);
184
            $srcset = [];
185
            foreach ([2, 3, 4] as $x) {
186
                $srcset[] = $this->imageUrl($width * $x, $height * $x, $fit) . ' ' . $x . 'x';
187
            }
188
        }
189
190
        if ($this->isImage()) {
191
            $image = '<img ' . Html::attributes($image_attributes + [
192
                        'dir'    => 'auto',
193
                        'src'    => $src,
194
                        'srcset' => implode(',', $srcset),
195
                        'alt'    => strip_tags($this->media->fullName()),
196
                    ]) . '>';
197
198
            $link_attributes = Html::attributes([
199
                'class'      => 'gallery',
200
                'type'       => $this->mimeType(),
201
                'href'       => $this->downloadUrl('inline'),
202
                'data-title' => strip_tags($this->media->fullName()),
203
            ]);
204
        } else {
205
            $image = view('icons/mime', ['type' => $this->mimeType()]);
206
207
            $link_attributes = Html::attributes([
208
                'type' => $this->mimeType(),
209
                'href' => $this->downloadUrl('inline'),
210
            ]);
211
        }
212
213
        return '<a ' . $link_attributes . '>' . $image . '</a>';
214
    }
215
216
    /**
217
     * Is the media file actually a URL?
218
     */
219
    public function isExternal(): bool
220
    {
221
        return str_contains($this->multimedia_file_refn, '://');
222
    }
223
224
    /**
225
     * Generate a URL for an image.
226
     *
227
     * @param int    $width  Maximum width in pixels
228
     * @param int    $height Maximum height in pixels
229
     * @param string $fit    "crop" or "contain"
230
     *
231
     * @return string
232
     */
233
    public function imageUrl(int $width, int $height, string $fit): string
234
    {
235
        // Sign the URL, to protect against mass-resize attacks.
236
        $glide_key = Site::getPreference('glide-key');
237
238
        if ($glide_key === '') {
239
            $glide_key = bin2hex(random_bytes(128));
240
            Site::setPreference('glide-key', $glide_key);
241
        }
242
243
        // The "mark" parameter is ignored, but needed for cache-busting.
244
        $params = [
245
            'xref'      => $this->media->xref(),
246
            'tree'      => $this->media->tree()->name(),
247
            'fact_id'   => $this->fact_id,
248
            'w'         => $width,
249
            'h'         => $height,
250
            'fit'       => $fit,
251
            'mark'      => Registry::imageFactory()->thumbnailNeedsWatermark($this, Auth::user())
252
        ];
253
254
        $params['s'] = $this->signature($params);
255
256
        return route(MediaFileThumbnail::class, $params);
257
    }
258
259
    /**
260
     * Is the media file an image?
261
     */
262
    public function isImage(): bool
263
    {
264
        return in_array($this->mimeType(), self::SUPPORTED_IMAGE_MIME_TYPES, true);
265
    }
266
267
    /**
268
     * What is the mime-type of this object?
269
     * For simplicity and efficiency, use the extension, rather than the contents.
270
     *
271
     * @return string
272
     */
273
    public function mimeType(): string
274
    {
275
        $extension = strtoupper(pathinfo($this->multimedia_file_refn, PATHINFO_EXTENSION));
0 ignored issues
show
It seems like pathinfo($this->multimed...fn, 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

275
        $extension = strtoupper(/** @scrutinizer ignore-type */ pathinfo($this->multimedia_file_refn, PATHINFO_EXTENSION));
Loading history...
276
277
        return Mime::TYPES[$extension] ?? Mime::DEFAULT_TYPE;
278
    }
279
280
    /**
281
     * Generate a URL to download a media file.
282
     *
283
     * @param string $disposition How should the image be returned: "attachment" or "inline"
284
     *
285
     * @return string
286
     */
287
    public function downloadUrl(string $disposition): string
288
    {
289
        // The "mark" parameter is ignored, but needed for cache-busting.
290
        return route(MediaFileDownload::class, [
291
            'xref'        => $this->media->xref(),
292
            'tree'        => $this->media->tree()->name(),
293
            'fact_id'     => $this->fact_id,
294
            'disposition' => $disposition,
295
            'mark'        => Registry::imageFactory()->fileNeedsWatermark($this, Auth::user())
296
        ]);
297
    }
298
299
    /**
300
     * A list of image attributes
301
     *
302
     * @return array<string,string>
303
     */
304
    public function attributes(): array
305
    {
306
        $attributes = [];
307
308
        if (!$this->isExternal() || $this->fileExists()) {
309
            try {
310
                $bytes = $this->media()->tree()->mediaFilesystem()->fileSize($this->filename());
311
                $kb    = intdiv($bytes + 1023, 1024);
312
                $text  = I18N::translate('%s KB', I18N::number($kb));
313
314
                $attributes[I18N::translate('File size')] = $text;
315
            } catch (FilesystemException | UnableToRetrieveMetadata $ex) {
316
                // External/missing files have no size.
317
            }
318
319
            try {
320
                $data       = $this->media()->tree()->mediaFilesystem()->read($this->filename());
321
                $image_size = getimagesizefromstring($data);
322
323
                if (is_array($image_size)) {
324
                    [$width, $height] = $image_size;
325
326
                    $text = I18N::translate('%1$s × %2$s pixels', I18N::number($width), I18N::number($height));
327
328
                    $attributes[I18N::translate('Image dimensions')] = $text;
329
                }
330
            } catch (FilesystemException | UnableToReadFile $ex) {
331
                // Cannot read the file.
332
            }
333
        }
334
335
        return $attributes;
336
    }
337
338
    /**
339
     * Read the contents of a media file.
340
     *
341
     * @return string
342
     */
343
    public function fileContents(): string
344
    {
345
        try {
346
            return $this->media->tree()->mediaFilesystem()->read($this->multimedia_file_refn);
347
        } catch (FilesystemException | UnableToReadFile $ex) {
348
            return '';
349
        }
350
    }
351
352
    /**
353
     * Check if the file exists on this server
354
     *
355
     * @return bool
356
     */
357
    public function fileExists(): bool
358
    {
359
        try {
360
            return $this->media->tree()->mediaFilesystem()->fileExists($this->multimedia_file_refn);
361
        } catch (FilesystemException | UnableToCheckFileExistence $ex) {
362
            return false;
363
        }
364
    }
365
366
    /**
367
     * @return Media
368
     */
369
    public function media(): Media
370
    {
371
        return $this->media;
372
    }
373
374
    /**
375
     * Get the filename.
376
     *
377
     * @return string
378
     */
379
    public function filename(): string
380
    {
381
        return $this->multimedia_file_refn;
382
    }
383
384
    /**
385
     * Create a URL signature parameter, using the same algorithm as league/glide,
386
     * for compatibility with URLs generated by older versions of webtrees.
387
     *
388
     * @param array<mixed> $params
389
     *
390
     * @return string
391
     */
392
    public function signature(array $params): string
393
    {
394
        unset($params['s']);
395
396
        ksort($params);
397
398
        // Sign the URL, to protect against mass-resize attacks.
399
        $glide_key = Site::getPreference('glide-key');
400
401
        if ($glide_key === '') {
402
            $glide_key = bin2hex(random_bytes(128));
403
            Site::setPreference('glide-key', $glide_key);
404
        }
405
406
        return md5($glide_key . ':?' . http_build_query($params));
407
    }
408
}
409