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