18f5f5da8SGreg Roach<?php 23976b470SGreg Roach 38f5f5da8SGreg Roach/** 48f5f5da8SGreg Roach * webtrees: online genealogy 5*5bfc6897SGreg Roach * Copyright (C) 2022 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; 25f7cf8a15SGreg Roachuse League\Flysystem\FilesystemOperator; 26f0448b68SGreg Roachuse League\Flysystem\UnableToCheckFileExistence; 27f0448b68SGreg Roachuse League\Flysystem\UnableToReadFile; 28f0448b68SGreg Roachuse League\Flysystem\UnableToRetrieveMetadata; 293976b470SGreg Roach 306577bfc3SGreg Roachuse function bin2hex; 31f7cf8a15SGreg Roachuse function getimagesizefromstring; 326577bfc3SGreg Roachuse function http_build_query; 3385a166d8SGreg Roachuse function intdiv; 346577bfc3SGreg Roachuse function ksort; 356577bfc3SGreg Roachuse function md5; 3685a166d8SGreg Roachuse function pathinfo; 376577bfc3SGreg Roachuse function random_bytes; 38dec352c1SGreg Roachuse function str_contains; 3985a166d8SGreg Roachuse function strtolower; 403976b470SGreg Roach 4185a166d8SGreg Roachuse const PATHINFO_EXTENSION; 428f5f5da8SGreg Roach 438f5f5da8SGreg Roach/** 448f5f5da8SGreg Roach * A GEDCOM media file. A media object can contain many media files, 458f5f5da8SGreg Roach * such as scans of both sides of a document, the transcript of an audio 468f5f5da8SGreg Roach * recording, etc. 478f5f5da8SGreg Roach */ 48c1010edaSGreg Roachclass MediaFile 49c1010edaSGreg Roach{ 5085a166d8SGreg Roach private const SUPPORTED_IMAGE_MIME_TYPES = [ 5185a166d8SGreg Roach 'image/gif', 5285a166d8SGreg Roach 'image/jpeg', 5385a166d8SGreg Roach 'image/png', 54c68bc8e2SGreg Roach 'image/webp', 5585a166d8SGreg Roach ]; 5685a166d8SGreg Roach 57693fd32aSGreg Roach private string $multimedia_file_refn = ''; 588f5f5da8SGreg Roach 59693fd32aSGreg Roach private string $multimedia_format = ''; 608f5f5da8SGreg Roach 61693fd32aSGreg Roach private string $source_media_type = ''; 628f5f5da8SGreg Roach 63693fd32aSGreg Roach private string $descriptive_title = ''; 648f5f5da8SGreg Roach 65693fd32aSGreg Roach private Media $media; 668f5f5da8SGreg Roach 67693fd32aSGreg Roach private string $fact_id; 6864b90bf1SGreg Roach 698f5f5da8SGreg Roach /** 708f5f5da8SGreg Roach * Create a MediaFile from raw GEDCOM data. 718f5f5da8SGreg Roach * 728f5f5da8SGreg Roach * @param string $gedcom 738f5f5da8SGreg Roach * @param Media $media 748f5f5da8SGreg Roach */ 7524f2a3afSGreg Roach public function __construct(string $gedcom, Media $media) 76c1010edaSGreg Roach { 778f5f5da8SGreg Roach $this->media = $media; 7864b90bf1SGreg Roach $this->fact_id = md5($gedcom); 798f5f5da8SGreg Roach 808f5f5da8SGreg Roach if (preg_match('/^\d FILE (.+)/m', $gedcom, $match)) { 818f5f5da8SGreg Roach $this->multimedia_file_refn = $match[1]; 82423c6ccdSGreg Roach $this->multimedia_format = pathinfo($match[1], PATHINFO_EXTENSION); 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 { 27585a166d8SGreg Roach $extension = strtolower(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 * 28371625badSGreg 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 * 302f7cf8a15SGreg Roach * @param FilesystemOperator $data_filesystem 3038a3784e1SGreg Roach * 30469cdf014SGreg Roach * @return array<string,string> 30585a166d8SGreg Roach */ 306f7cf8a15SGreg Roach public function attributes(FilesystemOperator $data_filesystem): array 30785a166d8SGreg Roach { 30885a166d8SGreg Roach $attributes = []; 30985a166d8SGreg Roach 310a04bb9a2SGreg Roach if (!$this->isExternal() || $this->fileExists($data_filesystem)) { 31185a166d8SGreg Roach try { 312f7cf8a15SGreg Roach $bytes = $this->media()->tree()->mediaFilesystem($data_filesystem)->fileSize($this->filename()); 31385a166d8SGreg Roach $kb = intdiv($bytes + 1023, 1024); 314693fd32aSGreg Roach $text = I18N::translate('%s KB', I18N::number($kb)); 315693fd32aSGreg Roach 316693fd32aSGreg Roach $attributes[I18N::translate('File size')] = $text; 317f0448b68SGreg Roach } catch (FilesystemException | UnableToRetrieveMetadata $ex) { 31885a166d8SGreg Roach // External/missing files have no size. 31985a166d8SGreg Roach } 32085a166d8SGreg Roach 321a04bb9a2SGreg Roach $filesystem = $this->media()->tree()->mediaFilesystem($data_filesystem); 322f0448b68SGreg Roach try { 323f0448b68SGreg Roach $data = $filesystem->read($this->filename()); 324693fd32aSGreg Roach $image_size = getimagesizefromstring($data); 325693fd32aSGreg Roach 326693fd32aSGreg Roach if (is_array($image_size)) { 327693fd32aSGreg Roach [$width, $height] = $image_size; 328693fd32aSGreg Roach 329693fd32aSGreg Roach $text = I18N::translate('%1$s × %2$s pixels', I18N::number($width), I18N::number($height)); 330693fd32aSGreg Roach 331693fd32aSGreg Roach $attributes[I18N::translate('Image dimensions')] = $text; 332693fd32aSGreg Roach } 333f0448b68SGreg Roach } catch (FilesystemException | UnableToReadFile $ex) { 334f0448b68SGreg Roach // Cannot read the file. 335f0448b68SGreg Roach } 3365c98992aSGreg Roach } 33785a166d8SGreg Roach 33885a166d8SGreg Roach return $attributes; 33985a166d8SGreg Roach } 34085a166d8SGreg Roach 34185a166d8SGreg Roach /** 34229518ad2SGreg Roach * Read the contents of a media file. 34329518ad2SGreg Roach * 344f7cf8a15SGreg Roach * @param FilesystemOperator $data_filesystem 345a04bb9a2SGreg Roach * 34629518ad2SGreg Roach * @return string 34729518ad2SGreg Roach */ 348f7cf8a15SGreg Roach public function fileContents(FilesystemOperator $data_filesystem): string 34929518ad2SGreg Roach { 350f0448b68SGreg Roach try { 351a04bb9a2SGreg Roach return $this->media->tree()->mediaFilesystem($data_filesystem)->read($this->multimedia_file_refn); 352f0448b68SGreg Roach } catch (FilesystemException | UnableToReadFile $ex) { 353f0448b68SGreg Roach return ''; 354f0448b68SGreg Roach } 35529518ad2SGreg Roach } 35629518ad2SGreg Roach 35729518ad2SGreg Roach /** 35829518ad2SGreg Roach * Check if the file exists on this server 35985a166d8SGreg Roach * 360f7cf8a15SGreg Roach * @param FilesystemOperator $data_filesystem 361a04bb9a2SGreg Roach * 36285a166d8SGreg Roach * @return bool 36385a166d8SGreg Roach */ 364f7cf8a15SGreg Roach public function fileExists(FilesystemOperator $data_filesystem): bool 36585a166d8SGreg Roach { 366f0448b68SGreg Roach try { 367f7cf8a15SGreg Roach return $this->media->tree()->mediaFilesystem($data_filesystem)->fileExists($this->multimedia_file_refn); 368f0448b68SGreg Roach } catch (FilesystemException | UnableToCheckFileExistence $ex) { 369f0448b68SGreg Roach return false; 370f0448b68SGreg Roach } 37185a166d8SGreg Roach } 37285a166d8SGreg Roach 37385a166d8SGreg Roach /** 37485a166d8SGreg Roach * @return Media 37585a166d8SGreg Roach */ 37685a166d8SGreg Roach public function media(): Media 37785a166d8SGreg Roach { 37885a166d8SGreg Roach return $this->media; 37985a166d8SGreg Roach } 38085a166d8SGreg Roach 38185a166d8SGreg Roach /** 38285a166d8SGreg Roach * Get the filename. 38385a166d8SGreg Roach * 38485a166d8SGreg Roach * @return string 38585a166d8SGreg Roach */ 38685a166d8SGreg Roach public function filename(): string 38785a166d8SGreg Roach { 38885a166d8SGreg Roach return $this->multimedia_file_refn; 38985a166d8SGreg Roach } 39085a166d8SGreg Roach 39185a166d8SGreg Roach /** 392fceda430SGreg Roach * Create a URL signature parameter, using the same algorithm as league/glide, 3936577bfc3SGreg Roach * for compatibility with URLs generated by older versions of webtrees. 3946577bfc3SGreg Roach * 3956577bfc3SGreg Roach * @param array<mixed> $params 3966577bfc3SGreg Roach * 3976577bfc3SGreg Roach * @return string 3986577bfc3SGreg Roach */ 3996577bfc3SGreg Roach public function signature(array $params): string 4006577bfc3SGreg Roach { 4016577bfc3SGreg Roach unset($params['s']); 4026577bfc3SGreg Roach 4036577bfc3SGreg Roach ksort($params); 4046577bfc3SGreg Roach 4056577bfc3SGreg Roach // Sign the URL, to protect against mass-resize attacks. 4066577bfc3SGreg Roach $glide_key = Site::getPreference('glide-key'); 4076577bfc3SGreg Roach 4086577bfc3SGreg Roach if ($glide_key === '') { 4096577bfc3SGreg Roach $glide_key = bin2hex(random_bytes(128)); 4106577bfc3SGreg Roach Site::setPreference('glide-key', $glide_key); 4116577bfc3SGreg Roach } 4126577bfc3SGreg Roach 4136577bfc3SGreg Roach return md5($glide_key . ':?' . http_build_query($params)); 4146577bfc3SGreg Roach } 4158f5f5da8SGreg Roach} 416