xref: /webtrees/app/MediaFile.php (revision 24931b29a0237a5f5f1b8620af661ea530451af0)
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
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\UnableToCheckFileExistence;
26use League\Flysystem\UnableToReadFile;
27use League\Flysystem\UnableToRetrieveMetadata;
28
29use function bin2hex;
30use function getimagesizefromstring;
31use function http_build_query;
32use function in_array;
33use function intdiv;
34use function is_array;
35use function ksort;
36use function md5;
37use function pathinfo;
38use function random_bytes;
39use function str_contains;
40use function strtoupper;
41
42use 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 */
49class 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));
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) {
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) {
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) {
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) {
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