xref: /webtrees/app/MediaFile.php (revision 5cf3b11faf191d83ec5e7576aa8e8fe3fd2ba15d)
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 intdiv;
34use function ksort;
35use function md5;
36use function pathinfo;
37use function random_bytes;
38use function str_contains;
39use function strtoupper;
40
41use const PATHINFO_EXTENSION;
42
43/**
44 * A GEDCOM media file.  A media object can contain many media files,
45 * such as scans of both sides of a document, the transcript of an audio
46 * recording, etc.
47 */
48class MediaFile
49{
50    private const SUPPORTED_IMAGE_MIME_TYPES = [
51        'image/gif',
52        'image/jpeg',
53        'image/png',
54        'image/webp',
55    ];
56
57    private string $multimedia_file_refn = '';
58
59    private string $multimedia_format = '';
60
61    private string $source_media_type = '';
62
63    private string $descriptive_title = '';
64
65    private Media $media;
66
67    private string $fact_id;
68
69    /**
70     * Create a MediaFile from raw GEDCOM data.
71     *
72     * @param string $gedcom
73     * @param Media  $media
74     */
75    public function __construct(string $gedcom, Media $media)
76    {
77        $this->media   = $media;
78        $this->fact_id = md5($gedcom);
79
80        if (preg_match('/^\d FILE (.+)/m', $gedcom, $match)) {
81            $this->multimedia_file_refn = $match[1];
82            $this->multimedia_format    = pathinfo($match[1], PATHINFO_EXTENSION);
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     * @param FilesystemOperator $data_filesystem
303     *
304     * @return array<string,string>
305     */
306    public function attributes(FilesystemOperator $data_filesystem): array
307    {
308        $attributes = [];
309
310        if (!$this->isExternal() || $this->fileExists($data_filesystem)) {
311            try {
312                $bytes = $this->media()->tree()->mediaFilesystem($data_filesystem)->fileSize($this->filename());
313                $kb    = intdiv($bytes + 1023, 1024);
314                $text  = I18N::translate('%s KB', I18N::number($kb));
315
316                $attributes[I18N::translate('File size')] = $text;
317            } catch (FilesystemException | UnableToRetrieveMetadata $ex) {
318                // External/missing files have no size.
319            }
320
321            $filesystem = $this->media()->tree()->mediaFilesystem($data_filesystem);
322            try {
323                $data       = $filesystem->read($this->filename());
324                $image_size = getimagesizefromstring($data);
325
326                if (is_array($image_size)) {
327                    [$width, $height] = $image_size;
328
329                    $text = I18N::translate('%1$s × %2$s pixels', I18N::number($width), I18N::number($height));
330
331                    $attributes[I18N::translate('Image dimensions')] = $text;
332                }
333            } catch (FilesystemException | UnableToReadFile $ex) {
334                // Cannot read the file.
335            }
336        }
337
338        return $attributes;
339    }
340
341    /**
342     * Read the contents of a media file.
343     *
344     * @param FilesystemOperator $data_filesystem
345     *
346     * @return string
347     */
348    public function fileContents(FilesystemOperator $data_filesystem): string
349    {
350        try {
351            return $this->media->tree()->mediaFilesystem($data_filesystem)->read($this->multimedia_file_refn);
352        } catch (FilesystemException | UnableToReadFile $ex) {
353            return '';
354        }
355    }
356
357    /**
358     * Check if the file exists on this server
359     *
360     * @param FilesystemOperator $data_filesystem
361     *
362     * @return bool
363     */
364    public function fileExists(FilesystemOperator $data_filesystem): bool
365    {
366        try {
367            return $this->media->tree()->mediaFilesystem($data_filesystem)->fileExists($this->multimedia_file_refn);
368        } catch (FilesystemException | UnableToCheckFileExistence $ex) {
369            return false;
370        }
371    }
372
373    /**
374     * @return Media
375     */
376    public function media(): Media
377    {
378        return $this->media;
379    }
380
381    /**
382     * Get the filename.
383     *
384     * @return string
385     */
386    public function filename(): string
387    {
388        return $this->multimedia_file_refn;
389    }
390
391    /**
392     * Create a URL signature parameter, using the same algorithm as league/glide,
393     * for compatibility with URLs generated by older versions of webtrees.
394     *
395     * @param array<mixed> $params
396     *
397     * @return string
398     */
399    public function signature(array $params): string
400    {
401        unset($params['s']);
402
403        ksort($params);
404
405        // Sign the URL, to protect against mass-resize attacks.
406        $glide_key = Site::getPreference('glide-key');
407
408        if ($glide_key === '') {
409            $glide_key = bin2hex(random_bytes(128));
410            Site::setPreference('glide-key', $glide_key);
411        }
412
413        return md5($glide_key . ':?' . http_build_query($params));
414    }
415}
416