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