__( 'Cloudflare Turnstile', 'pubforms' ), 'hcaptcha' => __( 'hCaptcha', 'pubforms' ), 'question' => __( 'Built-in question (no account needed)', 'pubforms' ), ); } /** * The method chosen under PubForms Settings, or '' for none. */ function pubforms_site_spam_method() { $method = (string) get_option( 'pubforms_spam_method', '' ); return isset( pubforms_spam_methods()[ $method ] ) ? $method : ''; } /** * A method is usable when it needs nothing more, or when its site and secret keys are saved. */ function pubforms_spam_method_ready( $method ) { if ( 'question' === $method ) { return true; } if ( ! in_array( $method, array( 'turnstile', 'hcaptcha' ), true ) ) { return false; } $keys = pubforms_spam_keys( $method ); return '' !== $keys['site'] && '' !== $keys['secret']; } /** * The method that applies to one form. A form's own choice wins ("none" turns it off for that form); * otherwise the site-wide method applies. Returns '' when nothing usable is configured. */ function pubforms_effective_spam_method( $form ) { $own = (string) ( $form['settings']['spam_provider'] ?? '' ); if ( 'none' === $own ) { return ''; } $method = isset( pubforms_spam_methods()[ $own ] ) ? $own : pubforms_site_spam_method(); return pubforms_spam_method_ready( $method ) ? $method : ''; } /* ------------------------------------------------------------ Signing */ function pubforms_spam_sign( $payload ) { return hash_hmac( 'sha256', $payload, wp_salt( 'auth' ) ); } /** * "payload.signature", or the payload when the signature is bad (returns '' for a bad token). */ function pubforms_spam_unseal( $token ) { $token = (string) $token; $dot = strrpos( $token, '.' ); if ( false === $dot ) { return ''; } $payload = substr( $token, 0, $dot ); return hash_equals( pubforms_spam_sign( $payload ), substr( $token, $dot + 1 ) ) ? $payload : ''; } function pubforms_spam_seal( $payload ) { return $payload . '.' . pubforms_spam_sign( $payload ); } /* ------------------------------------------------- Built-in question */ /** * The site's own questions, from Settings: one "Question | answer" per line. */ function pubforms_spam_questions() { $out = array(); foreach ( preg_split( '/\r\n|\r|\n/', (string) get_option( 'pubforms_spam_questions', '' ) ) as $line ) { $parts = array_map( 'trim', explode( '|', $line, 2 ) ); if ( 2 === count( $parts ) && '' !== $parts[0] && '' !== $parts[1] ) { $out[] = array( 'q' => $parts[0], 'a' => $parts[1] ); } } return array_slice( $out, 0, 50 ); } function pubforms_spam_normalise( $answer ) { return strtolower( trim( preg_replace( '/\s+/', ' ', (string) $answer ) ) ); } /** * A fresh challenge: [ question text, signed token ]. Uses one of the site's questions when there * are any, otherwise a small sum. The token carries what is needed to check the answer later, so * nothing is stored and it works on cached pages (until it expires). */ function pubforms_spam_challenge() { $questions = pubforms_spam_questions(); if ( $questions ) { $i = wp_rand( 0, count( $questions ) - 1 ); return array( $questions[ $i ]['q'], pubforms_spam_seal( 'q:' . $i . ':' . time() ) ); } $a = wp_rand( 1, 9 ); $b = wp_rand( 1, 9 ); /* translators: 1, 2: numbers to add */ return array( sprintf( __( 'What is %1$d + %2$d?', 'pubforms' ), $a, $b ), pubforms_spam_seal( 'm:' . $a . ':' . $b . ':' . time() ) ); } /** * Whether the visitor answered the challenge correctly. Tokens older than two days are rejected so a * captured answer cannot be reused forever. */ function pubforms_spam_check_challenge( $token, $answer ) { $payload = pubforms_spam_unseal( $token ); $answer = pubforms_spam_normalise( $answer ); if ( '' === $payload || '' === $answer ) { return false; } $parts = explode( ':', $payload ); $time = (int) end( $parts ); if ( $time > time() + 60 || time() - $time > 2 * DAY_IN_SECONDS ) { return false; } if ( 'm' === $parts[0] && 4 === count( $parts ) ) { return (string) ( (int) $parts[1] + (int) $parts[2] ) === $answer; } if ( 'q' === $parts[0] && 3 === count( $parts ) ) { $questions = pubforms_spam_questions(); $i = (int) $parts[1]; return isset( $questions[ $i ] ) && pubforms_spam_normalise( $questions[ $i ]['a'] ) === $answer; } return false; } /** * The challenge field markup. */ function pubforms_spam_challenge_html( $form_id ) { list( $question, $token ) = pubforms_spam_challenge(); $id = 'pubforms-' . (int) $form_id . '-challenge'; return '
' . '' . '' . '' . '' . esc_html__( 'This helps us block spam.', 'pubforms' ) . '' . '
'; } /* ------------------------------------------------- Minimum fill time */ /** * A signed "form was shown at" stamp for a hidden field. */ function pubforms_spam_timestamp_field() { return ''; } /** * True when a validly signed stamp says the form was submitted faster than a person could manage. * A missing or unreadable stamp is never penalised (cached pages, older embeds, API use). */ function pubforms_spam_too_fast( $token ) { $min = (int) get_option( 'pubforms_spam_min_seconds', 2 ); if ( $min <= 0 ) { return false; } $payload = pubforms_spam_unseal( $token ); if ( 0 !== strpos( $payload, 't:' ) ) { return false; } $elapsed = time() - (int) substr( $payload, 2 ); return $elapsed >= 0 && $elapsed < $min; } /* ----------------------------------------------------- Content filter */ /** * Every text the visitor typed, flattened. */ function pubforms_spam_texts( $values ) { $out = array(); array_walk_recursive( $values, function ( $v ) use ( &$out ) { if ( is_string( $v ) && '' !== $v ) { $out[] = $v; } } ); return $out; } /** * Why a submission's content looks like spam, or '' when it does not. Checks the link cap and the * site's blocklist (one word or phrase per line, matched case-insensitively). */ function pubforms_spam_content_problem( $values ) { $text = implode( "\n", pubforms_spam_texts( (array) $values ) ); if ( '' === $text ) { return ''; } $max_links = (int) get_option( 'pubforms_spam_max_links', 3 ); if ( $max_links > 0 && preg_match_all( '~(?:https?://|www\.)[^\s]+~i', $text ) > $max_links ) { return __( 'Your message contains too many links, so it was not sent. Please remove some and try again.', 'pubforms' ); } foreach ( preg_split( '/\r\n|\r|\n/', (string) get_option( 'pubforms_spam_blocklist', '' ) ) as $word ) { $word = trim( $word ); if ( '' !== $word && false !== stripos( $text, $word ) ) { return __( 'Your message contains words that are not allowed, so it was not sent.', 'pubforms' ); } } return ''; }