1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2022 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 <https://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees; 21 22use Fisharebest\Webtrees\Http\RequestHandlers\MediaFileDownload; 23use Fisharebest\Webtrees\Http\RequestHandlers\MediaFileThumbnail; 24use League\Flysystem\FilesystemException; 25use League\Flysystem\FilesystemOperator; 26use League\Flysystem\UnableToCheckFileExistence; 27use League\Flysystem\UnableToReadFile; 28use League\Flysystem\UnableToRetrieveMetadata; 29 30use function bin2hex; 31use function getimagesizefromstring; 32use function http_build_query; 33use function intdiv; 34use function ksort; 35use function md5; 36use function pathinfo; 37use function random_bytes; 38use function str_contains; 39use function strtoupper; 40 41use const PATHINFO_EXTENSION; 42 43/** 44 * A GEDCOM media file. A media object can contain many media files, 45 * such as scans of both sides of a document, the transcript of an audio 46 * recording, etc. 47 */ 48class MediaFile 49{ 50 private const SUPPORTED_IMAGE_MIME_TYPES = [ 51 'image/gif', 52 'image/jpeg', 53 'image/png', 54 'image/webp', 55 ]; 56 57 private string $multimedia_file_refn = ''; 58 59 private string $multimedia_format = ''; 60 61 private string $source_media_type = ''; 62 63 private string $descriptive_title = ''; 64 65 private Media $media; 66 67 private string $fact_id; 68 69 /** 70 * Create a MediaFile from raw GEDCOM data. 71 * 72 * @param string $gedcom 73 * @param Media $media 74 */ 75 public function __construct(string $gedcom, Media $media) 76 { 77 $this->media = $media; 78 $this->fact_id = md5($gedcom); 79 80 if (preg_match('/^\d FILE (.+)/m', $gedcom, $match)) { 81 $this->multimedia_file_refn = $match[1]; 82 } 83 84 if (preg_match('/^\d FORM (.+)/m', $gedcom, $match)) { 85 $this->multimedia_format = $match[1]; 86 } 87 88 if (preg_match('/^\d TYPE (.+)/m', $gedcom, $match)) { 89 $this->source_media_type = $match[1]; 90 } 91 92 if (preg_match('/^\d TITL (.+)/m', $gedcom, $match)) { 93 $this->descriptive_title = $match[1]; 94 } 95 } 96 97 /** 98 * Get the format. 99 * 100 * @return string 101 */ 102 public function format(): string 103 { 104 return $this->multimedia_format; 105 } 106 107 /** 108 * Get the type. 109 * 110 * @return string 111 */ 112 public function type(): string 113 { 114 return $this->source_media_type; 115 } 116 117 /** 118 * Get the title. 119 * 120 * @return string 121 */ 122 public function title(): string 123 { 124 return $this->descriptive_title; 125 } 126 127 /** 128 * Get the fact ID. 129 * 130 * @return string 131 */ 132 public function factId(): string 133 { 134 return $this->fact_id; 135 } 136 137 /** 138 * @return bool 139 */ 140 public function isPendingAddition(): bool 141 { 142 foreach ($this->media->facts() as $fact) { 143 if ($fact->id() === $this->fact_id) { 144 return $fact->isPendingAddition(); 145 } 146 } 147 148 return false; 149 } 150 151 /** 152 * @return bool 153 */ 154 public function isPendingDeletion(): bool 155 { 156 foreach ($this->media->facts() as $fact) { 157 if ($fact->id() === $this->fact_id) { 158 return $fact->isPendingDeletion(); 159 } 160 } 161 162 return false; 163 } 164 165 /** 166 * Display an image-thumbnail or a media-icon, and add markup for image viewers such as colorbox. 167 * 168 * @param int $width Pixels 169 * @param int $height Pixels 170 * @param string $fit "crop" or "contain" 171 * @param array<string,string> $image_attributes Additional HTML attributes 172 * 173 * @return string 174 */ 175 public function displayImage(int $width, int $height, string $fit, array $image_attributes = []): string 176 { 177 if ($this->isExternal()) { 178 $src = $this->multimedia_file_refn; 179 $srcset = []; 180 } else { 181 // Generate multiple images for displays with higher pixel densities. 182 $src = $this->imageUrl($width, $height, $fit); 183 $srcset = []; 184 foreach ([2, 3, 4] as $x) { 185 $srcset[] = $this->imageUrl($width * $x, $height * $x, $fit) . ' ' . $x . 'x'; 186 } 187 } 188 189 if ($this->isImage()) { 190 $image = '<img ' . Html::attributes($image_attributes + [ 191 'dir' => 'auto', 192 'src' => $src, 193 'srcset' => implode(',', $srcset), 194 'alt' => strip_tags($this->media->fullName()), 195 ]) . '>'; 196 197 $link_attributes = Html::attributes([ 198 'class' => 'gallery', 199 'type' => $this->mimeType(), 200 'href' => $this->downloadUrl('inline'), 201 'data-title' => strip_tags($this->media->fullName()), 202 ]); 203 } else { 204 $image = view('icons/mime', ['type' => $this->mimeType()]); 205 206 $link_attributes = Html::attributes([ 207 'type' => $this->mimeType(), 208 'href' => $this->downloadUrl('inline'), 209 ]); 210 } 211 212 return '<a ' . $link_attributes . '>' . $image . '</a>'; 213 } 214 215 /** 216 * Is the media file actually a URL? 217 */ 218 public function isExternal(): bool 219 { 220 return str_contains($this->multimedia_file_refn, '://'); 221 } 222 223 /** 224 * Generate a URL for an image. 225 * 226 * @param int $width Maximum width in pixels 227 * @param int $height Maximum height in pixels 228 * @param string $fit "crop" or "contain" 229 * 230 * @return string 231 */ 232 public function imageUrl(int $width, int $height, string $fit): string 233 { 234 // Sign the URL, to protect against mass-resize attacks. 235 $glide_key = Site::getPreference('glide-key'); 236 237 if ($glide_key === '') { 238 $glide_key = bin2hex(random_bytes(128)); 239 Site::setPreference('glide-key', $glide_key); 240 } 241 242 // The "mark" parameter is ignored, but needed for cache-busting. 243 $params = [ 244 'xref' => $this->media->xref(), 245 'tree' => $this->media->tree()->name(), 246 'fact_id' => $this->fact_id, 247 'w' => $width, 248 'h' => $height, 249 'fit' => $fit, 250 'mark' => Registry::imageFactory()->thumbnailNeedsWatermark($this, Auth::user()) 251 ]; 252 253 $params['s'] = $this->signature($params); 254 255 return route(MediaFileThumbnail::class, $params); 256 } 257 258 /** 259 * Is the media file an image? 260 */ 261 public function isImage(): bool 262 { 263 return in_array($this->mimeType(), self::SUPPORTED_IMAGE_MIME_TYPES, true); 264 } 265 266 /** 267 * What is the mime-type of this object? 268 * For simplicity and efficiency, use the extension, rather than the contents. 269 * 270 * @return string 271 */ 272 public function mimeType(): string 273 { 274 $extension = strtoupper(pathinfo($this->multimedia_file_refn, PATHINFO_EXTENSION)); 275 276 return Mime::TYPES[$extension] ?? Mime::DEFAULT_TYPE; 277 } 278 279 /** 280 * Generate a URL to download a media file. 281 * 282 * @param string $disposition How should the image be returned - "attachment" or "inline" 283 * 284 * @return string 285 */ 286 public function downloadUrl(string $disposition): string 287 { 288 // The "mark" parameter is ignored, but needed for cache-busting. 289 return route(MediaFileDownload::class, [ 290 'xref' => $this->media->xref(), 291 'tree' => $this->media->tree()->name(), 292 'fact_id' => $this->fact_id, 293 'disposition' => $disposition, 294 'mark' => Registry::imageFactory()->fileNeedsWatermark($this, Auth::user()) 295 ]); 296 } 297 298 /** 299 * A list of image attributes 300 * 301 * @param FilesystemOperator $data_filesystem 302 * 303 * @return array<string,string> 304 */ 305 public function attributes(FilesystemOperator $data_filesystem): array 306 { 307 $attributes = []; 308 309 if (!$this->isExternal() || $this->fileExists($data_filesystem)) { 310 try { 311 $bytes = $this->media()->tree()->mediaFilesystem($data_filesystem)->fileSize($this->filename()); 312 $kb = intdiv($bytes + 1023, 1024); 313 $text = I18N::translate('%s KB', I18N::number($kb)); 314 315 $attributes[I18N::translate('File size')] = $text; 316 } catch (FilesystemException | UnableToRetrieveMetadata $ex) { 317 // External/missing files have no size. 318 } 319 320 $filesystem = $this->media()->tree()->mediaFilesystem($data_filesystem); 321 try { 322 $data = $filesystem->read($this->filename()); 323 $image_size = getimagesizefromstring($data); 324 325 if (is_array($image_size)) { 326 [$width, $height] = $image_size; 327 328 $text = I18N::translate('%1$s × %2$s pixels', I18N::number($width), I18N::number($height)); 329 330 $attributes[I18N::translate('Image dimensions')] = $text; 331 } 332 } catch (FilesystemException | UnableToReadFile $ex) { 333 // Cannot read the file. 334 } 335 } 336 337 return $attributes; 338 } 339 340 /** 341 * Read the contents of a media file. 342 * 343 * @param FilesystemOperator $data_filesystem 344 * 345 * @return string 346 */ 347 public function fileContents(FilesystemOperator $data_filesystem): string 348 { 349 try { 350 return $this->media->tree()->mediaFilesystem($data_filesystem)->read($this->multimedia_file_refn); 351 } catch (FilesystemException | UnableToReadFile $ex) { 352 return ''; 353 } 354 } 355 356 /** 357 * Check if the file exists on this server 358 * 359 * @param FilesystemOperator $data_filesystem 360 * 361 * @return bool 362 */ 363 public function fileExists(FilesystemOperator $data_filesystem): bool 364 { 365 try { 366 return $this->media->tree()->mediaFilesystem($data_filesystem)->fileExists($this->multimedia_file_refn); 367 } catch (FilesystemException | UnableToCheckFileExistence $ex) { 368 return false; 369 } 370 } 371 372 /** 373 * @return Media 374 */ 375 public function media(): Media 376 { 377 return $this->media; 378 } 379 380 /** 381 * Get the filename. 382 * 383 * @return string 384 */ 385 public function filename(): string 386 { 387 return $this->multimedia_file_refn; 388 } 389 390 /** 391 * Create a URL signature parameter, using the same algorithm as league/glide, 392 * for compatibility with URLs generated by older versions of webtrees. 393 * 394 * @param array<mixed> $params 395 * 396 * @return string 397 */ 398 public function signature(array $params): string 399 { 400 unset($params['s']); 401 402 ksort($params); 403 404 // Sign the URL, to protect against mass-resize attacks. 405 $glide_key = Site::getPreference('glide-key'); 406 407 if ($glide_key === '') { 408 $glide_key = bin2hex(random_bytes(128)); 409 Site::setPreference('glide-key', $glide_key); 410 } 411 412 return md5($glide_key . ':?' . http_build_query($params)); 413 } 414} 415