self::signed_token( $form_id, $draft_id ), 'draft_id' => $draft_id, ); } public static function ensure( $draft_id, $form_id, array $meta = array() ) { $draft_id = self::clean_id( $draft_id ); if ( is_array( self::fetch( self::TRANSIENT_PREFIX . $draft_id ) ) ) { return; } self::store( self::TRANSIENT_PREFIX . $draft_id, self::new_draft_data( (int) $form_id, $meta, time() ), self::LIFETIME_SECONDS ); } public static function needs_new_token( $token ) { $parts = is_string( $token ) ? explode( '.', $token ) : array(); if ( 3 !== count( $parts ) ) { return true; } list( $form_id, $draft_id, $signature ) = $parts; $draft_id = self::clean_id( $draft_id ); if ( ! hash_equals( self::token_signature( absint( $form_id ), $draft_id ), (string) $signature ) || ! self::is_fresh( $draft_id ) ) { return true; } $draft = self::fetch( self::TRANSIENT_PREFIX . $draft_id ); return ! is_array( $draft ) || ! empty( $draft['bind'] ) || empty( $draft['meta']['resume_key'] ); } private static function new_draft_data( $form_id, array $meta, $started ) { return array( 'form_id' => (int) $form_id, 'answers' => array(), 'meta' => $meta, 'started' => (int) $started, 'furthest' => 0, ); } private static function is_fresh( $draft_id ) { $issued_at = hexdec( substr( (string) $draft_id, 0, 8 ) ); return $issued_at > time() - self::LIFETIME_SECONDS && $issued_at < time() + self::CLOCK_SKEW_SECONDS; } private static function request_meta() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- a visitor's form on a cached page has no nonce; the address is kept only if it is on this site. $url = ''; foreach ( array( 'holmesforms_url', 'holmesforms_redirect' ) as $field_name ) { if ( ! empty( $_POST[ $field_name ] ) && is_string( $_POST[ $field_name ] ) ) { $url = esc_url_raw( wp_unslash( $_POST[ $field_name ] ) ); break; } } // phpcs:enable if ( '' === $url && ! empty( $_SERVER['HTTP_REFERER'] ) ) { $url = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ); } $home_url = set_url_scheme( trailingslashit( home_url() ), 'https' ); $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); if ( '' !== $url && ( ! in_array( $scheme, array( 'http', 'https' ), true ) || 0 !== strpos( set_url_scheme( trailingslashit( $url ), 'https' ), $home_url ) ) ) { $url = ''; } $url = remove_query_arg( array( 'holmesforms_resume', 'holmesforms_errors', 'holmesforms_error', 'holmesforms_sent', 'holmesforms_form' ), $url ); $query = array(); parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $raw_query ); foreach ( (array) $raw_query as $key => $value ) { if ( is_scalar( $value ) && count( $query ) < self::MAX_QUERY_PARAMETERS ) { $query[ sanitize_key( $key ) ] = sanitize_text_field( (string) $value ); } } return array( 'url' => explode( '?', $url )[0], 'query' => $query, ); } /** @return string The form's page address, query included. */ public static function page_url( array $draft ) { $url = isset( $draft['meta']['url'] ) ? (string) $draft['meta']['url'] : ''; if ( '' !== $url && ! empty( $draft['meta']['query'] ) && is_array( $draft['meta']['query'] ) ) { $url = add_query_arg( array_map( 'rawurlencode', $draft['meta']['query'] ), $url ); } return $url; } public static function load( $token ) { $parts = is_string( $token ) ? explode( '.', $token ) : array(); if ( 3 !== count( $parts ) ) { return null; } list( $form_id, $draft_id, $signature ) = $parts; $form_id = absint( $form_id ); $draft_id = self::clean_id( $draft_id ); if ( ! hash_equals( self::token_signature( $form_id, $draft_id ), (string) $signature ) ) { return null; } if ( ! self::is_fresh( $draft_id ) ) { return null; } $draft = self::fetch( self::TRANSIENT_PREFIX . $draft_id ); if ( ! is_array( $draft ) ) { if ( false !== get_transient( self::USED_PREFIX . $draft_id ) ) { return null; } $draft = self::new_draft_data( $form_id, self::request_meta(), hexdec( substr( $draft_id, 0, 8 ) ) ); self::store( self::TRANSIENT_PREFIX . $draft_id, $draft, self::LIFETIME_SECONDS ); } if ( ! isset( $draft['form_id'] ) || (int) $draft['form_id'] !== $form_id ) { return null; } $bind_header = isset( $_SERVER['HTTP_X_HPF_BIND'] ) ? preg_replace( '/[^A-Za-z0-9]/', '', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_HPF_BIND'] ) ) ) : ''; if ( ! empty( $draft['bind'] ) ) { $is_bound = false; foreach ( array( $bind_header, isset( self::$issued_secrets[ $draft_id ] ) ? self::$issued_secrets[ $draft_id ] : '' ) as $candidate ) { if ( '' !== $candidate && hash_equals( (string) $draft['bind'], self::bind_hash( $draft_id, $candidate ) ) ) { $is_bound = true; } } if ( ! $is_bound ) { return null; } } elseif ( ( ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || wp_doing_ajax() ) && ! headers_sent() ) { $secret = strlen( $bind_header ) >= self::BIND_SECRET_LENGTH ? $bind_header : wp_generate_password( self::BIND_SECRET_LENGTH, false, false ); $draft['bind'] = self::bind_hash( $draft_id, $secret ); self::store( self::TRANSIENT_PREFIX . $draft_id, $draft, self::LIFETIME_SECONDS ); self::$issued_secrets[ $draft_id ] = $secret; header( 'X-HPF-Bind: ' . $secret ); } return array( 'form_id' => $form_id, 'draft_id' => $draft_id, 'answers' => isset( $draft['answers'] ) && is_array( $draft['answers'] ) ? $draft['answers'] : array(), 'meta' => isset( $draft['meta'] ) && is_array( $draft['meta'] ) ? $draft['meta'] : array(), 'started' => isset( $draft['started'] ) ? (int) $draft['started'] : time(), 'review' => ! empty( $draft['review'] ), 'kept' => isset( $draft['kept'] ) && is_array( $draft['kept'] ) ? $draft['kept'] : array(), 'changing' => isset( $draft['changing'] ) ? (string) $draft['changing'] : '', ); } public static function merge_answers( $draft_id, array $answers ) { $key = self::TRANSIENT_PREFIX . self::clean_id( $draft_id ); $draft = self::fetch( $key ); if ( ! is_array( $draft ) ) { return; } $draft['answers'] = array_merge( isset( $draft['answers'] ) && is_array( $draft['answers'] ) ? $draft['answers'] : array(), $answers ); self::store( $key, $draft, self::LIFETIME_SECONDS ); } public static function forget_answers( $draft_id, array $keys ) { if ( ! $keys ) { return; } $key = self::TRANSIENT_PREFIX . self::clean_id( $draft_id ); $draft = self::fetch( $key ); if ( ! is_array( $draft ) || empty( $draft['answers'] ) ) { return; } foreach ( $keys as $answer_key ) { unset( $draft['answers'][ $answer_key ] ); } self::store( $key, $draft, self::LIFETIME_SECONDS ); } public static function set_review( $draft_id, $is_review ) { $key = self::TRANSIENT_PREFIX . self::clean_id( $draft_id ); $draft = self::fetch( $key ); if ( ! is_array( $draft ) ) { return; } if ( $is_review ) { $draft['review'] = true; } else { unset( $draft['review'] ); } self::store( $key, $draft, self::LIFETIME_SECONDS ); } public static function keep_page( $draft_id, $page_id, array $answers ) { self::patch( $draft_id, static function ( array $draft ) use ( $page_id, $answers ) { if ( ! isset( $draft['kept'] ) || ! is_array( $draft['kept'] ) ) { $draft['kept'] = array(); } $draft['kept'][ $page_id ] = $answers; return $draft; } ); } public static function drop_kept( $draft_id, $page_id ) { self::patch( $draft_id, static function ( array $draft ) use ( $page_id ) { unset( $draft['kept'][ $page_id ] ); if ( empty( $draft['kept'] ) ) { unset( $draft['kept'] ); } return $draft; } ); } public static function forget_everywhere( $draft_id, array $keys ) { if ( ! $keys ) { return; } self::patch( $draft_id, static function ( array $draft ) use ( $keys ) { foreach ( $keys as $key ) { unset( $draft['answers'][ $key ] ); foreach ( array_keys( isset( $draft['kept'] ) && is_array( $draft['kept'] ) ? $draft['kept'] : array() ) as $page_id ) { unset( $draft['kept'][ $page_id ][ $key ] ); } } return $draft; } ); } /** @param string $page_id The page opened from Check your answers, or '' once it is confirmed or cancelled. */ public static function set_changing( $draft_id, $page_id ) { self::patch( $draft_id, static function ( array $draft ) use ( $page_id ) { if ( '' === $page_id ) { unset( $draft['changing'] ); } else { $draft['changing'] = $page_id; } return $draft; } ); } private static function patch( $draft_id, callable $change ) { $key = self::TRANSIENT_PREFIX . self::clean_id( $draft_id ); $draft = self::fetch( $key ); if ( ! is_array( $draft ) ) { return; } self::store( $key, $change( $draft ), self::LIFETIME_SECONDS ); } /** @return bool True when it is a new furthest page. */ public static function mark_furthest( $draft_id, $position ) { $key = self::TRANSIENT_PREFIX . self::clean_id( $draft_id ); $draft = self::fetch( $key ); if ( ! is_array( $draft ) ) { return false; } $position = max( 1, (int) $position ); if ( $position <= (int) ( $draft['furthest'] ?? 0 ) ) { return false; } $draft['furthest'] = $position; self::store( $key, $draft, self::LIFETIME_SECONDS ); return true; } public static function was_sent( $token ) { $parts = is_string( $token ) ? explode( '.', $token ) : array(); if ( 3 !== count( $parts ) ) { return false; } $draft_id = self::clean_id( $parts[1] ); return hash_equals( self::token_signature( absint( $parts[0] ), $draft_id ), (string) $parts[2] ) && false !== get_transient( self::USED_PREFIX . $draft_id ); } public static function destroy( $draft_id ) { $draft_id = self::clean_id( $draft_id ); delete_transient( self::TRANSIENT_PREFIX . $draft_id ); $seconds_left = hexdec( substr( $draft_id, 0, 8 ) ) + self::LIFETIME_SECONDS - time(); if ( $seconds_left > 0 ) { set_transient( self::USED_PREFIX . $draft_id, 1, $seconds_left ); } } public static function clean_id( $draft_id ) { return preg_replace( '/[^A-Za-z0-9]/', '', (string) $draft_id ); } private static function bind_hash( $draft_id, $secret ) { return hash_hmac( 'sha256', 'bind|' . $draft_id . '|' . $secret, wp_salt( self::SALT_SCHEME ) ); } private static function signed_token( $form_id, $draft_id ) { return $form_id . '.' . $draft_id . '.' . self::token_signature( $form_id, $draft_id ); } private static function token_signature( $form_id, $draft_id ) { return hash_hmac( 'sha256', $form_id . '|' . $draft_id, wp_salt( self::SALT_SCHEME ) ); } }