array( 'label' => __( 'Email notification', '2ranq-forms' ), 'help' => __( 'What happens when somebody sends this form. Leave a field empty and the global setting is used.', '2ranq-forms' ), 'settings' => array( 'notify_enabled' => array( 'label' => __( 'Send me an email on every entry', '2ranq-forms' ), 'type' => 'toggle', 'default' => true, ), 'notify_to' => array( 'label' => __( 'Send to', '2ranq-forms' ), 'type' => 'text', 'depends' => array( 'notify_enabled', true ), 'default' => '', 'placeholder' => __( 'office@example.com, sales@example.com', '2ranq-forms' ), 'help' => __( 'More than one address: separate with a comma.', '2ranq-forms' ), ), 'notify_subject' => array( 'label' => __( 'Subject', '2ranq-forms' ), 'type' => 'text', 'default' => '', 'placeholder' => __( 'New entry: {form_name}', '2ranq-forms' ), 'help' => __( 'You can use {form_name} and {site_name}.', '2ranq-forms' ), ), 'from_name' => array( 'label' => __( 'From name', '2ranq-forms' ), 'type' => 'text', 'default' => '', 'placeholder' => __( 'Your site name', '2ranq-forms' ), ), 'from_email' => array( 'label' => __( 'From address', '2ranq-forms' ), 'type' => 'text', 'default' => '', 'placeholder' => __( 'no-reply@yoursite.com', '2ranq-forms' ), 'help' => __( 'Use an address on your own domain. Somebody else’s address makes the mail look forged and it lands in spam.', '2ranq-forms' ), ), 'reply_to' => array( 'label' => __( 'Reply goes to', '2ranq-forms' ), 'type' => 'select', 'default' => 'visitor', 'options' => array( 'visitor' => __( 'The visitor, so you can answer with one click', '2ranq-forms' ), 'none' => __( 'Nobody, reply goes to the From address', '2ranq-forms' ), ), ), 'notify_format' => array( 'label' => __( 'How the message looks', '2ranq-forms' ), 'type' => 'select', 'default' => 'html', 'options' => array( 'html' => __( 'A card with a coloured frame', '2ranq-forms' ), 'text' => __( 'Plain text, no layout', '2ranq-forms' ), ), 'help' => __( 'The card carries the plain text inside it as well, so a reader that does not draw HTML still shows the answers.', '2ranq-forms' ), ), 'notify_color' => array( 'label' => __( 'Frame colour', '2ranq-forms' ), 'type' => 'color', 'default' => '', 'placeholder' => '#111111', 'depends' => array( 'notify_format', 'html' ), 'help' => __( 'The frame, the strip on top and the button take this colour. When it is light, the button keeps black text on its own.', '2ranq-forms' ), ), ), ), 'confirm' => array( 'label' => __( 'After sending', '2ranq-forms' ), 'help' => __( 'What the visitor sees the moment the form goes through.', '2ranq-forms' ), 'settings' => array( 'confirm_type' => array( 'label' => __( 'Then', '2ranq-forms' ), 'type' => 'select', 'default' => 'message', 'options' => array( 'message' => __( 'Show a message in place of the form', '2ranq-forms' ), 'redirect' => __( 'Send them to another page', '2ranq-forms' ), ), ), 'confirm_message' => array( 'label' => __( 'Message', '2ranq-forms' ), 'type' => 'textarea', 'depends' => array( 'confirm_type', 'message' ), 'default' => '', 'placeholder' => __( 'Thank you. Your message has been sent.', '2ranq-forms' ), ), 'confirm_url' => array( 'label' => __( 'Page address', '2ranq-forms' ), 'type' => 'text', 'depends' => array( 'confirm_type', 'redirect' ), 'default' => '', 'placeholder' => 'https://yoursite.com/thank-you/', ), 'confirm_keep' => array( 'label' => __( 'Keep the form on screen under the message', '2ranq-forms' ), 'type' => 'toggle', 'default' => false, 'depends' => array( 'confirm_type', 'message' ), ), ), ), 'connect' => array( 'label' => __( 'Where else the entry goes', '2ranq-forms' ), 'settings' => Ranq_Forms_Integrations::form_schema(), ), 'access' => array( 'label' => __( 'Who can send it', '2ranq-forms' ), 'settings' => array( 'login_only' => array( 'label' => __( 'Only logged in visitors', '2ranq-forms' ), 'type' => 'toggle', 'default' => false, ), 'login_text' => array( 'label' => __( 'What everybody else sees', '2ranq-forms' ), 'type' => 'textarea', 'depends' => array( 'login_only', true ), 'default' => '', 'placeholder' => __( 'Please log in to use this form.', '2ranq-forms' ), ), 'spam_check' => array( 'label' => __( 'Ask visitors to pass the spam check', '2ranq-forms' ), 'type' => 'toggle', 'default' => true, 'help' => __( 'Uses the service and keys set in Settings. Without keys nothing is asked and the form works as before.', '2ranq-forms' ), ), 'password' => array( 'label' => __( 'Ask for a password first', '2ranq-forms' ), 'type' => 'text', 'default' => '', 'help' => __( 'Leave empty for no password. Anyone with the password can send the form.', '2ranq-forms' ), 'placeholder' => __( 'No password', '2ranq-forms' ), ), 'open_from' => array( 'label' => __( 'Opens on', '2ranq-forms' ), 'type' => 'date', 'default' => '', 'help' => __( 'Before this date the form is not shown. Leave empty to have it open from the start.', '2ranq-forms' ), ), 'open_until' => array( 'label' => __( 'Closes after', '2ranq-forms' ), 'type' => 'date', 'default' => '', 'help' => __( 'The last day the form takes entries. Leave empty to keep it open.', '2ranq-forms' ), ), 'limit_entries' => array( 'label' => __( 'Stop after this many entries', '2ranq-forms' ), 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100000, 'help' => __( 'Zero means no limit. Useful for a limited number of places.', '2ranq-forms' ), ), 'closed_text' => array( 'label' => __( 'What people see when it is closed', '2ranq-forms' ), 'type' => 'textarea', 'default' => '', 'placeholder' => __( 'This form is closed.', '2ranq-forms' ), ), 'store' => array( 'label' => __( 'Keep entries in the database', '2ranq-forms' ), 'type' => 'toggle', 'default' => true, 'help' => __( 'Turn this off and the entry is emailed but never stored. Useful when the form carries data you do not want to keep.', '2ranq-forms' ), ), ), ), ); } /** * Why the form is not open. Empty means it is. * * The same answer is used both by the rendering and by the sending, so the * rule is not written twice and the two checks cannot drift apart one day. * * @param int $form_id The form id. * * @return string */ public static function closed_reason( $form_id ) { $s = self::get( $form_id ); // Today by the site time, not by the server time. $danas = current_time( 'Y-m-d' ); if ( '' !== $s['open_from'] && $danas < $s['open_from'] ) { return 'early'; } if ( '' !== $s['open_until'] && $danas > $s['open_until'] ) { return 'late'; } $limit = (int) $s['limit_entries']; if ( $limit > 0 && Ranq_Forms_Entries::count_for_form( $form_id ) >= $limit ) { return 'full'; } return ''; } /** * The sentence a visitor sees when the form is closed. * * @param int $form_id The form id. * @param string $reason The reason. * * @return string */ public static function closed_text( $form_id, $reason ) { $s = self::get( $form_id ); if ( '' !== trim( (string) $s['closed_text'] ) ) { return $s['closed_text']; } if ( 'early' === $reason ) { return __( 'This form is not open yet.', '2ranq-forms' ); } if ( 'full' === $reason ) { return __( 'This form has reached the number of entries it takes.', '2ranq-forms' ); } return __( 'This form is closed.', '2ranq-forms' ); } /** * Whether this address may still try the password. * * Counted by address in a short window, in a temporary record WordPress * clears by itself. Every attempt counts, right or wrong, so the count * cannot be reset by guessing correctly once. * * @return bool */ private static function password_tries_left() { /** * How many password attempts are allowed and in what window. * * @param array $limit The number of attempts and the number of seconds. */ $limit = apply_filters( 'ranq_forms_password_rate_limit', array( 'max' => 10, 'window' => 10 * MINUTE_IN_SECONDS, ) ); if ( empty( $limit['max'] ) ) { return true; } $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; // Behind a proxy that hides the address there is nothing to count, and // refusing everyone would shut the form for the whole site. if ( ! rest_is_ip_address( $ip ) ) { return true; } $key = 'ranq_forms_pw_try_' . md5( $ip ); $count = (int) get_transient( $key ); if ( $count >= (int) $limit['max'] ) { return false; } set_transient( $key, $count + 1, (int) $limit['window'] ); return true; } /** * Whether this address has run out of password attempts. * * Read only, so the screen can say why without spending another attempt. * * @return bool */ public static function password_locked_out() { $limit = apply_filters( 'ranq_forms_password_rate_limit', array( 'max' => 10, 'window' => 10 * MINUTE_IN_SECONDS, ) ); if ( empty( $limit['max'] ) ) { return false; } $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( ! rest_is_ip_address( $ip ) ) { return false; } return (int) get_transient( 'ranq_forms_pw_try_' . md5( $ip ) ) >= (int) $limit['max']; } /** * Whether the password for this form has already been given. * * It is remembered in a cookie, not in a session, because WordPress has no * sessions. The value in the cookie is a hash, so the password itself is * never stored on the visitor's side. * * @param int $form_id The form id. * * @return bool */ public static function password_ok( $form_id ) { $s = self::get( $form_id ); $lozinka = (string) $s['password']; if ( '' === trim( $lozinka ) ) { return true; } $otisak = wp_hash( $lozinka . '|' . $form_id ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- this is only a check, not an action that changes anything. if ( isset( $_POST['ranq_forms_password'] ) ) { // 🔴 This runs while the page is drawn, on every request that carries the // field, so it is not behind the check that guards a sent form. Without a // limit of its own the password could be guessed at machine speed. // `hash_equals` below defends against timing, not against volume. if ( ! self::password_tries_left() ) { return false; } $dato = sanitize_text_field( wp_unslash( $_POST['ranq_forms_password'] ) ); if ( hash_equals( $lozinka, $dato ) ) { // The options form, not the positional one, because only this // one takes SameSite. It needs PHP 7.3 and the plugin asks for 7.4. setcookie( 'ranq_forms_pw_' . $form_id, $otisak, array( 'expires' => time() + DAY_IN_SECONDS, 'path' => COOKIEPATH ? COOKIEPATH : '/', 'domain' => COOKIE_DOMAIN, 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ) ); return true; } return false; } return isset( $_COOKIE[ 'ranq_forms_pw_' . $form_id ] ) && hash_equals( $otisak, sanitize_text_field( wp_unslash( $_COOKIE[ 'ranq_forms_pw_' . $form_id ] ) ) ); } /** * The value that IS ALREADY IN EFFECT, for every setting the form did not * fill in. * * The Settings tab is read, not filled in. An empty field here does not mean * "nothing", it means "take whatever is already in effect", and until now * that was written in small grey text nobody reads. So the server works out * the REAL value and says where it comes from, and the builder shows it as * the value. * * It is worked out here and not in the builder, because only the server knows * what sits in the global settings and what WordPress itself would put there. * * @param int $form_id The form id. * * @return array Key => an array with the value and its source. */ public static function inherited( $form_id ) { $naslov = get_the_title( absint( $form_id ) ); $sajt = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); // Who the email goes to. The same reckoning the sending itself uses. $posta = (string) get_option( 'ranq_forms_notify_email', '' ); if ( '' !== trim( $posta ) ) { $adresa = $posta; $izvor = 'settings'; } else { $adresa = (string) get_option( 'admin_email' ); $izvor = 'wp'; } // The same reckoning `wp_mail` does when nobody sets a sender. $domen = wp_parse_url( network_home_url(), PHP_URL_HOST ); $domen = is_string( $domen ) ? preg_replace( '/^www\./i', '', $domen ) : ''; $out = array( 'notify_to' => array( 'value' => $adresa, 'source' => $izvor, ), 'notify_subject' => array( 'value' => sprintf( /* translators: 1: form name, 2: site name. */ __( 'New entry: %1$s (%2$s)', '2ranq-forms' ), $naslov, $sajt ), 'source' => 'builtin', ), 'from_name' => array( 'value' => 'WordPress', 'source' => 'wp', ), 'from_email' => array( 'value' => 'wordpress@' . $domen, 'source' => 'wp', ), 'confirm_message' => array( 'value' => __( 'Thank you. Your message has been sent.', '2ranq-forms' ), 'source' => 'builtin', ), 'login_text' => array( 'value' => __( 'Please log in to use this form.', '2ranq-forms' ), 'source' => 'builtin', ), 'closed_text' => array( 'value' => __( 'This form is closed.', '2ranq-forms' ), 'source' => 'builtin', ), ); /** * The values already in effect, the ones the form did not override. * * @param array $out Key => an array with the value and its source. * @param int $form_id The form id. */ return apply_filters( 'ranq_forms_inherited_settings', $out, absint( $form_id ) ); } /** * A flat list of every setting, key => description. * * @return array */ private static function flat() { $out = array(); foreach ( self::schema() as $group ) { foreach ( $group['settings'] as $key => $setting ) { $out[ $key ] = $setting; } } return $out; } /** * The settings of one form. * * @param int $form_id The form id. * * @return array */ public static function get( $form_id ) { $saved = get_post_meta( absint( $form_id ), self::META, true ); $saved = is_array( $saved ) ? $saved : array(); // Older forms stored two things separately, from before this tab existed. When // the newer record is missing, the older one is taken, so nobody loses what // they already set up. if ( ! isset( $saved['notify_to'] ) ) { $old = get_post_meta( absint( $form_id ), '_ranq_forms_notify_email', true ); if ( is_string( $old ) && '' !== trim( $old ) ) { $saved['notify_to'] = $old; } } if ( ! isset( $saved['confirm_message'] ) ) { $old = get_post_meta( absint( $form_id ), '_ranq_forms_success', true ); if ( is_string( $old ) && '' !== trim( $old ) ) { $saved['confirm_message'] = $old; } } return self::sanitize( $saved ); } /** * A single value. * * @param int $form_id The form id. * @param string $key The key. * * @return mixed */ public static function value( $form_id, $key ) { $all = self::get( $form_id ); return isset( $all[ $key ] ) ? $all[ $key ] : null; } /** * Cleans everything. * * @param array $raw The raw values. * * @return array */ public static function sanitize( $raw ) { $out = array(); $raw = is_array( $raw ) ? $raw : array(); foreach ( self::flat() as $key => $setting ) { $value = isset( $raw[ $key ] ) ? $raw[ $key ] : $setting['default']; switch ( $setting['type'] ) { case 'toggle': $out[ $key ] = (bool) filter_var( $value, FILTER_VALIDATE_BOOLEAN ); break; case 'select': $out[ $key ] = isset( $setting['options'][ $value ] ) ? (string) $value : $setting['default']; break; case 'textarea': $out[ $key ] = is_scalar( $value ) ? sanitize_textarea_field( (string) $value ) : ''; break; case 'number': $broj = is_scalar( $value ) ? (int) $value : 0; if ( isset( $setting['min'] ) ) { $broj = max( (int) $setting['min'], $broj ); } if ( isset( $setting['max'] ) ) { $broj = min( (int) $setting['max'], $broj ); } $out[ $key ] = $broj; break; case 'color': // 🔴 This value ends up inside a `style` attribute of an email that // lands in somebody else's inbox. Only a hex number passes, everything // else falls back to empty, which means the built in colour. $boja = is_scalar( $value ) ? strtolower( trim( (string) $value ) ) : ''; $out[ $key ] = preg_match( '/^#([0-9a-f]{3}|[0-9a-f]{6})$/', $boja ) ? $boja : ''; break; case 'date': // The year-month-day shape only. Everything else is thrown away, because // this decides whether the form is open. $datum = is_scalar( $value ) ? trim( (string) $value ) : ''; $out[ $key ] = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $datum ) ? $datum : ''; break; default: $out[ $key ] = is_scalar( $value ) ? sanitize_text_field( (string) $value ) : ''; break; } } return $out; } /** * Saves. * * @param int $form_id The form id. * @param array $raw The raw values. * * @return array */ public static function save( $form_id, $raw ) { $clean = self::sanitize( $raw ); update_post_meta( absint( $form_id ), self::META, $clean ); return $clean; } }