xref: /webtrees/app/MediaFile.php (revision e5766395c1a71e715ebaadcf2d63d036d60fb649)
18f5f5da8SGreg Roach<?php
23976b470SGreg Roach
38f5f5da8SGreg Roach/**
48f5f5da8SGreg Roach * webtrees: online genealogy
5d11be702SGreg Roach * Copyright (C) 2023 webtrees development team
68f5f5da8SGreg Roach * This program is free software: you can redistribute it and/or modify
78f5f5da8SGreg Roach * it under the terms of the GNU General Public License as published by
88f5f5da8SGreg Roach * the Free Software Foundation, either version 3 of the License, or
98f5f5da8SGreg Roach * (at your option) any later version.
108f5f5da8SGreg Roach * This program is distributed in the hope that it will be useful,
118f5f5da8SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of
128f5f5da8SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
138f5f5da8SGreg Roach * GNU General Public License for more details.
148f5f5da8SGreg Roach * You should have received a copy of the GNU General Public License
1589f7189bSGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>.
168f5f5da8SGreg Roach */
17fcfa147eSGreg Roach
18e7f56f2aSGreg Roachdeclare(strict_types=1);
19e7f56f2aSGreg Roach
208f5f5da8SGreg Roachnamespace Fisharebest\Webtrees;
218f5f5da8SGreg Roach
2246b03695SGreg Roachuse Fisharebest\Webtrees\Http\RequestHandlers\MediaFileDownload;
2346b03695SGreg Roachuse Fisharebest\Webtrees\Http\RequestHandlers\MediaFileThumbnail;
24f7cf8a15SGreg Roachuse League\Flysystem\FilesystemException;
25f0448b68SGreg Roachuse League\Flysystem\UnableToCheckFileExistence;
26f0448b68SGreg Roachuse League\Flysystem\UnableToReadFile;
27f0448b68SGreg Roachuse League\Flysystem\UnableToRetrieveMetadata;
283976b470SGreg Roach
296577bfc3SGreg Roachuse function bin2hex;
30f7cf8a15SGreg Roachuse function getimagesizefromstring;
316577bfc3SGreg Roachuse function http_build_query;
3210e06497SGreg Roachuse function in_array;
3385a166d8SGreg Roachuse function intdiv;
3410e06497SGreg Roachuse function is_array;
356577bfc3SGreg Roachuse function ksort;
366577bfc3SGreg Roachuse function md5;
3785a166d8SGreg Roachuse function pathinfo;
386577bfc3SGreg Roachuse function random_bytes;
39dec352c1SGreg Roachuse function str_contains;
400ea5fc1cSGreg Roachuse function strtoupper;
413976b470SGreg Roach
4285a166d8SGreg Roachuse const PATHINFO_EXTENSION;
438f5f5da8SGreg Roach
448f5f5da8SGreg Roach/**
458f5f5da8SGreg Roach * A GEDCOM media file.  A media object can contain many media files,
468f5f5da8SGreg Roach * such as scans of both sides of a document, the transcript of an audio
478f5f5da8SGreg Roach * recording, etc.
488f5f5da8SGreg Roach */
49c1010edaSGreg Roachclass MediaFile
50c1010edaSGreg Roach{
5185a166d8SGreg Roach    private const SUPPORTED_IMAGE_MIME_TYPES = [
5285a166d8SGreg Roach        'image/gif',
5385a166d8SGreg Roach        'image/jpeg',
5485a166d8SGreg Roach        'image/png',
55c68bc8e2SGreg Roach        'image/webp',
5685a166d8SGreg Roach    ];
5785a166d8SGreg Roach
58693fd32aSGreg Roach    private string $multimedia_file_refn = '';
598f5f5da8SGreg Roach
60693fd32aSGreg Roach    private string $multimedia_format = '';
618f5f5da8SGreg Roach
62693fd32aSGreg Roach    private string $source_media_type = '';
638f5f5da8SGreg Roach
64693fd32aSGreg Roach    private string $descriptive_title = '';
658f5f5da8SGreg Roach
66693fd32aSGreg Roach    private Media $media;
678f5f5da8SGreg Roach
68693fd32aSGreg Roach    private string $fact_id;
6964b90bf1SGreg Roach
708f5f5da8SGreg Roach    /**
718f5f5da8SGreg Roach     * Create a MediaFile from raw GEDCOM data.
728f5f5da8SGreg Roach     *
738f5f5da8SGreg Roach     * @param string $gedcom
748f5f5da8SGreg Roach     * @param Media  $media
758f5f5da8SGreg Roach     */
7624f2a3afSGreg Roach    public function __construct(string $gedcom, Media $media)
77c1010edaSGreg Roach    {
788f5f5da8SGreg Roach        $this->media   = $media;
7964b90bf1SGreg Roach        $this->fact_id = md5($gedcom);
808f5f5da8SGreg Roach
818f5f5da8SGreg Roach        if (preg_match('/^\d FILE (.+)/m', $gedcom, $match)) {
828f5f5da8SGreg Roach            $this->multimedia_file_refn = $match[1];
838f5f5da8SGreg Roach        }
848f5f5da8SGreg Roach
858f5f5da8SGreg Roach        if (preg_match('/^\d FORM (.+)/m', $gedcom, $match)) {
868f5f5da8SGreg Roach            $this->multimedia_format = $match[1];
878f5f5da8SGreg Roach        }
888f5f5da8SGreg Roach
898f5f5da8SGreg Roach        if (preg_match('/^\d TYPE (.+)/m', $gedcom, $match)) {
908f5f5da8SGreg Roach            $this->source_media_type = $match[1];
918f5f5da8SGreg Roach        }
928f5f5da8SGreg Roach
938f5f5da8SGreg Roach        if (preg_match('/^\d TITL (.+)/m', $gedcom, $match)) {
948f5f5da8SGreg Roach            $this->descriptive_title = $match[1];
958f5f5da8SGreg Roach        }
968f5f5da8SGreg Roach    }
978f5f5da8SGreg Roach
988f5f5da8SGreg Roach    /**
9964b90bf1SGreg Roach     * Get the format.
1008f5f5da8SGreg Roach     *
1018f5f5da8SGreg Roach     * @return string
1028f5f5da8SGreg Roach     */
103c1010edaSGreg Roach    public function format(): string
104c1010edaSGreg Roach    {
1058f5f5da8SGreg Roach        return $this->multimedia_format;
1068f5f5da8SGreg Roach    }
1078f5f5da8SGreg Roach
1088f5f5da8SGreg Roach    /**
10964b90bf1SGreg Roach     * Get the type.
1108f5f5da8SGreg Roach     *
1118f5f5da8SGreg Roach     * @return string
1128f5f5da8SGreg Roach     */
113c1010edaSGreg Roach    public function type(): string
114c1010edaSGreg Roach    {
1158f5f5da8SGreg Roach        return $this->source_media_type;
1168f5f5da8SGreg Roach    }
1178f5f5da8SGreg Roach
1188f5f5da8SGreg Roach    /**
11964b90bf1SGreg Roach     * Get the title.
1208f5f5da8SGreg Roach     *
1218f5f5da8SGreg Roach     * @return string
1228f5f5da8SGreg Roach     */
123c1010edaSGreg Roach    public function title(): string
124c1010edaSGreg Roach    {
1258f5f5da8SGreg Roach        return $this->descriptive_title;
1268f5f5da8SGreg Roach    }
1278f5f5da8SGreg Roach
1288f5f5da8SGreg Roach    /**
12964b90bf1SGreg Roach     * Get the fact ID.
13064b90bf1SGreg Roach     *
13164b90bf1SGreg Roach     * @return string
13264b90bf1SGreg Roach     */
133c1010edaSGreg Roach    public function factId(): string
134c1010edaSGreg Roach    {
13564b90bf1SGreg Roach        return $this->fact_id;
13664b90bf1SGreg Roach    }
13764b90bf1SGreg Roach
13864b90bf1SGreg Roach    /**
139d6641c58SGreg Roach     * @return bool
140d6641c58SGreg Roach     */
1418f53f488SRico Sonntag    public function isPendingAddition(): bool
142c1010edaSGreg Roach    {
14330158ae7SGreg Roach        foreach ($this->media->facts() as $fact) {
144905ab80aSGreg Roach            if ($fact->id() === $this->fact_id) {
145d6641c58SGreg Roach                return $fact->isPendingAddition();
146d6641c58SGreg Roach            }
147d6641c58SGreg Roach        }
148d6641c58SGreg Roach
149d6641c58SGreg Roach        return false;
150d6641c58SGreg Roach    }
151d6641c58SGreg Roach
152d6641c58SGreg Roach    /**
153d6641c58SGreg Roach     * @return bool
154d6641c58SGreg Roach     */
1558f53f488SRico Sonntag    public function isPendingDeletion(): bool
156c1010edaSGreg Roach    {
15730158ae7SGreg Roach        foreach ($this->media->facts() as $fact) {
158905ab80aSGreg Roach            if ($fact->id() === $this->fact_id) {
159d6641c58SGreg Roach                return $fact->isPendingDeletion();
160d6641c58SGreg Roach            }
161d6641c58SGreg Roach        }
162d6641c58SGreg Roach
163d6641c58SGreg Roach        return false;
164d6641c58SGreg Roach    }
165d6641c58SGreg Roach
166d6641c58SGreg Roach    /**
16764b90bf1SGreg Roach     * Display an image-thumbnail or a media-icon, and add markup for image viewers such as colorbox.
16864b90bf1SGreg Roach     *
16964b90bf1SGreg Roach     * @param int                  $width            Pixels
17064b90bf1SGreg Roach     * @param int                  $height           Pixels
17164b90bf1SGreg Roach     * @param string               $fit              "crop" or "contain"
17224f2a3afSGreg Roach     * @param array<string,string> $image_attributes Additional HTML attributes
17364b90bf1SGreg Roach     *
17464b90bf1SGreg Roach     * @return string
17564b90bf1SGreg Roach     */
17624f2a3afSGreg Roach    public function displayImage(int $width, int $height, string $fit, array $image_attributes = []): string
177c1010edaSGreg Roach    {
17864b90bf1SGreg Roach        if ($this->isExternal()) {
17964b90bf1SGreg Roach            $src    = $this->multimedia_file_refn;
18064b90bf1SGreg Roach            $srcset = [];
18164b90bf1SGreg Roach        } else {
18264b90bf1SGreg Roach            // Generate multiple images for displays with higher pixel densities.
18364b90bf1SGreg Roach            $src    = $this->imageUrl($width, $height, $fit);
18464b90bf1SGreg Roach            $srcset = [];
185bb308685SGreg Roach            foreach ([2, 3, 4] as $x) {
18664b90bf1SGreg Roach                $srcset[] = $this->imageUrl($width * $x, $height * $x, $fit) . ' ' . $x . 'x';
18764b90bf1SGreg Roach            }
18864b90bf1SGreg Roach        }
18964b90bf1SGreg Roach
19048b53306SGreg Roach        if ($this->isImage()) {
191e364afe4SGreg Roach            $image = '<img ' . Html::attributes($image_attributes + [
19264b90bf1SGreg Roach                        'dir'    => 'auto',
19364b90bf1SGreg Roach                        'src'    => $src,
19464b90bf1SGreg Roach                        'srcset' => implode(',', $srcset),
195d33f6d5eSGreg Roach                        'alt'    => strip_tags($this->media->fullName()),
19664b90bf1SGreg Roach                    ]) . '>';
19764b90bf1SGreg Roach
198e364afe4SGreg Roach            $link_attributes = Html::attributes([
19964b90bf1SGreg Roach                'class'      => 'gallery',
20064b90bf1SGreg Roach                'type'       => $this->mimeType(),
2016577bfc3SGreg Roach                'href'       => $this->downloadUrl('inline'),
202d33f6d5eSGreg Roach                'data-title' => strip_tags($this->media->fullName()),
20364b90bf1SGreg Roach            ]);
204e1d1700bSGreg Roach        } else {
20548b53306SGreg Roach            $image = view('icons/mime', ['type' => $this->mimeType()]);
206e364afe4SGreg Roach
207e364afe4SGreg Roach            $link_attributes = Html::attributes([
208e1d1700bSGreg Roach                'type' => $this->mimeType(),
20971625badSGreg Roach                'href' => $this->downloadUrl('inline'),
210e1d1700bSGreg Roach            ]);
211e1d1700bSGreg Roach        }
21264b90bf1SGreg Roach
213e364afe4SGreg Roach        return '<a ' . $link_attributes . '>' . $image . '</a>';
21464b90bf1SGreg Roach    }
21564b90bf1SGreg Roach
2164a9f750fSGreg Roach    /**
2174a9f750fSGreg Roach     * Is the media file actually a URL?
2184a9f750fSGreg Roach     */
219c1010edaSGreg Roach    public function isExternal(): bool
220c1010edaSGreg Roach    {
221dec352c1SGreg Roach        return str_contains($this->multimedia_file_refn, '://');
2224a9f750fSGreg Roach    }
2234a9f750fSGreg Roach
2244a9f750fSGreg Roach    /**
2258f5f5da8SGreg Roach     * Generate a URL for an image.
2268f5f5da8SGreg Roach     *
2278f5f5da8SGreg Roach     * @param int    $width  Maximum width in pixels
2288f5f5da8SGreg Roach     * @param int    $height Maximum height in pixels
2298f5f5da8SGreg Roach     * @param string $fit    "crop" or "contain"
2308f5f5da8SGreg Roach     *
2318f5f5da8SGreg Roach     * @return string
2328f5f5da8SGreg Roach     */
23324f2a3afSGreg Roach    public function imageUrl(int $width, int $height, string $fit): string
234c1010edaSGreg Roach    {
2358f5f5da8SGreg Roach        // Sign the URL, to protect against mass-resize attacks.
2368f5f5da8SGreg Roach        $glide_key = Site::getPreference('glide-key');
23746b03695SGreg Roach
23854c1ab5eSGreg Roach        if ($glide_key === '') {
2398f5f5da8SGreg Roach            $glide_key = bin2hex(random_bytes(128));
2408f5f5da8SGreg Roach            Site::setPreference('glide-key', $glide_key);
2418f5f5da8SGreg Roach        }
2428f5f5da8SGreg Roach
2436577bfc3SGreg Roach        // The "mark" parameter is ignored, but needed for cache-busting.
244ee4364daSGreg Roach        $params = [
245c0935879SGreg Roach            'xref'      => $this->media->xref(),
246d72b284aSGreg Roach            'tree'      => $this->media->tree()->name(),
2474a9f750fSGreg Roach            'fact_id'   => $this->fact_id,
2488f5f5da8SGreg Roach            'w'         => $width,
2498f5f5da8SGreg Roach            'h'         => $height,
2508f5f5da8SGreg Roach            'fit'       => $fit,
2516b9cb339SGreg Roach            'mark'      => Registry::imageFactory()->thumbnailNeedsWatermark($this, Auth::user())
25246b03695SGreg Roach        ];
25346b03695SGreg Roach
2546577bfc3SGreg Roach        $params['s'] = $this->signature($params);
255ee4364daSGreg Roach
25646b03695SGreg Roach        return route(MediaFileThumbnail::class, $params);
2578f5f5da8SGreg Roach    }
2588f5f5da8SGreg Roach
2598f5f5da8SGreg Roach    /**
26085a166d8SGreg Roach     * Is the media file an image?
2618f5f5da8SGreg Roach     */
26285a166d8SGreg Roach    public function isImage(): bool
263c1010edaSGreg Roach    {
26485a166d8SGreg Roach        return in_array($this->mimeType(), self::SUPPORTED_IMAGE_MIME_TYPES, true);
2658f5f5da8SGreg Roach    }
2668f5f5da8SGreg Roach
2678f5f5da8SGreg Roach    /**
2688f5f5da8SGreg Roach     * What is the mime-type of this object?
2698f5f5da8SGreg Roach     * For simplicity and efficiency, use the extension, rather than the contents.
2708f5f5da8SGreg Roach     *
2718f5f5da8SGreg Roach     * @return string
2728f5f5da8SGreg Roach     */
2738f53f488SRico Sonntag    public function mimeType(): string
274c1010edaSGreg Roach    {
2750ea5fc1cSGreg Roach        $extension = strtoupper(pathinfo($this->multimedia_file_refn, PATHINFO_EXTENSION));
27685a166d8SGreg Roach
277e7f16b43SGreg Roach        return Mime::TYPES[$extension] ?? Mime::DEFAULT_TYPE;
27885a166d8SGreg Roach    }
27985a166d8SGreg Roach
28085a166d8SGreg Roach    /**
2816577bfc3SGreg Roach     * Generate a URL to download a media file.
28285a166d8SGreg Roach     *
283*e5766395SGreg Roach     * @param string $disposition How should the image be returned: "attachment" or "inline"
28471625badSGreg Roach     *
28585a166d8SGreg Roach     * @return string
28685a166d8SGreg Roach     */
28771625badSGreg Roach    public function downloadUrl(string $disposition): string
28885a166d8SGreg Roach    {
2896577bfc3SGreg Roach        // The "mark" parameter is ignored, but needed for cache-busting.
29046b03695SGreg Roach        return route(MediaFileDownload::class, [
29185a166d8SGreg Roach            'xref'        => $this->media->xref(),
292d72b284aSGreg Roach            'tree'        => $this->media->tree()->name(),
29385a166d8SGreg Roach            'fact_id'     => $this->fact_id,
29471625badSGreg Roach            'disposition' => $disposition,
2956b9cb339SGreg Roach            'mark'        => Registry::imageFactory()->fileNeedsWatermark($this, Auth::user())
29685a166d8SGreg Roach        ]);
29785a166d8SGreg Roach    }
29885a166d8SGreg Roach
29985a166d8SGreg Roach    /**
30085a166d8SGreg Roach     * A list of image attributes
30185a166d8SGreg Roach     *
30269cdf014SGreg Roach     * @return array<string,string>
30385a166d8SGreg Roach     */
3049458f20aSGreg Roach    public function attributes(): array
30585a166d8SGreg Roach    {
30685a166d8SGreg Roach        $attributes = [];
30785a166d8SGreg Roach
3089458f20aSGreg Roach        if (!$this->isExternal() || $this->fileExists()) {
30985a166d8SGreg Roach            try {
3109458f20aSGreg Roach                $bytes = $this->media()->tree()->mediaFilesystem()->fileSize($this->filename());
31185a166d8SGreg Roach                $kb    = intdiv($bytes + 1023, 1024);
312693fd32aSGreg Roach                $text  = I18N::translate('%s KB', I18N::number($kb));
313693fd32aSGreg Roach
314693fd32aSGreg Roach                $attributes[I18N::translate('File size')] = $text;
31528d026adSGreg Roach            } catch (FilesystemException | UnableToRetrieveMetadata) {
31685a166d8SGreg Roach                // External/missing files have no size.
31785a166d8SGreg Roach            }
31885a166d8SGreg Roach
319f0448b68SGreg Roach            try {
3209458f20aSGreg Roach                $data       = $this->media()->tree()->mediaFilesystem()->read($this->filename());
321693fd32aSGreg Roach                $image_size = getimagesizefromstring($data);
322693fd32aSGreg Roach
323693fd32aSGreg Roach                if (is_array($image_size)) {
324693fd32aSGreg Roach                    [$width, $height] = $image_size;
325693fd32aSGreg Roach
326693fd32aSGreg Roach                    $text = I18N::translate('%1$s × %2$s pixels', I18N::number($width), I18N::number($height));
327693fd32aSGreg Roach
328693fd32aSGreg Roach                    $attributes[I18N::translate('Image dimensions')] = $text;
329693fd32aSGreg Roach                }
33028d026adSGreg Roach            } catch (FilesystemException | UnableToReadFile) {
331f0448b68SGreg Roach                // Cannot read the file.
332f0448b68SGreg Roach            }
3335c98992aSGreg Roach        }
33485a166d8SGreg Roach
33585a166d8SGreg Roach        return $attributes;
33685a166d8SGreg Roach    }
33785a166d8SGreg Roach
33885a166d8SGreg Roach    /**
33929518ad2SGreg Roach     * Read the contents of a media file.
34029518ad2SGreg Roach     *
34129518ad2SGreg Roach     * @return string
34229518ad2SGreg Roach     */
3439458f20aSGreg Roach    public function fileContents(): string
34429518ad2SGreg Roach    {
345f0448b68SGreg Roach        try {
3469458f20aSGreg Roach            return $this->media->tree()->mediaFilesystem()->read($this->multimedia_file_refn);
34728d026adSGreg Roach        } catch (FilesystemException | UnableToReadFile) {
348f0448b68SGreg Roach            return '';
349f0448b68SGreg Roach        }
35029518ad2SGreg Roach    }
35129518ad2SGreg Roach
35229518ad2SGreg Roach    /**
35329518ad2SGreg Roach     * Check if the file exists on this server
35485a166d8SGreg Roach     *
35585a166d8SGreg Roach     * @return bool
35685a166d8SGreg Roach     */
3579458f20aSGreg Roach    public function fileExists(): bool
35885a166d8SGreg Roach    {
359f0448b68SGreg Roach        try {
3609458f20aSGreg Roach            return $this->media->tree()->mediaFilesystem()->fileExists($this->multimedia_file_refn);
36128d026adSGreg Roach        } catch (FilesystemException | UnableToCheckFileExistence) {
362f0448b68SGreg Roach            return false;
363f0448b68SGreg Roach        }
36485a166d8SGreg Roach    }
36585a166d8SGreg Roach
36685a166d8SGreg Roach    /**
36785a166d8SGreg Roach     * @return Media
36885a166d8SGreg Roach     */
36985a166d8SGreg Roach    public function media(): Media
37085a166d8SGreg Roach    {
37185a166d8SGreg Roach        return $this->media;
37285a166d8SGreg Roach    }
37385a166d8SGreg Roach
37485a166d8SGreg Roach    /**
37585a166d8SGreg Roach     * Get the filename.
37685a166d8SGreg Roach     *
37785a166d8SGreg Roach     * @return string
37885a166d8SGreg Roach     */
37985a166d8SGreg Roach    public function filename(): string
38085a166d8SGreg Roach    {
38185a166d8SGreg Roach        return $this->multimedia_file_refn;
38285a166d8SGreg Roach    }
38385a166d8SGreg Roach
38485a166d8SGreg Roach    /**
385fceda430SGreg Roach     * Create a URL signature parameter, using the same algorithm as league/glide,
3866577bfc3SGreg Roach     * for compatibility with URLs generated by older versions of webtrees.
3876577bfc3SGreg Roach     *
3886577bfc3SGreg Roach     * @param array<mixed> $params
3896577bfc3SGreg Roach     *
3906577bfc3SGreg Roach     * @return string
3916577bfc3SGreg Roach     */
3926577bfc3SGreg Roach    public function signature(array $params): string
3936577bfc3SGreg Roach    {
3946577bfc3SGreg Roach        unset($params['s']);
3956577bfc3SGreg Roach
3966577bfc3SGreg Roach        ksort($params);
3976577bfc3SGreg Roach
3986577bfc3SGreg Roach        // Sign the URL, to protect against mass-resize attacks.
3996577bfc3SGreg Roach        $glide_key = Site::getPreference('glide-key');
4006577bfc3SGreg Roach
4016577bfc3SGreg Roach        if ($glide_key === '') {
4026577bfc3SGreg Roach            $glide_key = bin2hex(random_bytes(128));
4036577bfc3SGreg Roach            Site::setPreference('glide-key', $glide_key);
4046577bfc3SGreg Roach        }
4056577bfc3SGreg Roach
4066577bfc3SGreg Roach        return md5($glide_key . ':?' . http_build_query($params));
4076577bfc3SGreg Roach    }
4088f5f5da8SGreg Roach}
409