PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / V3.3.2
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder vV3.3.2
V3.3.2 3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 All 139 releases
← All changes | includes/Core/Util/FileHandler.php +236 -28 3.1.3 → V3.3.2 View file →
@@ -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