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