xref: /webtrees/app/MediaFile.php (revision 090a06287954f43677f06ea778b3f67c029de8fe)
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
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees;
21
22use Fisharebest\Webtrees\Http\RequestHandlers\MediaFileDownload;
23use Fisharebest\Webtrees\Http\RequestHandlers\MediaFileThumbnail;
24use League\Flysystem\FilesystemException;
25use League\Flysystem\FilesystemOperator;
26
27use function bin2hex;
28use function getimagesizefromstring;
29use function http_build_query;
30use function intdiv;
31use function ksort;
32use function md5;
33use function pathinfo;
34use function random_bytes;
35use function str_contains;
36use function strtolower;
37
38use const PATHINFO_EXTENSION;
39
40/**
41 * A GEDCOM media file.  A media object can contain many media files,
42 * such as scans of both sides of a document, the transcript of an audio
43 * recording, etc.
44 */
45class MediaFile
46{
47    private const SUPPORTED_IMAGE_MIME_TYPES = [
48        'image/gif',
49        'image/jpeg',
50        'image/png',
51        'image/webp',
52    ];
53
54    /** @var string The filename */
55    private $multimedia_file_refn = '';
56
57    /** @var string The file extension; jpeg, txt, mp4, etc. */
58    private $multimedia_format = '';
59
60    /** @var string The type of document; newspaper, microfiche, etc. */
61    private $source_media_type = '';
62    /** @var string The filename */
63
64    /** @var string The name of the document */
65    private $descriptive_title = '';
66
67    /** @var Media $media The media object to which this file belongs */
68    private $media;
69
70    /** @var string */
71    private $fact_id;
72
73    /**
74     * Create a MediaFile from raw GEDCOM data.
75     *
76     * @param string $gedcom
77     * @param Media  $media
78     */
79    public function __construct(string $gedcom, Media $media)
80    {
81        $this->media   = $media;
82        $this->fact_id = md5($gedcom);
83
84        if (preg_match('/^\d FILE (.+)/m', $gedcom, $match)) {
85            $this->multimedia_file_refn = $match[1];
86            $this->multimedia_format    = pathinfo($match[1], PATHINFO_EXTENSION);
87        }
88
89        if (preg_match('/^\d FORM (.+)/m', $gedcom, $match)) {
90            $this->multimedia_format = $match[1];
91        }
92
93        if (preg_match('/^\d TYPE (.+)/m', $gedcom, $match)) {
94            $this->source_media_type = $match[1];
95        }
96
97        if (preg_match('/^\d TITL (.+)/m', $gedcom, $match)) {
98            $this->descriptive_title = $match[1];
99        }
100    }
101
102    /**
103     * Get the format.
104     *
105     * @return string
106     */
107    public function format(): string
108    {
109        return $this->multimedia_format;
110    }
111
112    /**
113     * Get the type.
114     *
115     * @return string
116     */
117    public function type(): string
118    {
119        return $this->source_media_type;
120    }
121
122    /**
123     * Get the title.
124     *
125     * @return string
126     */
127    public function title(): string
128    {
129        return $this->descriptive_title;
130    }
131
132    /**
133     * Get the fact ID.
134     *
135     * @return string
136     */
137    public function factId(): string
138    {
139        return $this->fact_id;
140    }
141
142    /**
143     * @return bool
144     */
145    public function isPendingAddition(): bool
146    {
147        foreach ($this->media->facts() as $fact) {
148            if ($fact->id() === $this->fact_id) {
149                return $fact->isPendingAddition();
150            }
151        }
152
153        return false;
154    }
155
156    /**
157     * @return bool
158     */
159    public function isPendingDeletion(): bool
160    {
161        foreach ($this->media->facts() as $fact) {
162            if ($fact->id() === $this->fact_id) {
163                return $fact->isPendingDeletion();
164            }
165        }
166
167        return false;
168    }
169
170    /**
171     * Display an image-thumbnail or a media-icon, and add markup for image viewers such as colorbox.
172     *
173     * @param int                  $width            Pixels
174     * @param int                  $height           Pixels
175     * @param string               $fit              "crop" or "contain"
176     * @param array<string,string> $image_attributes Additional HTML attributes
177     *
178     * @return string
179     */
180    public function displayImage(int $width, int $height, string $fit, array $image_attributes = []): string
181    {
182        if ($this->isExternal()) {
183            $src    = $this->multimedia_file_refn;
184            $srcset = [];
185        } else {
186            // Generate multiple images for displays with higher pixel densities.
187            $src    = $this->imageUrl($width, $height, $fit);
188            $srcset = [];
189            foreach ([2, 3, 4] as $x) {
190                $srcset[] = $this->imageUrl($width * $x, $height * $x, $fit) . ' ' . $x . 'x';
191            }
192        }
193
194        if ($this->isImage()) {
195            $image = '<img ' . Html::attributes($image_attributes + [
196                        'dir'    => 'auto',
197                        'src'    => $src,
198                        'srcset' => implode(',', $srcset),
199                        'alt'    => strip_tags($this->media->fullName()),
200                    ]) . '>';
201
202            $link_attributes = Html::attributes([
203                'class'      => 'gallery',
204                'type'       => $this->mimeType(),
205                'href'       => $this->downloadUrl('inline'),
206                'data-title' => strip_tags($this->media->fullName()),
207            ]);
208        } else {
209            $image = view('icons/mime', ['type' => $this->mimeType()]);
210
211            $link_attributes = Html::attributes([
212                'type' => $this->mimeType(),
213                'href' => $this->downloadUrl('inline'),
214            ]);
215        }
216
217        return '<a ' . $link_attributes . '>' . $image . '</a>';
218    }
219
220    /**
221     * Is the media file actually a URL?
222     */
223    public function isExternal(): bool
224    {
225        return str_contains($this->multimedia_file_refn, '://');
226    }
227
228    /**
229     * Generate a URL for an image.
230     *
231     * @param int    $width  Maximum width in pixels
232     * @param int    $height Maximum height in pixels
233     * @param string $fit    "crop" or "contain"
234     *
235     * @return string
236     */
237    public function imageUrl(int $width, int $height, string $fit): string
238    {
239        // Sign the URL, to protect against mass-resize attacks.
240        $glide_key = Site::getPreference('glide-key');
241
242        if ($glide_key === '') {
243            $glide_key = bin2hex(random_bytes(128));
244            Site::setPreference('glide-key', $glide_key);
245        }
246
247        // The "mark" parameter is ignored, but needed for cache-busting.
248        $params = [
249            'xref'      => $this->media->xref(),
250            'tree'      => $this->media->tree()->name(),
251            'fact_id'   => $this->fact_id,
252            'w'         => $width,
253            'h'         => $height,
254            'fit'       => $fit,
255            'mark'      => Registry::imageFactory()->thumbnailNeedsWatermark($this, Auth::user())
256        ];
257
258        $params['s'] = $this->signature($params);
259
260        return route(MediaFileThumbnail::class, $params);
261    }
262
263    /**
264     * Is the media file an image?
265     */
266    public function isImage(): bool
267    {
268        return in_array($this->mimeType(), self::SUPPORTED_IMAGE_MIME_TYPES, true);
269    }
270
271    /**
272     * What is the mime-type of this object?
273     * For simplicity and efficiency, use the extension, rather than the contents.
274     *
275     * @return string
276     */
277    public function mimeType(): string
278    {
279        $extension = strtolower(pathinfo($this->multimedia_file_refn, PATHINFO_EXTENSION));
280
281        return Mime::TYPES[$extension] ?? Mime::DEFAULT_TYPE;
282    }
283
284    /**
285     * Generate a URL to download a media file.
286     *
287     * @param string $disposition How should the image be returned - "attachment" or "inline"
288     *
289     * @return string
290     */
291    public function downloadUrl(string $disposition): string
292    {
293        // The "mark" parameter is ignored, but needed for cache-busting.
294        return route(MediaFileDownload::class, [
295            'xref'        => $this->media->xref(),
296            'tree'        => $this->media->tree()->name(),
297            'fact_id'     => $this->fact_id,
298            'disposition' => $disposition,
299            'mark'        => Registry::imageFactory()->fileNeedsWatermark($this, Auth::user())
300        ]);
301    }
302
303    /**
304     * A list of image attributes
305     *
306     * @param FilesystemOperator $data_filesystem
307     *
308     * @return array<string>
309     */
310    public function attributes(FilesystemOperator $data_filesystem): array
311    {
312        $attributes = [];
313
314        if (!$this->isExternal() || $this->fileExists($data_filesystem)) {
315            try {
316                $bytes                       = $this->media()->tree()->mediaFilesystem($data_filesystem)->fileSize($this->filename());
317                $kb                          = intdiv($bytes + 1023, 1024);
318                $attributes['__FILE_SIZE__'] = I18N::translate('%s KB', I18N::number($kb));
319            } catch (FilesystemException $ex) {
320                // External/missing files have no size.
321            }
322
323            // Note: getAdapter() is defined on Filesystem, but not on FilesystemInterface.
324            $filesystem = $this->media()->tree()->mediaFilesystem($data_filesystem);
325            [$width, $height] = getimagesizefromstring($filesystem->read($this->filename()));
326            $attributes['__IMAGE_SIZE__'] = I18N::translate('%1$s × %2$s pixels', I18N::number($width), I18N::number($height));
327        }
328
329        return $attributes;
330    }
331
332    /**
333     * Read the contents of a media file.
334     *
335     * @param FilesystemOperator $data_filesystem
336     *
337     * @return string
338     */
339    public function fileContents(FilesystemOperator $data_filesystem): string
340    {
341        return $this->media->tree()->mediaFilesystem($data_filesystem)->read($this->multimedia_file_refn);
342    }
343
344    /**
345     * Check if the file exists on this server
346     *
347     * @param FilesystemOperator $data_filesystem
348     *
349     * @return bool
350     */
351    public function fileExists(FilesystemOperator $data_filesystem): bool
352    {
353        return $this->media->tree()->mediaFilesystem($data_filesystem)->fileExists($this->multimedia_file_refn);
354    }
355
356    /**
357     * @return Media
358     */
359    public function media(): Media
360    {
361        return $this->media;
362    }
363
364    /**
365     * Get the filename.
366     *
367     * @return string
368     */
369    public function filename(): string
370    {
371        return $this->multimedia_file_refn;
372    }
373
374    /**
375     * Create a URL signature parameter, using the same algorithm as league/glide,
376     * for compatibility with URLs generated by older versions of webtrees.
377     *
378     * @param array<mixed> $params
379     *
380     * @return string
381     */
382    public function signature(array $params): string
383    {
384        unset($params['s']);
385
386        ksort($params);
387
388        // Sign the URL, to protect against mass-resize attacks.
389        $glide_key = Site::getPreference('glide-key');
390
391        if ($glide_key === '') {
392            $glide_key = bin2hex(random_bytes(128));
393            Site::setPreference('glide-key', $glide_key);
394        }
395
396        return md5($glide_key . ':?' . http_build_query($params));
397    }
398}
399