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