settings = $settings; } /** * Hooks route registration. * * @return void */ public function register(): void { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * Absolute URL of the public log endpoint. * * @return string */ public static function log_url(): string { return rest_url( self::REST_NAMESPACE . '/log' ); } /** * Registers the routes. * * @return void */ public function register_routes(): void { register_rest_route( self::REST_NAMESPACE, '/log', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_log_entry' ), // Deliberately public: visitors' browsers report navigation failures. // The callback refuses unless `error_logging` is on, validates strictly, // rate limits globally and never stores personal data. 'permission_callback' => '__return_true', 'args' => $this->log_args(), ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_log' ), 'permission_callback' => array( $this, 'can_manage' ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_log' ), 'permission_callback' => array( $this, 'can_manage' ), ), ) ); register_rest_route( self::REST_NAMESPACE, '/diagnostics', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_diagnostics' ), 'permission_callback' => array( $this, 'can_manage' ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save_diagnostics' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'ok' => array( 'description' => __( 'Whether all client checks passed.', 'ajax-loading' ), 'type' => 'boolean', ), 'checks' => array( 'description' => __( 'Client check results.', 'ajax-loading' ), 'type' => 'array', 'items' => array( 'type' => 'object' ), ), ), ), ) ); } /** * Argument schema of POST /log (documentation + validation of JSON bodies). * * Nothing is marked required so `text/plain` beacons reach the callback, * which validates every field itself. * * @return array */ private function log_args(): array { return array( 'code' => array( 'description' => __( 'Navigation error code.', 'ajax-loading' ), 'type' => 'string', 'enum' => self::ERROR_CODES, ), 'path' => array( 'description' => __( 'Root-relative path of the page that failed (query string is discarded).', 'ajax-loading' ), 'type' => 'string', ), 'status' => array( 'description' => __( 'HTTP status of the failed response, 0 when none.', 'ajax-loading' ), 'type' => 'integer', 'minimum' => 0, 'maximum' => 599, ), 'message' => array( 'description' => __( 'Short error message (truncated to 200 characters).', 'ajax-loading' ), 'type' => 'string', ), 'nav' => array( 'description' => __( 'Navigation type.', 'ajax-loading' ), 'type' => 'string', 'enum' => self::NAV_TYPES, ), ); } /** * Permission callback of the admin routes. * * @return bool */ public function can_manage(): bool { return current_user_can( 'manage_options' ); } /** * POST /log. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function create_log_entry( WP_REST_Request $request ) { if ( ! WPAJXL_Settings::to_bool( $this->settings->get( 'error_logging', false ) ) ) { return new WP_Error( 'rest_forbidden', __( 'Client error logging is disabled.', 'ajax-loading' ), array( 'status' => 403 ) ); } $body = $request->get_body(); if ( is_string( $body ) && strlen( $body ) > self::MAX_BODY_BYTES ) { return new WP_Error( 'ajax_loading_payload_too_large', __( 'The log entry is too large.', 'ajax-loading' ), array( 'status' => 413 ) ); } $entry = $this->sanitize_log_payload( $this->read_payload( $request ) ); if ( is_wp_error( $entry ) ) { return $entry; } if ( ! $this->consume_rate_limit() ) { return new WP_Error( 'ajax_loading_rate_limited', __( 'Too many log entries. Try again later.', 'ajax-loading' ), array( 'status' => 429 ) ); } $entry['time'] = time(); if ( ! WPAJXL_Logger::add( $entry ) ) { return new WP_Error( 'ajax_loading_log_failed', __( 'The log entry could not be stored.', 'ajax-loading' ), array( 'status' => 500 ) ); } return new WP_REST_Response( array( 'logged' => true ), 201 ); } /** * GET /log: entries newest first. * * @return WP_REST_Response */ public function get_log(): WP_REST_Response { $entries = WPAJXL_Logger::get(); $response = new WP_REST_Response( $entries, 200 ); $response->header( 'X-WP-Total', (string) count( $entries ) ); return $response; } /** * DELETE /log. * * @return WP_REST_Response */ public function delete_log(): WP_REST_Response { WPAJXL_Logger::clear(); return new WP_REST_Response( array( 'deleted' => true, 'count' => 0, ), 200 ); } /** * GET /diagnostics. * * @return WP_REST_Response */ public function get_diagnostics(): WP_REST_Response { return new WP_REST_Response( array( 'environment' => $this->environment_report(), 'client' => self::last_diagnostics(), 'generated' => time(), ), 200 ); } /** * POST /diagnostics. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function save_diagnostics( WP_REST_Request $request ) { $summary = $this->sanitize_diagnostics_payload( $this->read_payload( $request ) ); if ( is_wp_error( $summary ) ) { return $summary; } if ( null === get_option( self::DIAGNOSTICS_OPTION, null ) ) { add_option( self::DIAGNOSTICS_OPTION, $summary, '', false ); } else { update_option( self::DIAGNOSTICS_OPTION, $summary, false ); } return new WP_REST_Response( $summary, 200 ); } /** * Last stored client diagnostics summary. * * @return array|null `{ time, ok, checks[] }` or null when none stored. */ public static function last_diagnostics() { $stored = get_option( self::DIAGNOSTICS_OPTION, null ); if ( ! is_array( $stored ) || ! isset( $stored['time'] ) ) { return null; } return array( 'time' => (int) $stored['time'], 'ok' => ! empty( $stored['ok'] ), 'checks' => ( isset( $stored['checks'] ) && is_array( $stored['checks'] ) ) ? array_values( $stored['checks'] ) : array(), ); } /** * Validates a client log payload. * * @param mixed $payload Decoded request data. * @return array|WP_Error `{ code, path, status, message, nav }`. */ public function sanitize_log_payload( $payload ) { if ( ! is_array( $payload ) ) { return $this->invalid_param( 'code' ); } $code = ( isset( $payload['code'] ) && is_string( $payload['code'] ) ) ? strtolower( trim( $payload['code'] ) ) : ''; if ( ! in_array( $code, self::ERROR_CODES, true ) ) { return $this->invalid_param( 'code' ); } $path = $this->sanitize_path( isset( $payload['path'] ) ? $payload['path'] : null ); if ( null === $path ) { return $this->invalid_param( 'path' ); } $status = 0; if ( isset( $payload['status'] ) && '' !== $payload['status'] ) { $raw_status = $payload['status']; if ( is_float( $raw_status ) && is_finite( $raw_status ) && floor( $raw_status ) === $raw_status && abs( $raw_status ) < 1000 ) { $raw_status = (int) $raw_status; } if ( is_string( $raw_status ) && 1 === preg_match( '/^\d{1,3}$/', $raw_status ) ) { $raw_status = (int) $raw_status; } if ( ! is_int( $raw_status ) || $raw_status < 0 || $raw_status > 599 ) { return $this->invalid_param( 'status' ); } $status = $raw_status; } $message = ''; if ( isset( $payload['message'] ) ) { if ( ! is_string( $payload['message'] ) && ! is_int( $payload['message'] ) && ! is_float( $payload['message'] ) ) { return $this->invalid_param( 'message' ); } $message = $this->scrub_message( (string) $payload['message'] ); } $nav = ''; if ( isset( $payload['nav'] ) && '' !== $payload['nav'] ) { $nav = is_string( $payload['nav'] ) ? strtolower( trim( $payload['nav'] ) ) : ''; if ( ! in_array( $nav, self::NAV_TYPES, true ) ) { return $this->invalid_param( 'nav' ); } } return array( 'code' => $code, 'path' => $path, 'status' => $status, 'message' => $message, 'nav' => $nav, ); } /** * Validates a client diagnostics payload. * * @param mixed $payload Decoded request data. * @return array|WP_Error `{ time, ok, checks[] }`. */ public function sanitize_diagnostics_payload( $payload ) { if ( ! is_array( $payload ) ) { return $this->invalid_param( 'checks' ); } $raw_checks = isset( $payload['checks'] ) ? $payload['checks'] : array(); if ( ! is_array( $raw_checks ) ) { return $this->invalid_param( 'checks' ); } $checks = array(); $has_error = false; foreach ( $raw_checks as $item ) { if ( count( $checks ) >= self::MAX_CHECKS ) { break; } if ( ! is_array( $item ) ) { continue; } $status = ( isset( $item['status'] ) && is_string( $item['status'] ) ) ? strtolower( trim( $item['status'] ) ) : ''; if ( ! in_array( $status, self::CHECK_STATUSES, true ) ) { continue; } $label = $this->clean_text( isset( $item['label'] ) ? $item['label'] : '', 100 ); if ( '' === $label ) { continue; } $check = array(); if ( isset( $item['id'] ) && is_string( $item['id'] ) ) { $id = substr( sanitize_key( $item['id'] ), 0, 64 ); if ( '' !== $id ) { $check['id'] = $id; } } $check['label'] = $label; $check['status'] = $status; $check['message'] = $this->clean_text( isset( $item['message'] ) ? $item['message'] : '', 300 ); if ( 'error' === $status ) { $has_error = true; } $checks[] = $check; } $ok = ! $has_error; if ( array_key_exists( 'ok', $payload ) ) { $flag = WPAJXL_Settings::to_bool( $payload['ok'] ); if ( null === $flag ) { return $this->invalid_param( 'ok' ); } $ok = $flag; } return array( 'time' => time(), 'ok' => $ok, 'checks' => $checks, ); } /** * Server-side environment report from WPAJXL_Compat. * * @return array[] List of `{ id, status, label, message }`. */ private function environment_report(): array { $report = array(); if ( is_callable( array( 'WPAJXL_Compat', 'environment_report' ) ) ) { try { $report = call_user_func( array( 'WPAJXL_Compat', 'environment_report' ) ); } catch ( Throwable $e ) { $report = array( array( 'id' => 'report_failed', 'status' => 'error', 'label' => __( 'Environment report', 'ajax-loading' ), 'message' => __( 'The environment report could not be generated.', 'ajax-loading' ), ), ); } } $items = array(); foreach ( (array) $report as $item ) { if ( ! is_array( $item ) ) { continue; } $item['id'] = isset( $item['id'] ) && is_scalar( $item['id'] ) ? (string) $item['id'] : ''; $item['status'] = ( isset( $item['status'] ) && in_array( $item['status'], self::CHECK_STATUSES, true ) ) ? $item['status'] : 'info'; $item['label'] = isset( $item['label'] ) && is_scalar( $item['label'] ) ? (string) $item['label'] : ''; $item['message'] = isset( $item['message'] ) && is_scalar( $item['message'] ) ? (string) $item['message'] : ''; $items[] = $item; } return $items; } /** * Decoded request data from JSON, a JSON body sent as text/plain * (navigator.sendBeacon) or form fields. Query parameters are ignored. * * @param WP_REST_Request $request Request. * @return array */ private function read_payload( WP_REST_Request $request ): array { $json = $request->get_json_params(); if ( is_array( $json ) && ! empty( $json ) ) { return $json; } $body = $request->get_body(); if ( is_string( $body ) && '' !== $body && strlen( $body ) <= self::MAX_BODY_BYTES * 16 ) { $decoded = json_decode( $body, true, 16 ); if ( is_array( $decoded ) ) { return $decoded; } } $form = $request->get_body_params(); return is_array( $form ) ? $form : array(); } /** * Validates and reduces a reported path. * * @param mixed $value Raw path. * @return string|null Null when invalid. */ private function sanitize_path( $value ) { if ( ! is_string( $value ) ) { return null; } $value = trim( $value ); if ( '' === $value || strlen( $value ) > self::MAX_RAW_PATH_CHARS || '/' !== $value[0] ) { return null; } // Protocol-relative URLs are hosts, not paths. if ( isset( $value[1] ) && ( '/' === $value[1] || '\\' === $value[1] ) ) { return null; } $path = WPAJXL_Logger::clean_path( $value ); return '' === $path ? null : $path; } /** * Sanitises a client message and removes likely personal data. * * @param string $message Raw message. * @return string */ private function scrub_message( string $message ): string { $message = sanitize_text_field( $message ); // Drop query strings and fragments of URLs and paths. $message = (string) preg_replace( '#((?:https?://|/)[^\s?\#]*)[?\#]\S*#i', '$1', $message ); // E-mail addresses and IPv4 addresses. $message = (string) preg_replace( '/[^\s@<>()",;:]+@[^\s@<>()",;:]+\.[a-z]{2,}/i', '[email]', $message ); $message = (string) preg_replace( '/\b(?:\d{1,3}\.){3}\d{1,3}\b/', '[ip]', $message ); return $this->clean_text( $message, WPAJXL_Logger::MAX_MESSAGE_CHARS ); } /** * Sanitises and truncates a scalar to a single line of text. * * @param mixed $value Raw value. * @param int $length Maximum characters. * @return string */ private function clean_text( $value, int $length ): string { if ( ! is_string( $value ) && ! is_int( $value ) && ! is_float( $value ) ) { return ''; } $value = sanitize_text_field( (string) $value ); if ( strlen( $value ) > $length ) { $value = function_exists( 'mb_substr' ) ? (string) mb_substr( $value, 0, $length, 'UTF-8' ) : substr( $value, 0, $length ); } return trim( $value ); } /** * Counts one accepted entry against the global window. * * @return bool False when the window is exhausted. */ private function consume_rate_limit(): bool { $now = time(); $state = get_transient( self::RATE_TRANSIENT ); if ( ! is_array( $state ) || ! isset( $state['start'], $state['count'] ) ) { $state = null; } else { $start = (int) $state['start']; if ( $start > $now || $now - $start >= self::RATE_WINDOW ) { $state = null; } } if ( null === $state ) { $state = array( 'start' => $now, 'count' => 0, ); } if ( (int) $state['count'] >= self::RATE_LIMIT ) { return false; } $state['count'] = (int) $state['count'] + 1; $remaining = self::RATE_WINDOW - ( $now - (int) $state['start'] ); set_transient( self::RATE_TRANSIENT, $state, max( 1, $remaining ) ); return true; } /** * Builds a 400 error for one parameter. * * @param string $param Parameter name. * @return WP_Error */ private function invalid_param( string $param ): WP_Error { /* translators: %s: parameter name. */ $message = sprintf( __( 'Invalid parameter: %s', 'ajax-loading' ), $param ); return new WP_Error( 'rest_invalid_param', $message, array( 'status' => 400, 'params' => array( $param => $message ), ) ); } }