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