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