xref: /webtrees/app/MediaFile.php (revision 051c822d024c344f8050d1178cd077a864982c84)
1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2019 webtrees development team
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 */
16declare(strict_types=1);
17
18namespace Fisharebest\Webtrees;
19
20use League\Flysystem\FileNotFoundException;
21use League\Glide\Urls\UrlBuilderFactory;
22use Psr\Http\Message\ServerRequestInterface;
23use Throwable;
24use function app;
25use function getimagesize;
26use function intdiv;
27use function pathinfo;
28use function strtolower;
29use const PATHINFO_EXTENSION;
30
31/**
32 * A GEDCOM media file.  A media object can contain many media files,
33 * such as scans of both sides of a document, the transcript of an audio
34 * recording, etc.
35 */
36class MediaFile
37{
38    private const MIME_TYPES = [
39        'bmp'  => 'image/bmp',
40        'doc'  => 'application/msword',
41        'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
42        'ged'  => 'text/x-gedcom',
43        'gif'  => 'image/gif',
44        'html' => 'text/html',
45        'htm'  => 'text/html',
46        'jpeg' => 'image/jpeg',
47        'jpg'  => 'image/jpeg',
48        'mov'  => 'video/quicktime',
49        'mp3'  => 'audio/mpeg',
50        'mp4'  => 'video/mp4',
51        'ogv'  => 'video/ogg',
52        'pdf'  => 'application/pdf',
53        'png'  => 'image/png',
54        'rar'  => 'application/x-rar-compressed',
55        'swf'  => 'application/x-shockwave-flash',
56        'svg'  => 'image/svg',
57        'tiff' => 'image/tiff',
58        'tif'  => 'image/tiff',
59        'xls'  => 'application/vnd-ms-excel',
60        'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
61        'wmv'  => 'video/x-ms-wmv',
62        'zip'  => 'application/zip',
63    ];
64
65    private const SUPPORTED_IMAGE_MIME_TYPES = [
66        'image/gif',
67        'image/jpeg',
68        'image/png',
69    ];
70
71    /** @var string The filename */
72    private $multimedia_file_refn = '';
73
74    /** @var string The file extension; jpeg, txt, mp4, etc. */
75    private $multimedia_format = '';
76
77    /** @var string The type of document; newspaper, microfiche, etc. */
78    private $source_media_type = '';
79    /** @var string The filename */
80
81    /** @var string The name of the document */
82    private $descriptive_title = '';
83
84    /** @var Media $media The media object to which this file belongs */
85    private $media;
86
87    /** @var string */
88    private $fact_id;
89
90    /**
91     * Create a MediaFile from raw GEDCOM data.
92     *
93     * @param string $gedcom
94     * @param Media  $media
95     */
96    public function __construct($gedcom, Media $media)
97    {
98        $this->media   = $media;
99        $this->fact_id = md5($gedcom);
100
101        if (preg_match('/^\d FILE (.+)/m', $gedcom, $match)) {
102            $this->multimedia_file_refn = $match[1];
103            $this->multimedia_format    = pathinfo($match[1], PATHINFO_EXTENSION);
104        }
105
106        if (preg_match('/^\d FORM (.+)/m', $gedcom, $match)) {
107            $this->multimedia_format = $match[1];
108        }
109
110        if (preg_match('/^\d TYPE (.+)/m', $gedcom, $match)) {
111            $this->source_media_type = $match[1];
112        }
113
114        if (preg_match('/^\d TITL (.+)/m', $gedcom, $match)) {
115            $this->descriptive_title = $match[1];
116        }
117    }
118
119    /**
120     * Get the format.
121     *
122     * @return string
123     */
124    public function format(): string
125    {
126        return $this->multimedia_format;
127    }
128
129    /**
130     * Get the type.
131     *
132     * @return string
133     */
134    public function type(): string
135    {
136        return $this->source_media_type;
137    }
138
139    /**
140     * Get the title.
141     *
142     * @return string
143     */
144    public function title(): string
145    {
146        return $this->descriptive_title;
147    }
148
149    /**
150     * Get the fact ID.
151     *
152     * @return string
153     */
154    public function factId(): string
155    {
156        return $this->fact_id;
157    }
158
159    /**
160     * @return bool
161     */
162    public function isPendingAddition(): bool
163    {
164        foreach ($this->media->facts() as $fact) {
165            if ($fact->id() === $this->fact_id) {
166                return $fact->isPendingAddition();
167            }
168        }
169
170        return false;
171    }
172
173    /**
174     * @return bool
175     */
176    public function isPendingDeletion(): bool
177    {
178        foreach ($this->media->facts() as $fact) {
179            if ($fact->id() === $this->fact_id) {
180                return $fact->isPendingDeletion();
181            }
182        }
183
184        return false;
185    }
186
187    /**
188     * Display an image-thumbnail or a media-icon, and add markup for image viewers such as colorbox.
189     *
190     * @param int      $width            Pixels
191     * @param int      $height           Pixels
192     * @param string   $fit              "crop" or "contain"
193     * @param string[] $image_attributes Additional HTML attributes
194     *
195     * @return string
196     */
197    public function displayImage($width, $height, $fit, $image_attributes = []): string
198    {
199        if ($this->isExternal()) {
200            $src    = $this->multimedia_file_refn;
201            $srcset = [];
202        } else {
203            // Generate multiple images for displays with higher pixel densities.
204            $src    = $this->imageUrl($width, $height, $fit);
205            $srcset = [];
206            foreach ([2, 3, 4] as $x) {
207                $srcset[] = $this->imageUrl($width * $x, $height * $x, $fit) . ' ' . $x . 'x';
208            }
209        }
210
211        if ($this->isImage()) {
212            $image = '<img ' . Html::attributes($image_attributes + [
213                        'dir'    => 'auto',
214                        'src'    => $src,
215                        'srcset' => implode(',', $srcset),
216                        'alt'    => htmlspecialchars_decode(strip_tags($this->media->fullName())),
217                    ]) . '>';
218
219            $link_attributes = Html::attributes([
220                'class'      => 'gallery',
221                'type'       => $this->mimeType(),
222                'href'       => $this->imageUrl(0, 0, 'contain'),
223                'data-title' => htmlspecialchars_decode(strip_tags($this->media->fullName())),
224            ]);
225        } else {
226            $image = view('icons/mime', ['type' => $this->mimeType()]);
227
228            $link_attributes = Html::attributes([
229                'type' => $this->mimeType(),
230                'href' => $this->downloadUrl(),
231            ]);
232        }
233
234        return '<a ' . $link_attributes . '>' . $image . '</a>';
235    }
236
237    /**
238     * Is the media file actually a URL?
239     */
240    public function isExternal(): bool
241    {
242        return strpos($this->multimedia_file_refn, '://') !== false;
243    }
244
245    /**
246     * Generate a URL for an image.
247     *
248     * @param int    $width  Maximum width in pixels
249     * @param int    $height Maximum height in pixels
250     * @param string $fit    "crop" or "contain"
251     *
252     * @return string
253     */
254    public function imageUrl($width, $height, $fit): string
255    {
256        // Sign the URL, to protect against mass-resize attacks.
257        $glide_key = Site::getPreference('glide-key');
258        if (empty($glide_key)) {
259            $glide_key = bin2hex(random_bytes(128));
260            Site::setPreference('glide-key', $glide_key);
261        }
262
263        if (Auth::accessLevel($this->media->tree()) > $this->media->tree()->getPreference('SHOW_NO_WATERMARK')) {
264            $mark = 'watermark.png';
265        } else {
266            $mark = '';
267        }
268
269        $base_url = app(ServerRequestInterface::class)->getAttribute('base_url');
270
271        $url_builder = UrlBuilderFactory::create($base_url, $glide_key);
272
273        $url = $url_builder->getUrl('index.php', [
274            'route'     => 'media-thumbnail',
275            'xref'      => $this->media->xref(),
276            'ged'       => $this->media->tree()->name(),
277            'fact_id'   => $this->fact_id,
278            'w'         => $width,
279            'h'         => $height,
280            'fit'       => $fit,
281            'mark'      => $mark,
282            'markh'     => '100h',
283            'markw'     => '100w',
284            'markalpha' => 25,
285            'or'        => 0,
286            // Intervention uses exif_read_data() which is very buggy.
287        ]);
288
289        return $url;
290    }
291
292    /**
293     * Is the media file an image?
294     */
295    public function isImage(): bool
296    {
297        return in_array($this->mimeType(), self::SUPPORTED_IMAGE_MIME_TYPES, true);
298    }
299
300    /**
301     * What is the mime-type of this object?
302     * For simplicity and efficiency, use the extension, rather than the contents.
303     *
304     * @return string
305     */
306    public function mimeType(): string
307    {
308        $extension = strtolower(pathinfo($this->multimedia_file_refn, PATHINFO_EXTENSION));
309
310        return self::MIME_TYPES[$extension] ?? 'application/octet-stream';
311    }
312
313    /**
314     * Generate a URL to download a non-image media file.
315     *
316     * @return string
317     */
318    public function downloadUrl(): string
319    {
320        return route('media-download', [
321            'xref'    => $this->media->xref(),
322            'ged'     => $this->media->tree()->name(),
323            'fact_id' => $this->fact_id,
324        ]);
325    }
326
327    /**
328     * A list of image attributes
329     *
330     * @return string[]
331     */
332    public function attributes(): array
333    {
334        $attributes = [];
335
336        if (!$this->isExternal() || $this->fileExists()) {
337            try {
338                $bytes                       = $this->media()->tree()->mediaFilesystem()->getSize($this->filename());
339                $kb                          = intdiv($bytes + 1023, 1024);
340                $attributes['__FILE_SIZE__'] = I18N::translate('%s KB', I18N::number($kb));
341            } catch (FileNotFoundException $ex) {
342                // External/missing files have no size.
343            }
344
345            try {
346                $file = $this->media()->tree()->mediaFilesystem()->getAdapter()->applyPathPrefix($this->filename());
347                [$width, $height] = getimagesize($file);
348                $attributes['__IMAGE_SIZE__'] = I18N::translate('%1$s × %2$s pixels', I18N::number($width), I18N::number($height));
349            } catch (Throwable $ex) {
350                // Only works for local filesystems.
351            }
352        }
353
354        return $attributes;
355    }
356
357    /**
358     * check if the file exists on this server
359     *
360     * @return bool
361     */
362    public function fileExists(): bool
363    {
364        return $this->media->tree()->mediaFilesystem()->has($this->multimedia_file_refn);
365    }
366
367    /**
368     * @return Media
369     */
370    public function media(): Media
371    {
372        return $this->media;
373    }
374
375    /**
376     * Get the filename.
377     *
378     * @return string
379     */
380    public function filename(): string
381    {
382        return $this->multimedia_file_refn;
383    }
384
385    /**
386     * Get the filename on the server - for those (very few!) functions which actually
387     * need the filename, such as the PDF reports.
388     *
389     * @return string
390     */
391    public function getServerFilename(): string
392    {
393        $MEDIA_DIRECTORY = $this->media->tree()->getPreference('MEDIA_DIRECTORY');
394
395        if ($this->multimedia_file_refn === '' || $this->isExternal()) {
396            // External image, or (in the case of corrupt GEDCOM data) no image at all
397            return $this->multimedia_file_refn;
398        }
399
400        // Main image
401        return WT_DATA_DIR . $MEDIA_DIRECTORY . $this->multimedia_file_refn;
402    }
403
404    /**
405     * What file extension is used by this file?
406     *
407     * @return string
408     */
409    public function extension(): string
410    {
411        return pathinfo($this->multimedia_file_refn, PATHINFO_EXTENSION);
412    }
413}
414