WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'get_status' ), 'permission_callback' => array( __CLASS__, 'check_api_key' ), ) ); // Latest scan summary. register_rest_route( self::API_NAMESPACE, '/scan/latest', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'get_latest_scan' ), 'permission_callback' => array( __CLASS__, 'check_api_key' ), ) ); // All scans list. register_rest_route( self::API_NAMESPACE, '/scans', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'get_scans' ), 'permission_callback' => array( __CLASS__, 'check_api_key' ), ) ); // Trigger a new scan (push from hub). register_rest_route( self::API_NAMESPACE, '/scan/trigger', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( __CLASS__, 'trigger_scan' ), 'permission_callback' => array( __CLASS__, 'check_api_key' ), ) ); // Regenerate API key — admin only via nonce, no API key required. register_rest_route( self::API_NAMESPACE, '/key/regenerate', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( __CLASS__, 'regenerate_key' ), 'permission_callback' => '__return_true', ) ); } /** * Permission: validate X-AccessIQ-Key header against stored key. * * @param WP_REST_Request $request * @return bool */ public static function check_api_key( $request ) { $stored = get_option( self::OPTION_KEY, '' ); if ( empty( $stored ) ) { return false; } $provided = $request->get_header( 'X-AccessIQ-Key' ); return ! empty( $provided ) && hash_equals( $stored, $provided ); } /** * GET /status — site info + plugin version. * * @return WP_REST_Response */ public static function get_status( $request ) { return rest_ensure_response( array( 'site_name' => get_bloginfo( 'name' ), 'site_url' => get_site_url(), 'plugin_version' => ACCESSIQ_VERSION, 'wp_version' => get_bloginfo( 'version' ), 'connected' => true, 'timestamp' => current_time( 'c' ), ) ); } /** * GET /scan/latest — most recent scan data. * * @return WP_REST_Response */ public static function get_latest_scan( $request ) { $scans = get_posts( array( 'post_type' => ACCESSIQ_SCAN_CPT, 'post_status' => 'publish', 'posts_per_page' => 1, 'orderby' => 'date', 'order' => 'DESC', ) ); if ( empty( $scans ) ) { return rest_ensure_response( array( 'scan' => null ) ); } return rest_ensure_response( array( 'scan' => self::format_scan( $scans[0] ), ) ); } /** * GET /scans — paginated list of scans. * * @param WP_REST_Request $request Supports ?page=&per_page= params. * @return WP_REST_Response */ public static function get_scans( $request ) { $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ); $limit = min( 50, max( 1, (int) ( $request->get_param( 'per_page' ) ?? 10 ) ) ); $scans = get_posts( array( 'post_type' => ACCESSIQ_SCAN_CPT, 'post_status' => 'publish', 'posts_per_page' => $limit, 'paged' => $page, 'orderby' => 'date', 'order' => 'DESC', ) ); return rest_ensure_response( array( 'scans' => array_map( array( __CLASS__, 'format_scan' ), $scans ), 'total' => (int) wp_count_posts( ACCESSIQ_SCAN_CPT )->publish, 'page' => $page, ) ); } /** * POST /scan/trigger — schedule an immediate scan of a given URL. * * @param WP_REST_Request $request Expects JSON body: { "url": "https://..." } * @return WP_REST_Response */ public static function trigger_scan( $request ) { $url = sanitize_url( $request->get_param( 'url' ) ?: get_site_url() ); // Schedule via WP Cron to run immediately (non-blocking). wp_schedule_single_event( time(), 'accessiq_remote_triggered_scan', array( $url ) ); return rest_ensure_response( array( 'triggered' => true, 'url' => $url, 'message' => __( 'Scan scheduled. Results will be available shortly.', 'accessiq' ), ) ); } /** * POST /key/regenerate — admin only, regenerates the API key. * Requires valid WP nonce (X-WP-Nonce header or _wpnonce param) + manage_options cap. * * @param WP_REST_Request $request * @return WP_REST_Response|WP_Error */ public static function regenerate_key( $request ) { $nonce = $request->get_header( 'X-WP-Nonce' ) ?: $request->get_param( '_wpnonce' ); if ( ! wp_verify_nonce( $nonce, 'accessiq_regenerate_key' ) || ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'forbidden', __( 'Unauthorized.', 'accessiq' ), array( 'status' => 403 ) ); } $new_key = self::generate_api_key(); update_option( self::OPTION_KEY, $new_key ); return rest_ensure_response( array( 'api_key' => $new_key ) ); } /** * Format a scan post for API response. * * @param WP_Post $scan * @return array */ private static function format_scan( $scan ) { $score = (int) get_post_meta( $scan->ID, '_accessiq_score', true ); return array( 'id' => $scan->ID, 'date' => get_the_date( 'c', $scan ), 'url' => (string) get_post_meta( $scan->ID, '_accessiq_url', true ), 'score' => $score, 'errors' => (int) get_post_meta( $scan->ID, '_accessiq_errors', true ), 'warnings' => (int) get_post_meta( $scan->ID, '_accessiq_warnings', true ), 'wcag_level' => (string) ( get_post_meta( $scan->ID, '_accessiq_level', true ) ?: 'AA' ), 'status' => self::score_to_status( $score ), ); } /** * Convert numeric score to a status label. * * @param int $score * @return string excellent|good|fair|critical */ private static function score_to_status( $score ) { if ( $score >= 90 ) { return 'excellent'; } if ( $score >= 70 ) { return 'good'; } if ( $score >= 50 ) { return 'fair'; } return 'critical'; } /** * Generate a secure random API key. * * @return string aiq_ prefixed hex string (52 chars total). */ public static function generate_api_key() { return 'aiq_' . bin2hex( random_bytes( 24 ) ); } /** * Get existing API key or create one on first call. * * @return string */ public static function get_or_create_key() { $key = get_option( self::OPTION_KEY, '' ); if ( empty( $key ) ) { $key = self::generate_api_key(); update_option( self::OPTION_KEY, $key ); } return $key; } }