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