1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2018 webtrees development team 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * You should have received a copy of the GNU General Public License 14 * along with this program. If not, see <http://www.gnu.org/licenses/>. 15 */ 16namespace Fisharebest\Webtrees; 17 18use League\Glide\Urls\UrlBuilderFactory; 19use Throwable; 20 21/** 22 * A GEDCOM media file. A media object can contain many media files, 23 * such as scans of both sides of a document, the transcript of an audio 24 * recording, etc. 25 */ 26class MediaFile { 27 /** @var string The filename */ 28 private $multimedia_file_refn = ''; 29 30 /** @var string The file extension; jpeg, txt, mp4, etc. */ 31 private $multimedia_format = ''; 32 33 /** @var string The type of document; newspaper, microfiche, etc. */ 34 private $source_media_type = ''; 35 /** @var string The filename */ 36 37 /** @var string The name of the document */ 38 private $descriptive_title = ''; 39 40 /** @var Media $media The media object to which this file belongs */ 41 private $media; 42 43 /** @var string */ 44 private $fact_id; 45 46 /** 47 * Create a MediaFile from raw GEDCOM data. 48 * 49 * @param string $gedcom 50 * @param Media $media 51 */ 52 public function __construct($gedcom, Media $media) { 53 $this->media = $media; 54 $this->fact_id = md5($gedcom); 55 56 if (preg_match('/^\d FILE (.+)/m', $gedcom, $match)) { 57 $this->multimedia_file_refn = $match[1]; 58 } 59 60 if (preg_match('/^\d FORM (.+)/m', $gedcom, $match)) { 61 $this->multimedia_format = $match[1]; 62 } 63 64 if (preg_match('/^\d TYPE (.+)/m', $gedcom, $match)) { 65 $this->source_media_type = $match[1]; 66 } 67 68 if (preg_match('/^\d TITL (.+)/m', $gedcom, $match)) { 69 $this->descriptive_title = $match[1]; 70 } 71 } 72 73 /** 74 * Get the filename. 75 * 76 * @return string 77 */ 78 public function filename(): string { 79 return $this->multimedia_file_refn; 80 } 81 82 /** 83 * Get the base part of the filename. 84 * 85 * @return string 86 */ 87 public function basename(): string { 88 return basename($this->multimedia_file_refn); 89 } 90 91 /** 92 * Get the folder part of the filename. 93 * 94 * @return string 95 */ 96 public function dirname(): string { 97 $dirname = dirname($this->multimedia_file_refn); 98 99 if ($dirname === '.') { 100 return ''; 101 } else { 102 return $dirname; 103 } 104 } 105 106 /** 107 * Get the format. 108 * 109 * @return string 110 */ 111 public function format(): string { 112 return $this->multimedia_format; 113 } 114 115 /** 116 * Get the type. 117 * 118 * @return string 119 */ 120 public function type(): string { 121 return $this->source_media_type; 122 } 123 124 /** 125 * Get the title. 126 * 127 * @return string 128 */ 129 public function title(): string { 130 return $this->descriptive_title; 131 } 132 133 /** 134 * Get the fact ID. 135 * 136 * @return string 137 */ 138 public function factId(): string { 139 return $this->fact_id; 140 } 141 142 /** 143 * @return bool 144 */ 145 public function isPendingAddition() { 146 foreach ($this->media->getFacts() as $fact) { 147 if ($fact->getFactId() === $this->fact_id) { 148 return $fact->isPendingAddition(); 149 } 150 } 151 152 return false; 153 } 154 155 /** 156 * @return bool 157 */ 158 public function isPendingDeletion() { 159 foreach ($this->media->getFacts() as $fact) { 160 if ($fact->getFactId() === $this->fact_id) { 161 return $fact->isPendingDeletion(); 162 } 163 } 164 165 return false; 166 } 167 168 /** 169 * Display an image-thumbnail or a media-icon, and add markup for image viewers such as colorbox. 170 * 171 * @param int $width Pixels 172 * @param int $height Pixels 173 * @param string $fit "crop" or "contain" 174 * @param string[] $attributes Additional HTML attributes 175 * 176 * @return string 177 */ 178 public function displayImage($width, $height, $fit, $attributes = []) { 179 if ($this->isExternal()) { 180 $src = $this->multimedia_file_refn; 181 $srcset = []; 182 } else { 183 // Generate multiple images for displays with higher pixel densities. 184 $src = $this->imageUrl($width, $height, $fit); 185 $srcset = []; 186 foreach ([2, 3, 4] as $x) { 187 $srcset[] = $this->imageUrl($width * $x, $height * $x, $fit) . ' ' . $x . 'x'; 188 } 189 } 190 191 $image = '<img ' . Html::attributes($attributes + [ 192 'dir' => 'auto', 193 'src' => $src, 194 'srcset' => implode(',', $srcset), 195 'alt' => htmlspecialchars_decode(strip_tags($this->media->getFullName())), 196 ]) . '>'; 197 198 $attributes = Html::attributes([ 199 'class' => 'gallery', 200 'type' => $this->mimeType(), 201 'href' => $this->imageUrl(0, 0, 'contain'), 202 'data-title' => htmlspecialchars_decode(strip_tags($this->media->getFullName())), 203 ]); 204 205 return '<a ' . $attributes . '>' . $image . '</a>'; 206 } 207 208 /** 209 * A list of image attributes 210 * 211 * @return string[] 212 */ 213 public function attributes(): array { 214 $attributes = []; 215 216 if (!$this->isExternal() || $this->fileExists()) { 217 $file = $this->folder() . $this->multimedia_file_refn; 218 219 $attributes['__FILE_SIZE__'] = $this->fileSizeKB(); 220 221 $imgsize = getimagesize($file); 222 if (is_array($imgsize) && !empty($imgsize['0'])) { 223 $attributes['__IMAGE_SIZE__'] = I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1'])); 224 } 225 } 226 227 return $attributes; 228 } 229 230 /** 231 * check if the file exists on this server 232 * 233 * @return bool 234 */ 235 public function fileExists() { 236 return !$this->isExternal() && file_exists($this->folder() . $this->multimedia_file_refn); 237 } 238 239 /** 240 * Is the media file actually a URL? 241 */ 242 public function isExternal(): bool { 243 return strpos($this->multimedia_file_refn, '://') !== false; 244 } 245 246 /** 247 * Is the media file an image? 248 */ 249 public function isImage(): bool { 250 return in_array($this->extension(), ['jpeg', 'jpg', 'gif', 'png']); 251 } 252 253 /** 254 * Where is the file stored on disk? 255 */ 256 public function folder(): string { 257 return WT_DATA_DIR . $this->media->getTree()->getPreference('MEDIA_DIRECTORY'); 258 } 259 260 /** 261 * A user-friendly view of the file size 262 * 263 * @return int 264 */ 265 private function fileSizeBytes(): int { 266 try { 267 return filesize($this->folder() . $this->multimedia_file_refn); 268 } catch (Throwable $ex) { 269 DebugBar::addThrowable($ex); 270 271 return 0; 272 } 273 } 274 275 /** 276 * get the media file size in KB 277 * 278 * @return string 279 */ 280 public function fileSizeKB() { 281 $size = $this->filesizeBytes(); 282 $size = (int) (($size + 1023) / 1024); 283 284 return /* I18N: size of file in KB */ I18N::translate('%s KB', I18N::number($size)); 285 } 286 287 /** 288 * Get the filename on the server - for those (very few!) functions which actually 289 * need the filename, such as the PDF reports. 290 * 291 * @return string 292 */ 293 public function getServerFilename() { 294 $MEDIA_DIRECTORY = $this->media->getTree()->getPreference('MEDIA_DIRECTORY'); 295 296 if ($this->isExternal() || !$this->multimedia_file_refn) { 297 // External image, or (in the case of corrupt GEDCOM data) no image at all 298 return $this->multimedia_file_refn; 299 } else { 300 // Main image 301 return WT_DATA_DIR . $MEDIA_DIRECTORY . $this->multimedia_file_refn; 302 } 303 } 304 305 /** 306 * get image properties 307 * 308 * @return array 309 */ 310 public function getImageAttributes() { 311 $imgsize = []; 312 if ($this->fileExists()) { 313 try { 314 $imgsize = getimagesize($this->getServerFilename()); 315 if (is_array($imgsize) && !empty($imgsize['0'])) { 316 // this is an image 317 $imageTypes = ['', 'GIF', 'JPG', 'PNG', 'SWF', 'PSD', 'BMP', 'TIFF', 'TIFF', 'JPC', 'JP2', 'JPX', 'JB2', 'SWC', 'IFF', 'WBMP', 'XBM']; 318 $imgsize['ext'] = $imageTypes[0 + $imgsize[2]]; 319 // this is for display purposes, always show non-adjusted info 320 $imgsize['WxH'] = /* I18N: image dimensions, width × height */ 321 I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1'])); 322 } 323 } catch (Throwable $ex) { 324 DebugBar::addThrowable($ex); 325 326 // Not an image, or not a valid image? 327 $imgsize = false; 328 } 329 } 330 331 if (!is_array($imgsize) || empty($imgsize['0'])) { 332 // this is not an image, OR the file doesn’t exist OR it is a url 333 $imgsize[0] = 0; 334 $imgsize[1] = 0; 335 $imgsize['ext'] = ''; 336 $imgsize['mime'] = ''; 337 $imgsize['WxH'] = ''; 338 } 339 340 if (empty($imgsize['mime'])) { 341 // this is not an image, OR the file doesn’t exist OR it is a url 342 // set file type equal to the file extension - can’t use parse_url because this may not be a full url 343 $exp = explode('?', $this->multimedia_file_refn); 344 $imgsize['ext'] = strtoupper(pathinfo($exp[0], PATHINFO_EXTENSION)); 345 // all mimetypes we wish to serve with the media firewall must be added to this array. 346 $mime = [ 347 'DOC' => 'application/msword', 348 'MOV' => 'video/quicktime', 349 'MP3' => 'audio/mpeg', 350 'PDF' => 'application/pdf', 351 'PPT' => 'application/vnd.ms-powerpoint', 352 'RTF' => 'text/rtf', 353 'SID' => 'image/x-mrsid', 354 'TXT' => 'text/plain', 355 'XLS' => 'application/vnd.ms-excel', 356 'WMV' => 'video/x-ms-wmv', 357 ]; 358 if (empty($mime[$imgsize['ext']])) { 359 // if we don’t know what the mimetype is, use something ambiguous 360 $imgsize['mime'] = 'application/octet-stream'; 361 if ($this->fileExists()) { 362 // alert the admin if we cannot determine the mime type of an existing file 363 // as the media firewall will be unable to serve this file properly 364 Log::addMediaLog('Media Firewall error: >Unknown Mimetype< for file >' . $this->multimedia_file_refn . '<'); 365 } 366 } else { 367 $imgsize['mime'] = $mime[$imgsize['ext']]; 368 } 369 } 370 371 return $imgsize; 372 } 373 374 /** 375 * Generate a URL for an image. 376 * 377 * @param int $width Maximum width in pixels 378 * @param int $height Maximum height in pixels 379 * @param string $fit "crop" or "contain" 380 * 381 * @return string 382 */ 383 public function imageUrl($width, $height, $fit) { 384 // Sign the URL, to protect against mass-resize attacks. 385 $glide_key = Site::getPreference('glide-key'); 386 if (empty($glide_key)) { 387 $glide_key = bin2hex(random_bytes(128)); 388 Site::setPreference('glide-key', $glide_key); 389 } 390 391 if (Auth::accessLevel($this->media->getTree()) > $this->media->getTree()->getPreference('SHOW_NO_WATERMARK')) { 392 $mark = 'watermark.png'; 393 } else { 394 $mark = ''; 395 } 396 397 $url_builder = UrlBuilderFactory::create(WT_BASE_URL, $glide_key); 398 399 $url = $url_builder->getUrl('index.php', [ 400 'route' => 'media-thumbnail', 401 'xref' => $this->media->getXref(), 402 'ged' => $this->media->getTree()->getName(), 403 'fact_id' => $this->fact_id, 404 'w' => $width, 405 'h' => $height, 406 'fit' => $fit, 407 'mark' => $mark, 408 'markh' => '100h', 409 'markw' => '100w', 410 'markalpha' => 25, 411 'or' => 0, // Intervention uses exif_read_data() which is very buggy. 412 ]); 413 414 return $url; 415 } 416 417 /** 418 * What file extension is used by this file? 419 * 420 * @return string 421 */ 422 public function extension() { 423 if (preg_match('/\.([a-zA-Z0-9]+)$/', $this->multimedia_file_refn, $match)) { 424 return strtolower($match[1]); 425 } else { 426 return ''; 427 } 428 } 429 430 /** 431 * What is the mime-type of this object? 432 * For simplicity and efficiency, use the extension, rather than the contents. 433 * 434 * @return string 435 */ 436 public function mimeType() { 437 // Themes contain icon definitions for some/all of these mime-types 438 switch ($this->extension()) { 439 case 'bmp': 440 return 'image/bmp'; 441 case 'doc': 442 return 'application/msword'; 443 case 'docx': 444 return 'application/msword'; 445 case 'ged': 446 return 'text/x-gedcom'; 447 case 'gif': 448 return 'image/gif'; 449 case 'htm': 450 return 'text/html'; 451 case 'html': 452 return 'text/html'; 453 case 'jpeg': 454 return 'image/jpeg'; 455 case 'jpg': 456 return 'image/jpeg'; 457 case 'mov': 458 return 'video/quicktime'; 459 case 'mp3': 460 return 'audio/mpeg'; 461 case 'mp4': 462 return 'video/mp4'; 463 case 'ogv': 464 return 'video/ogg'; 465 case 'pdf': 466 return 'application/pdf'; 467 case 'png': 468 return 'image/png'; 469 case 'rar': 470 return 'application/x-rar-compressed'; 471 case 'swf': 472 return 'application/x-shockwave-flash'; 473 case 'svg': 474 return 'image/svg'; 475 case 'tif': 476 return 'image/tiff'; 477 case 'tiff': 478 return 'image/tiff'; 479 case 'xls': 480 return 'application/vnd-ms-excel'; 481 case 'xlsx': 482 return 'application/vnd-ms-excel'; 483 case 'wmv': 484 return 'video/x-ms-wmv'; 485 case 'zip': 486 return 'application/zip'; 487 default: 488 return 'application/octet-stream'; 489 } 490 } 491} 492