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