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