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