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