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