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