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