| @@ -317,25 +317,145 @@ | ||
| 317 | 317 | } |
| 318 | 318 | } |
| 319 | 319 | } |
| 320 | 320 | |
| 321 | - private function getByteSizeByUnit($sizeString) | |
| 321 | + private function getByteSizeByUnit($sizeString, $base = 1024) | |
| 322 | 322 | { |
| 323 | 323 | // split 2MB into 2 and MB |
| 324 | - $size = preg_replace('/[^0-9\.]/', '', $sizeString); | |
| 325 | - $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); | |
| 326 | 326 | $unit = strtolower($unit); |
| 327 | 327 | if ('kb' === $unit) { |
| 328 | - return $size * 1024; | |
| 328 | + return $size * $base; | |
| 329 | 329 | } elseif ('mb' === $unit) { |
| 330 | - return $size * 1024 * 1024; | |
| 330 | + return $size * $base * $base; | |
| 331 | 331 | } elseif ('gb' === $unit) { |
| 332 | - return $size * 1024 * 1024 * 1024; | |
| 332 | + return $size * $base * $base * $base; | |
| 333 | 333 | } else { |
| 334 | 334 | return $size; |
| 335 | 335 | } |
| 336 | 336 | } |
| 337 | 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 | + | |
| 338 | 458 | public function validation($field_key, $file_details, $form_id) |
| 339 | 459 | { |
| 340 | 460 | if (!function_exists('wp_check_filetype_and_ext')) { |
| 341 | 461 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| @@ -348,8 +468,10 @@ | ||
| 348 | 468 | $fieldType = $fieldDetail->typ; |
| 349 | 469 | $maxSizeDetails = []; |
| 350 | 470 | $allowFileTypes = []; |
| 351 | 471 | $maxSize = null; |
| 472 | + $minSize = null; | |
| 473 | + $fieldConfig = isset($fieldDetail->config) && is_object($fieldDetail->config) ? $fieldDetail->config : null; | |
| 352 | 474 | if ('file-up' === $fieldType) { |
| 353 | 475 | $allowFileTypes = !empty($fieldDetail->config->allowedFileType) ? $fieldDetail->config->allowedFileType : []; |
| 354 | 476 | if (!empty($fieldDetail->config->allowMaxSize)) { |
| 355 | 477 | if (!empty($fieldDetail->config->maxSize)) { |
| @@ -370,8 +492,14 @@ | ||
| 370 | 492 | } |
| 371 | 493 | if (!empty($fieldDetail->config->maxTotalFileSize)) { |
| 372 | 494 | $maxSizeDetails['maxTotalFileSize'] = $fieldDetail->config->maxTotalFileSize; |
| 373 | 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 | + } | |
| 374 | 502 | } |
| 375 | 503 | } |
| 376 | 504 | if (!empty($maxSizeDetails['maxSize'])) { |
| 377 | 505 | $maxSize = $this->getByteSizeByUnit($maxSizeDetails['maxSize']); |
| @@ -390,9 +518,13 @@ | ||
| 390 | 518 | 'tmp_name' => $file_details['tmp_name'][$rowIndex], |
| 391 | 519 | 'error' => $file_details['error'][$rowIndex], |
| 392 | 520 | 'size' => $file_details['size'][$rowIndex], |
| 393 | 521 | ]; |
| 394 | - $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); | |
| 395 | 527 | if (!empty($validateState) && !empty($validateState['message'])) { |
| 396 | 528 | return $validateState; |
| 397 | 529 | } |
| 398 | 530 | } |
| @@ -397,15 +529,36 @@ | ||
| 397 | 529 | } |
| 398 | 530 | } |
| 399 | 531 | } |
| 400 | 532 | } else { |
| 401 | - 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); | |
| 402 | 538 | } |
| 403 | 539 | return []; |
| 404 | 540 | } |
| 405 | 541 | |
| 406 | - 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) | |
| 407 | 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 | + { | |
| 408 | 561 | $errorMessage = [ |
| 409 | 562 | 'message' => '', |
| 410 | 563 | 'error_type' => '', |
| 411 | 564 | ]; |
| @@ -420,9 +573,9 @@ | ||
| 420 | 573 | 'error' => $file_details['error'][$key], |
| 421 | 574 | 'size' => $file_details['size'][$key], |
| 422 | 575 | ]; |
| 423 | 576 | $totalSize += $fileInfo['size']; |
| 424 | - $validateState = $this->validateSingleFile($fieldType, $fileInfo, $allowFileTypes, $maxSize); | |
| 577 | + $validateState = $this->validateSingleFile($fieldType, $fileInfo, $allowFileTypes, $maxSize, $minSize); | |
| 425 | 578 | if (!empty($validateState)) { |
| 426 | 579 | return $validateState; |
| 427 | 580 | } |
| 428 | 581 | } |
| @@ -432,9 +585,9 @@ | ||
| 432 | 585 | $errorMessage['error_type'] = 'file_size_error'; |
| 433 | 586 | return $errorMessage; |
| 434 | 587 | } |
| 435 | 588 | } else { |
| 436 | - $validateState = $this->validateSingleFile($fieldType, $file_details, $allowFileTypes, $maxSize); | |
| 589 | + $validateState = $this->validateSingleFile($fieldType, $file_details, $allowFileTypes, $maxSize, $minSize); | |
| 437 | 590 | if (!empty($validateState)) { |
| 438 | 591 | return $validateState; |
| 439 | 592 | } |
| 440 | 593 | } |
| @@ -441,9 +594,9 @@ | ||
| 441 | 594 | |
| 442 | 595 | return $errorMessage; |
| 443 | 596 | } |
| 444 | 597 | |
| 445 | - private function validateSingleFile($fieldType, &$file, $allowTypes, $maxSize = null) | |
| 598 | + private function validateSingleFile($fieldType, &$file, $allowTypes, $maxSize = null, $minSize = null) | |
| 446 | 599 | { |
| 447 | 600 | // 0) Basic sanity & transport integrity |
| 448 | 601 | if (!is_array($file) || empty($file['tmp_name'])) { |
| 449 | 602 | // return ['message' => __('No file uploaded.', 'bit-form'), 'error_type' => 'file_missing']; |
| @@ -471,8 +624,11 @@ | ||
| 471 | 624 | } |
| 472 | 625 | if (!empty($maxSize) && $onDiskSize > $maxSize) { |
| 473 | 626 | return ['message' => __('File size is too large', 'bit-form'), 'error_type' => 'file_size_error']; |
| 474 | 627 | } |
| 628 | + if (!empty($minSize) && $onDiskSize < $minSize) { | |
| 629 | + return ['message' => __('File size is too small', 'bit-form'), 'error_type' => 'file_size_error']; | |
| 630 | + } | |
| 475 | 631 | |
| 476 | 632 | // 2) Determine ext + MIME using WP + finfo |
| 477 | 633 | $wpCheck = wp_check_filetype_and_ext($file['tmp_name'], $fileName); // ['ext'=>'jpg','type'=>'image/jpeg'] |
| 478 | 634 | $fileExtension = strtolower((string)(empty($wpCheck['ext']) ? pathinfo($fileName, PATHINFO_EXTENSION) : $wpCheck['ext'])); |
| @@ -533,9 +689,9 @@ | ||
| 533 | 689 | $allowMimes = array_values(array_unique($allowMimes)); |
| 534 | 690 | |
| 535 | 691 | $hasAllowlist = (!empty($allowExts) || !empty($allowMimes)); |
| 536 | 692 | $extMatch = in_array($fileExtension, $allowExts, true); |
| 537 | - $mimeMatch = in_array($detectedMime, $allowMimes, true); | |
| 693 | + $mimeMatch = in_array($detectedMime, $allowMimes, true) || self::matchesWildcardMime($detectedMime, $allowMimes); | |
| 538 | 694 | |
| 539 | 695 | // 5) Require BOTH a legit WP mapping AND a match to the allowlist |
| 540 | 696 | $wpOk = ('' !== $wpExt && '' !== $wpType); |
| 541 | 697 | |