(string) $query_id, 'sourcePostId' => absint( $source_post_id ), 'viewerId' => get_current_user_id(), 'attributes' => $attributes, 'innerBlocks' => (string) $inner_blocks, ); $reference = self::reference( $definition ); $key = 'airowp_query_source_' . $reference; self::store( $key, $definition ); $envelope = array( 'v' => self::VERSION, 'queryId' => $definition['queryId'], 'sourcePostId' => $definition['sourcePostId'], 'ref' => $reference, ); // Page facts travel in the signed envelope, never in the stored record: // they vary per request (a search term, an archive date), so keying the // record on them would let anonymous requests mint a 30-day row each. // The signature covers the envelope, so they still can't be forged. if ( ! empty( $page_context['currentPostId'] ) || ! empty( $page_context['inheritedVars'] ) ) { $envelope['ctx'] = $page_context; } $source = base64_encode( (string) wp_json_encode( $envelope ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- transport encoding, see class docblock. return array( 'source' => $source, 'signature' => self::signature( $source ), ); } /** * Persist a definition, avoiding a database write on every page view. * * Without a persistent object cache a transient lives in wp_options, so an * unconditional set_transient() is two option writes per Query per view. * An unchanged record is only rewritten once half its lifetime has elapsed, * which keeps it comfortably ahead of any page cache that holds the key. * * @param string $key Transient key. * @param array $definition Definition to store. * @return void */ private static function store( $key, array $definition ) { if ( ! wp_using_ext_object_cache() && get_transient( $key ) === $definition ) { $expires = (int) get_option( '_transient_timeout_' . $key, 0 ); if ( $expires - time() > self::LIFETIME / 2 ) { return; } } set_transient( $key, $definition, self::LIFETIME ); } /** * Verify and decode a signed source. * * @param mixed $source Encoded source from the request. * @param mixed $signature Signature from the request. * @param string $query_id Query ID the request names. * @return array{queryId: string, sourcePostId: int, attributes: array, innerBlocks: string, pageContext?: array}|null Definition, or null when it doesn't verify. */ public static function verify( $source, $signature, $query_id ) { if ( ! is_string( $source ) || '' === $source || ! is_string( $signature ) ) { return null; } if ( ! hash_equals( self::signature( $source ), $signature ) ) { return null; } $data = json_decode( (string) base64_decode( $source, true ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- transport encoding, see class docblock. if ( ! is_array( $data ) || ! isset( $data['v'], $data['queryId'], $data['sourcePostId'], $data['ref'] ) || self::VERSION !== $data['v'] || $data['queryId'] !== (string) $query_id || ! is_int( $data['sourcePostId'] ) || ! is_string( $data['ref'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $data['ref'] ) || ! is_array( $data['ctx'] ?? array() ) ) { return null; } $definition = get_transient( 'airowp_query_source_' . $data['ref'] ); if ( ! is_array( $definition ) || ( $definition['queryId'] ?? null ) !== $data['queryId'] || ( $definition['sourcePostId'] ?? null ) !== $data['sourcePostId'] || ( $definition['viewerId'] ?? null ) !== get_current_user_id() || ! is_array( $definition['attributes'] ?? null ) || ! is_string( $definition['innerBlocks'] ?? null ) || ! hash_equals( self::reference( $definition ), $data['ref'] ) ) { return null; } if ( ! empty( $data['ctx'] ) ) { $definition['pageContext'] = $data['ctx']; } return $definition; } /** * Whether the current user may see the post a query was placed in. * * A query outside post content (sourcePostId 0) has no additional post gate. * Its issuing-viewer binding has already been checked by verify(). * * @param int $source_post_id Post whose content holds the query, or 0. * @return bool */ public static function can_view_source( $source_post_id ) { if ( ! $source_post_id ) { return true; } $post = get_post( $source_post_id ); if ( ! $post || post_password_required( $post ) ) { return false; } return is_post_publicly_viewable( $post ) || current_user_can( 'read_post', $post->ID ); } /** * Stable, unguessable key for an unchanged definition/viewer on this site. * * @param array $definition Query definition, kept entirely server-side. * @return string Hex reference. */ private static function reference( array $definition ) { return hash_hmac( 'sha256', 'airo-wp/query-template|' . get_current_blog_id() . '|' . wp_json_encode( $definition ), wp_salt( 'auth' ) ); } /** * HMAC over the encoded source, keyed to this site. * * The blog ID is part of the message because AUTH_SALT is shared across a * multisite network: without it, one site's signed query would render on * another, against that site's content. * * @param string $source Encoded source. * @return string Hex signature. */ private static function signature( $source ) { return hash_hmac( 'sha256', 'airo-wp/query-refresh-source|' . get_current_blog_id() . '|' . $source, wp_salt( 'auth' ) ); } }