prepare) are intended.
// phpcs:disable WordPress.DB.DirectDatabaseQuery
/**
* Verify the site's CAPTCHA token against the provider when the form uses one. Returns true when
* there is no provider configured, the token checks out, or the check couldn't be reached (so a brief
* provider outage never silently breaks a form — the trap field and rate limit still apply).
*/
function pubforms_verify_spam( $form, $post ) {
$provider = pubforms_effective_spam_method( $form );
if ( ! in_array( $provider, array( 'turnstile', 'hcaptcha' ), true ) ) {
return true; // No third-party challenge applies (the built-in question is checked separately).
}
$token = sanitize_text_field( (string) ( $post['pubforms_spam_token'] ?? '' ) );
if ( '' === $token ) {
return false;
}
$check = pubforms_siteverify( $provider, $token );
return $check['unreachable'] ? true : $check['success'];
}
/**
* Ask the provider whether a CAPTCHA token is genuine, using the saved secret key.
*
* @return array success, unreachable (the provider could not be contacted), errors (provider error
* codes), hostname (the site the token was issued for, when the provider says).
*/
function pubforms_siteverify( $provider, $token ) {
$out = array( 'success' => false, 'unreachable' => false, 'errors' => array(), 'hostname' => '' );
$keys = pubforms_spam_keys( $provider );
if ( empty( $keys['secret'] ) || empty( $keys['site'] ) ) {
$out['errors'][] = 'missing-keys';
return $out;
}
if ( 'turnstile' === $provider ) {
$endpoint = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- server-to-server CAPTCHA verification, required by the provider.
} else {
$endpoint = 'https://hcaptcha.com/siteverify'; // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- server-to-server CAPTCHA verification, required by the provider.
}
$response = wp_remote_post( $endpoint, array(
'body' => array( 'secret' => $keys['secret'], 'response' => $token ),
'timeout' => 10,
) );
if ( is_wp_error( $response ) ) {
$out['unreachable'] = true;
$out['errors'][] = $response->get_error_message();
return $out;
}
$body = json_decode( (string) wp_remote_retrieve_body( $response ), true );
$out['success'] = ! empty( $body['success'] );
$out['errors'] = array_map( 'sanitize_text_field', (array) ( $body['error-codes'] ?? array() ) );
$out['hostname'] = sanitize_text_field( (string) ( $body['hostname'] ?? '' ) );
return $out;
}
/**
* Check and clean what a visitor submitted.
*
* @param array $form Form from pubforms_get_form().
* @param array $raw The submitted pubforms_f array (unslashed).
* @return array [ values by field id, errors by field id ].
*/
function pubforms_validate_values( $form, $raw ) {
$raw = is_array( $raw ) ? $raw : array();
$values = array();
$errors = array();
// Hidden (conditional) fields are skipped entirely.
foreach ( $form['fields'] as $field ) {
if ( ! empty( $field['logic'] ) && ! pubforms_field_visible( $field, $raw ) ) {
$values[ $field['id'] ] = is_array( $raw[ $field['id'] ] ?? null ) ? array() : '';
}
}
foreach ( $form['fields'] as $field ) {
$id = $field['id'];
if ( array_key_exists( $id, $values ) && ! isset( $errors[ $id ] ) ) {
continue;
}
if ( 'checkbox_multi' === $field['type'] ) {
$picked = array();
if ( is_array( $raw[ $id ] ?? null ) ) {
foreach ( $raw[ $id ] as $candidate ) {
$candidate = (string) $candidate;
if ( in_array( $candidate, $field['options'], true ) && ! in_array( $candidate, $picked, true ) ) {
$picked[] = $candidate;
}
}
}
if ( ! $picked && $field['required'] ) {
$errors[ $id ] = __( 'Please choose at least one option.', 'pubforms' );
}
$values[ $id ] = $picked;
continue;
}
$value = isset( $raw[ $id ] ) && is_scalar( $raw[ $id ] ) ? (string) $raw[ $id ] : '';
switch ( $field['type'] ) {
case 'textarea':
$value = mb_substr( sanitize_textarea_field( $value ), 0, 5000 );
break;
case 'email':
// Not sanitize_email() first: it empties an invalid address, which would turn "not an email" into
// "required" and lose what the visitor typed. Validate the trimmed text instead.
$value = mb_substr( sanitize_text_field( $value ), 0, 254 );
break;
case 'number':
$value = trim( $value );
break;
case 'url':
$value = mb_substr( sanitize_text_field( $value ), 0, 1000 );
break;
case 'date':
$value = mb_substr( sanitize_text_field( $value ), 0, 10 );
break;
case 'time':
$value = mb_substr( sanitize_text_field( $value ), 0, 8 );
break;
case 'week':
$value = mb_substr( sanitize_text_field( $value ), 0, 8 );
break;
case 'month':
$value = mb_substr( sanitize_text_field( $value ), 0, 7 );
break;
case 'phone':
$value = mb_substr( sanitize_text_field( $value ), 0, 40 );
break;
case 'select':
case 'radio':
case 'quiz':
$value = in_array( $value, $field['options'], true ) ? $value : '';
break;
case 'checkbox':
$value = '' !== $value && '0' !== $value ? __( 'Yes', 'pubforms' ) : '';
break;
case 'file':
$value = ''; // Files are handled in pubforms_process_files().
break;
case 'signature':
$value = trim( sanitize_text_field( $value ) );
if ( '' !== $value ) {
if ( 0 !== strpos( $value, 'data:image/png;base64,' ) || strlen( $value ) > 7 * 1000000 ) {
$errors[ $id ] = __( 'Please sign on the line before continuing.', 'pubforms' );
$value = '';
}
}
break;
case 'calculation':
$value = ''; // Computed from other fields in pubforms_calculate().
break;
case 'section':
case 'html':
$value = '';
break;
default:
$value = mb_substr( sanitize_text_field( $value ), 0, 1000 );
}
if ( '' === $value ) {
if ( $field['required'] && ! in_array( $field['type'], array( 'section', 'html', 'file' ), true ) ) {
$errors[ $id ] = 'checkbox' === $field['type'] ? __( 'Please tick this box to continue.', 'pubforms' ) : __( 'This field is required.', 'pubforms' );
}
} elseif ( 'email' === $field['type'] && ! is_email( $value ) ) {
$errors[ $id ] = __( 'Please enter a valid email address.', 'pubforms' );
} elseif ( 'email' === $field['type'] ) {
$value = sanitize_email( $value );
} elseif ( 'number' === $field['type'] && ! is_numeric( $value ) ) {
$errors[ $id ] = __( 'Please enter a number.', 'pubforms' );
} elseif ( 'url' === $field['type'] && ! filter_var( $value, FILTER_VALIDATE_URL ) ) {
$errors[ $id ] = __( 'Please enter a valid web address.', 'pubforms' );
} elseif ( 'date' === $field['type'] && ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) {
$errors[ $id ] = __( 'Please use the format YYYY-MM-DD.', 'pubforms' );
} elseif ( 'time' === $field['type'] && ! preg_match( '/^([01]?\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/', $value ) ) {
$errors[ $id ] = __( 'Please enter a valid time.', 'pubforms' );
} elseif ( 'month' === $field['type'] && ! preg_match( '/^\d{4}-\d{2}$/', $value ) ) {
$errors[ $id ] = __( 'Please use the format YYYY-MM.', 'pubforms' );
} elseif ( 'week' === $field['type'] && ! preg_match( '/^\d{4}-W\d{2}$/', $value ) ) {
$errors[ $id ] = __( 'Please use the format YYYY-Www.', 'pubforms' );
}
$values[ $id ] = $value;
}
return array( $values, $errors );
}
/**
* Whether a field with conditional logic should be shown, given the values submitted so far.
* Fields without logic are always visible. Hidden fields are skipped (not validated, not required).
*/
function pubforms_field_visible( $field, $raw ) {
$logic = $field['logic'] ?? array();
if ( empty( $logic['match'] ) ) {
return true;
}
$match = $raw[ $logic['match'] ] ?? array();
$match = is_array( $match ) ? $match : array( (string) $match );
$match = array_map( 'trim', $match );
$match = array_filter( $match, 'strlen' );
switch ( $logic['op'] ) {
case 'empty':
return empty( $match );
case 'not_empty':
return ! empty( $match );
case 'is_not':
return ! in_array( (string) ( $logic['value'] ?? '' ), $match, true );
default: // is
return in_array( (string) ( $logic['value'] ?? '' ), $match, true );
}
}
/**
* Substitute a calculation formula's {field_id} tokens with the submitted numeric values and
* evaluate the safe arithmetic expression. Returns the result as a float, or null when invalid.
*/
function pubforms_calculate( $formula, $values ) {
$formula = (string) $formula;
if ( '' === $formula ) {
return null;
}
// Replace tokens with validated numeric values only; anything non-numeric becomes 0.
$formula = preg_replace_callback( '/\{([a-z0-9\-_]+)\}/i', function ( $m ) use ( $values ) {
$v = $values[ $m[1] ] ?? '';
return is_numeric( $v ) ? (string) $v : '0';
}, $formula );
// Strip anything that is not a number, an operator or parentheses.
$formula = preg_replace( '/[^0-9\.\+\-\*\/\(\)]/', '', $formula );
if ( '' === $formula ) {
return null;
}
$tokens = pubforms_tokenize_calc( $formula );
if ( null === $tokens ) {
return null;
}
$pos = 0;
$value = pubforms_parse_expression( $tokens, $pos );
if ( null === $value || count( $tokens ) !== $pos ) {
return null;
}
return $value;
}
/**
* Split a formula into number/operator/paren tokens, or null if it contains anything unexpected.
*
* @return array|null
*/
function pubforms_tokenize_calc( $formula ) {
$tokens = array();
$i = 0;
$len = strlen( $formula );
while ( $i < $len ) {
$c = $formula[ $i ];
if ( ctype_space( $c ) ) {
$i++;
continue;
}
if ( false !== strpos( '+-*/()', $c ) ) {
$tokens[] = $c;
$i++;
continue;
}
if ( ctype_digit( $c ) || '.' === $c ) {
$start = $i;
while ( $i < $len && ( ctype_digit( $formula[ $i ] ) || '.' === $formula[ $i ] ) ) {
$i++;
}
$num = substr( $formula, $start, $i - $start );
if ( ! is_numeric( $num ) ) {
return null;
}
$tokens[] = (float) $num;
continue;
}
return null; // Unexpected character.
}
return $tokens;
}
/** @internal Recursive-descent: expression = term { (+|-) term } */
function pubforms_parse_expression( &$tokens, &$pos ) {
$left = pubforms_parse_term( $tokens, $pos );
if ( null === $left ) {
return null;
}
while ( isset( $tokens[ $pos ] ) && ( '+' === $tokens[ $pos ] || '-' === $tokens[ $pos ] ) ) {
$op = $tokens[ $pos ];
$pos++;
$right = pubforms_parse_term( $tokens, $pos );
if ( null === $right ) {
return null;
}
$left = '+' === $op ? $left + $right : $left - $right;
}
return $left;
}
/** @internal Recursive-descent: term = factor { (*|/) factor } */
function pubforms_parse_term( &$tokens, &$pos ) {
$left = pubforms_parse_factor( $tokens, $pos );
if ( null === $left ) {
return null;
}
while ( isset( $tokens[ $pos ] ) && ( '*' === $tokens[ $pos ] || '/' === $tokens[ $pos ] ) ) {
$op = $tokens[ $pos ];
$pos++;
$right = pubforms_parse_factor( $tokens, $pos );
if ( null === $right ) {
return null;
}
if ( '/' === $op && 0.0 === $right ) {
return null; // Division by zero.
}
$left = '*' === $op ? $left * $right : $left / $right;
}
return $left;
}
/** @internal Recursive-descent: factor = number | ( expression ) | - factor */
function pubforms_parse_factor( &$tokens, &$pos ) {
if ( ! isset( $tokens[ $pos ] ) ) {
return null;
}
if ( is_float( $tokens[ $pos ] ) || is_int( $tokens[ $pos ] ) ) {
return (float) $tokens[ $pos++ ];
}
if ( '-' === $tokens[ $pos ] ) {
$pos++;
$value = pubforms_parse_factor( $tokens, $pos );
return null === $value ? null : -$value;
}
if ( '(' === $tokens[ $pos ] ) {
$pos++;
$value = pubforms_parse_expression( $tokens, $pos );
if ( null === $value || ( $tokens[ $pos ] ?? '' ) !== ')' ) {
return null;
}
$pos++;
return $value;
}
return null;
}
/**
* Whether a quiz field's chosen answer is correct. Empty choices are never correct.
*/
function pubforms_grade_quiz( $field, $chosen ) {
$chosen = (string) $chosen;
$expected = (string) ( $field['correct'] ?? '' );
return '' !== $chosen && '' !== $expected && $chosen === $expected;
}
/**
* Allow a visitor a limited number of submissions per hour. The key is a hash, so no address is kept.
*/
function pubforms_throttled( $form_id, $visitor ) {
$limit = (int) apply_filters( 'pubforms_submissions_per_hour', (int) get_option( 'pubforms_submissions_per_hour', 10 ), $form_id );
if ( '' === $visitor || $limit <= 0 ) {
return false;
}
$key = 'pubforms_rate_' . md5( $form_id . '|' . $visitor );
$count = (int) get_transient( $key );
if ( $count >= $limit ) {
return true;
}
set_transient( $key, $count + 1, HOUR_IN_SECONDS );
return false;
}
/**
* Who to email. The submitter's email address, when the form has an email field, leads the message body.
*/
function pubforms_build_notification( $form, $items, $entry_id ) {
$to = array_filter( array_map( 'trim', explode( ',', (string) $form['settings']['notify_email'] ) ), 'is_email' );
$to = $to ? $to : array( (string) get_option( 'admin_email' ) );
$subject = str_replace( '{form}', $form['title'], $form['settings']['subject'] );
$subject = trim( preg_replace( '/[\r\n]+/', ' ', $subject ) );
$body = '';
foreach ( $items as $item ) {
if ( 'email' === $item['type'] && is_email( $item['value'] ) ) {
/* translators: %s: the visitor's email address */
$body = sprintf( __( 'Visitor email: %s', 'pubforms' ), $item['value'] ) . "\n\n";
break;
}
}
foreach ( $items as $item ) {
$body .= $item['label'] . ":\n" . ( '' !== $item['value'] ? $item['value'] : '-' ) . "\n";
$body .= "\n";
}
/* translators: %s: link to the entry in the admin */
$body .= sprintf( __( 'View this entry: %s', 'pubforms' ), pubforms_entry_url( $form['id'], $entry_id ) ) . "\n";
// The visitor's address goes in the body, not a Reply-To header: many mail services refuse a message
// whose Reply-To domain cannot receive mail (a mistyped address is enough), and the notification is lost.
// A site that wants Reply-To can add it with the pubforms_notification filter.
$headers = array();
$sender_email = get_option( 'pubforms_sender_email' );
if ( is_email( (string) $sender_email ) ) {
$sender_name = get_option( 'pubforms_sender_name' );
$headers[] = 'From: ' . ( $sender_name ? $sender_name . ' <' . $sender_email . '>' : $sender_email );
}
return apply_filters( 'pubforms_notification', array( 'to' => $to, 'subject' => $subject, 'body' => $body, 'headers' => $headers ), $form, $entry_id );
}
/**
* Validate, store and announce one submission.
*
* @param int $form_id
* @param array $post Unslashed submitted data: pubforms_f and the trap field.
* @param string $visitor Anything identifying the visitor for rate limiting ('' to skip).
* @return array ok, spam, errors, values, message, entry_id.
*/
function pubforms_process_submission( $form_id, $post, $visitor = '' ) {
$result = array( 'ok' => false, 'spam' => false, 'errors' => array(), 'values' => array(), 'message' => '', 'entry_id' => 0 );
$form = pubforms_get_form( $form_id );
if ( ! $form || 'publish' !== $form['status'] || ! $form['fields'] ) {
$result['message'] = __( 'This form is not available.', 'pubforms' );
return $result;
}
if ( ! empty( $post['pubforms_website'] ) ) { // The hidden trap field: only bots fill it in. Pretend success.
$result['ok'] = true;
$result['spam'] = true;
return $result;
}
if ( pubforms_throttled( $form['id'], $visitor ) ) {
$result['message'] = __( 'Too many submissions from your connection. Please try again later.', 'pubforms' );
return $result;
}
if ( ! pubforms_verify_spam( $form, $post ) ) {
$result['ok'] = true;
$result['spam'] = true;
return $result;
}
list( $values, $errors ) = pubforms_validate_values( $form, $post['pubforms_f'] ?? array() );
$result['values'] = $values;
// Checks a person can fail by accident get a visible message and keep what they typed.
if ( pubforms_spam_too_fast( $post['pubforms_ts'] ?? '' ) ) {
$result['errors'] = $errors;
$result['message'] = __( 'That was very quick. Please check your answers and submit again.', 'pubforms' );
return $result;
}
if ( 'question' === pubforms_effective_spam_method( $form ) && ! pubforms_spam_check_challenge( $post['pubforms_challenge_token'] ?? '', $post['pubforms_challenge_answer'] ?? '' ) ) {
$result['errors'] = $errors;
$result['message'] = __( 'Please answer the anti-spam question correctly.', 'pubforms' );
return $result;
}
$problem = pubforms_spam_content_problem( $values );
if ( '' !== $problem ) {
$result['errors'] = $errors;
$result['message'] = $problem;
return $result;
}
if ( $errors ) {
$result['errors'] = $errors;
return $result;
}
// Save any uploaded files. If one fails, throw the whole submission back with the error.
list( $files, $file_errors ) = pubforms_process_files( $form, $post['pubforms_files'] ?? array() );
if ( $file_errors ) {
pubforms_remove_stored_files( $form['id'], $files );
$result['errors'] = $file_errors;
return $result;
}
// Save any page-drawn signatures the same way.
list( $signatures, $signature_errors ) = pubforms_process_signatures( $form, $values );
if ( $signature_errors ) {
pubforms_remove_stored_files( $form['id'], $files );
pubforms_remove_stored_files( $form['id'], $signatures );
$result['errors'] = $signature_errors;
return $result;
}
$items = array();
foreach ( $form['fields'] as $field ) {
if ( 'file' === $field['type'] ) {
$items[] = array_merge(
array( 'id' => $field['id'], 'label' => $field['label'], 'type' => 'file' ),
isset( $files[ $field['id'] ] ) ? $files[ $field['id'] ] : array( 'filename' => '', 'name' => '', 'path' => '' )
);
continue;
}
if ( 'signature' === $field['type'] ) {
$items[] = array_merge(
array( 'id' => $field['id'], 'label' => $field['label'], 'type' => 'signature' ),
isset( $signatures[ $field['id'] ] ) ? $signatures[ $field['id'] ] : array( 'filename' => '', 'name' => '', 'path' => '' )
);
continue;
}
$field_value = $values[ $field['id'] ];
if ( is_array( $field_value ) ) {
$field_value = implode( ', ', $field_value );
}
if ( 'calculation' === $field['type'] ) {
$calc = pubforms_calculate( $field['formula'] ?? '', $values );
$field_value = null === $calc ? '' : (string) $calc;
}
if ( 'quiz' === $field['type'] ) {
$items[] = array( 'id' => $field['id'], 'label' => $field['label'], 'type' => 'quiz', 'value' => $field_value, 'correct' => pubforms_grade_quiz( $field, $field_value ), 'expected' => (string) ( $field['correct'] ?? '' ) );
continue;
}
$items[] = array( 'id' => $field['id'], 'label' => $field['label'], 'type' => $field['type'], 'value' => $field_value );
}
$entry_id = pubforms_insert_entry( $form['id'], $items );
$mail = pubforms_build_notification( $form, $items, $entry_id );
$sent = wp_mail( $mail['to'], $mail['subject'], $mail['body'], $mail['headers'] );
if ( $sent ) {
global $wpdb;
$wpdb->update( pubforms_entries_table(), array( 'notified' => 1 ), array( 'id' => $entry_id ) );
}
pubforms_send_autoreply( $form, $items );
do_action( 'pubforms_entry_created', $entry_id, $form, $values );
pubforms_deliver_webhooks( $form, pubforms_get_entry( $entry_id ) );
$result['ok'] = true;
$result['entry_id'] = $entry_id;
return $result;
}
/**
* Where uploaded files are kept on disk, one folder per form.
*
* The folder is private: files are never linked by URL. They are served by pubforms_serve_file() to signed-in
* users who can edit the form. A site on a web server that ignores .htaccess (nginx, IIS) can move the whole
* folder out of the web root with the pubforms_uploads_basedir filter.
*
* 'url' is only the placeholder WordPress's upload handler expects; it is not shown or stored.
*/
function pubforms_uploads_dir( $form_id ) {
$base = wp_upload_dir();
$root = untrailingslashit( (string) apply_filters( 'pubforms_uploads_basedir', trailingslashit( $base['basedir'] ) . 'pubforms' ) );
return array(
'path' => $root . '/' . (int) $form_id,
'url' => trailingslashit( $base['baseurl'] ) . 'pubforms/' . (int) $form_id,
);
}
/**
* Create a form's upload folder and put a deny-all .htaccess and an empty index.php in it and in its parent,
* so the files cannot be fetched or listed directly. File names are also random (see pubforms_store_upload()).
*
* @return bool Whether the folder exists and is writable.
*/
function pubforms_prepare_uploads_dir( $path ) {
wp_mkdir_p( $path );
if ( ! is_dir( $path ) || ! wp_is_writable( $path ) ) {
return false;
}
$guards = array(
'.htaccess' => "# Written by PubForms: these files are private and are only served to signed-in users.\n\nRequire all denied\n\n\nOrder allow,deny\nDeny from all\n\n",
'index.php' => " $contents ) {
if ( ! file_exists( $dir . '/' . $name ) && wp_is_writable( $dir ) ) {
file_put_contents( $dir . '/' . $name, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- tiny static guard files; no core equivalent.
}
}
}
return true;
}
// Folders created by an earlier version get the same guard files the next time an admin screen loads.
add_action( 'admin_init', function () {
if ( get_option( 'pubforms_uploads_guarded' ) === PUBFORMS_VERSION ) {
return;
}
$root = dirname( pubforms_uploads_dir( 0 )['path'] );
if ( is_dir( $root ) ) {
foreach ( (array) glob( $root . '/*', GLOB_ONLYDIR ) as $folder ) {
pubforms_prepare_uploads_dir( $folder );
}
}
update_option( 'pubforms_uploads_guarded', PUBFORMS_VERSION, false );
} );
/**
* The single (large-ish) file cap in bytes. A field can lower this to its own max_size.
*/
function pubforms_upload_max_size() {
return (int) apply_filters( 'pubforms_upload_max_size', 10 * MB_IN_BYTES );
}
/**
* Validate and store one uploaded file for a file field.
*
* @return array A stored-file reference (filename, name, url, path) on success, an
* ['error' => message] array for a bad file, or false when no file and not required.
*/
function pubforms_store_upload( $form_id, $field, $name, $tmp, $size, $error ) {
if ( UPLOAD_ERR_NO_FILE === $error ) {
return $field['required'] ? array( 'error' => __( 'This field is required.', 'pubforms' ) ) : false;
}
if ( UPLOAD_ERR_OK !== $error ) {
return array( 'error' => __( 'The file could not be uploaded.', 'pubforms' ) );
}
// Real browser uploads must be genuine HTTP uploads. The local-file path is only reachable when a
// test explicitly opts in, so the integration tests can move files around on disk rather than over HTTP.
$is_http = is_uploaded_file( $tmp );
if ( ! $is_http && ! apply_filters( 'pubforms_allow_local_upload', false, $tmp ) ) {
return array( 'error' => __( 'The file could not be uploaded.', 'pubforms' ) );
}
$name = sanitize_file_name( (string) $name );
if ( '' === $name ) {
return array( 'error' => __( 'Please choose a file.', 'pubforms' ) );
}
// Type check: the extension must be allowed, and must match what the file really is.
$allowed = array_filter( array_map( 'strtolower', array_map( 'trim', explode( ',', (string) $field['accept'] ) ) ) );
$ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
if ( $allowed && ! in_array( $ext, $allowed, true ) ) {
return array( 'error' => sprintf( /* translators: %s: allowed file types */ __( 'Files of this type are not allowed. Allowed: %s', 'pubforms' ), implode( ', ', $allowed ) ) );
}
$real = wp_check_filetype_and_ext( $tmp, $name );
if ( empty( $real['ext'] ) || empty( $real['type'] ) ) {
return array( 'error' => __( 'This file type is not allowed.', 'pubforms' ) );
}
$max = $field['max_size'] ? min( (int) $field['max_size'] * MB_IN_BYTES, pubforms_upload_max_size() ) : pubforms_upload_max_size();
if ( $size <= 0 || $size > $max ) {
return array( 'error' => sprintf( /* translators: %d: maximum size in MB */ __( 'The file is too large (limit %d MB).', 'pubforms' ), (int) ( $max / MB_IN_BYTES ) ) );
}
require_once ABSPATH . 'wp-admin/includes/file.php';
// Keep submissions in this form's own private folder instead of the media library's dated folders.
$dir = pubforms_uploads_dir( $form_id );
if ( ! pubforms_prepare_uploads_dir( $dir['path'] ) ) {
return array( 'error' => __( 'The file could not be saved.', 'pubforms' ) );
}
$redirect = function ( $uploads ) use ( $dir ) {
$uploads['path'] = $dir['path'];
$uploads['url'] = $dir['url'];
$uploads['subdir'] = '/pubforms/' . (int) basename( $dir['path'] );
$uploads['error'] = false;
return $uploads;
};
// The stored name is random and says nothing about the visitor or the file; the original name is kept in the entry.
$stored_ext = sanitize_key( (string) $real['ext'] );
$unique_name = function () use ( $stored_ext ) {
return wp_generate_password( 32, false ) . '.' . $stored_ext;
};
// Real browser uploads use wp_handle_upload(); the sideload handler is only reachable from tests (see above).
$handler = $is_http ? 'wp_handle_upload' : 'wp_handle_sideload';
add_filter( 'upload_dir', $redirect );
$file = array( 'name' => $name, 'type' => $real['type'], 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => $size );
$moved = $handler(
$file,
array(
'test_form' => false,
'unique_filename_callback' => $unique_name,
)
);
remove_filter( 'upload_dir', $redirect );
if ( isset( $moved['error'] ) || empty( $moved['file'] ) ) {
return array( 'error' => __( 'The file could not be saved.', 'pubforms' ) );
}
$full = $moved['file'];
return array(
'filename' => wp_basename( $full ),
'name' => $name,
'value' => $name,
'path' => $full,
);
}
/**
* Save every file field of a submission; returns stored files keyed by field id and any errors.
*
* @return array [ $files_by_id, $errors_by_id ]
*/
function pubforms_process_files( $form, $raw ) {
$files = array();
$errors = array();
if ( ! is_array( $raw ) ) {
$raw = array();
}
foreach ( $form['fields'] as $field ) {
if ( 'file' !== $field['type'] ) {
continue;
}
$id = $field['id'];
$name = (string) ( $raw['name'][ $id ] ?? '' );
$tmp = (string) ( $raw['tmp_name'][ $id ] ?? '' );
$size = (int) ( $raw['size'][ $id ] ?? 0 );
$err = (int) ( $raw['error'][ $id ] ?? UPLOAD_ERR_NO_FILE );
$stored = pubforms_store_upload( $form['id'], $field, $name, $tmp, $size, $err );
if ( isset( $stored['error'] ) ) {
$errors[ $id ] = $stored['error'];
} elseif ( $stored ) {
$files[ $id ] = $stored;
}
}
return array( $files, $errors );
}
/**
* Delete a set of stored files for a form (used to roll back a submission that failed validation).
*/
function pubforms_remove_stored_files( $form_id, $files ) {
$dir = pubforms_uploads_dir( $form_id );
foreach ( (array) $files as $file ) {
$path = (string) ( $file['path'] ?? '' );
if ( '' !== $path && strpos( $path, $dir['path'] ) === 0 && file_exists( $path ) ) {
wp_delete_file( $path );
}
}
}
/**
* Check that data is a well-formed PNG and return a clean copy of it, or false.
*
* Every chunk is walked (length and CRC), IHDR must come first and IEND last with nothing after it, and the
* dimensions must be sane. Only the chunks needed to draw the picture are kept, so text, profile or any other
* data a file might carry in extra chunks, or appended after the image, is dropped.
*
* @param string $data Raw bytes.
* @param int $max_width Largest accepted width in pixels.
* @param int $max_height Largest accepted height in pixels.
* @return string|false
*/
function pubforms_clean_png( $data, $max_width = 2000, $max_height = 1000 ) {
$signature = "\x89PNG\r\n\x1a\n";
$length = strlen( $data );
if ( $length < 45 || 0 !== strncmp( $data, $signature, 8 ) ) {
return false;
}
$keep = array( 'IHDR', 'PLTE', 'tRNS', 'IDAT', 'IEND' );
$out = $signature;
$pos = 8;
$first = true;
$has_data = false;
$ended = false;
while ( $pos + 12 <= $length ) {
$size = unpack( 'N', substr( $data, $pos, 4 ) )[1];
$type = substr( $data, $pos + 4, 4 );
if ( $pos + 12 + $size > $length ) {
return false;
}
$body = substr( $data, $pos + 8, $size );
$crc = unpack( 'N', substr( $data, $pos + 8 + $size, 4 ) )[1];
if ( sprintf( '%u', crc32( $type . $body ) ) !== sprintf( '%u', $crc ) ) {
return false;
}
if ( $first ) {
if ( 'IHDR' !== $type || 13 !== $size ) {
return false;
}
$dims = unpack( 'Nwidth/Nheight', $body );
if ( $dims['width'] < 1 || $dims['height'] < 1 || $dims['width'] > $max_width || $dims['height'] > $max_height ) {
return false;
}
$first = false;
} elseif ( 'IHDR' === $type ) {
return false;
}
if ( 'IDAT' === $type ) {
$has_data = true;
}
if ( in_array( $type, $keep, true ) ) {
$out .= substr( $data, $pos, 12 + $size );
}
$pos += 12 + $size;
if ( 'IEND' === $type ) {
$ended = true;
break;
}
}
if ( ! $ended || ! $has_data || $pos !== $length ) {
return false;
}
// Last check with PHP's own reader.
$info = getimagesizefromstring( $out );
if ( ! $info || IMAGETYPE_PNG !== $info[2] ) {
return false;
}
return $out;
}
/**
* Decode a page-drawn signature (a PNG data URL), confirm it really is a PNG, and store it in the form's
* private upload folder like an uploaded file.
*
* @return array A stored-file reference, or ['error'=>message], or false when there is no signature.
*/
function pubforms_store_signature( $form_id, $field, $data_url ) {
$data_url = (string) $data_url;
if ( '' === $data_url ) {
return false;
}
if ( 0 !== strpos( $data_url, 'data:image/png;base64,' ) ) {
return array( 'error' => __( 'Please sign on the line before continuing.', 'pubforms' ) );
}
$data = base64_decode( substr( $data_url, strlen( 'data:image/png;base64,' ) ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding a visitor-supplied PNG, validated below and written to a file, never evaluated.
if ( false === $data || strlen( $data ) > 2 * 1000000 ) {
return array( 'error' => __( 'The signature is invalid or too large.', 'pubforms' ) );
}
$png = pubforms_clean_png( $data );
if ( false === $png ) {
return array( 'error' => __( 'The signature is not a valid image. Please sign again.', 'pubforms' ) );
}
$dir = pubforms_uploads_dir( $form_id );
if ( ! pubforms_prepare_uploads_dir( $dir['path'] ) ) {
return array( 'error' => __( 'The signature could not be saved.', 'pubforms' ) );
}
$fname = wp_unique_filename( $dir['path'], 'signature-' . wp_generate_password( 32, false ) . '.png' );
$full = $dir['path'] . '/' . $fname;
if ( false === file_put_contents( $full, $png ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- no core equivalent for raw bytes.
return array( 'error' => __( 'The signature could not be saved.', 'pubforms' ) );
}
return array(
'filename' => $fname,
'name' => __( 'Signature', 'pubforms' ),
'value' => $fname,
'path' => $full,
);
}
/**
* Save each drawn signature on a form; returns stored files keyed by field id and any errors.
*
* @return array [ $signatures_by_id, $errors_by_id ]
*/
function pubforms_process_signatures( $form, $values ) {
$signatures = array();
$errors = array();
foreach ( $form['fields'] as $field ) {
if ( 'signature' !== $field['type'] ) {
continue;
}
$id = $field['id'];
$val = $values[ $id ] ?? '';
if ( '' === $val ) {
continue; // Empty is fine; required was enforced in validation.
}
$stored = pubforms_store_signature( $form['id'], $field, (string) $val );
if ( isset( $stored['error'] ) ) {
$errors[ $id ] = $stored['error'];
} elseif ( $stored ) {
$signatures[ $id ] = $stored;
}
}
return array( $signatures, $errors );
}
/**
* Return the allowed file cap for a field, in MB (0 = the global default).
*/
function pubforms_field_max_mb( $field ) {
return (int) $field['max_size'];
}
/**
* Optionally email the person who submitted the form a thank-you, when the form has an email field
* and auto-reply is on. Never blocks the submission.
*/
function pubforms_send_autoreply( $form, $items ) {
if ( empty( $form['settings']['reply_enabled'] ) ) {
return;
}
$to = '';
foreach ( $items as $item ) {
if ( 'email' === $item['type'] && is_email( $item['value'] ) ) {
$to = $item['value'];
break;
}
}
if ( '' === $to ) {
return;
}
$subject = str_replace( '{form}', $form['title'], $form['settings']['reply_subject'] );
$body = str_replace( '{form}', $form['title'], $form['settings']['reply_body'] );
wp_mail( array( $to ), $subject, $body );
}
/**
* Where a visitor with a confirmation of "redirect" should land. Only same-site targets are allowed,
* so a form can never be used to bounce someone to an arbitrary third-party site.
*
* @return string The validated target, or $fallback if none/not allowed.
*/
function pubforms_confirmation_target( $form, $fallback ) {
$target = trim( (string) ( $form['settings']['redirect_to'] ?? '' ) );
if ( 'redirect' === ( $form['settings']['confirmation_type'] ?? '' ) && '' !== $target ) {
return wp_validate_redirect( $target, $fallback );
}
return $fallback;
}
/**
* Store a partially-filled form so the visitor can come back later. Returns a resume token, or null
* if the form cannot be saved. Values are sanitised but required fields are not enforced on a draft.
*/
function pubforms_save_draft( $form_id, $raw ) {
$form = pubforms_get_form( $form_id );
if ( ! $form || 'publish' !== $form['status'] ) {
return null;
}
list( $values ) = pubforms_validate_values( $form, $raw['pubforms_f'] ?? array() );
$values = pubforms_trim_stored_values( $values );
$token = strtolower( wp_generate_password( 12, false ) ); // lowercase so url+transient lookup always match.
set_transient( 'pubforms_resume_' . $token, array( 'form' => $form['id'], 'values' => $values, 'saved' => time() ), 7 * DAY_IN_SECONDS );
return $token;
}
/**
* Store one abandonment capture (a visibly-unfinished form). Returns the entry id, or 0 when there was
* nothing worth keeping or the visitor is rate-limited. Never notifies and never counts as a real entry.
*/
function pubforms_capture_abandon( $form_id, $raw ) {
$form = pubforms_get_form( $form_id );
if ( ! $form || 'publish' !== $form['status'] || empty( $form['settings']['save_abandoned'] ) ) {
return 0;
}
list( $values ) = pubforms_validate_values( $form, $raw['pubforms_f'] ?? array() );
$filled = false;
foreach ( $form['fields'] as $field ) {
if ( in_array( $field['type'], array( 'section', 'html', 'file', 'signature' ), true ) ) {
continue;
}
$v = $values[ $field['id'] ] ?? '';
if ( is_array( $v ) ? ! empty( $v ) : '' !== $v ) {
$filled = true;
break;
}
}
if ( ! $filled ) {
return 0;
}
$visitor = pubforms_visitor_hash();
if ( '' !== $visitor ) {
$key = 'pubforms_abandon_' . $form_id . '_' . $visitor;
if ( get_transient( $key ) ) {
return 0;
}
set_transient( $key, 1, 15 * MINUTE_IN_SECONDS );
}
$items = array();
foreach ( $form['fields'] as $field ) {
// Only typed answers are kept: never a file, or a signature image (a multi-megabyte data URL).
if ( in_array( $field['type'], array( 'section', 'html', 'file', 'signature' ), true ) ) {
continue;
}
$v = $values[ $field['id'] ] ?? '';
if ( is_array( $v ) ) {
$v = implode( ', ', $v );
}
$items[] = array( 'id' => $field['id'], 'label' => $field['label'], 'type' => $field['type'], 'value' => $v );
}
return pubforms_insert_entry( $form_id, $items, true );
}
/**
* A keyed, one-way hash of the visitor's address, used only to rate-limit. The address itself is never stored.
*/
function pubforms_visitor_hash() {
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
return '' === $ip ? '' : wp_hash( $ip );
}
/**
* Whether the request carries a valid form nonce. Works for logged-out visitors too (user ID 0).
*/
function pubforms_nonce_ok() {
$nonce = isset( $_POST['pubforms_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['pubforms_nonce'] ) ) : '';
return '' !== $nonce && false !== wp_verify_nonce( $nonce, 'pubforms_form' );
}
function pubforms_handle_abandon() {
if ( ! pubforms_nonce_ok() ) {
status_header( 403 );
exit;
}
$raw = array( 'pubforms_f' => isset( $_POST['pubforms_f'] ) && is_array( $_POST['pubforms_f'] ) ? wp_unslash( $_POST['pubforms_f'] ) : array() ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- nonce verified above; an array, sanitised field by field in pubforms_validate_values().
pubforms_capture_abandon( absint( $_POST['pubforms_form_id'] ?? 0 ), $raw ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by pubforms_nonce_ok() above.
status_header( 204 );
exit;
}
add_action( 'admin_post_nopriv_pubforms_abandon', 'pubforms_handle_abandon' );
add_action( 'admin_post_pubforms_abandon', 'pubforms_handle_abandon' );
function pubforms_handle_submit() {
$nonce_ok = pubforms_nonce_ok();
// phpcs:disable WordPress.Security.NonceVerification.Missing -- verified above by pubforms_nonce_ok(); the request is acted on only when it passed.
$form_id = absint( $_POST['pubforms_form_id'] ?? 0 );
$return = wp_validate_redirect( esc_url_raw( wp_unslash( $_POST['pubforms_return'] ?? '' ) ), home_url( '/' ) );
$return = remove_query_arg( array( 'pubforms_sent', 'pubforms_state' ), $return );
$visitor = pubforms_visitor_hash();
$posted = array(
'pubforms_website' => sanitize_text_field( wp_unslash( $_POST['pubforms_website'] ?? '' ) ),
'pubforms_f' => isset( $_POST['pubforms_f'] ) && is_array( $_POST['pubforms_f'] ) ? wp_unslash( $_POST['pubforms_f'] ) : array(), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- cleaned field by field in pubforms_validate_values().
'pubforms_ts' => sanitize_text_field( wp_unslash( $_POST['pubforms_ts'] ?? '' ) ),
'pubforms_challenge_token' => sanitize_text_field( wp_unslash( $_POST['pubforms_challenge_token'] ?? '' ) ),
'pubforms_challenge_answer' => sanitize_text_field( wp_unslash( $_POST['pubforms_challenge_answer'] ?? '' ) ),
'pubforms_files' => (array) ( $_FILES['pubforms_f'] ?? array() ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- uploaded files, validated and moved in pubforms_process_files().
'pubforms_spam_token' => sanitize_text_field( wp_unslash( $_POST['cf-turnstile-response'] ?? $_POST['h-captcha-response'] ?? '' ) ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- single scalar, sanitised.
);
// phpcs:enable
$return = remove_query_arg( array( 'pubforms_resume' ), $return );
if ( ! $nonce_ok ) {
// Most likely an expired page: send the visitor back with their answers kept.
$form = pubforms_get_form( $form_id );
list( $kept ) = $form && ! pubforms_throttled( $form_id, $visitor ) ? pubforms_validate_values( $form, $posted['pubforms_f'] ) : array( array() );
$key = pubforms_stash_state( array(
'form' => $form_id,
'errors' => array(),
'values' => $kept,
'message' => __( 'Your session expired. Please check your answers and submit again.', 'pubforms' ),
) );
wp_safe_redirect( add_query_arg( 'pubforms_state', $key, $return ) . '#pubforms-' . $form_id );
exit;
}
if ( 'save' === sanitize_key( $_POST['pubforms_action'] ?? 'submit' ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above.
// Drafts are stored on the server, so they share the per-visitor hourly limit.
$token = pubforms_throttled( $form_id, $visitor ) ? null : pubforms_save_draft( $form_id, $posted );
if ( $token ) {
$return = add_query_arg( 'pubforms_resume', $token, $return );
}
wp_safe_redirect( $return . '#pubforms-' . $form_id );
exit;
}
$result = pubforms_process_submission( $form_id, $posted, $visitor );
if ( $result['ok'] ) {
$form = pubforms_get_form( $form_id );
$target = pubforms_confirmation_target( $form, $return );
// "Show a thank-you message" returns to the form's page, which shows the message when it sees
// pubforms_sent. A chosen redirect page is left clean.
$url = $target === $return ? add_query_arg( 'pubforms_sent', $form_id, $target ) : $target;
} else {
$key = pubforms_stash_state( array( 'form' => $form_id, 'errors' => $result['errors'], 'values' => $result['values'], 'message' => $result['message'] ) );
$url = add_query_arg( 'pubforms_state', $key, $return );
}
wp_safe_redirect( $url . '#pubforms-' . $form_id );
exit;
}
add_action( 'admin_post_nopriv_pubforms_submit', 'pubforms_handle_submit' );
add_action( 'admin_post_pubforms_submit', 'pubforms_handle_submit' );