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