1<?php 2/** 3 * webtrees: online genealogy 4 * Copyright (C) 2017 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 Fisharebest\Webtrees\Functions\FunctionsPrintFacts; 19use League\Glide\Urls\UrlBuilderFactory; 20 21/** 22 * A GEDCOM media (OBJE) object. 23 */ 24class Media extends GedcomRecord { 25 const RECORD_TYPE = 'OBJE'; 26 const URL_PREFIX = 'mediaviewer.php?mid='; 27 28 /** @var string The "TITL" value from the GEDCOM */ 29 private $title = ''; 30 31 /** @var string The "FILE" value from the GEDCOM */ 32 private $file = ''; 33 34 /** 35 * Create a GedcomRecord object from raw GEDCOM data. 36 * 37 * @param string $xref 38 * @param string $gedcom an empty string for new/pending records 39 * @param string|null $pending null for a record with no pending edits, 40 * empty string for records with pending deletions 41 * @param Tree $tree 42 */ 43 public function __construct($xref, $gedcom, $pending, $tree) { 44 parent::__construct($xref, $gedcom, $pending, $tree); 45 46 if (preg_match('/\n1 FILE (.+)/', $gedcom . $pending, $match)) { 47 $this->file = $match[1]; 48 } 49 if (preg_match('/\n\d TITL (.+)/', $gedcom . $pending, $match)) { 50 $this->title = $match[1]; 51 } 52 } 53 54 /** 55 * Each object type may have its own special rules, and re-implement this function. 56 * 57 * @param int $access_level 58 * 59 * @return bool 60 */ 61 protected function canShowByType($access_level) { 62 // Hide media objects if they are attached to private records 63 $linked_ids = Database::prepare( 64 "SELECT l_from FROM `##link` WHERE l_to = ? AND l_file = ?" 65 )->execute([ 66 $this->xref, $this->tree->getTreeId(), 67 ])->fetchOneColumn(); 68 foreach ($linked_ids as $linked_id) { 69 $linked_record = GedcomRecord::getInstance($linked_id, $this->tree); 70 if ($linked_record && !$linked_record->canShow($access_level)) { 71 return false; 72 } 73 } 74 75 // ... otherwise apply default behaviour 76 return parent::canShowByType($access_level); 77 } 78 79 /** 80 * Fetch data from the database 81 * 82 * @param string $xref 83 * @param int $tree_id 84 * 85 * @return null|string 86 */ 87 protected static function fetchGedcomRecord($xref, $tree_id) { 88 return Database::prepare( 89 "SELECT m_gedcom FROM `##media` WHERE m_id = :xref AND m_file = :tree_id" 90 )->execute([ 91 'xref' => $xref, 92 'tree_id' => $tree_id, 93 ])->fetchOne(); 94 } 95 96 /** 97 * Get the first note attached to this media object 98 * 99 * @return null|string 100 */ 101 public function getNote() { 102 $note = $this->getFirstFact('NOTE'); 103 if ($note) { 104 $text = $note->getValue(); 105 if (preg_match('/^@' . WT_REGEX_XREF . '@$/', $text)) { 106 $text = $note->getTarget()->getNote(); 107 } 108 109 return $text; 110 } else { 111 return ''; 112 } 113 } 114 115 /** 116 * Get the main media filename 117 * 118 * @return string 119 */ 120 public function getFilename() { 121 return $this->file; 122 } 123 124 /** 125 * Get the media's title (name) 126 * 127 * @return string 128 */ 129 public function getTitle() { 130 return $this->title; 131 } 132 133 /** 134 * Get the filename on the server - for those (very few!) functions which actually 135 * need the filename, such as mediafirewall.php and the PDF reports. 136 * 137 * @return string 138 */ 139 public function getServerFilename() { 140 $MEDIA_DIRECTORY = $this->tree->getPreference('MEDIA_DIRECTORY'); 141 142 if ($this->isExternal() || !$this->file) { 143 // External image, or (in the case of corrupt GEDCOM data) no image at all 144 return $this->file; 145 } else { 146 // Main image 147 return WT_DATA_DIR . $MEDIA_DIRECTORY . $this->file; 148 } 149 } 150 151 /** 152 * check if the file exists on this server 153 * 154 * @return bool 155 */ 156 public function fileExists() { 157 return file_exists($this->getServerFilename()); 158 } 159 160 /** 161 * Determine if the file is an external url 162 * 163 * @return bool 164 */ 165 public function isExternal() { 166 return strpos($this->file, '://') !== false; 167 } 168 169 /** 170 * get the media file size in KB 171 * 172 * @return string 173 */ 174 public function getFilesize() { 175 $size = $this->getFilesizeraw(); 176 // Round up to the nearest KB. 177 $size = (int) (($size + 1023) / 1024); 178 179 return /* I18N: size of file in KB */ 180 I18N::translate('%s KB', I18N::number($size)); 181 } 182 183 /** 184 * get the media file size, unformatted 185 * 186 * @return int 187 */ 188 public function getFilesizeraw() { 189 try { 190 return filesize($this->getServerFilename()); 191 } catch (\ErrorException $ex) { 192 return 0; 193 } 194 } 195 196 /** 197 * Deprecated? This does not need to be a function here. 198 * 199 * @return string 200 */ 201 public function getMediaType() { 202 if (preg_match('/\n\d TYPE (.+)/', $this->gedcom, $match)) { 203 return strtolower($match[1]); 204 } else { 205 return ''; 206 } 207 } 208 209 /** 210 * Is this object marked as a highlighted image? 211 * 212 * @return string 213 */ 214 public function isPrimary() { 215 if (preg_match('/\n\d _PRIM ([YN])/', $this->getGedcom(), $match)) { 216 return $match[1]; 217 } else { 218 return ''; 219 } 220 } 221 222 /** 223 * get image properties 224 * 225 * @return array 226 */ 227 public function getImageAttributes() { 228 $imgsize = []; 229 if ($this->fileExists()) { 230 try { 231 $imgsize = getimagesize($this->getServerFilename()); 232 if (is_array($imgsize) && !empty($imgsize['0'])) { 233 // this is an image 234 $imageTypes = ['', 'GIF', 'JPG', 'PNG', 'SWF', 'PSD', 'BMP', 'TIFF', 'TIFF', 'JPC', 'JP2', 'JPX', 'JB2', 'SWC', 'IFF', 'WBMP', 'XBM']; 235 $imgsize['ext'] = $imageTypes[0 + $imgsize[2]]; 236 // this is for display purposes, always show non-adjusted info 237 $imgsize['WxH'] = /* I18N: image dimensions, width × height */ 238 I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1'])); 239 } 240 } catch (\ErrorException $ex) { 241 // Not an image, or not a valid image? 242 $imgsize = false; 243 } 244 } 245 246 if (!is_array($imgsize) || empty($imgsize['0'])) { 247 // this is not an image, OR the file doesn’t exist OR it is a url 248 $imgsize[0] = 0; 249 $imgsize[1] = 0; 250 $imgsize['ext'] = ''; 251 $imgsize['mime'] = ''; 252 $imgsize['WxH'] = ''; 253 } 254 255 if (empty($imgsize['mime'])) { 256 // this is not an image, OR the file doesn’t exist OR it is a url 257 // set file type equal to the file extension - can’t use parse_url because this may not be a full url 258 $exp = explode('?', $this->file); 259 $imgsize['ext'] = strtoupper(pathinfo($exp[0], PATHINFO_EXTENSION)); 260 // all mimetypes we wish to serve with the media firewall must be added to this array. 261 $mime = [ 262 'DOC' => 'application/msword', 263 'MOV' => 'video/quicktime', 264 'MP3' => 'audio/mpeg', 265 'PDF' => 'application/pdf', 266 'PPT' => 'application/vnd.ms-powerpoint', 267 'RTF' => 'text/rtf', 268 'SID' => 'image/x-mrsid', 269 'TXT' => 'text/plain', 270 'XLS' => 'application/vnd.ms-excel', 271 'WMV' => 'video/x-ms-wmv', 272 ]; 273 if (empty($mime[$imgsize['ext']])) { 274 // if we don’t know what the mimetype is, use something ambiguous 275 $imgsize['mime'] = 'application/octet-stream'; 276 if ($this->fileExists()) { 277 // alert the admin if we cannot determine the mime type of an existing file 278 // as the media firewall will be unable to serve this file properly 279 Log::addMediaLog('Media Firewall error: >Unknown Mimetype< for file >' . $this->file . '<'); 280 } 281 } else { 282 $imgsize['mime'] = $mime[$imgsize['ext']]; 283 } 284 } 285 286 return $imgsize; 287 } 288 289 /** 290 * Generate a URL for an image. 291 * 292 * @param int $width Maximum width in pixels 293 * @param int $height Maximum height in pixels 294 * @param string $fit "crop" or "contain" 295 * 296 * @return string 297 */ 298 public function imageUrl($width, $height, $fit) { 299 // Sign the URL, to protect against mass-resize attacks. 300 $glide_key = Site::getPreference('glide-key'); 301 if (empty($glide_key)) { 302 $glide_key = bin2hex(random_bytes(128)); 303 Site::setPreference('glide-key', $glide_key); 304 } 305 306 if (Auth::accessLevel($this->getTree()) > $this->getTree()->getPreference('SHOW_NO_WATERMARK')) { 307 $mark = 'watermark.png'; 308 } else { 309 $mark = ''; 310 } 311 312 $url = UrlBuilderFactory::create(WT_BASE_URL, $glide_key) 313 ->getUrl('mediafirewall.php', [ 314 'mid' => $this->getXref(), 315 'ged' => $this->tree->getName(), 316 'w' => $width, 317 'h' => $height, 318 'fit' => $fit, 319 'mark' => $mark, 320 'markh' => '100h', 321 'markw' => '100w', 322 'markalpha' => 25, 323 'or' => 0, // Intervention uses exif_read_data() which is very buggy. 324 ]); 325 326 return $url; 327 } 328 329 /** 330 * What file extension is used by this file? 331 * 332 * @return string 333 */ 334 public function extension() { 335 if (preg_match('/\.([a-zA-Z0-9]+)$/', $this->file, $match)) { 336 return strtolower($match[1]); 337 } else { 338 return ''; 339 } 340 } 341 342 /** 343 * What is the mime-type of this object? 344 * For simplicity and efficiency, use the extension, rather than the contents. 345 * 346 * @return string 347 */ 348 public function mimeType() { 349 // Themes contain icon definitions for some/all of these mime-types 350 switch ($this->extension()) { 351 case 'bmp': 352 return 'image/bmp'; 353 case 'doc': 354 return 'application/msword'; 355 case 'docx': 356 return 'application/msword'; 357 case 'ged': 358 return 'text/x-gedcom'; 359 case 'gif': 360 return 'image/gif'; 361 case 'htm': 362 return 'text/html'; 363 case 'html': 364 return 'text/html'; 365 case 'jpeg': 366 return 'image/jpeg'; 367 case 'jpg': 368 return 'image/jpeg'; 369 case 'mov': 370 return 'video/quicktime'; 371 case 'mp3': 372 return 'audio/mpeg'; 373 case 'mp4': 374 return 'video/mp4'; 375 case 'ogv': 376 return 'video/ogg'; 377 case 'pdf': 378 return 'application/pdf'; 379 case 'png': 380 return 'image/png'; 381 case 'rar': 382 return 'application/x-rar-compressed'; 383 case 'swf': 384 return 'application/x-shockwave-flash'; 385 case 'svg': 386 return 'image/svg'; 387 case 'tif': 388 return 'image/tiff'; 389 case 'tiff': 390 return 'image/tiff'; 391 case 'xls': 392 return 'application/vnd-ms-excel'; 393 case 'xlsx': 394 return 'application/vnd-ms-excel'; 395 case 'wmv': 396 return 'video/x-ms-wmv'; 397 case 'zip': 398 return 'application/zip'; 399 default: 400 return 'application/octet-stream'; 401 } 402 } 403 404 /** 405 * Display an image-thumbnail or a media-icon, and add markup for image viewers such as colorbox. 406 * 407 * @param int $width Pixels 408 * @param int $height Pixels 409 * @param string $fit "crop" or "contain" 410 * @param string[] $attributes Additional HTML attributes 411 * 412 * @return string 413 */ 414 public function displayImage($width, $height, $fit, $attributes = []) { 415 // Default image for external, missing or corrupt images. 416 $image 417 = '<i' . 418 ' dir="auto"' . // For the tool-tip 419 ' class="icon-mime-' . str_replace('/', '-', $this->mimeType()) . '"' . 420 ' title="' . strip_tags($this->getFullName()) . '"' . 421 '></i>'; 422 423 // Use a thumbnail image. 424 if ($this->isExternal()) { 425 $src = $this->getFilename(); 426 $srcset = []; 427 } else { 428 // Generate multiple images for displays with higher pixel densities. 429 $src = $this->imageUrl($width, $height, $fit); 430 $srcset = []; 431 foreach ([2, 3, 4] as $x) { 432 $srcset[] = $this->imageUrl($width * $x, $height * $x, $fit) . ' ' . $x . 'x'; 433 } 434 } 435 436 $image = '<img ' . Html::attributes($attributes + [ 437 'dir' => 'auto', 438 'src' => $src, 439 'srcset' => implode(',', $srcset), 440 'alt' => strip_tags($this->getFullName()), 441 'title' => strip_tags($this->getFullName()), 442 ]) . '>'; 443 444 return 445 '<a' . 446 ' class="gallery"' . 447 ' href="' . $this->imageUrl(0, 0, '') . '"' . 448 ' type="' . $this->mimeType() . '"' . 449 ' data-obje-url="' . $this->getHtmlUrl() . '"' . 450 ' data-obje-note="' . Html::escape($this->getNote()) . '"' . 451 ' data-title="' . Html::escape($this->getFullName()) . '"' . 452 '>' . $image . '</a>'; 453 } 454 455 /** 456 * If this object has no name, what do we call it? 457 * 458 * @return string 459 */ 460 public function getFallBackName() { 461 if ($this->canShow()) { 462 return basename($this->file); 463 } else { 464 return $this->getXref(); 465 } 466 } 467 468 /** 469 * Extract names from the GEDCOM record. 470 */ 471 public function extractNames() { 472 // Earlier gedcom versions had level 1 titles 473 // Later gedcom versions had level 2 titles 474 $this->extractNamesFromFacts(2, 'TITL', $this->getFacts('FILE')); 475 $this->extractNamesFromFacts(1, 'TITL', $this->getFacts('TITL')); 476 } 477 478 /** 479 * This function should be redefined in derived classes to show any major 480 * identifying characteristics of this record. 481 * 482 * @return string 483 */ 484 public function formatListDetails() { 485 ob_start(); 486 FunctionsPrintFacts::printMediaLinks('1 OBJE @' . $this->getXref() . '@', 1); 487 488 return ob_get_clean(); 489 } 490} 491