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