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