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