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