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