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