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