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