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