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