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