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