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