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