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