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 * get image properties 211 * 212 * @return array 213 */ 214 public function getImageAttributes() { 215 $imgsize = []; 216 if ($this->fileExists()) { 217 try { 218 $imgsize = getimagesize($this->getServerFilename()); 219 if (is_array($imgsize) && !empty($imgsize['0'])) { 220 // this is an image 221 $imageTypes = ['', 'GIF', 'JPG', 'PNG', 'SWF', 'PSD', 'BMP', 'TIFF', 'TIFF', 'JPC', 'JP2', 'JPX', 'JB2', 'SWC', 'IFF', 'WBMP', 'XBM']; 222 $imgsize['ext'] = $imageTypes[0 + $imgsize[2]]; 223 // this is for display purposes, always show non-adjusted info 224 $imgsize['WxH'] = /* I18N: image dimensions, width × height */ 225 I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1'])); 226 } 227 } catch (\ErrorException $ex) { 228 // Not an image, or not a valid image? 229 $imgsize = false; 230 } 231 } 232 233 if (!is_array($imgsize) || empty($imgsize['0'])) { 234 // this is not an image, OR the file doesn’t exist OR it is a url 235 $imgsize[0] = 0; 236 $imgsize[1] = 0; 237 $imgsize['ext'] = ''; 238 $imgsize['mime'] = ''; 239 $imgsize['WxH'] = ''; 240 } 241 242 if (empty($imgsize['mime'])) { 243 // this is not an image, OR the file doesn’t exist OR it is a url 244 // set file type equal to the file extension - can’t use parse_url because this may not be a full url 245 $exp = explode('?', $this->file); 246 $imgsize['ext'] = strtoupper(pathinfo($exp[0], PATHINFO_EXTENSION)); 247 // all mimetypes we wish to serve with the media firewall must be added to this array. 248 $mime = [ 249 'DOC' => 'application/msword', 250 'MOV' => 'video/quicktime', 251 'MP3' => 'audio/mpeg', 252 'PDF' => 'application/pdf', 253 'PPT' => 'application/vnd.ms-powerpoint', 254 'RTF' => 'text/rtf', 255 'SID' => 'image/x-mrsid', 256 'TXT' => 'text/plain', 257 'XLS' => 'application/vnd.ms-excel', 258 'WMV' => 'video/x-ms-wmv', 259 ]; 260 if (empty($mime[$imgsize['ext']])) { 261 // if we don’t know what the mimetype is, use something ambiguous 262 $imgsize['mime'] = 'application/octet-stream'; 263 if ($this->fileExists()) { 264 // alert the admin if we cannot determine the mime type of an existing file 265 // as the media firewall will be unable to serve this file properly 266 Log::addMediaLog('Media Firewall error: >Unknown Mimetype< for file >' . $this->file . '<'); 267 } 268 } else { 269 $imgsize['mime'] = $mime[$imgsize['ext']]; 270 } 271 } 272 273 return $imgsize; 274 } 275 276 /** 277 * Generate a URL for an image. 278 * 279 * @param int $width Maximum width in pixels 280 * @param int $height Maximum height in pixels 281 * @param string $fit "crop" or "contain" 282 * 283 * @return string 284 */ 285 public function imageUrl($width, $height, $fit) { 286 // Sign the URL, to protect against mass-resize attacks. 287 $glide_key = Site::getPreference('glide-key'); 288 if (empty($glide_key)) { 289 $glide_key = bin2hex(random_bytes(128)); 290 Site::setPreference('glide-key', $glide_key); 291 } 292 293 if (Auth::accessLevel($this->getTree()) > $this->getTree()->getPreference('SHOW_NO_WATERMARK')) { 294 $mark = 'watermark.png'; 295 } else { 296 $mark = ''; 297 } 298 299 $url = UrlBuilderFactory::create(WT_BASE_URL, $glide_key) 300 ->getUrl('mediafirewall.php', [ 301 'mid' => $this->getXref(), 302 'ged' => $this->tree->getName(), 303 'w' => $width, 304 'h' => $height, 305 'fit' => $fit, 306 'mark' => $mark, 307 'markh' => '100h', 308 'markw' => '100w', 309 'markalpha' => 25, 310 'or' => 0, // Intervention uses exif_read_data() which is very buggy. 311 ]); 312 313 return $url; 314 } 315 316 /** 317 * What file extension is used by this file? 318 * 319 * @return string 320 */ 321 public function extension() { 322 if (preg_match('/\.([a-zA-Z0-9]+)$/', $this->file, $match)) { 323 return strtolower($match[1]); 324 } else { 325 return ''; 326 } 327 } 328 329 /** 330 * What is the mime-type of this object? 331 * For simplicity and efficiency, use the extension, rather than the contents. 332 * 333 * @return string 334 */ 335 public function mimeType() { 336 // Themes contain icon definitions for some/all of these mime-types 337 switch ($this->extension()) { 338 case 'bmp': 339 return 'image/bmp'; 340 case 'doc': 341 return 'application/msword'; 342 case 'docx': 343 return 'application/msword'; 344 case 'ged': 345 return 'text/x-gedcom'; 346 case 'gif': 347 return 'image/gif'; 348 case 'htm': 349 return 'text/html'; 350 case 'html': 351 return 'text/html'; 352 case 'jpeg': 353 return 'image/jpeg'; 354 case 'jpg': 355 return 'image/jpeg'; 356 case 'mov': 357 return 'video/quicktime'; 358 case 'mp3': 359 return 'audio/mpeg'; 360 case 'mp4': 361 return 'video/mp4'; 362 case 'ogv': 363 return 'video/ogg'; 364 case 'pdf': 365 return 'application/pdf'; 366 case 'png': 367 return 'image/png'; 368 case 'rar': 369 return 'application/x-rar-compressed'; 370 case 'swf': 371 return 'application/x-shockwave-flash'; 372 case 'svg': 373 return 'image/svg'; 374 case 'tif': 375 return 'image/tiff'; 376 case 'tiff': 377 return 'image/tiff'; 378 case 'xls': 379 return 'application/vnd-ms-excel'; 380 case 'xlsx': 381 return 'application/vnd-ms-excel'; 382 case 'wmv': 383 return 'video/x-ms-wmv'; 384 case 'zip': 385 return 'application/zip'; 386 default: 387 return 'application/octet-stream'; 388 } 389 } 390 391 /** 392 * Display an image-thumbnail or a media-icon, and add markup for image viewers such as colorbox. 393 * 394 * @param int $width Pixels 395 * @param int $height Pixels 396 * @param string $fit "crop" or "contain" 397 * @param string[] $attributes Additional HTML attributes 398 * 399 * @return string 400 */ 401 public function displayImage($width, $height, $fit, $attributes = []) { 402 // Default image for external, missing or corrupt images. 403 $image 404 = '<i' . 405 ' dir="auto"' . // For the tool-tip 406 ' class="icon-mime-' . str_replace('/', '-', $this->mimeType()) . '"' . 407 ' title="' . strip_tags($this->getFullName()) . '"' . 408 '></i>'; 409 410 // Use a thumbnail image. 411 if ($this->isExternal()) { 412 $src = $this->getFilename(); 413 $srcset = []; 414 } else { 415 // Generate multiple images for displays with higher pixel densities. 416 $src = $this->imageUrl($width, $height, $fit); 417 $srcset = []; 418 foreach ([2, 3, 4] as $x) { 419 $srcset[] = $this->imageUrl($width * $x, $height * $x, $fit) . ' ' . $x . 'x'; 420 } 421 } 422 423 $image = '<img ' . Html::attributes($attributes + [ 424 'dir' => 'auto', 425 'src' => $src, 426 'srcset' => implode(',', $srcset), 427 'alt' => strip_tags($this->getFullName()), 428 ]) . '>'; 429 430 $attributes = Html::attributes([ 431 'class' => 'gallery', 432 'type' => $this->mimeType(), 433 'href' => $this->imageUrl(0, 0, ''), 434 ]); 435 436 return '<a ' . $attributes . '>' . $image . '</a>'; 437 } 438 439 /** 440 * If this object has no name, what do we call it? 441 * 442 * @return string 443 */ 444 public function getFallBackName() { 445 if ($this->canShow()) { 446 return basename($this->file); 447 } else { 448 return $this->getXref(); 449 } 450 } 451 452 /** 453 * Extract names from the GEDCOM record. 454 */ 455 public function extractNames() { 456 // Earlier gedcom versions had level 1 titles 457 // Later gedcom versions had level 2 titles 458 $this->extractNamesFromFacts(2, 'TITL', $this->getFacts('FILE')); 459 $this->extractNamesFromFacts(1, 'TITL', $this->getFacts('TITL')); 460 } 461 462 /** 463 * This function should be redefined in derived classes to show any major 464 * identifying characteristics of this record. 465 * 466 * @return string 467 */ 468 public function formatListDetails() { 469 ob_start(); 470 FunctionsPrintFacts::printMediaLinks('1 OBJE @' . $this->getXref() . '@', 1); 471 472 return ob_get_clean(); 473 } 474} 475