[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})'; /** * Settings repository. * * @var Repository */ private Repository $settings; /** * Log storage. * * @var LogRepository */ private LogRepository $log; /** * Rate limit. * * @var RateLimiter */ private RateLimiter $limiter; /** * Constructor. * * @param Repository $settings Settings repository. * @param LogRepository $log Log storage. * @param RateLimiter $limiter Rate limit. */ public function __construct( Repository $settings, LogRepository $log, RateLimiter $limiter ) { $this->settings = $settings; $this->log = $log; $this->limiter = $limiter; } /** * Hook the route. */ public function register(): void { add_action( 'rest_api_init', array( $this, 'register_route' ) ); } /** * Register the route. */ public function register_route(): void { register_rest_route( self::NAMESPACE, self::ROUTE, array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle' ), 'permission_callback' => '__return_true', // Public by design; the checks are in handle(). ) ); register_rest_route( self::NAMESPACE, self::RECORD, array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'show' ), 'permission_callback' => '__return_true', // Public by design: the UUID is the secret. ) ); } /** * Where the banner reads a record: the path to which the consent ID is appended. */ public static function record_url(): string { return self::url() . '/'; } /** * Where the banner posts: a path on this site, so it works from whichever host serves the page. */ public static function url(): string { return wp_make_link_relative( rest_url( self::NAMESPACE . self::ROUTE ) ); } /** * Record a decision. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function handle( WP_REST_Request $request ) { $settings = $this->settings->get(); if ( true !== ( $settings['consent']['log_enabled'] ?? true ) ) { return self::done(); } if ( ! self::is_same_site( $request ) ) { return new WP_Error( 'consentfold_bad_origin', 'The request does not come from this site.', array( 'status' => 403 ) ); } if ( strlen( $request->get_body() ) > self::MAX_BODY ) { return new WP_Error( 'consentfold_too_large', 'The request is too large.', array( 'status' => 413 ) ); } $ua_class = Visitor::ua_class( (string) $request->get_header( 'user_agent' ) ); if ( Visitor::BOT === $ua_class ) { return self::done(); } $data = $request->get_json_params(); if ( ConsentPayload::is_trapped( $data ) ) { return self::done(); } try { $fields = ConsentPayload::parse( $data, array_keys( Categories::resolve( $settings ) ) ); } catch ( InvalidArgumentException $e ) { return new WP_Error( 'consentfold_invalid_payload', $e->getMessage(), array( 'status' => 400 ) ); } $visitor_hash = $this->visitor_hash( $request, $ua_class, $settings ); if ( ! $this->limiter->hit( $visitor_hash, $this->limit( $request, $settings ) ) ) { return new WP_Error( 'consentfold_rate_limited', 'Too many requests.', array( 'status' => 429 ) ); } $record = array( 'visitor_hash' => $visitor_hash, 'ua_class' => $ua_class, ) + $fields; /** * Filters a consent record before it is written. Return null to skip it, or the record to store; keep * to the shape given, as the log holds no personal data and must not start to. * * @param array $record Record: visitor_hash, consent_id, categories (list), method, banner_version, policy_version, region, lang, ua_class. * @param WP_REST_Request $request Request. */ $filtered = apply_filters( 'consentfold_log_record', $record, $request ); // A filter that drops a required field (region, lang and ua_class are optional) or changes its type would break the insert: treat it as a skip. if ( ! is_array( $filtered ) || array() !== array_diff_key( array_flip( array( 'visitor_hash', 'consent_id', 'categories', 'method', 'banner_version', 'policy_version' ) ), $filtered ) || ! is_array( $filtered['categories'] ) || ! is_string( $filtered['consent_id'] ) ) { return self::done(); } try { $id = $this->log->insert( $filtered ); } catch ( RuntimeException $e ) { return new WP_Error( 'consentfold_log_failed', 'The decision could not be recorded.', array( 'status' => 500 ) ); } $user_id = UserConsentIds::current_user_id(); if ( $user_id > 0 ) { UserConsentIds::remember( $user_id, $filtered['consent_id'] ); } /** * Fires after a consent record was written. * * @param int $id Row id. * @param array $record The record as stored. */ do_action( 'consentfold_consent_logged', $id, $filtered ); return self::done(); } /** * Answer with the latest record of a consent ID. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function show( WP_REST_Request $request ): WP_REST_Response { if ( true !== ( $this->settings->get()['consent']['log_enabled'] ?? true ) ) { return self::not_found(); } $settings = $this->settings->get(); $ua_class = Visitor::ua_class( (string) $request->get_header( 'user_agent' ) ); $visitor_hash = $this->visitor_hash( $request, $ua_class, $settings ); // Another key than the POST's, so that reading a record does not use up the budget for reporting decisions. if ( ! $this->limiter->hit( 'r' . $visitor_hash, $this->limit( $request, $settings ) ) ) { return self::no_store( new WP_REST_Response( self::problem( 'consentfold_rate_limited', 'Too many requests.', 429 ), 429 ) ); } $record = $this->log->find_by_consent_id( strtolower( (string) $request->get_param( 'uuid' ) ) ); if ( null === $record ) { return self::not_found(); } return self::no_store( new WP_REST_Response( $record, 200 ) ); } /** * The visitor hash of a request, from the client address behind the trusted proxies of the settings. A * forwarding header from a peer that is not trusted is noted for the Consent log tab, unless the site sets the * address with the `consentfold_client_ip` filter. * * @param WP_REST_Request $request Request. * @param string $ua_class Device class. * @param array $settings Effective settings. */ private function visitor_hash( WP_REST_Request $request, string $ua_class, array $settings ): string { if ( $this->behind_unlisted_proxy( $request, $settings ) ) { ProxyNotice::note(); } return Visitor::hash( Visitor::client_ip( $request, self::trusted( $settings ) ), $ua_class, Visitor::daily_salt() ); } /** * Requests a minute for this visitor: more when the request shows a proxy that the settings do not list and * whose address is private or reserved, because then every visitor shares the address of the proxy and with it * one budget. A peer with a public address is a client connecting directly, and the header it writes changes * nothing. * * @param WP_REST_Request $request Request. * @param array $settings Effective settings. */ private function limit( WP_REST_Request $request, array $settings ): int { return Visitor::peer_is_internal() && $this->behind_unlisted_proxy( $request, $settings ) ? RateLimiter::SHARED_LIMIT : RateLimiter::LIMIT; } /** * Whether a forwarding header came from a peer that is not trusted, unless the site sets the address itself with * the `consentfold_client_ip` filter. * * @param WP_REST_Request $request Request. * @param array $settings Effective settings. */ private function behind_unlisted_proxy( WP_REST_Request $request, array $settings ): bool { return ! has_filter( 'consentfold_client_ip' ) && Visitor::has_untrusted_forwarding( $request, self::trusted( $settings ) ); } /** * The trusted proxies of the settings. * * @param array $settings Effective settings. * @return array */ private static function trusted( array $settings ): array { return array_values( array_filter( (array) ( $settings['consent']['trusted_proxies'] ?? array() ), 'is_string' ) ); } /** * The `404` of an unknown record, as a response so that it can carry the cache header. */ private static function not_found(): WP_REST_Response { return self::no_store( new WP_REST_Response( self::problem( 'consentfold_no_record', 'No record found.', 404 ), 404 ) ); } /** * A REST error body. * * @param string $code Error code. * @param string $message Message. * @param int $status HTTP status. * @return array{code: string, message: string, data: array{status: int}} */ private static function problem( string $code, string $message, int $status ): array { return array( 'code' => $code, 'message' => $message, 'data' => array( 'status' => $status ), ); } /** * Keep an answer out of page caches and the browser's. * * @param WP_REST_Response $response Response. * @return WP_REST_Response The same response. */ private static function no_store( WP_REST_Response $response ): WP_REST_Response { $response->header( 'Cache-Control', 'no-store' ); return $response; } /** * The empty answer of a request that was handled, recorded or not. */ private static function done(): WP_REST_Response { return self::no_store( new WP_REST_Response( null, 204 ) ); } /** * Whether `Origin`, or when the browser sent none `Referer`, names this site's host. * * @param WP_REST_Request $request Request. */ private static function is_same_site( WP_REST_Request $request ): bool { $origin = $request->get_header( 'origin' ); $source = is_string( $origin ) && '' !== $origin ? $origin : $request->get_header( 'referer' ); $host = is_string( $source ) ? wp_parse_url( $source, PHP_URL_HOST ) : null; $allowed = array_filter( array( wp_parse_url( home_url(), PHP_URL_HOST ), wp_parse_url( site_url(), PHP_URL_HOST ) ), 'is_string' ); return is_string( $host ) && in_array( strtolower( $host ), array_map( 'strtolower', $allowed ), true ); } }