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