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' );