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