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