1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2022 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 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Services; 21 22use Fisharebest\Webtrees\Exceptions\FileUploadException; 23use Fisharebest\Webtrees\FlashMessages; 24use Fisharebest\Webtrees\I18N; 25use Fisharebest\Webtrees\Registry; 26use Fisharebest\Webtrees\Tree; 27use Fisharebest\Webtrees\Validator; 28use Illuminate\Database\Capsule\Manager as DB; 29use Illuminate\Database\Query\Expression; 30use Illuminate\Database\Query\JoinClause; 31use Illuminate\Support\Collection; 32use InvalidArgumentException; 33use League\Flysystem\FilesystemException; 34use League\Flysystem\FilesystemOperator; 35use League\Flysystem\FilesystemReader; 36use League\Flysystem\StorageAttributes; 37use Psr\Http\Message\ServerRequestInterface; 38use RuntimeException; 39 40use function array_combine; 41use function array_diff; 42use function array_intersect; 43use function dirname; 44use function explode; 45use function ini_get; 46use function intdiv; 47use function is_float; 48use function min; 49use function pathinfo; 50use function sha1; 51use function sort; 52use function str_contains; 53use function strlen; 54use function strtoupper; 55use function strtr; 56use function substr; 57use function trim; 58 59use const PATHINFO_EXTENSION; 60use const PHP_INT_MAX; 61use const UPLOAD_ERR_OK; 62 63/** 64 * Managing media files. 65 */ 66class MediaFileService 67{ 68 public const EXTENSION_TO_FORM = [ 69 'JPEG' => 'JPG', 70 'TIFF' => 'TIF', 71 ]; 72 73 private const IGNORE_FOLDERS = [ 74 // Old versions of webtrees 75 'thumbs', 76 'watermarks', 77 // Windows 78 'Thumbs.db', 79 // Synology 80 '@eaDir', 81 // QNAP, 82 '.@__thumb', 83 // WebDAV, 84 '_DAV', 85 ]; 86 87 /** 88 * What is the largest file a user may upload? 89 */ 90 public function maxUploadFilesize(): string 91 { 92 $sizePostMax = $this->parseIniFileSize((string) ini_get('post_max_size')); 93 $sizeUploadMax = $this->parseIniFileSize((string) ini_get('upload_max_filesize')); 94 95 $bytes = min($sizePostMax, $sizeUploadMax); 96 $kb = intdiv($bytes + 1023, 1024); 97 98 return I18N::translate('%s KB', I18N::number($kb)); 99 } 100 101 /** 102 * Returns the given size from an ini value in bytes. 103 * 104 * @param string $size 105 * 106 * @return int 107 */ 108 private function parseIniFileSize(string $size): int 109 { 110 $number = (int) $size; 111 112 $units = [ 113 'g' => 1073741824, 114 'G' => 1073741824, 115 'm' => 1048576, 116 'M' => 1048576, 117 'k' => 1024, 118 'K' => 1024, 119 ]; 120 121 $number *= $units[substr($size, -1)] ?? 1; 122 123 if (is_float($number)) { 124 // Probably a 32bit version of PHP, with an INI setting >= 2GB 125 return PHP_INT_MAX; 126 } 127 128 return $number; 129 } 130 131 /** 132 * A list of media files not already linked to a media object. 133 * 134 * @param Tree $tree 135 * @param FilesystemOperator $data_filesystem 136 * 137 * @return array<string> 138 */ 139 public function unusedFiles(Tree $tree, FilesystemOperator $data_filesystem): array 140 { 141 $used_files = DB::table('media_file') 142 ->where('m_file', '=', $tree->id()) 143 ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 144 ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 145 ->pluck('multimedia_file_refn') 146 ->all(); 147 148 $media_filesystem = $tree->mediaFilesystem($data_filesystem); 149 $disk_files = $this->allFilesOnDisk($media_filesystem, '', FilesystemReader::LIST_DEEP)->all(); 150 $unused_files = array_diff($disk_files, $used_files); 151 152 sort($unused_files); 153 154 return array_combine($unused_files, $unused_files); 155 } 156 157 /** 158 * Store an uploaded file (or URL), either to be added to a media object 159 * or to create a media object. 160 * 161 * @param ServerRequestInterface $request 162 * 163 * @return string The value to be stored in the 'FILE' field of the media object. 164 * @throws FilesystemException 165 */ 166 public function uploadFile(ServerRequestInterface $request): string 167 { 168 $tree = Validator::attributes($request)->tree(); 169 170 $data_filesystem = Registry::filesystem()->data(); 171 $file_location = Validator::parsedBody($request)->string('file_location'); 172 173 switch ($file_location) { 174 case 'url': 175 $remote = Validator::parsedBody($request)->string('remote'); 176 177 if (str_contains($remote, '://')) { 178 return $remote; 179 } 180 181 return ''; 182 183 case 'unused': 184 $unused = Validator::parsedBody($request)->string('unused'); 185 186 if ($tree->mediaFilesystem($data_filesystem)->fileExists($unused)) { 187 return $unused; 188 } 189 190 return ''; 191 192 case 'upload': 193 $folder = Validator::parsedBody($request)->string('folder'); 194 $auto = Validator::parsedBody($request)->string('auto'); 195 $new_file = Validator::parsedBody($request)->string('new_file'); 196 197 $uploaded_file = $request->getUploadedFiles()['file'] ?? null; 198 199 if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) { 200 throw new FileUploadException($uploaded_file); 201 } 202 203 // The filename 204 $new_file = strtr($new_file, ['\\' => '/']); 205 if ($new_file !== '' && !str_contains($new_file, '/')) { 206 $file = $new_file; 207 } else { 208 $file = $uploaded_file->getClientFilename(); 209 } 210 211 // The folder 212 $folder = strtr($folder, ['\\' => '/']); 213 $folder = trim($folder, '/'); 214 if ($folder !== '') { 215 $folder .= '/'; 216 } 217 218 // Generate a unique name for the file? 219 if ($auto === '1' || $tree->mediaFilesystem($data_filesystem)->fileExists($folder . $file)) { 220 $folder = ''; 221 $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION); 222 $file = sha1((string) $uploaded_file->getStream()) . '.' . $extension; 223 } 224 225 try { 226 $tree->mediaFilesystem($data_filesystem)->writeStream($folder . $file, $uploaded_file->getStream()->detach()); 227 228 return $folder . $file; 229 } catch (RuntimeException | InvalidArgumentException $ex) { 230 FlashMessages::addMessage(I18N::translate('There was an error uploading your file.')); 231 232 return ''; 233 } 234 } 235 236 return ''; 237 } 238 239 /** 240 * Convert the media file attributes into GEDCOM format. 241 * 242 * @param string $file 243 * @param string $type 244 * @param string $title 245 * @param string $note 246 * 247 * @return string 248 */ 249 public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string 250 { 251 $gedcom = '1 FILE ' . $file; 252 253 if (str_contains($file, '://')) { 254 $format = ''; 255 } else { 256 $format = strtoupper(pathinfo($file, PATHINFO_EXTENSION)); 257 $format = self::EXTENSION_TO_FORM[$format] ?? $format; 258 } 259 260 if ($format !== '' && strlen($format) <= 4) { 261 $gedcom .= "\n2 FORM " . $format; 262 } elseif ($type !== '') { 263 $gedcom .= "\n2 FORM"; 264 } 265 266 if ($type !== '') { 267 $gedcom .= "\n3 TYPE " . $type; 268 } 269 270 if ($title !== '') { 271 $gedcom .= "\n2 TITL " . $title; 272 } 273 274 if ($note !== '') { 275 // Convert HTML line endings to GEDCOM continuations 276 $gedcom .= "\n1 NOTE " . strtr($note, ["\r\n" => "\n2 CONT "]); 277 } 278 279 return $gedcom; 280 } 281 282 /** 283 * Fetch a list of all files on disk (in folders used by any tree). 284 * 285 * @param FilesystemOperator $filesystem $filesystem to search 286 * @param string $folder Root folder 287 * @param bool $subfolders Include subfolders 288 * 289 * @return Collection<int,string> 290 */ 291 public function allFilesOnDisk(FilesystemOperator $filesystem, string $folder, bool $subfolders): Collection 292 { 293 try { 294 $files = $filesystem 295 ->listContents($folder, $subfolders) 296 ->filter(fn (StorageAttributes $attributes): bool => $attributes->isFile()) 297 ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path())) 298 ->map(fn (StorageAttributes $attributes): string => $attributes->path()) 299 ->toArray(); 300 } catch (FilesystemException $ex) { 301 $files = []; 302 } 303 304 return new Collection($files); 305 } 306 307 /** 308 * Fetch a list of all files on in the database. 309 * 310 * @param string $media_folder Root folder 311 * @param bool $subfolders Include subfolders 312 * 313 * @return Collection<int,string> 314 */ 315 public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection 316 { 317 $query = DB::table('media_file') 318 ->join('gedcom_setting', 'gedcom_id', '=', 'm_file') 319 ->where('setting_name', '=', 'MEDIA_DIRECTORY') 320 //->where('multimedia_file_refn', 'LIKE', '%/%') 321 ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 322 ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 323 ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%') 324 ->select(new Expression('setting_value || multimedia_file_refn AS path')) 325 ->orderBy(new Expression('setting_value || multimedia_file_refn')); 326 327 if (!$subfolders) { 328 $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%'); 329 } 330 331 return $query->pluck('path'); 332 } 333 334 /** 335 * Generate a list of all folders used by a tree. 336 * 337 * @param Tree $tree 338 * 339 * @return Collection<int,string> 340 * @throws FilesystemException 341 */ 342 public function mediaFolders(Tree $tree): Collection 343 { 344 $folders = Registry::filesystem()->media($tree) 345 ->listContents('', FilesystemReader::LIST_DEEP) 346 ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir()) 347 ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path())) 348 ->map(fn (StorageAttributes $attributes): string => $attributes->path()) 349 ->toArray(); 350 351 return new Collection($folders); 352 } 353 354 /** 355 * Generate a list of all folders in either the database or the filesystem. 356 * 357 * @param FilesystemOperator $data_filesystem 358 * 359 * @return Collection<array-key,string> 360 * @throws FilesystemException 361 */ 362 public function allMediaFolders(FilesystemOperator $data_filesystem): Collection 363 { 364 $db_folders = DB::table('media_file') 365 ->leftJoin('gedcom_setting', static function (JoinClause $join): void { 366 $join 367 ->on('gedcom_id', '=', 'm_file') 368 ->where('setting_name', '=', 'MEDIA_DIRECTORY'); 369 }) 370 ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 371 ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 372 ->select(new Expression("COALESCE(setting_value, 'media/') || multimedia_file_refn AS path")) 373 ->pluck('path') 374 ->map(static function (string $path): string { 375 return dirname($path) . '/'; 376 }); 377 378 $media_roots = DB::table('gedcom') 379 ->leftJoin('gedcom_setting', static function (JoinClause $join): void { 380 $join 381 ->on('gedcom.gedcom_id', '=', 'gedcom_setting.gedcom_id') 382 ->where('setting_name', '=', 'MEDIA_DIRECTORY'); 383 }) 384 ->where('gedcom.gedcom_id', '>', '0') 385 ->pluck(new Expression("COALESCE(setting_value, 'media/')")) 386 ->uniqueStrict(); 387 388 $disk_folders = new Collection($media_roots); 389 390 foreach ($media_roots as $media_folder) { 391 $tmp = $data_filesystem 392 ->listContents($media_folder, FilesystemReader::LIST_DEEP) 393 ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir()) 394 ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path())) 395 ->map(fn (StorageAttributes $attributes): string => $attributes->path() . '/') 396 ->toArray(); 397 398 $disk_folders = $disk_folders->concat($tmp); 399 } 400 401 return $disk_folders->concat($db_folders) 402 ->uniqueStrict() 403 ->sort(I18N::comparator()) 404 ->mapWithKeys(static function (string $folder): array { 405 return [$folder => $folder]; 406 }); 407 } 408 409 /** 410 * Ignore special media folders. 411 * 412 * @param string $path 413 * 414 * @return bool 415 */ 416 private function ignorePath(string $path): bool 417 { 418 return array_intersect(self::IGNORE_FOLDERS, explode('/', $path)) !== []; 419 } 420} 421