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