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