field name => value. * * @var array> */ private static $captured = array(); /** * Last cookie value this request set ('' when it cleared the cookie), or * null when it set none. Lets tests see what a redirect would send. * * @var string|null */ private static $sent = null; /** * Query args naming why a submission failed, for the redirect back. * * @param \WP_Error $error Submission error. * @return array Args to add ('' values are dropped by add_query_arg()). */ public static function redirect_args( \WP_Error $error ): array { $code = (string) $error->get_error_code(); if ( '' === self::reason( $code, '' ) ) { return array(); } $data = $error->get_error_data(); $field = is_array( $data ) && isset( $data['field'] ) && is_string( $data['field'] ) ? $data['field'] : ''; return array_filter( array( 'airowp_form_error' => $code, 'airowp_form_field' => $field, ) ); } /** * The sentence explaining an error code, or '' for codes that get none. * * Spam and server errors get no detail: it would only help a bot, or say * nothing the visitor can act on. * * @param string $code Error code. * @param string $label Label of the field concerned, or ''. * @return string Translated sentence. */ public static function reason( string $code, string $label ): string { switch ( $code ) { case 'required_field_missing': /* translators: %s: form field label. */ return '' === $label ? __( 'Please fill in every required field.', 'airo-wp' ) : sprintf( __( 'Please fill in ā€œ%sā€.', 'airo-wp' ), $label ); case 'validation_error': /* translators: %s: form field label. */ return '' === $label ? __( 'Please check your answers.', 'airo-wp' ) : sprintf( __( 'Please check your answer to ā€œ%sā€.', 'airo-wp' ), $label ); case 'too_fast': return __( 'The form was sent too quickly. Please wait a few seconds and send it again.', 'airo-wp' ); case 'rate_limit': case 'global_rate_limit': return __( 'Too many submissions were sent. Please wait a few minutes and try again.', 'airo-wp' ); case 'invalid_nonce': return __( 'Your session expired. Please send the form again.', 'airo-wp' ); } return ''; } /** * Keep a failed submission's answers for the page the visitor returns to. * * Not for a submission caught as spam (the honeypot was filled): there is * no visitor to give the answers back to. * * @param string $form_id Form ID. * @param array> $fields Submitted fields (name, value, type). * @param string $code Error code the submission failed with. */ public static function remember( string $form_id, array $fields, string $code = '' ): void { if ( 'spam_detected' === $code ) { return; } $value = self::encode( $form_id, $fields ); if ( '' !== $value ) { self::send_cookie( $value, time() + self::TTL ); } } /** * Signed cookie value for a form's answers, or '' when there are none. * * Hidden fields are left out: the page renders their values itself. When * the answers are too big for a cookie, the longest are dropped first, so * the short ones a visitor would retype are the ones kept. * * @param string $form_id Form ID. * @param array> $fields Submitted fields (name, value, type). * @return string Cookie value. */ public static function encode( string $form_id, array $fields ): string { $values = array(); $expires = time() + self::TTL; foreach ( $fields as $field ) { $name = isset( $field['name'] ) && is_string( $field['name'] ) ? $field['name'] : ''; $value = isset( $field['value'] ) && is_string( $field['value'] ) ? $field['value'] : ''; if ( '' !== $name && '' !== $value && 'hidden' !== ( $field['type'] ?? '' ) ) { $values[ $name ] = $value; } } uasort( $values, static fn( $a, $b ) => strlen( $b ) <=> strlen( $a ) ); while ( $values ) { $payload = base64_encode( (string) wp_json_encode( array( $form_id, $values, $expires ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Cookie-safe transport, not obfuscation. $cookie = $payload . '.' . self::sign( $payload ); if ( strlen( $cookie ) <= self::MAX_BYTES ) { return $cookie; } unset( $values[ array_key_first( $values ) ] ); } return ''; } /** * Read and clear this request's answers cookie. * * Runs on template_redirect, before any output, so the cookie can still * be expired: the answers are offered once, then forgotten. */ public static function capture(): void { if ( ! isset( $_COOKIE[ self::COOKIE ] ) || ! is_string( $_COOKIE[ self::COOKIE ] ) ) { // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE -- No-JS fallback; the retry cookie is the only channel. return; } $decoded = self::decode( sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE ] ) ) ); // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE -- No-JS fallback; the retry cookie is the only channel. if ( null !== $decoded ) { self::$captured[ $decoded[0] ] = $decoded[1]; } self::send_cookie( '', time() - HOUR_IN_SECONDS ); } /** * Verify and unpack a cookie value. * * The expiry is part of the signed data, so a copied cookie stops working * after TTL even if a browser keeps sending it. * * @param string $cookie Cookie value. * @return array{0: string, 1: array}|null Form ID and answers, or null when invalid. */ public static function decode( string $cookie ): ?array { $parts = explode( '.', $cookie ); if ( 2 !== count( $parts ) || ! hash_equals( self::sign( $parts[0] ), $parts[1] ) ) { return null; } $data = json_decode( (string) base64_decode( $parts[0], true ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- See encode(). if ( ! is_array( $data ) || ! isset( $data[0], $data[1], $data[2] ) || ! is_string( $data[0] ) || ! is_array( $data[1] ) || ! is_int( $data[2] ) || $data[2] < time() ) { return null; } return array( $data[0], array_filter( $data[1], 'is_string' ) ); } /** * Signature for a cookie payload. * * Keyed with the site's auth salt and bound to this cookie's purpose, so * no other value the site signs can stand in for it. * * @param string $payload Encoded payload. * @return string Hex HMAC. */ private static function sign( string $payload ): string { return hash_hmac( 'sha256', self::COOKIE . '|' . $payload, wp_salt( 'auth' ) ); } /** * Answers captured for a form on this request. * * @param string $form_id Form ID. * @return array Field name => value. */ public static function values( string $form_id ): array { return self::$captured[ $form_id ] ?? array(); } /** * The cookie value this request last set ('' when cleared), or null when * it set none. For tests: PHPUnit has already sent headers. * * @return string|null */ public static function sent(): ?string { return self::$sent; } /** * Forget captured answers and sent cookies. For tests. */ public static function reset(): void { self::$captured = array(); self::$sent = null; } /** * Write the cookie, unless output has already started. * * @param string $value Cookie value ('' to clear). * @param int $expires Expiry timestamp. */ private static function send_cookie( string $value, int $expires ): void { self::$sent = $value; if ( headers_sent() ) { return; } setcookie( self::COOKIE, $value, array( 'expires' => $expires, 'path' => COOKIEPATH, 'domain' => COOKIE_DOMAIN, 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ) ); } }