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