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