| @@ -234,19 +234,23 @@ | ||
| 234 | 234 | $tempDir = $upload_dir['basedir'] . '/bitforms/temp'; |
| 235 | 235 | $destinationDir = self::getEntriesFileUploadDir($formId, $entryID) . DIRECTORY_SEPARATOR; |
| 236 | 236 | self::createIndexFile($destinationDir); |
| 237 | 237 | |
| 238 | + $consumedFiles = []; | |
| 239 | + | |
| 238 | 240 | foreach ($submitted_data as $key => $data) { |
| 239 | 241 | if (isset($fields[$key]) && 'advanced-file-up' === $fields[$key]['type']) { |
| 240 | - $files = $data; | |
| 241 | 242 | $fldData = $submitted_data[$key]; |
| 242 | - $files = explode(',', $fldData); | |
| 243 | - if (is_array($files) && count($files) > 0) { | |
| 244 | - foreach ($files as $file) { | |
| 245 | - self::fileCopy($tempDir, $destinationDir, trim($file)); | |
| 243 | + // A repeater row (or a pre-split value) hands this field over as an array; explode() | |
| 244 | + // on an array is a TypeError on PHP 8, which would fatal mid-submission. | |
| 245 | + $files = \is_array($fldData) ? $fldData : explode(',', \is_scalar($fldData) ? (string) $fldData : ''); | |
| 246 | + foreach ($files as $file) { | |
| 247 | + $safeFile = \is_scalar($file) ? trim((string) $file) : ''; | |
| 248 | + if ('' === $safeFile) { | |
| 249 | + continue; | |
| 246 | 250 | } |
| 247 | - } else { | |
| 248 | - self::fileCopy($tempDir, $destinationDir, trim($files)); | |
| 251 | + self::fileCopy($tempDir, $destinationDir, $safeFile); | |
| 252 | + $consumedFiles[] = $safeFile; | |
| 249 | 253 | } |
| 250 | 254 | if (!empty($files)) { |
| 251 | 255 | $submitted_data[$key] = $files; |
| 252 | 256 | } |
| @@ -251,39 +255,207 @@ | ||
| 251 | 255 | $submitted_data[$key] = $files; |
| 252 | 256 | } |
| 253 | 257 | } |
| 254 | 258 | } |
| 259 | + | |
| 260 | + self::cleanupTempUploads($tempDir, $consumedFiles); | |
| 261 | + | |
| 262 | + return $submitted_data; | |
| 263 | + } | |
| 264 | + | |
| 265 | + /** | |
| 266 | + * Clear the shared temp upload staging area after a submission has taken what it needs. | |
| 267 | + * | |
| 268 | + * @param string $tempDir shared staging directory | |
| 269 | + * @param array $consumedFiles file names copied into the entry directory by this submission | |
| 270 | + * | |
| 271 | + * @return void | |
| 272 | + */ | |
| 273 | + private static function cleanupTempUploads($tempDir, array $consumedFiles) | |
| 274 | + { | |
| 255 | 275 | $tempBase = realpath($tempDir); |
| 256 | - if (false !== $tempBase) { | |
| 257 | - $tmpFiles = glob($tempBase . DIRECTORY_SEPARATOR . '*'); | |
| 258 | - foreach ((array) $tmpFiles as $tmpFile) { | |
| 259 | - $resolved = realpath($tmpFile); | |
| 260 | - if (false !== $resolved && 0 === strpos($resolved, $tempBase . DIRECTORY_SEPARATOR)) { | |
| 261 | - wp_delete_file($resolved); | |
| 262 | - } | |
| 276 | + if (false === $tempBase) { | |
| 277 | + return; | |
| 278 | + } | |
| 279 | + $boundary = $tempBase . DIRECTORY_SEPARATOR; | |
| 280 | + | |
| 281 | + // Never remove the directory-hardening files the uploads dir relies on. | |
| 282 | + $protected = ['index.php', 'index.html', '.htaccess']; | |
| 283 | + | |
| 284 | + foreach ($consumedFiles as $file) { | |
| 285 | + $resolved = realpath($boundary . $file); | |
| 286 | + if (false === $resolved || 0 !== strpos($resolved, $boundary) || !is_file($resolved)) { | |
| 287 | + continue; | |
| 263 | 288 | } |
| 289 | + if (\in_array(basename($resolved), $protected, true)) { | |
| 290 | + continue; | |
| 291 | + } | |
| 292 | + wp_delete_file($resolved); | |
| 264 | 293 | } |
| 265 | 294 | |
| 266 | - return $submitted_data; | |
| 295 | + // Sweep abandoned uploads. Anything still here after the retention window belongs to a form | |
| 296 | + // that was never submitted. Filterable so a site with very long multi-step forms can extend | |
| 297 | + // it; 0 or less disables the sweep entirely. | |
| 298 | + $retention = apply_filters('bitform_temp_upload_retention', DAY_IN_SECONDS); | |
| 299 | + $retention = is_numeric($retention) ? (int) $retention : DAY_IN_SECONDS; | |
| 300 | + if ($retention <= 0) { | |
| 301 | + return; | |
| 302 | + } | |
| 303 | + | |
| 304 | + $cutoff = time() - $retention; | |
| 305 | + $tmpFiles = glob($boundary . '*'); | |
| 306 | + foreach ((array) $tmpFiles as $tmpFile) { | |
| 307 | + $resolved = realpath($tmpFile); | |
| 308 | + if (false === $resolved || 0 !== strpos($resolved, $boundary) || !is_file($resolved)) { | |
| 309 | + continue; | |
| 310 | + } | |
| 311 | + if (\in_array(basename($resolved), $protected, true)) { | |
| 312 | + continue; | |
| 313 | + } | |
| 314 | + $modified = @filemtime($resolved); | |
| 315 | + if (false !== $modified && $modified < $cutoff) { | |
| 316 | + wp_delete_file($resolved); | |
| 317 | + } | |
| 318 | + } | |
| 267 | 319 | } |
| 268 | 320 | |
| 269 | - private function getByteSizeByUnit($sizeString) | |
| 321 | + private function getByteSizeByUnit($sizeString, $base = 1024) | |
| 270 | 322 | { |
| 271 | 323 | // split 2MB into 2 and MB |
| 272 | - $size = preg_replace('/[^0-9\.]/', '', $sizeString); | |
| 273 | - $unit = preg_replace('/[^a-zA-Z]/', '', $sizeString); | |
| 324 | + $size = (float) preg_replace('/[^0-9\.]/', '', (string) $sizeString); | |
| 325 | + $unit = preg_replace('/[^a-zA-Z]/', '', (string) $sizeString); | |
| 274 | 326 | $unit = strtolower($unit); |
| 275 | 327 | if ('kb' === $unit) { |
| 276 | - return $size * 1024; | |
| 328 | + return $size * $base; | |
| 277 | 329 | } elseif ('mb' === $unit) { |
| 278 | - return $size * 1024 * 1024; | |
| 330 | + return $size * $base * $base; | |
| 279 | 331 | } elseif ('gb' === $unit) { |
| 280 | - return $size * 1024 * 1024 * 1024; | |
| 332 | + return $size * $base * $base * $base; | |
| 281 | 333 | } else { |
| 282 | 334 | return $size; |
| 283 | 335 | } |
| 284 | 336 | } |
| 285 | 337 | |
| 338 | + /** | |
| 339 | + * How many files an Advanced File Upload field takes per submission, mirroring FilePond: a | |
| 340 | + * single-file field holds one, and maxFiles only applies once multiple files are allowed. | |
| 341 | + * | |
| 342 | + * @param object|null $config field config | |
| 343 | + * | |
| 344 | + * @return int|null null when there is no limit | |
| 345 | + */ | |
| 346 | + public static function getMaxFilesLimit($config) | |
| 347 | + { | |
| 348 | + if (!is_object($config)) { | |
| 349 | + return null; | |
| 350 | + } | |
| 351 | + if (empty($config->allowMultiple)) { | |
| 352 | + return 1; | |
| 353 | + } | |
| 354 | + $max = isset($config->maxFiles) && is_numeric($config->maxFiles) ? (int) $config->maxFiles : 0; | |
| 355 | + | |
| 356 | + return $max > 0 ? $max : null; | |
| 357 | + } | |
| 358 | + | |
| 359 | + /** | |
| 360 | + * Files submitted for one field: names already uploaded on select (comma list or array) plus | |
| 361 | + * the files sent with the form. | |
| 362 | + * | |
| 363 | + * @param mixed $uploadedNames posted value of the field | |
| 364 | + * @param mixed $postedFileNames $_FILES[field]['name'] | |
| 365 | + * | |
| 366 | + * @return int | |
| 367 | + */ | |
| 368 | + public static function countSubmittedFiles($uploadedNames, $postedFileNames) | |
| 369 | + { | |
| 370 | + $count = 0; | |
| 371 | + foreach ([$uploadedNames, $postedFileNames] as $source) { | |
| 372 | + $names = is_array($source) ? $source : (is_scalar($source) ? explode(',', (string) $source) : []); | |
| 373 | + foreach ($names as $name) { | |
| 374 | + if (is_scalar($name) && '' !== trim((string) $name)) { | |
| 375 | + $count++; | |
| 376 | + } | |
| 377 | + } | |
| 378 | + } | |
| 379 | + | |
| 380 | + return $count; | |
| 381 | + } | |
| 382 | + | |
| 383 | + /** | |
| 384 | + * The browser drops files past the limit, so more can only arrive from a crafted request. | |
| 385 | + * | |
| 386 | + * @param object|null $config field config | |
| 387 | + * @param mixed $uploadedNames posted value of the field | |
| 388 | + * @param mixed $postedFileNames $_FILES[field]['name'] | |
| 389 | + * | |
| 390 | + * @return string error message, '' when within the limit | |
| 391 | + */ | |
| 392 | + public static function getMaxFilesError($config, $uploadedNames, $postedFileNames) | |
| 393 | + { | |
| 394 | + $limit = self::getMaxFilesLimit($config); | |
| 395 | + if (null === $limit || self::countSubmittedFiles($uploadedNames, $postedFileNames) <= $limit) { | |
| 396 | + return ''; | |
| 397 | + } | |
| 398 | + | |
| 399 | + return sprintf( | |
| 400 | + /* translators: %d: maximum number of files */ | |
| 401 | + _n('You can upload only %d file.', 'You can upload up to %d files.', $limit, 'bit-form'), | |
| 402 | + $limit | |
| 403 | + ); | |
| 404 | + } | |
| 405 | + | |
| 406 | + /** | |
| 407 | + * getMaxFilesError() for a field of a stored form. | |
| 408 | + * | |
| 409 | + * @param int|string $formId | |
| 410 | + * @param string $fieldKey | |
| 411 | + * @param mixed $uploadedNames posted value of the field | |
| 412 | + * @param mixed $postedFileNames $_FILES[field]['name'] | |
| 413 | + * | |
| 414 | + * @return string | |
| 415 | + */ | |
| 416 | + public static function getMaxFilesErrorForField($formId, $fieldKey, $uploadedNames, $postedFileNames) | |
| 417 | + { | |
| 418 | + $formContent = FormManager::getInstance($formId)->getFormContent(); | |
| 419 | + $field = is_object($formContent) && isset($formContent->fields->{$fieldKey}) ? $formContent->fields->{$fieldKey} : null; | |
| 420 | + if (!is_object($field) || !isset($field->typ) || 'advanced-file-up' !== $field->typ) { | |
| 421 | + return ''; | |
| 422 | + } | |
| 423 | + | |
| 424 | + return self::getMaxFilesError(isset($field->config) ? $field->config : null, $uploadedNames, $postedFileNames); | |
| 425 | + } | |
| 426 | + | |
| 427 | + /** | |
| 428 | + * Whether a MIME matches a `type/*` entry of the allow list, as FilePond accepts it. Markup and | |
| 429 | + * script types never match a wildcard: they must be listed explicitly. | |
| 430 | + * | |
| 431 | + * @param mixed $mime detected MIME, lowercase | |
| 432 | + * @param array $allowMimes allow-listed MIME entries, lowercase | |
| 433 | + * | |
| 434 | + * @return bool | |
| 435 | + */ | |
| 436 | + public static function matchesWildcardMime($mime, $allowMimes) | |
| 437 | + { | |
| 438 | + $neverByWildcard = [ | |
| 439 | + 'text/html', | |
| 440 | + 'application/xhtml+xml', | |
| 441 | + 'text/javascript', | |
| 442 | + 'application/javascript', | |
| 443 | + 'application/x-javascript', | |
| 444 | + 'application/ecmascript', | |
| 445 | + 'text/ecmascript', | |
| 446 | + ]; | |
| 447 | + if (!is_string($mime) || in_array($mime, $neverByWildcard, true)) { | |
| 448 | + return false; | |
| 449 | + } | |
| 450 | + $slash = strpos($mime, '/'); | |
| 451 | + if (false === $slash || 0 === $slash) { | |
| 452 | + return false; | |
| 453 | + } | |
| 454 | + | |
| 455 | + return in_array(substr($mime, 0, $slash) . '/*', (array) $allowMimes, true); | |
| 456 | + } | |
| 457 | + | |
| 286 | 458 | public function validation($field_key, $file_details, $form_id) |
| 287 | 459 | { |
| 288 | 460 | if (!function_exists('wp_check_filetype_and_ext')) { |
| 289 | 461 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| @@ -296,8 +468,10 @@ | ||
| 296 | 468 | $fieldType = $fieldDetail->typ; |
| 297 | 469 | $maxSizeDetails = []; |
| 298 | 470 | $allowFileTypes = []; |
| 299 | 471 | $maxSize = null; |
| 472 | + $minSize = null; | |
| 473 | + $fieldConfig = isset($fieldDetail->config) && is_object($fieldDetail->config) ? $fieldDetail->config : null; | |
| 300 | 474 | if ('file-up' === $fieldType) { |
| 301 | 475 | $allowFileTypes = !empty($fieldDetail->config->allowedFileType) ? $fieldDetail->config->allowedFileType : []; |
| 302 | 476 | if (!empty($fieldDetail->config->allowMaxSize)) { |
| 303 | 477 | if (!empty($fieldDetail->config->maxSize)) { |
| @@ -318,8 +492,14 @@ | ||
| 318 | 492 | } |
| 319 | 493 | if (!empty($fieldDetail->config->maxTotalFileSize)) { |
| 320 | 494 | $maxSizeDetails['maxTotalFileSize'] = $fieldDetail->config->maxTotalFileSize; |
| 321 | 495 | } |
| 496 | + // A client transform re-encodes images, so the uploaded copy can legitimately come out | |
| 497 | + // smaller than the original the browser checked. | |
| 498 | + if (!empty($fieldDetail->config->minFileSize) && empty($fieldDetail->config->allowImageTransform)) { | |
| 499 | + // FilePond counts 1KB as 1000 bytes; a 1024 base would reject files the browser accepted. | |
| 500 | + $minSize = $this->getByteSizeByUnit($fieldDetail->config->minFileSize, 1000); | |
| 501 | + } | |
| 322 | 502 | } |
| 323 | 503 | } |
| 324 | 504 | if (!empty($maxSizeDetails['maxSize'])) { |
| 325 | 505 | $maxSize = $this->getByteSizeByUnit($maxSizeDetails['maxSize']); |
| @@ -338,9 +518,13 @@ | ||
| 338 | 518 | 'tmp_name' => $file_details['tmp_name'][$rowIndex], |
| 339 | 519 | 'error' => $file_details['error'][$rowIndex], |
| 340 | 520 | 'size' => $file_details['size'][$rowIndex], |
| 341 | 521 | ]; |
| 342 | - $validateState = $this->validateFileInfo($fieldType, $fileDetails, $allowFileTypes, $maxSize, $maxTotalFileSize); | |
| 522 | + $countState = $this->maxFilesState($fieldType, $fieldConfig, $file); | |
| 523 | + if (!empty($countState)) { | |
| 524 | + return $countState; | |
| 525 | + } | |
| 526 | + $validateState = $this->validateFileInfo($fieldType, $fileDetails, $allowFileTypes, $maxSize, $maxTotalFileSize, $minSize); | |
| 343 | 527 | if (!empty($validateState) && !empty($validateState['message'])) { |
| 344 | 528 | return $validateState; |
| 345 | 529 | } |
| 346 | 530 | } |
| @@ -345,15 +529,36 @@ | ||
| 345 | 529 | } |
| 346 | 530 | } |
| 347 | 531 | } |
| 348 | 532 | } else { |
| 349 | - return $this->validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize); | |
| 533 | + $countState = $this->maxFilesState($fieldType, $fieldConfig, isset($file_details['name']) ? $file_details['name'] : null); | |
| 534 | + if (!empty($countState)) { | |
| 535 | + return $countState; | |
| 536 | + } | |
| 537 | + return $this->validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize, $minSize); | |
| 350 | 538 | } |
| 351 | 539 | return []; |
| 352 | 540 | } |
| 353 | 541 | |
| 354 | - private function validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize) | |
| 542 | + /** | |
| 543 | + * @param string $fieldType | |
| 544 | + * @param object|null $fieldConfig | |
| 545 | + * @param mixed $postedFileNames $_FILES[field]['name'] (or one repeater row of it) | |
| 546 | + * | |
| 547 | + * @return array validation state, empty when within the limit | |
| 548 | + */ | |
| 549 | + private function maxFilesState($fieldType, $fieldConfig, $postedFileNames) | |
| 355 | 550 | { |
| 551 | + if ('advanced-file-up' !== $fieldType) { | |
| 552 | + return []; | |
| 553 | + } | |
| 554 | + $message = self::getMaxFilesError($fieldConfig, null, $postedFileNames); | |
| 555 | + | |
| 556 | + return '' === $message ? [] : ['message' => $message, 'error_type' => 'file_count_error']; | |
| 557 | + } | |
| 558 | + | |
| 559 | + private function validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize, $minSize = null) | |
| 560 | + { | |
| 356 | 561 | $errorMessage = [ |
| 357 | 562 | 'message' => '', |
| 358 | 563 | 'error_type' => '', |
| 359 | 564 | ]; |
| @@ -368,9 +573,9 @@ | ||
| 368 | 573 | 'error' => $file_details['error'][$key], |
| 369 | 574 | 'size' => $file_details['size'][$key], |
| 370 | 575 | ]; |
| 371 | 576 | $totalSize += $fileInfo['size']; |
| 372 | - $validateState = $this->validateSingleFile($fieldType, $fileInfo, $allowFileTypes, $maxSize); | |
| 577 | + $validateState = $this->validateSingleFile($fieldType, $fileInfo, $allowFileTypes, $maxSize, $minSize); | |
| 373 | 578 | if (!empty($validateState)) { |
| 374 | 579 | return $validateState; |
| 375 | 580 | } |
| 376 | 581 | } |
| @@ -380,9 +585,9 @@ | ||
| 380 | 585 | $errorMessage['error_type'] = 'file_size_error'; |
| 381 | 586 | return $errorMessage; |
| 382 | 587 | } |
| 383 | 588 | } else { |
| 384 | - $validateState = $this->validateSingleFile($fieldType, $file_details, $allowFileTypes, $maxSize); | |
| 589 | + $validateState = $this->validateSingleFile($fieldType, $file_details, $allowFileTypes, $maxSize, $minSize); | |
| 385 | 590 | if (!empty($validateState)) { |
| 386 | 591 | return $validateState; |
| 387 | 592 | } |
| 388 | 593 | } |
| @@ -389,9 +594,9 @@ | ||
| 389 | 594 | |
| 390 | 595 | return $errorMessage; |
| 391 | 596 | } |
| 392 | 597 | |
| 393 | - private function validateSingleFile($fieldType, &$file, $allowTypes, $maxSize = null) | |
| 598 | + private function validateSingleFile($fieldType, &$file, $allowTypes, $maxSize = null, $minSize = null) | |
| 394 | 599 | { |
| 395 | 600 | // 0) Basic sanity & transport integrity |
| 396 | 601 | if (!is_array($file) || empty($file['tmp_name'])) { |
| 397 | 602 | // return ['message' => __('No file uploaded.', 'bit-form'), 'error_type' => 'file_missing']; |
| @@ -419,8 +624,11 @@ | ||
| 419 | 624 | } |
| 420 | 625 | if (!empty($maxSize) && $onDiskSize > $maxSize) { |
| 421 | 626 | return ['message' => __('File size is too large', 'bit-form'), 'error_type' => 'file_size_error']; |
| 422 | 627 | } |
| 628 | + if (!empty($minSize) && $onDiskSize < $minSize) { | |
| 629 | + return ['message' => __('File size is too small', 'bit-form'), 'error_type' => 'file_size_error']; | |
| 630 | + } | |
| 423 | 631 | |
| 424 | 632 | // 2) Determine ext + MIME using WP + finfo |
| 425 | 633 | $wpCheck = wp_check_filetype_and_ext($file['tmp_name'], $fileName); // ['ext'=>'jpg','type'=>'image/jpeg'] |
| 426 | 634 | $fileExtension = strtolower((string)(empty($wpCheck['ext']) ? pathinfo($fileName, PATHINFO_EXTENSION) : $wpCheck['ext'])); |
| @@ -481,9 +689,9 @@ | ||
| 481 | 689 | $allowMimes = array_values(array_unique($allowMimes)); |
| 482 | 690 | |
| 483 | 691 | $hasAllowlist = (!empty($allowExts) || !empty($allowMimes)); |
| 484 | 692 | $extMatch = in_array($fileExtension, $allowExts, true); |
| 485 | - $mimeMatch = in_array($detectedMime, $allowMimes, true); | |
| 693 | + $mimeMatch = in_array($detectedMime, $allowMimes, true) || self::matchesWildcardMime($detectedMime, $allowMimes); | |
| 486 | 694 | |
| 487 | 695 | // 5) Require BOTH a legit WP mapping AND a match to the allowlist |
| 488 | 696 | $wpOk = ('' !== $wpExt && '' !== $wpType); |
| 489 | 697 | |