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