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\Urls\UrlBuilderFactory; 23use Psr\Http\Message\ServerRequestInterface; 24use Throwable; 25 26use function app; 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 (empty($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 $base_url = app(ServerRequestInterface::class)->getAttribute('base_url'); 273 274 $url_builder = UrlBuilderFactory::create($base_url, $glide_key); 275 276 $url = $url_builder->getUrl('index.php', [ 277 'route' => 'media-thumbnail', 278 'xref' => $this->media->xref(), 279 'ged' => $this->media->tree()->name(), 280 'fact_id' => $this->fact_id, 281 'w' => $width, 282 'h' => $height, 283 'fit' => $fit, 284 'mark' => $mark, 285 'markh' => '100h', 286 'markw' => '100w', 287 'markalpha' => 25, 288 'or' => 0, 289 // Intervention uses exif_read_data() which is very buggy. 290 ]); 291 292 return $url; 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 * @return string 320 */ 321 public function downloadUrl(): string 322 { 323 return route('media-download', [ 324 'xref' => $this->media->xref(), 325 'ged' => $this->media->tree()->name(), 326 'fact_id' => $this->fact_id, 327 ]); 328 } 329 330 /** 331 * A list of image attributes 332 * 333 * @return string[] 334 */ 335 public function attributes(): array 336 { 337 $attributes = []; 338 339 if (!$this->isExternal() || $this->fileExists()) { 340 try { 341 $bytes = $this->media()->tree()->mediaFilesystem()->getSize($this->filename()); 342 $kb = intdiv($bytes + 1023, 1024); 343 $attributes['__FILE_SIZE__'] = I18N::translate('%s KB', I18N::number($kb)); 344 } catch (FileNotFoundException $ex) { 345 // External/missing files have no size. 346 } 347 348 try { 349 $file = $this->media()->tree()->mediaFilesystem()->getAdapter()->applyPathPrefix($this->filename()); 350 [$width, $height] = getimagesize($file); 351 $attributes['__IMAGE_SIZE__'] = I18N::translate('%1$s × %2$s pixels', I18N::number($width), I18N::number($height)); 352 } catch (Throwable $ex) { 353 // Only works for local filesystems. 354 } 355 } 356 357 return $attributes; 358 } 359 360 /** 361 * Read the contents of a media file. 362 * 363 * @return string 364 */ 365 public function fileContents(): string 366 { 367 return $this->media->tree()->mediaFilesystem()->read($this->multimedia_file_refn); 368 } 369 370 /** 371 * Check if the file exists on this server 372 * 373 * @return bool 374 */ 375 public function fileExists(): bool 376 { 377 return $this->media->tree()->mediaFilesystem()->has($this->multimedia_file_refn); 378 } 379 380 /** 381 * @return Media 382 */ 383 public function media(): Media 384 { 385 return $this->media; 386 } 387 388 /** 389 * Get the filename. 390 * 391 * @return string 392 */ 393 public function filename(): string 394 { 395 return $this->multimedia_file_refn; 396 } 397 398 /** 399 * What file extension is used by this file? 400 * 401 * @return string 402 */ 403 public function extension(): string 404 { 405 return pathinfo($this->multimedia_file_refn, PATHINFO_EXTENSION); 406 } 407} 408