xref: /webtrees/app/MediaFile.php (revision 06a438b41c4b328354bcb5bd8d8d578a3a78f995)
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 League\Flysystem\Adapter\Local;
23use League\Flysystem\FileNotFoundException;
24use League\Flysystem\Filesystem;
25use League\Flysystem\FilesystemInterface;
26use League\Glide\Signatures\SignatureFactory;
27
28use function extension_loaded;
29use function getimagesize;
30use function intdiv;
31use function pathinfo;
32use function str_contains;
33use function strtolower;
34
35use const PATHINFO_EXTENSION;
36
37/**
38 * A GEDCOM media file.  A media object can contain many media files,
39 * such as scans of both sides of a document, the transcript of an audio
40 * recording, etc.
41 */
42class MediaFile
43{
44    private const SUPPORTED_IMAGE_MIME_TYPES = [
45        'image/gif',
46        'image/jpeg',
47        'image/png',
48    ];
49
50    /** @var string The filename */
51    private $multimedia_file_refn = '';
52
53    /** @var string The file extension; jpeg, txt, mp4, etc. */
54    private $multimedia_format = '';
55
56    /** @var string The type of document; newspaper, microfiche, etc. */
57    private $source_media_type = '';
58    /** @var string The filename */
59
60    /** @var string The name of the document */
61    private $descriptive_title = '';
62
63    /** @var Media $media The media object to which this file belongs */
64    private $media;
65
66    /** @var string */
67    private $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($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 string[] $image_attributes Additional HTML attributes
173     *
174     * @return string
175     */
176    public function displayImage($width, $height, $fit, $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->imageUrl(0, 0, 'contain'),
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($width, $height, $fit): string
234    {
235        // Sign the URL, to protect against mass-resize attacks.
236        $glide_key = Site::getPreference('glide-key');
237        if ($glide_key === '') {
238            $glide_key = bin2hex(random_bytes(128));
239            Site::setPreference('glide-key', $glide_key);
240        }
241
242        if (Auth::accessLevel($this->media->tree()) > $this->media->tree()->getPreference('SHOW_NO_WATERMARK')) {
243            $mark = 'watermark.png';
244        } else {
245            $mark = '';
246        }
247
248        // Automatic rotation only works when the php-exif library is loaded.
249        $orientation = extension_loaded('exif') ? 'or' : 0;
250
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'      => $mark,
259            'markh'     => '100h',
260            'markw'     => '100w',
261            'markpos'   => 'center',
262            'markalpha' => 25,
263            'or'        => $orientation,
264            'q'         => 45,
265        ];
266
267        $signature = SignatureFactory::create($glide_key)->generateSignature('', $params);
268
269        $params = ['route' => '/media-thumbnail', 's' => $signature] + $params;
270
271        return route('media-thumbnail', $params);
272    }
273
274    /**
275     * Is the media file an image?
276     */
277    public function isImage(): bool
278    {
279        return in_array($this->mimeType(), self::SUPPORTED_IMAGE_MIME_TYPES, true);
280    }
281
282    /**
283     * What is the mime-type of this object?
284     * For simplicity and efficiency, use the extension, rather than the contents.
285     *
286     * @return string
287     */
288    public function mimeType(): string
289    {
290        $extension = strtolower(pathinfo($this->multimedia_file_refn, PATHINFO_EXTENSION));
291
292        return Mime::TYPES[$extension] ?? Mime::DEFAULT_TYPE;
293    }
294
295    /**
296     * Generate a URL to download a non-image media file.
297     *
298     * @param string $disposition How should the image be returned - "attachment" or "inline"
299     *
300     * @return string
301     */
302    public function downloadUrl(string $disposition): string
303    {
304        return route('media-download', [
305            'xref'        => $this->media->xref(),
306            'tree'        => $this->media->tree()->name(),
307            'fact_id'     => $this->fact_id,
308            'disposition' => $disposition,
309        ]);
310    }
311
312    /**
313     * A list of image attributes
314     *
315     * @param FilesystemInterface $data_filesystem
316     *
317     * @return string[]
318     */
319    public function attributes(FilesystemInterface $data_filesystem): array
320    {
321        $attributes = [];
322
323        if (!$this->isExternal() || $this->fileExists($data_filesystem)) {
324            try {
325                $bytes                       = $this->media()->tree()->mediaFilesystem($data_filesystem)->getSize($this->filename());
326                $kb                          = intdiv($bytes + 1023, 1024);
327                $attributes['__FILE_SIZE__'] = I18N::translate('%s KB', I18N::number($kb));
328            } catch (FileNotFoundException $ex) {
329                // External/missing files have no size.
330            }
331
332            // Note: getAdapter() is defined on Filesystem, but not on FilesystemInterface.
333            $filesystem = $this->media()->tree()->mediaFilesystem($data_filesystem);
334            if ($filesystem instanceof Filesystem) {
335                $adapter = $filesystem->getAdapter();
336                // Only works for local filesystems.
337                if ($adapter instanceof Local) {
338                    $file = $adapter->applyPathPrefix($this->filename());
339                    [$width, $height] = getimagesize($file);
340                    $attributes['__IMAGE_SIZE__'] = I18N::translate('%1$s × %2$s pixels', I18N::number($width), I18N::number($height));
341                }
342            }
343        }
344
345        return $attributes;
346    }
347
348    /**
349     * Read the contents of a media file.
350     *
351     * @param FilesystemInterface $data_filesystem
352     *
353     * @return string
354     */
355    public function fileContents(FilesystemInterface $data_filesystem): string
356    {
357        return $this->media->tree()->mediaFilesystem($data_filesystem)->read($this->multimedia_file_refn);
358    }
359
360    /**
361     * Check if the file exists on this server
362     *
363     * @param FilesystemInterface $data_filesystem
364     *
365     * @return bool
366     */
367    public function fileExists(FilesystemInterface $data_filesystem): bool
368    {
369        return $this->media->tree()->mediaFilesystem($data_filesystem)->has($this->multimedia_file_refn);
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     * What file extension is used by this file?
392     *
393     * @return string
394     *
395     * @deprecated since 2.0.4.  Will be removed in 2.1.0
396     */
397    public function extension(): string
398    {
399        return pathinfo($this->multimedia_file_refn, PATHINFO_EXTENSION);
400    }
401}
402