__( 'This page had been open for too long. Please send the form again.', '2ranq-forms' ) ); return; } } // The bot trap. The field sits in the form, moved off the screen, and a person // neither sees it nor can fill it in with the keyboard. A robot that fills in // everything it finds fills that in too. The message is deliberately vague, it // does not say which check failed, so a robot learns nothing from it. But it // is no longer silent, because a browser extension that autofills every field // it finds can trip the trap for a real person. That person gets their text // back, the trap field renders empty again, and the second send goes through. if ( ! empty( $_POST['ranq_hp'] ) ) { self::$errors = array( '_form' => __( 'The form could not be sent. Please try again.', '2ranq-forms' ) ); return; } // The rate limit. Without this one person can fill the database and, through // the email notifications, get the site onto blocklists. if ( ! self::within_rate_limit() ) { self::$errors = array( '_form' => __( 'Too many submissions from your connection. Please wait a minute and try again.', '2ranq-forms' ) ); return; } $settings = Ranq_Forms_Form_Settings::get( $form_id ); // The form is closed to guests. The check has to be here too, not only when // rendering, because this point can be reached by sending around the page. if ( $settings['login_only'] && ! is_user_logged_in() ) { self::$errors = array( '_form' => __( 'Please log in to use this form.', '2ranq-forms' ) ); return; } // A closed form. The check has to be here too, because sending can be reached // around the page, and the date and the seat count would only hold on the eye. $zatvorena = Ranq_Forms_Form_Settings::closed_reason( $form_id ); if ( '' !== $zatvorena ) { self::$errors = array( '_form' => Ranq_Forms_Form_Settings::closed_text( $form_id, $zatvorena ) ); return; } // A password protected form. if ( ! Ranq_Forms_Form_Settings::password_ok( $form_id ) ) { self::$errors = array( '_form' => __( 'This form needs a password.', '2ranq-forms' ) ); return; } // The check with the outside service. It comes BEFORE reading the fields, // because there is no sense processing and accepting files if the send fails. $spam = Ranq_Forms_Spam::check( $form_id ); if ( '' !== $spam ) { self::$errors = array( '_form' => $spam ); return; } $processed = self::collect_fields( $form_id ); if ( ! empty( $processed['errors'] ) ) { self::$errors = $processed['errors']; // The file is already on disk and the form did not pass. Without this anyone // could fill the disk by sending a form with a required field left empty. self::discard_files( $processed['values'] ); return; } // When the owner does not want entries stored, the email notification still has // to go out. Otherwise turning storage off would quietly turn the email off. if ( $settings['store'] ) { self::$sent = self::save( $form_id, $processed['values'] ) > 0; } else { /** This action is documented in includes/class-ranq-forms-submission.php */ do_action( 'ranq_forms_entry_saved', 0, $form_id, $processed['values'] ); self::$sent = true; } // The redirect goes from here, while nothing has been printed yet. Later would // be too late and the header could not be sent. if ( self::$sent && 'redirect' === $settings['confirm_type'] && '' !== $settings['confirm_url'] ) { $url = wp_validate_redirect( $settings['confirm_url'], '' ); if ( $url ) { wp_safe_redirect( $url ); exit; } } } /** * Whether this visitor has crossed the allowed number of sends. * * Counted by address, in a short window. It needs no table and no cron, it * uses a temporary record WordPress clears by itself. * * @return bool */ private static function within_rate_limit() { /** * How many sends are allowed and in what window. * * @param array $limit The number of sends and the number of seconds. */ $limit = apply_filters( 'ranq_forms_rate_limit', array( 'max' => 10, 'window' => 5 * MINUTE_IN_SECONDS, ) ); if ( empty( $limit['max'] ) ) { return true; } $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( ! rest_is_ip_address( $ip ) ) { return true; } $key = 'ranq_forms_rate_' . md5( $ip ); $count = (int) get_transient( $key ); if ( $count >= (int) $limit['max'] ) { return false; } set_transient( $key, $count + 1, (int) $limit['window'] ); return true; } /** * Deletes files that were accepted while the form did not pass. * * @param array $values The cleaned values. * * @return void */ private static function discard_files( $values ) { $dir = Ranq_Forms_Uploads::dir(); if ( ! $dir ) { return; } foreach ( $values as $value ) { if ( ! is_array( $value ) || empty( $value['files'] ) ) { continue; } foreach ( $value['files'] as $file ) { if ( empty( $file['file'] ) ) { continue; } // The path is built out of our own folder and a name WE gave, // never out of what the visitor sent. $path = trailingslashit( $dir ) . ltrim( (string) $file['file'], '/\\' ); if ( 0 === strpos( $path, $dir ) && is_file( $path ) ) { wp_delete_file( $path ); } } } } /** * The errors from the last send, so the form can show them. * * @var array */ private static $errors = array(); /** * The id of the form that was last sent. * * @var int */ private static $submitted = 0; /** * Whether the last send went through. * * @var bool */ private static $sent = false; /** * The errors for one form. An empty array when this form was not sent. * * @param int $form_id The form id. * * @return array */ public static function get_errors( $form_id = 0 ) { if ( $form_id && self::$submitted !== (int) $form_id ) { return array(); } return self::$errors; } /** * Whether this very form was just sent successfully. * * @param int $form_id The form id. * * @return bool */ public static function was_sent( $form_id ) { return self::$sent && self::$submitted === (int) $form_id; } /** * What the visitor sent for one field, raw, or null. * * It only answers while THIS form's send has just failed, so the fields can * give the visitor their typed values back instead of a blank form. Any other * time, for any other form on the page, and after a send that went through, * it stays quiet and the field falls back to its default. The value comes * back raw on purpose: only the field's own class knows how to clean it, so * cleaning happens there, never here. * * @param int $form_id The form id. * @param string $key The field key. * * @return mixed The raw sent value, or null when there is nothing to give back. */ public static function posted_raw( $form_id, $key ) { if ( self::$sent || self::$submitted !== (int) $form_id ) { return null; } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read only to hand the visitor's text back to the field, which cleans it with its own sanitize() before printing. $raw = isset( $_POST['ranq_fields'] ) && is_array( $_POST['ranq_fields'] ) ? wp_unslash( $_POST['ranq_fields'] ) : array(); return isset( $raw[ $key ] ) ? $raw[ $key ] : null; } /** * Collects, cleans and validates the sent fields. * * It walks the **form definition**, not what arrived. That way a field that * is not in the form cannot get into the entry, however much somebody sends it. * * @param int $form_id The form id. * * @return array An array with the keys `values` and `errors`. */ private static function collect_fields( $form_id ) { $values = array(); $errors = array(); $fields = Ranq_Forms_Fields::get_form_fields( $form_id ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- the nonce is already checked in maybe_handle(), and every field is cleaned by its own class in the pass below, because only that class knows what a valid value is for it. $raw = isset( $_POST['ranq_fields'] ) && is_array( $_POST['ranq_fields'] ) ? wp_unslash( $_POST['ranq_fields'] ) : array(); // FIRST PASS: everything is cleaned, nothing is validated yet. // // It has to be this way, because one field's conditional logic looks at the // value of another. Validating in one pass would let form order decide it. $clean = array(); // Files are LEFT for the end of the first pass. The reason: cleaning a file // field means moving the file onto the disk, so that must not be done until // it is known whether the field is visible at all. $file_fields = array(); foreach ( $fields as $field ) { $key = isset( $field['id'] ) ? sanitize_key( $field['id'] ) : ''; $object = isset( $field['type'] ) ? Ranq_Forms_Fields::get( $field['type'] ) : null; if ( '' === $key || ! $object instanceof Ranq_Forms_Field || ! $object->is_input() ) { continue; } if ( 'file' === $field['type'] ) { $file_fields[ $key ] = array( $object, $field ); $clean[ $key ] = array(); continue; } $sent = isset( $raw[ $key ] ) ? $raw[ $key ] : ''; $clean[ $key ] = $object->sanitize( $sent, $field ); } // Now it is known what is visible, so a file is accepted ONLY by a field that // really shows. A hidden file field no longer lets anything onto the disk. foreach ( $file_fields as $key => $pair ) { if ( ! Ranq_Forms_Conditions::is_visible( $pair[1], $clean ) ) { continue; } $clean[ $key ] = $pair[0]->sanitize( '', $pair[1] ); } // SECOND PASS: the cleaned values say what is visible, so only what a person // actually sees is validated. foreach ( $fields as $field ) { $key = isset( $field['id'] ) ? sanitize_key( $field['id'] ) : ''; $object = isset( $field['type'] ) ? Ranq_Forms_Fields::get( $field['type'] ) : null; if ( '' === $key || ! $object instanceof Ranq_Forms_Field || ! $object->is_input() ) { continue; } // A hidden field is NOT validated and does NOT enter the entry. Otherwise a // required hidden field would block the send, and a planted value in a hidden // field would end up in the database. if ( ! Ranq_Forms_Conditions::is_visible( $field, $clean ) ) { continue; } $error = $object->validate( $clean[ $key ], $field ); if ( '' !== $error ) { $errors[ $key ] = $error; continue; } $values[ $key ] = $clean[ $key ]; } return array( 'values' => $values, 'errors' => $errors, ); } /** * Writes the entry into our table. * * @param int $form_id The form id. * @param array $fields The cleaned fields. * * @return int The id of the written row, or zero. */ private static function save( $form_id, $fields ) { global $wpdb; $table = Ranq_Forms_Install::entries_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $ok = $wpdb->insert( $table, array( 'form_id' => $form_id, 'fields' => wp_json_encode( $fields ), 'status' => 'published', 'ip' => self::client_ip(), 'user_agent' => self::user_agent(), 'user_id' => get_current_user_id(), 'created_at' => current_time( 'mysql', true ), ), array( '%d', '%s', '%s', '%s', '%s', '%d', '%s' ) ); if ( ! $ok ) { return 0; } $entry_id = (int) $wpdb->insert_id; /** * The entry is saved. Email notifications and links to other services hook * on here. * * @param int $entry_id The entry id. * @param int $form_id The form id. * @param array $fields The cleaned fields. */ do_action( 'ranq_forms_entry_saved', $entry_id, $form_id, $fields ); return $entry_id; } /** * The visitor's address. * * It is stored WHOLE, if the site owner left that switched on in the * settings. Whoever does not want it turns it off, and then nothing is * written. * * @return string */ private static function client_ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; /** * Whether the address is stored at all. The site owner must be able to * switch it off. * * @param bool $store Whether it is stored. */ if ( ! apply_filters( 'ranq_forms_store_ip', (bool) get_option( 'ranq_forms_store_ip', true ) ) ) { return ''; } return rest_is_ip_address( $ip ) ? $ip : ''; } /** * The visitor's browser, cut to the length of the column. * * @return string */ private static function user_agent() { $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; // The column takes 255 characters, not 255 bytes. `substr` would cut an // accented letter in half and leave a broken record. return function_exists( 'mb_substr' ) ? mb_substr( $agent, 0, 255 ) : substr( $agent, 0, 255 ); } }