*/ final class PushController { /** * REST namespace shared by every push route. * * @since 4.3.0 * @var string */ const REST_NAMESPACE = 'actirise/v1'; /** * Base path, relative to the namespace, shared by every push route. * * @since 4.3.0 * @var string */ const REST_BASE = 'push'; /** * Maximum receive requests accepted per source IP within the rate window. * * @since 4.3.0 * @var int */ const RATE_LIMIT_MAX_REQUESTS = 60; /** * Rolling rate-limit window, in seconds. * * @since 4.3.0 * @var int */ const RATE_LIMIT_WINDOW_SECONDS = 60; /** * Transient-key prefix for the per-IP rate-limit counter. * * @since 4.3.0 * @var string */ const RATE_LIMIT_TRANSIENT_PREFIX = 'actirise_push_rl_'; /** * Logger context (file) used for push pipeline log entries. * * @since 4.3.0 * @var string */ const LOG_CONTEXT = 'include/push'; /** * Per-type apply handlers, keyed by their endpoint slug. Also the source of * truth for which receiving routes are registered. * * @since 4.3.0 * @var array */ private $handlers; /** * Outbound client for the verify callback. * * @since 4.3.0 * @var VerifyClient */ private $verify_client; /** * Event-id deduplication service. * * @since 4.3.0 * @var Idempotency */ private $idempotency; /** * Initialize the controller. * * @since 4.3.0 */ public function __construct() { $this->verify_client = new VerifyClient(); $this->idempotency = new Idempotency(); $this->handlers = $this->build_handlers(); } /** * Build the endpoint-slug => handler registry. Exactly one handler per * endpoint, each applying its data type for real (US 3–7). * * @since 4.3.0 * @return array */ private function build_handlers() { $handlers = array(); $list = array( new AdstxtPushHandler(), new AdstxtMonitoringPushHandler(), new PresizedDivPushHandler(), new WebsitePushHandler(), new OrganizationPushHandler(), new CmpConfigPushHandler(), ); foreach ( $list as $handler ) { $handlers[ $handler->endpoint() ] = $handler; } return $handlers; } /** * Register the push routes. Hooked on `rest_api_init` through the Loader. * * @since 4.3.0 * @return void */ public function register_routes() { register_rest_route( self::REST_NAMESPACE, '/' . self::REST_BASE . '/ping', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'ping' ), 'permission_callback' => '__return_true', ) ); foreach ( array_keys( $this->handlers ) as $endpoint ) { register_rest_route( self::REST_NAMESPACE, '/' . self::REST_BASE . '/' . $endpoint, array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => $this->receiver_callback( $endpoint ), 'permission_callback' => '__return_true', ) ); } } /** * Build the REST callback for one receiving endpoint, carrying its slug so * the shared pipeline can dispatch to the right handler. * * @since 4.3.0 * @param string $endpoint The endpoint slug this route serves. * @return callable */ private function receiver_callback( $endpoint ) { return function ( $request ) use ( $endpoint ) { /** @var \WP_REST_Request> $request */ return $this->handle_receive( $request, $endpoint ); }; } /** * Public liveness probe used by the REST self-test. * * @since 4.3.0 * @param \WP_REST_Request> $request The incoming request (unused). * @return \WP_REST_Response 200 with a structured pong payload. */ public function ping( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Required by the REST callback signature. return new \WP_REST_Response( array( 'success' => true, 'message' => 'pong', ), 200 ); } /** * Run the common receive pipeline shared by every push endpoint. * * Order (design §6): HTTPS check, per-IP rate limit, raw-body hashing, * envelope parse + schema validation, handler resolution, per-handler * event-version gate, idempotency check, verify callback, defensive identity * check, then dispatch to the endpoint's apply handler. The `event_id` is * marked processed only after a successful apply. * * @since 4.3.0 * @param \WP_REST_Request> $request The incoming request. * @param string $endpoint The endpoint slug this route serves. * @return \WP_REST_Response Structured response with the contract status code. */ public function handle_receive( $request, $endpoint ) { if ( ! $this->is_https( $request ) ) { return $this->respond( 400, 'https_required', 'Push notifications must be sent over HTTPS.' ); } if ( $this->is_rate_limited() ) { return $this->respond( 429, 'rate_limited', 'Too many push notifications from this source.' ); } $raw_body = (string) $request->get_body(); $payload_hash = 'sha256:' . hash( 'sha256', $raw_body ); $envelope = $this->parse_envelope( $raw_body ); if ( null === $envelope ) { return $this->respond( 400, 'invalid_payload', 'The notification payload is malformed.' ); } $handler = isset( $this->handlers[ $endpoint ] ) ? $this->handlers[ $endpoint ] : null; if ( null === $handler ) { // Routes are derived from the handler registry, so this is only // reachable through a misconfiguration; fail as the contract's 404. return $this->respond( 404, 'unknown_endpoint', 'No handler is registered for this endpoint.' ); } if ( ! in_array( $envelope['event_version'], $handler->supported_versions(), true ) ) { return $this->respond( 426, 'unsupported_version', 'The notification event version is not supported.' ); } // Idempotency: a retry of an already-applied event is acknowledged with // 200 and never re-applied. Checked before the verify callback so a known // duplicate costs no outbound request. if ( $this->idempotency->is_processed( $envelope['event_id'] ) ) { return $this->respond_success(); } $website_id = Options::get( 'website-id' ); $verdict = $this->verify_client->verify( $envelope['event_id'], $payload_hash, $website_id ); if ( empty( $verdict['verified'] ) ) { $reason = isset( $verdict['reason'] ) && is_string( $verdict['reason'] ) ? $verdict['reason'] : 'unknown'; Logger::add_log( 'Push verification failed (' . $reason . ') for event ' . $envelope['event_id'], self::LOG_CONTEXT, 'error' ); return $this->respond( 403, 'verification_failed', 'The notification could not be verified.' ); } if ( ! $this->identity_matches( $envelope['data'], $verdict, $website_id ) ) { Logger::add_log( 'Push identity mismatch for event ' . $envelope['event_id'], self::LOG_CONTEXT, 'error' ); return $this->respond( 403, 'identity_mismatch', 'The notification identity did not match this site.' ); } if ( ! $handler->apply( $envelope['data'] ) ) { return $this->respond( 400, 'apply_failed', 'The notification payload could not be applied.' ); } $this->idempotency->mark_processed( $envelope['event_id'] ); Logger::add_log( 'Applied push ' . $handler->event_type() . ' for event ' . $envelope['event_id'], self::LOG_CONTEXT, 'debug' ); return $this->respond_success(); } /** * Whether the request reached the plugin over HTTPS. * * Honours a reverse-proxy `X-Forwarded-Proto` header in addition to is_ssl(), * since many WordPress sites terminate TLS at a proxy/CDN where is_ssl() is a * false negative. Authenticity still rests on the verify callback; this is a * best-effort transport check. * * @since 4.3.0 * @param \WP_REST_Request> $request The incoming request. * @return bool */ private function is_https( $request ) { if ( is_ssl() ) { return true; } $forwarded_proto = $request->get_header( 'x_forwarded_proto' ); if ( is_string( $forwarded_proto ) && '' !== $forwarded_proto ) { $parts = explode( ',', $forwarded_proto ); if ( 'https' === strtolower( trim( $parts[0] ) ) ) { return true; } } return false; } /** * Enforce a rolling per-source-IP rate limit backed by a transient. * * @since 4.3.0 * @return bool True when the source IP has exceeded the threshold. */ private function is_rate_limited() { $ip = $this->client_ip(); if ( '' === $ip ) { return false; } $key = self::RATE_LIMIT_TRANSIENT_PREFIX . md5( $ip ); $raw_count = get_transient( $key ); $count = is_numeric( $raw_count ) ? (int) $raw_count : 0; if ( $count >= self::RATE_LIMIT_MAX_REQUESTS ) { return true; } set_transient( $key, $count + 1, self::RATE_LIMIT_WINDOW_SECONDS ); return false; } /** * The sanitized source IP of the current request, or '' when unavailable. * * @since 4.3.0 * @return string */ private function client_ip() { if ( ! isset( $_SERVER['REMOTE_ADDR'] ) ) { return ''; } return sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); } /** * Parse and schema-validate the notification envelope. * * Strictly validates the raw public body: it must be a JSON object carrying a * UUID `event_id`, an integer `event_version` >= 1, and a `data` member. * * @since 4.3.0 * @param string $raw_body The raw request body. * @return array{event_id: string, event_version: int, data: mixed}|null Null when malformed. */ private function parse_envelope( $raw_body ) { if ( '' === $raw_body ) { return null; } $decoded = json_decode( $raw_body, true ); if ( ! is_array( $decoded ) ) { return null; } if ( ! isset( $decoded['event_id'], $decoded['event_version'] ) || ! array_key_exists( 'data', $decoded ) ) { return null; } $event_id = $decoded['event_id']; if ( ! is_string( $event_id ) || ! $this->is_uuid( $event_id ) ) { return null; } $event_version = $decoded['event_version']; if ( is_int( $event_version ) ) { $version = $event_version; } elseif ( is_string( $event_version ) && ctype_digit( $event_version ) ) { $version = (int) $event_version; } else { return null; } if ( $version < 1 ) { return null; } return array( 'event_id' => $event_id, 'event_version' => $version, 'data' => $decoded['data'], ); } /** * Whether a value is a canonically-formatted UUID string. * * @since 4.3.0 * @param string $value The candidate id. * @return bool */ private function is_uuid( $value ) { return 1 === preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value ); } /** * Defensive identity check: any identifier carried in the payload (or echoed * by the verify response) must match this site's local values. * * The verify callback already proves the event was addressed to this plugin; * this guards against API-side bugs that would otherwise apply silently. The * plugin no longer stores an organization id (contract §5.5), so only the * website identity is cross-checked. * * @since 4.3.0 * @param mixed $data The event-specific payload. * @param array $verdict The normalised verify verdict. * @param mixed $local_website_id The plugin's local website id. * @return bool */ private function identity_matches( $data, $verdict, $local_website_id ) { $local = is_scalar( $local_website_id ) ? (string) $local_website_id : ''; if ( isset( $verdict['website_id'] ) && '' !== $local ) { $verdict_website_id = $verdict['website_id']; if ( is_scalar( $verdict_website_id ) && (string) $verdict_website_id !== $local ) { return false; } } if ( is_array( $data ) && isset( $data['@id'] ) && is_string( $data['@id'] ) ) { $matches = array(); if ( 1 === preg_match( '#/websites/([0-9]+)#', $data['@id'], $matches ) ) { if ( '' === $local || $matches[1] !== $local ) { return false; } } } return true; } /** * Build the success response per the contract (§4.3): `200 {success:true}`. * Shared by a fresh apply and a duplicate, which are indistinguishable to the * API by design. * * @since 4.3.0 * @return \WP_REST_Response */ private function respond_success() { return new \WP_REST_Response( array( 'success' => true ), 200 ); } /** * Build a structured non-2xx response per the contract (§4.3) body shape. * * @since 4.3.0 * @param int $status HTTP status code. * @param string $code Machine-readable code. * @param string $message Human-readable message. * @return \WP_REST_Response */ private function respond( $status, $code, $message ) { return new \WP_REST_Response( array( 'success' => false, 'code' => $code, 'message' => $message, ), $status ); } }