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\Services; 21 22use Fig\Http\Message\StatusCodeInterface; 23use Fisharebest\Flysystem\Adapter\ChrootAdapter; 24use Fisharebest\Webtrees\FlashMessages; 25use Fisharebest\Webtrees\GedcomTag; 26use Fisharebest\Webtrees\I18N; 27use Fisharebest\Webtrees\Mime; 28use Fisharebest\Webtrees\Tree; 29use Illuminate\Database\Capsule\Manager as DB; 30use Illuminate\Database\Query\Expression; 31use Illuminate\Support\Collection; 32use InvalidArgumentException; 33use League\Flysystem\Adapter\Local; 34use League\Flysystem\Filesystem; 35use League\Flysystem\FilesystemInterface; 36use League\Glide\Filesystem\FileNotFoundException; 37use League\Glide\ServerFactory; 38use Psr\Http\Message\ResponseInterface; 39use Psr\Http\Message\ServerRequestInterface; 40use Psr\Http\Message\UploadedFileInterface; 41use RuntimeException; 42use Throwable; 43 44use function array_combine; 45use function array_diff; 46use function array_filter; 47use function array_map; 48use function assert; 49use function dirname; 50use function explode; 51use function extension_loaded; 52use function implode; 53use function ini_get; 54use function intdiv; 55use function min; 56use function pathinfo; 57use function preg_replace; 58use function response; 59use function sha1; 60use function sort; 61use function str_contains; 62use function strlen; 63use function strtolower; 64use function strtr; 65use function substr; 66use function trim; 67use function view; 68 69use const PATHINFO_EXTENSION; 70use const UPLOAD_ERR_OK; 71 72/** 73 * Managing media files. 74 */ 75class MediaFileService 76{ 77 public const EDIT_RESTRICTIONS = [ 78 'locked', 79 ]; 80 81 public const PRIVACY_RESTRICTIONS = [ 82 'none', 83 'privacy', 84 'confidential', 85 ]; 86 87 public const EXTENSION_TO_FORM = [ 88 'jpg' => 'jpeg', 89 'tif' => 'tiff', 90 ]; 91 92 public const SUPPORTED_LIBRARIES = ['imagick', 'gd']; 93 94 /** 95 * What is the largest file a user may upload? 96 */ 97 public function maxUploadFilesize(): string 98 { 99 $sizePostMax = $this->parseIniFileSize(ini_get('post_max_size')); 100 $sizeUploadMax = $this->parseIniFileSize(ini_get('upload_max_filesize')); 101 102 $bytes = min($sizePostMax, $sizeUploadMax); 103 $kb = intdiv($bytes + 1023, 1024); 104 105 return I18N::translate('%s KB', I18N::number($kb)); 106 } 107 108 /** 109 * Returns the given size from an ini value in bytes. 110 * 111 * @param string $size 112 * 113 * @return int 114 */ 115 private function parseIniFileSize(string $size): int 116 { 117 $number = (int) $size; 118 119 switch (substr($size, -1)) { 120 case 'g': 121 case 'G': 122 return $number * 1073741824; 123 case 'm': 124 case 'M': 125 return $number * 1048576; 126 case 'k': 127 case 'K': 128 return $number * 1024; 129 default: 130 return $number; 131 } 132 } 133 134 /** 135 * A list of key/value options for media types. 136 * 137 * @param string $current 138 * 139 * @return array<string,string> 140 */ 141 public function mediaTypes($current = ''): array 142 { 143 $media_types = GedcomTag::getFileFormTypes(); 144 145 $media_types = ['' => ''] + [$current => $current] + $media_types; 146 147 return $media_types; 148 } 149 150 /** 151 * A list of media files not already linked to a media object. 152 * 153 * @param Tree $tree 154 * @param FilesystemInterface $data_filesystem 155 * 156 * @return array<string> 157 */ 158 public function unusedFiles(Tree $tree, FilesystemInterface $data_filesystem): array 159 { 160 $used_files = DB::table('media_file') 161 ->where('m_file', '=', $tree->id()) 162 ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 163 ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 164 ->pluck('multimedia_file_refn') 165 ->all(); 166 167 $disk_files = $tree->mediaFilesystem($data_filesystem)->listContents('', true); 168 169 $disk_files = array_filter($disk_files, static function (array $item) { 170 // Older versions of webtrees used a couple of special folders. 171 return 172 $item['type'] === 'file' && 173 !str_contains($item['path'], '/thumbs/') && 174 !str_contains($item['path'], '/watermarks/'); 175 }); 176 177 $disk_files = array_map(static function (array $item): string { 178 return $item['path']; 179 }, $disk_files); 180 181 $unused_files = array_diff($disk_files, $used_files); 182 183 sort($unused_files); 184 185 return array_combine($unused_files, $unused_files); 186 } 187 188 /** 189 * Store an uploaded file (or URL), either to be added to a media object 190 * or to create a media object. 191 * 192 * @param ServerRequestInterface $request 193 * 194 * @return string The value to be stored in the 'FILE' field of the media object. 195 */ 196 public function uploadFile(ServerRequestInterface $request): string 197 { 198 $tree = $request->getAttribute('tree'); 199 assert($tree instanceof Tree); 200 201 $data_filesystem = $request->getAttribute('filesystem.data'); 202 assert($data_filesystem instanceof FilesystemInterface); 203 204 $params = (array) $request->getParsedBody(); 205 $file_location = $params['file_location']; 206 207 switch ($file_location) { 208 case 'url': 209 $remote = $params['remote']; 210 211 if (str_contains($remote, '://')) { 212 return $remote; 213 } 214 215 return ''; 216 217 case 'unused': 218 $unused = $params['unused']; 219 220 if ($tree->mediaFilesystem($data_filesystem)->has($unused)) { 221 return $unused; 222 } 223 224 return ''; 225 226 case 'upload': 227 default: 228 $folder = $params['folder']; 229 $auto = $params['auto']; 230 $new_file = $params['new_file']; 231 232 /** @var UploadedFileInterface|null $uploaded_file */ 233 $uploaded_file = $request->getUploadedFiles()['file']; 234 if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) { 235 return ''; 236 } 237 238 // The filename 239 $new_file = strtr($new_file, ['\\' => '/']); 240 if ($new_file !== '' && !str_contains($new_file, '/')) { 241 $file = $new_file; 242 } else { 243 $file = $uploaded_file->getClientFilename(); 244 } 245 246 // The folder 247 $folder = strtr($folder, ['\\' => '/']); 248 $folder = trim($folder, '/'); 249 if ($folder !== '') { 250 $folder .= '/'; 251 } 252 253 // Generate a unique name for the file? 254 if ($auto === '1' || $tree->mediaFilesystem($data_filesystem)->has($folder . $file)) { 255 $folder = ''; 256 $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION); 257 $file = sha1((string) $uploaded_file->getStream()) . '.' . $extension; 258 } 259 260 try { 261 $tree->mediaFilesystem($data_filesystem)->putStream($folder . $file, $uploaded_file->getStream()->detach()); 262 263 return $folder . $file; 264 } catch (RuntimeException | InvalidArgumentException $ex) { 265 FlashMessages::addMessage(I18N::translate('There was an error uploading your file.')); 266 267 return ''; 268 } 269 } 270 } 271 272 /** 273 * Convert the media file attributes into GEDCOM format. 274 * 275 * @param string $file 276 * @param string $type 277 * @param string $title 278 * @param string $note 279 * 280 * @return string 281 */ 282 public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string 283 { 284 // Tidy non-printing characters 285 $type = trim(preg_replace('/\s+/', ' ', $type)); 286 $title = trim(preg_replace('/\s+/', ' ', $title)); 287 288 $gedcom = '1 FILE ' . $file; 289 290 $format = strtolower(pathinfo($file, PATHINFO_EXTENSION)); 291 $format = self::EXTENSION_TO_FORM[$format] ?? $format; 292 293 if ($format !== '') { 294 $gedcom .= "\n2 FORM " . $format; 295 } elseif ($type !== '') { 296 $gedcom .= "\n2 FORM"; 297 } 298 299 if ($type !== '') { 300 $gedcom .= "\n3 TYPE " . $type; 301 } 302 303 if ($title !== '') { 304 $gedcom .= "\n2 TITL " . $title; 305 } 306 307 if ($note !== '') { 308 // Convert HTML line endings to GEDCOM continuations 309 $gedcom .= "\n1 NOTE " . strtr($note, ["\r\n" => "\n2 CONT "]); 310 } 311 312 return $gedcom; 313 } 314 315 /** 316 * Fetch a list of all files on disk (in folders used by any tree). 317 * 318 * @param FilesystemInterface $data_filesystem Fileystem to search 319 * @param string $media_folder Root folder 320 * @param bool $subfolders Include subfolders 321 * 322 * @return Collection<string> 323 */ 324 public function allFilesOnDisk(FilesystemInterface $data_filesystem, string $media_folder, bool $subfolders): Collection 325 { 326 $array = $data_filesystem->listContents($media_folder, $subfolders); 327 328 return Collection::make($array) 329 ->filter(static function (array $metadata): bool { 330 return 331 $metadata['type'] === 'file' && 332 !str_contains($metadata['path'], '/thumbs/') && 333 !str_contains($metadata['path'], '/watermark/'); 334 }) 335 ->map(static function (array $metadata): string { 336 return $metadata['path']; 337 }); 338 } 339 340 /** 341 * Fetch a list of all files on in the database. 342 * 343 * @param string $media_folder Root folder 344 * @param bool $subfolders Include subfolders 345 * 346 * @return Collection<string> 347 */ 348 public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection 349 { 350 $query = DB::table('media_file') 351 ->join('gedcom_setting', 'gedcom_id', '=', 'm_file') 352 ->where('setting_name', '=', 'MEDIA_DIRECTORY') 353 //->where('multimedia_file_refn', 'LIKE', '%/%') 354 ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 355 ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 356 ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%') 357 ->select(new Expression('setting_value || multimedia_file_refn AS path')) 358 ->orderBy(new Expression('setting_value || multimedia_file_refn')); 359 360 if (!$subfolders) { 361 $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%'); 362 } 363 364 return $query->pluck('path'); 365 } 366 367 /** 368 * Generate a list of all folders in either the database or the filesystem. 369 * 370 * @param FilesystemInterface $data_filesystem 371 * 372 * @return Collection<string,string> 373 */ 374 public function allMediaFolders(FilesystemInterface $data_filesystem): Collection 375 { 376 $db_folders = DB::table('media_file') 377 ->join('gedcom_setting', 'gedcom_id', '=', 'm_file') 378 ->where('setting_name', '=', 'MEDIA_DIRECTORY') 379 ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 380 ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 381 ->select(new Expression('setting_value || multimedia_file_refn AS path')) 382 ->pluck('path') 383 ->map(static function (string $path): string { 384 return dirname($path) . '/'; 385 }); 386 387 $media_roots = DB::table('gedcom_setting') 388 ->where('setting_name', '=', 'MEDIA_DIRECTORY') 389 ->where('gedcom_id', '>', '0') 390 ->pluck('setting_value') 391 ->uniqueStrict(); 392 393 $disk_folders = new Collection($media_roots); 394 395 foreach ($media_roots as $media_folder) { 396 $tmp = Collection::make($data_filesystem->listContents($media_folder, true)) 397 ->filter(static function (array $metadata) { 398 return $metadata['type'] === 'dir'; 399 }) 400 ->map(static function (array $metadata): string { 401 return $metadata['path'] . '/'; 402 }) 403 ->filter(static function (string $dir): bool { 404 return !str_contains($dir, '/thumbs/') && !str_contains($dir, 'watermarks'); 405 }); 406 407 $disk_folders = $disk_folders->concat($tmp); 408 } 409 410 return $disk_folders->concat($db_folders) 411 ->uniqueStrict() 412 ->mapWithKeys(static function (string $folder): array { 413 return [$folder => $folder]; 414 }); 415 } 416 417 /** 418 * Send a replacement image, to replace one that could not be found or created. 419 * 420 * @param string $status HTTP status code or file extension 421 * 422 * @return ResponseInterface 423 */ 424 public function replacementImage(string $status): ResponseInterface 425 { 426 $svg = view('errors/image-svg', ['status' => $status]); 427 428 // We can't use the actual status code, as browsers won't show images with 4xx/5xx 429 return response($svg, StatusCodeInterface::STATUS_OK, [ 430 'Content-Type' => 'image/svg+xml', 431 'Content-Length' => (string) strlen($svg), 432 ]); 433 } 434 435 /** 436 * Generate a thumbnail image for a file. 437 * 438 * @param string $folder 439 * @param string $file 440 * @param FilesystemInterface $filesystem 441 * @param array<string> $params 442 * 443 * @return ResponseInterface 444 */ 445 public function generateImage(string $folder, string $file, FilesystemInterface $filesystem, array $params): ResponseInterface 446 { 447 // Automatic rotation only works when the php-exif library is loaded. 448 if (!extension_loaded('exif')) { 449 $params['or'] = '0'; 450 } 451 452 try { 453 $cache_path = 'thumbnail-cache/' . $folder; 454 $cache_filesystem = new Filesystem(new ChrootAdapter($filesystem, $cache_path)); 455 $source_filesystem = new Filesystem(new ChrootAdapter($filesystem, $folder)); 456 $watermark_filesystem = new Filesystem(new Local('resources/img')); 457 458 $server = ServerFactory::create([ 459 'cache' => $cache_filesystem, 460 'driver' => $this->graphicsDriver(), 461 'source' => $source_filesystem, 462 'watermarks' => $watermark_filesystem, 463 ]); 464 465 // Workaround for https://github.com/thephpleague/glide/issues/227 466 $file = implode('/', array_map('rawurlencode', explode('/', $file))); 467 468 $thumbnail = $server->makeImage($file, $params); 469 $cache = $server->getCache(); 470 471 return response($cache->read($thumbnail), StatusCodeInterface::STATUS_OK, [ 472 'Content-Type' => $cache->getMimetype($thumbnail) ?: Mime::DEFAULT_TYPE, 473 'Content-Length' => (string) $cache->getSize($thumbnail), 474 'Cache-Control' => 'public,max-age=31536000', 475 ]); 476 } catch (FileNotFoundException $ex) { 477 return $this->replacementImage((string) StatusCodeInterface::STATUS_NOT_FOUND); 478 } catch (Throwable $ex) { 479 return $this->replacementImage((string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR) 480 ->withHeader('X-Thumbnail-Exception', $ex->getMessage()); 481 } 482 } 483 484 /** 485 * Which graphics driver should we use for glide/intervention? 486 * Prefer ImageMagick 487 * 488 * @return string 489 */ 490 private function graphicsDriver(): string 491 { 492 foreach (self::SUPPORTED_LIBRARIES as $library) { 493 if (extension_loaded($library)) { 494 return $library; 495 } 496 } 497 498 throw new RuntimeException('No PHP graphics library is installed.'); 499 } 500} 501