client = $client; } /** * Register all AJAX actions. */ public function register(): void { add_action( 'wp_ajax_accesslens_start_scan', array( $this, 'handle_start_scan' ) ); add_action( 'wp_ajax_accesslens_poll_scan', array( $this, 'handle_poll_scan' ) ); add_action( 'wp_ajax_accesslens_get_report', array( $this, 'handle_get_report' ) ); add_action( 'wp_ajax_accesslens_dismiss_onboarding', array( $this, 'handle_dismiss_onboarding' ) ); add_action( 'wp_ajax_accesslens_dismiss_review', array( $this, 'handle_dismiss_review' ) ); } // ---------------------------------------------------------------- // Helpers // ---------------------------------------------------------------- /** * Verify nonce and capability, die on failure. */ private function verify_request(): void { check_ajax_referer( 'accesslens_audit_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'accesslens' ) ), 403 ); } } /** * Get transient key for current user's active job. */ private function job_transient_key(): string { return 'accesslens_job_' . get_current_user_id(); } /** * Persist the active job for the current user. Stores the scanned URL and * mode alongside the jobId so we can label the persisted "last scan" * (option accesslens_last_scan) and append to the scan history once the * job completes. * * @param string $job_id Job identifier returned by the engine. * @param string $scan_url URL originally submitted for this job. * @param string $mode Scan mode originally submitted for this job. */ private function set_active_job( string $job_id, string $scan_url, string $mode ): void { set_transient( $this->job_transient_key(), array( 'jobId' => $job_id, 'url' => $scan_url, 'mode' => $mode, ), self::JOB_TTL ); } /** * Get the active job for the current user. * * @return array{jobId:string,url:string,mode:string}|null */ private function get_active_job(): ?array { $job = get_transient( $this->job_transient_key() ); if ( empty( $job ) ) { return null; } // Retrocompatibilidad: versiones anteriores guardaban solo el jobId (string). if ( is_string( $job ) ) { return array( 'jobId' => $job, 'url' => '', 'mode' => 'single', ); } if ( ! is_array( $job ) || empty( $job['jobId'] ) ) { return null; } return array( 'jobId' => (string) $job['jobId'], 'url' => (string) ( $job['url'] ?? '' ), 'mode' => (string) ( $job['mode'] ?? 'single' ), ); } /** * Defensa en profundidad: el motor tambien valida esto, pero lo comprobamos * en el plugin antes de llamar al motor para no gastar cuota en peticiones invalidas. * * @param string $url URL a comprobar. * @return bool True si el host de $url coincide con el host del propio sitio. */ private function is_same_site_host( string $url ): bool { $target_host = wp_parse_url( $url, PHP_URL_HOST ); $site_host = wp_parse_url( home_url(), PHP_URL_HOST ); if ( empty( $target_host ) || empty( $site_host ) ) { return false; } return 0 === strcasecmp( (string) $target_host, (string) $site_host ); } /** * Map a WP_Error coming from AccessLens_Engine_Client to an HTTP status + a * clear Spanish message for the browser. Centraliza el mapeo de codigos * estables del motor (pro_required, quota_exceeded, rate_limited, * domain_not_allowed, service_busy) para los 3 handlers AJAX. * * @param WP_Error $error Error devuelto por el cliente del motor. * @return array{status:int,payload:array} */ private function engine_error_to_response( WP_Error $error ): array { $code = $error->get_error_code(); $data = $error->get_error_data(); $data = is_array( $data ) ? $data : array(); $reset_at = sanitize_text_field( (string) ( $data['resetAt'] ?? '' ) ); $retry_after = absint( $data['retryAfter'] ?? 0 ); $http_status = ! empty( $data['http_status'] ) ? (int) $data['http_status'] : 502; switch ( $code ) { case 'pro_required': $upgrade_url = function_exists( 'accesslens_fs' ) ? accesslens_fs()->get_upgrade_url() : ''; return array( 'status' => 402, 'payload' => array( 'message' => __( 'This feature requires a Pro subscription to the AccessLens service.', 'accesslens' ), 'upgradeUrl' => esc_url_raw( (string) $upgrade_url ), ), ); case 'quota_exceeded': return array( 'status' => 429, 'payload' => array( 'message' => __( 'You have reached the daily scan limit. It resets tomorrow.', 'accesslens' ), 'resetAt' => $reset_at, ), ); case 'rate_limited': return array( 'status' => 429, 'payload' => array( 'message' => __( 'Too many requests in a row. Wait a few seconds and try again.', 'accesslens' ), 'retryAfter' => $retry_after, ), ); case 'domain_not_allowed': return array( 'status' => 403, 'payload' => array( 'message' => __( 'You can only audit pages on your own site.', 'accesslens' ), ), ); case 'service_busy': return array( 'status' => 503, 'payload' => array( 'message' => __( 'The service is currently busy. Try again in a few minutes.', 'accesslens' ), 'retryAfter' => $retry_after, ), ); default: return array( 'status' => $http_status, 'payload' => array( 'message' => $error->get_error_message() ), ); } } /** * Sanitize the quota object the engine may include in its responses. * * @param array $data Raw data from engine (start_scan or get_status response). * @return array|null Sanitized quota, or null if the engine did not send one. */ private function sanitize_quota( array $data ): ?array { if ( ! isset( $data['quota'] ) || ! is_array( $data['quota'] ) ) { return null; } $quota = $data['quota']; return array( 'plan' => sanitize_text_field( (string) ( $quota['plan'] ?? '' ) ), 'scansUsed' => absint( $quota['scansUsed'] ?? 0 ), 'scansLimit' => absint( $quota['scansLimit'] ?? 0 ), 'resetAt' => sanitize_text_field( (string) ( $quota['resetAt'] ?? '' ) ), ); } // ---------------------------------------------------------------- // Handlers // ---------------------------------------------------------------- /** * Handle accesslens_start_scan AJAX action. */ public function handle_start_scan(): void { $this->verify_request(); // verifica nonce (check_ajax_referer) + capability. // El nonce se verifica en verify_request(); PHPCS no rastrea helpers. // phpcs:disable WordPress.Security.NonceVerification.Missing // Sanitize inputs. $raw_url = isset( $_POST['scan_url'] ) ? sanitize_text_field( wp_unslash( $_POST['scan_url'] ) ) : ''; $raw_mode = isset( $_POST['scan_mode'] ) ? sanitize_text_field( wp_unslash( $_POST['scan_mode'] ) ) : 'single'; // phpcs:enable WordPress.Security.NonceVerification.Missing $scan_url = esc_url_raw( $raw_url ); if ( empty( $scan_url ) ) { wp_send_json_error( array( 'message' => __( 'Invalid URL.', 'accesslens' ) ), 400 ); return; } // Defensa en profundidad: el motor tambien restringe el target al mismo dominio, // pero lo comprobamos aqui primero para no gastar cuota en peticiones invalidas. if ( ! $this->is_same_site_host( $scan_url ) ) { $mapped = $this->engine_error_to_response( new WP_Error( 'domain_not_allowed', '' ) ); wp_send_json_error( $mapped['payload'], $mapped['status'] ); return; } // Validacion basica del modo. El nivel free/Pro lo decide el servicio externo // (serviceware): el plugin no bloquea funciones; envia la licencia y el motor aplica el plan. $mode = in_array( $raw_mode, array( 'single', 'sitemap', 'crawl' ), true ) ? $raw_mode : 'single'; $result = $this->client->start_scan( $scan_url, $mode, accesslens_license_key() ); if ( is_wp_error( $result ) ) { $mapped = $this->engine_error_to_response( $result ); wp_send_json_error( $mapped['payload'], $mapped['status'] ); return; } // Persist jobId + scanned URL + mode for this user (se usan para etiquetar // el "ultimo scan" persistido y para el historico de scans cuando el job termine). $this->set_active_job( sanitize_text_field( $result['jobId'] ), $scan_url, $mode ); wp_send_json_success( array( 'jobId' => esc_html( $result['jobId'] ), 'status' => esc_html( $result['status'] ?? 'queued' ), 'pageLimit' => absint( $result['pageLimit'] ?? 0 ), 'quota' => $this->sanitize_quota( $result ), ) ); } /** * Handle accesslens_poll_scan AJAX action. */ public function handle_poll_scan(): void { $this->verify_request(); $job = $this->get_active_job(); if ( null === $job ) { wp_send_json_error( array( 'message' => __( 'There is no active scan for this user.', 'accesslens' ) ), 404 ); return; } $result = $this->client->get_status( $job['jobId'] ); if ( is_wp_error( $result ) ) { $mapped = $this->engine_error_to_response( $result ); wp_send_json_error( $mapped['payload'], $mapped['status'] ); return; } $sanitized = $this->sanitize_status_response( $result ); // Job terminado: liberamos el transient de inmediato en vez de esperar el TTL de 4h. if ( in_array( $sanitized['status'], array( 'done', 'error' ), true ) ) { delete_transient( $this->job_transient_key() ); } if ( 'done' === $sanitized['status'] ) { $sanitized['history'] = $this->persist_last_scan( $job['url'], $job['mode'], $sanitized ); AccessLens_Options::increment_scan_count(); } wp_send_json_success( $sanitized ); } /** * Persist the completed scan as option `accesslens_last_scan` (autoload no) * and append a compact entry to the `accesslens_scan_history` option. * Flattens violations across all scanned pages and caps the stored list at * 100 entries (payloads for sitemap/crawl scans can be large). * * @param string $scan_url URL originally submitted for this job. * @param string $mode Scan mode originally submitted for this job. * @param array $sanitized Sanitized status response (see sanitize_status_response()). * @return array Updated scan history (see append_scan_history()), for the AJAX response. */ private function persist_last_scan( string $scan_url, string $mode, array $sanitized ): array { $stats = array( 'critical' => 0, 'serious' => 0, 'moderate' => 0, 'minor' => 0, 'total' => 0, ); $violations = array(); foreach ( $sanitized['results'] as $page ) { foreach ( array_keys( $stats ) as $severity ) { $stats[ $severity ] += absint( $page['summary'][ $severity ] ?? 0 ); } foreach ( $page['violations'] as $violation ) { $violations[] = array_merge( $violation, array( 'pageUrl' => $page['url'] ) ); } } $truncated = count( $violations ) > 100; AccessLens_Options::set( 'accesslens_last_scan', array( 'timestamp' => time(), 'url' => esc_url_raw( $scan_url ), 'stats' => $stats, 'violations' => array_slice( $violations, 0, 100 ), 'truncated' => $truncated, ) ); return $this->append_scan_history( $scan_url, $mode, count( $sanitized['results'] ), $stats ); } /** * Append a compact entry (no violation detail — that lives in * accesslens_last_scan) to the scan history option, capped at * self::HISTORY_CAP entries (oldest discarded first). * * @param string $scan_url URL originally submitted for this job. * @param string $mode Scan mode ('single'|'sitemap'|'crawl'). * @param int $pages_scanned Number of pages included in this scan. * @param array $stats Severity counts {critical,serious,moderate,minor[,total]}. * @return array Updated, capped history (oldest first). */ private function append_scan_history( string $scan_url, string $mode, int $pages_scanned, array $stats ): array { $history = AccessLens_Options::get( 'accesslens_scan_history', array() ); if ( ! is_array( $history ) ) { $history = array(); } $history[] = array( 'timestamp' => time(), 'url' => esc_url_raw( $scan_url ), 'mode' => in_array( $mode, array( 'single', 'sitemap', 'crawl' ), true ) ? $mode : 'single', 'pagesScanned' => absint( $pages_scanned ), 'stats' => array( 'critical' => absint( $stats['critical'] ?? 0 ), 'serious' => absint( $stats['serious'] ?? 0 ), 'moderate' => absint( $stats['moderate'] ?? 0 ), 'minor' => absint( $stats['minor'] ?? 0 ), ), ); if ( count( $history ) > self::HISTORY_CAP ) { $history = array_slice( $history, -self::HISTORY_CAP ); } AccessLens_Options::set( 'accesslens_scan_history', $history ); return $history; } /** * Handle accesslens_get_report AJAX action. * Proxies the PDF/HTML from the engine and forces browser download. */ public function handle_get_report(): void { $this->verify_request(); $job = $this->get_active_job(); if ( null === $job ) { wp_send_json_error( array( 'message' => __( 'There is no completed scan to download.', 'accesslens' ) ), 404 ); return; } $job_id = $job['jobId']; // El nonce se verifica en verify_request(); PHPCS no rastrea helpers. // phpcs:disable WordPress.Security.NonceVerification.Missing // Collect and sanitize report metadata from POST. // Nota: 'siteUrl' no se recoge aqui — AccessLens_Engine_Client::get_report() lo fija // siempre a home_url('/'), el motor lo usa para restringir el target al propio dominio. $meta = array( 'siteName' => sanitize_text_field( wp_unslash( $_POST['site_name'] ?? get_bloginfo( 'name' ) ) ), 'entityType' => in_array( wp_unslash( $_POST['entity_type'] ?? '' ), array( 'private', 'public' ), true ) ? sanitize_text_field( wp_unslash( $_POST['entity_type'] ) ) : 'private', 'contactEmail' => sanitize_email( wp_unslash( $_POST['contact_email'] ?? '' ) ), 'contactUrl' => esc_url_raw( wp_unslash( $_POST['contact_url'] ?? '' ) ), 'preparedBy' => sanitize_text_field( wp_unslash( $_POST['prepared_by'] ?? '' ) ), ); // phpcs:enable WordPress.Security.NonceVerification.Missing $format = 'pdf'; // Force PDF for now. // Envia la licencia; el motor decide si incluye las funciones Pro (IA) en el informe. $result = $this->client->get_report( $job_id, $meta, $format, accesslens_license_key() ); if ( is_wp_error( $result ) ) { $mapped = $this->engine_error_to_response( $result ); wp_send_json_error( $mapped['payload'], $mapped['status'] ); return; } // Stream binary to browser. $content_type = $result['content_type'] ?: 'application/pdf'; $filename = 'accesslens-report-' . sanitize_title( $meta['siteName'] ) . '.pdf'; header( 'Content-Type: ' . sanitize_mime_type( $content_type ) ); header( 'Content-Disposition: attachment; filename="' . rawurlencode( $filename ) . '"' ); header( 'Content-Length: ' . strlen( $result['body'] ) ); header( 'Cache-Control: no-store' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo $result['body']; wp_die(); } // ---------------------------------------------------------------- // Sanitizers // ---------------------------------------------------------------- /** * Sanitize the status response array before sending to JS. * * @param array $data Raw data from engine. * @return array Sanitized data. */ private function sanitize_status_response( array $data ): array { $allowed_statuses = array( 'queued', 'discovering', 'running', 'done', 'error' ); $status = sanitize_text_field( $data['status'] ?? 'queued' ); if ( ! in_array( $status, $allowed_statuses, true ) ) { $status = 'queued'; } $progress = array( 'total' => absint( $data['progress']['total'] ?? 0 ), 'done' => absint( $data['progress']['done'] ?? 0 ), ); $results = array(); if ( is_array( $data['results'] ?? null ) ) { foreach ( $data['results'] as $page ) { if ( ! is_array( $page ) ) { continue; } $results[] = $this->sanitize_page_result( $page ); } } $errors = array(); if ( is_array( $data['errors'] ?? null ) ) { foreach ( $data['errors'] as $err ) { $errors[] = sanitize_text_field( (string) $err ); } } return array( 'status' => $status, 'progress' => $progress, 'results' => $results, 'errors' => $errors, 'error' => sanitize_text_field( $data['error'] ?? '' ), 'quota' => $this->sanitize_quota( $data ), ); } /** * Sanitize a single page result object. * * @param array $page Raw page result. * @return array Sanitized. */ private function sanitize_page_result( array $page ): array { $summary = array( 'total' => absint( $page['summary']['total'] ?? 0 ), 'critical' => absint( $page['summary']['critical'] ?? 0 ), 'serious' => absint( $page['summary']['serious'] ?? 0 ), 'moderate' => absint( $page['summary']['moderate'] ?? 0 ), 'minor' => absint( $page['summary']['minor'] ?? 0 ), ); $violations = array(); if ( is_array( $page['violations'] ?? null ) ) { foreach ( $page['violations'] as $v ) { if ( ! is_array( $v ) ) { continue; } $wcag = array(); if ( is_array( $v['wcagCriteria'] ?? null ) ) { foreach ( $v['wcagCriteria'] as $w ) { $wcag[] = sanitize_text_field( (string) $w ); } } $violations[] = array( 'id' => sanitize_text_field( $v['id'] ?? '' ), 'impact' => sanitize_text_field( $v['impact'] ?? '' ), 'wcagCriteria' => $wcag, 'help' => sanitize_text_field( $v['help'] ?? '' ), 'nodes' => absint( count( $v['nodes'] ?? array() ) ), ); } } return array( 'url' => esc_url_raw( $page['url'] ?? '' ), 'pageTitle' => sanitize_text_field( $page['pageTitle'] ?? '' ), 'summary' => $summary, 'violations' => $violations, ); } // ---------------------------------------------------------------- // Dismissible notices // ---------------------------------------------------------------- /** * Handle accesslens_dismiss_onboarding AJAX action. */ public function handle_dismiss_onboarding(): void { $this->verify_request(); AccessLens_Options::set( 'accesslens_onboarding_dismissed', true ); wp_send_json_success(); } /** * Handle accesslens_dismiss_review AJAX action. */ public function handle_dismiss_review(): void { $this->verify_request(); AccessLens_Options::set( 'accesslens_review_dismissed', true ); wp_send_json_success(); } }