*/ private array $quick_check_ids; private Budget $quick_budget; /** @var list */ private array $full_check_ids; private ?Budget $full_budget; private ?CrawlPageRepository $crawl_pages; private string $same_site_origin; private ?TransactionManager $transaction; /** @var list */ private array $deep_code_check_ids; private ?Budget $deep_code_budget; private ?CodeFileRepository $code_files; /** @var list */ private array $vendor_check_ids; private ?Budget $vendor_budget; private ?CodeFileRepository $vendor_files; private ?VendorCodeScopeProvider $vendor_provider; /** @var list */ private array $rendered_check_ids; private ?Budget $rendered_budget; private ?RenderedSubmissionService $rendered_submission; private string $registry_version; private ReportService $reports; /** * @param list $quick_check_ids Implemented local quick profile. * @param list $full_check_ids Implemented same-site Full profile. * @param list $deep_code_check_ids Implemented local deep-code profile. * @param list $vendor_check_ids Implemented explicit Vendor profile. * @param list $rendered_check_ids Implemented browser profile. */ public function __construct( RunCoordinator $coordinator, RunRepository $runs, ReportService $reports, ChunkWorker $runner, array $quick_check_ids, Budget $quick_budget, string $registry_version, array $full_check_ids = array(), ?Budget $full_budget = null, ?CrawlPageRepository $crawl_pages = null, string $same_site_origin = '', ?TransactionManager $transaction = null, array $deep_code_check_ids = array(), ?Budget $deep_code_budget = null, ?CodeFileRepository $code_files = null, array $vendor_check_ids = array(), ?Budget $vendor_budget = null, ?CodeFileRepository $vendor_files = null, ?VendorCodeScopeProvider $vendor_provider = null, array $rendered_check_ids = array(), ?Budget $rendered_budget = null, ?RenderedSubmissionService $rendered_submission = null ) { $this->coordinator = $coordinator; $this->runs = $runs; $this->reports = $reports; $this->runner = $runner; $this->quick_check_ids = $quick_check_ids; $this->quick_budget = $quick_budget; $this->full_check_ids = $full_check_ids; $this->full_budget = $full_budget; $this->crawl_pages = $crawl_pages; $this->same_site_origin = $same_site_origin; $this->transaction = $transaction; $this->registry_version = $registry_version; $this->deep_code_check_ids = $deep_code_check_ids; $this->deep_code_budget = $deep_code_budget; $this->code_files = $code_files; $this->vendor_check_ids = $vendor_check_ids; $this->vendor_budget = $vendor_budget; $this->vendor_files = $vendor_files; $this->vendor_provider = $vendor_provider; $this->rendered_check_ids = $rendered_check_ids; $this->rendered_budget = $rendered_budget; $this->rendered_submission = $rendered_submission; } public function register_routes(): void { register_rest_route( self::NAMESPACE, '/runs', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create' ), 'permission_callback' => array( $this, 'authorize' ), 'args' => array( 'profile' => array( 'type' => 'string', 'enum' => array( 'quick', 'full', 'deep_code', 'vendor', 'rendered' ), 'required' => true, ), 'language' => array( 'type' => 'string', 'enum' => array( 'en', 'ru' ), 'required' => true, ), 'same_site_confirmed' => array( 'type' => 'boolean', 'default' => false, ), 'disclosure_version' => array( 'type' => 'string', 'maxLength' => 24, 'default' => '', ), 'vendor_confirmed' => array( 'type' => 'boolean', 'default' => false, ), 'vendor_targets' => array( 'type' => 'array', 'maxItems' => VendorCodeScopeProvider::MAX_SELECTION, 'default' => array(), 'items' => array( 'type' => 'string', 'maxLength' => 240, ), ), 'rendered_confirmed' => array( 'type' => 'boolean', 'default' => false, ), 'rendered_disclosure_version' => array( 'type' => 'string', 'maxLength' => 24, 'default' => '', ), ), ) ); register_rest_route( self::NAMESPACE, '/runs/(?P[0-9a-fA-F-]{36})/rendered-observation', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rendered_observation' ), 'permission_callback' => array( $this, 'authorize' ), 'args' => array( 'observation' => array( 'type' => 'object', 'required' => true, ), ), ) ); register_rest_route( self::NAMESPACE, '/runs/latest', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'latest' ), 'permission_callback' => array( $this, 'authorize' ), ) ); register_rest_route( self::NAMESPACE, '/runs/(?P[0-9a-fA-F-]{36})', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'summary' ), 'permission_callback' => array( $this, 'authorize' ), ) ); register_rest_route( self::NAMESPACE, '/runs/(?P[0-9a-fA-F-]{36})/(?Pstart|pause|resume|cancel)', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'change_state' ), 'permission_callback' => array( $this, 'authorize' ), ) ); register_rest_route( self::NAMESPACE, '/runs/(?P[0-9a-fA-F-]{36})/work', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'work' ), 'permission_callback' => array( $this, 'authorize' ), ) ); register_rest_route( self::NAMESPACE, '/runs/(?P[0-9a-fA-F-]{36})/findings', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'findings' ), 'permission_callback' => array( $this, 'authorize' ), ) ); register_rest_route( self::NAMESPACE, '/runs/(?P[0-9a-fA-F-]{36})/events', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'events' ), 'permission_callback' => array( $this, 'authorize' ), ) ); } /** * @param WP_REST_Request> $request REST request. * * @return WP_REST_Response|WP_Error */ public function summary( WP_REST_Request $request ) { try { $summary = $this->runs->summary( (string) $request->get_param( 'uuid' ) ); } catch ( StorageException $exception ) { return $this->safe_error( $exception ); } if ( null === $summary ) { return $this->not_found(); } return new WP_REST_Response( array( 'run' => $summary ), 200 ); } /** * @return WP_REST_Response|WP_Error */ public function latest() { try { $summary = $this->runs->latest_summary(); } catch ( StorageException $exception ) { return $this->safe_error( $exception ); } return new WP_REST_Response( array( 'run' => $summary ), 200 ); } /** * @param WP_REST_Request> $request REST request. * * @return WP_REST_Response|WP_Error */ public function findings( WP_REST_Request $request ) { try { $run = $this->runs->get_by_uuid( (string) $request->get_param( 'uuid' ) ); if ( null === $run ) { return $this->not_found(); } $report = $this->reports->view( $run ); $summary = $this->runs->summary( $run->uuid() ); } catch ( InvalidArgumentException | StorageException $exception ) { return $this->safe_error( $exception ); } return new WP_REST_Response( array( 'run' => $summary, 'count' => count( $report['findings'] ), 'findings' => $report['findings'], 'finding_refs' => $report['finding_refs'], 'finding_meta' => $report['finding_meta'], 'actions' => $report['actions'], 'comparison' => $report['comparison'], 'readiness' => $report['readiness'], 'status_counts' => $report['status_counts'], ), 200 ); } /** * Accept one minimized same-origin browser observation. * * @param WP_REST_Request> $request REST request. * @return WP_REST_Response|WP_Error */ public function rendered_observation( WP_REST_Request $request ) { $observation = $request->get_param( 'observation' ); if ( null === $this->rendered_submission || ! is_array( $observation ) ) { return $this->safe_error( new InvalidArgumentException( 'rendered_observation_invalid' ) ); } try { $uuid = (string) $request->get_param( 'uuid' ); $result = $this->rendered_submission->submit( $uuid, $observation ); $result['run'] = $this->runs->summary( $uuid ); } catch ( \Throwable $exception ) { return $this->safe_error( $exception ); } return new WP_REST_Response( $result, 200 ); } /** * Execute at most one bounded checker chunk. * * @param WP_REST_Request> $request REST request. * * @return WP_REST_Response|WP_Error */ public function work( WP_REST_Request $request ) { try { $uuid = (string) $request->get_param( 'uuid' ); $result = $this->runner->run_one( $uuid ); $result['run'] = $this->runs->summary( $uuid ); $record = $this->runs->get_by_uuid( $uuid ); if ( null !== $record && null !== $this->crawl_pages && 'seo.discovery_indexability' === ( $result['check_id'] ?? null ) ) { $result['crawl_progress'] = $this->crawl_pages->progress( $record->id() ); } } catch ( StorageException $exception ) { return $this->safe_error( $exception ); } return new WP_REST_Response( $result, 200 ); } /** * Require both administrator capability and the current wp_rest nonce. * * @param WP_REST_Request> $request REST request. * * @return true|WP_Error */ public function authorize( WP_REST_Request $request ) { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'px320_audit_forbidden', __( 'You are not allowed to manage site audits.', '320px-site-audit' ), array( 'status' => 403 ) ); } $nonce = $request->get_header( 'X-WP-Nonce' ); if ( ! is_string( $nonce ) || false === wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new WP_Error( 'px320_audit_invalid_nonce', __( 'The audit request could not be verified. Refresh the page and try again.', '320px-site-audit' ), array( 'status' => 403 ) ); } return true; } /** * @param WP_REST_Request> $request REST request. * * @return WP_REST_Response|WP_Error */ public function create( WP_REST_Request $request ) { try { $profile = (string) $request->get_param( 'profile' ); $actor = get_current_user_id(); $transaction = null; $vendor_seeds = array(); if ( 'full' === $profile ) { if ( array() === $this->full_check_ids || null === $this->full_budget || null === $this->crawl_pages || '' === $this->same_site_origin || null === $this->transaction || true !== $request->get_param( 'same_site_confirmed' ) || self::SAME_SITE_DISCLOSURE_VERSION !== (string) $request->get_param( 'disclosure_version' ) ) { throw new InvalidArgumentException( 'same_site_disclosure_required' ); } $budget = $this->full_budget; $check_ids = $this->full_check_ids; $transaction = $this->transaction; } elseif ( 'deep_code' === $profile ) { if ( array() === $this->deep_code_check_ids || null === $this->deep_code_budget || null === $this->code_files ) { throw new InvalidArgumentException( 'deep_code_profile_unavailable' ); } $budget = $this->deep_code_budget; $check_ids = $this->deep_code_check_ids; } elseif ( 'vendor' === $profile ) { $targets = $request->get_param( 'vendor_targets' ); if ( array() === $this->vendor_check_ids || null === $this->vendor_budget || null === $this->vendor_files || null === $this->vendor_provider || null === $this->transaction || true !== $request->get_param( 'vendor_confirmed' ) || ! is_array( $targets ) ) { throw new InvalidArgumentException( 'vendor_selection_required' ); } $vendor_seeds = $this->vendor_provider->selected_seeds( $targets ); $budget = $this->vendor_budget; $check_ids = $this->vendor_check_ids; $transaction = $this->transaction; } elseif ( 'rendered' === $profile ) { if ( array() === $this->rendered_check_ids || null === $this->rendered_budget || null === $this->rendered_submission || null === $this->transaction || true !== $request->get_param( 'rendered_confirmed' ) || self::RENDERED_DISCLOSURE_VERSION !== (string) $request->get_param( 'rendered_disclosure_version' ) ) { throw new InvalidArgumentException( 'rendered_disclosure_required' ); } $budget = $this->rendered_budget; $check_ids = $this->rendered_check_ids; $transaction = $this->transaction; } else { $budget = $this->quick_budget; $check_ids = $this->quick_check_ids; } if ( $transaction instanceof TransactionManager ) { $transaction->begin(); } try { $run = $this->coordinator->create( $profile, (string) $request->get_param( 'language' ), $this->registry_version, $budget, $check_ids, $actor ); if ( 'vendor' === $profile ) { foreach ( $vendor_seeds as $seed ) { if ( ! $this->vendor_files->enqueue( $run->id(), $seed['scope'], $seed['path'], $seed['type'] ) ) { throw new StorageException( 'vendor_seed_failed' ); } } $this->runs->record_vendor_selection_confirmation( $run->uuid(), $actor ); } if ( 'full' === $profile && ! $this->crawl_pages->enqueue_robots( $run->id() ) ) { throw new StorageException( 'crawl_seed_failed' ); } if ( $transaction instanceof TransactionManager ) { if ( 'full' === $profile ) { $this->runs->record_same_site_disclosure( $run->uuid(), $actor ); } if ( 'rendered' === $profile ) { $this->runs->record_rendered_disclosure( $run->uuid(), $actor ); } $transaction->commit(); } } catch ( InvalidArgumentException | StorageException $exception ) { if ( $transaction instanceof TransactionManager ) { $transaction->rollback(); } throw $exception; } } catch ( InvalidArgumentException | StorageException $exception ) { return $this->safe_error( $exception ); } return new WP_REST_Response( $this->run_data( $run ), 201 ); } /** * @param WP_REST_Request> $request REST request. * * @return WP_REST_Response|WP_Error */ public function change_state( WP_REST_Request $request ) { $uuid = (string) $request->get_param( 'uuid' ); $action = (string) $request->get_param( 'action' ); $actor = get_current_user_id(); try { $existing = $this->runs->get_by_uuid( $uuid ); if ( null === $existing ) { return $this->not_found(); } switch ( $action ) { case 'start': $run = $this->coordinator->start( $uuid, $actor ); break; case 'pause': $run = $this->coordinator->pause( $uuid, $actor ); break; case 'resume': $run = $this->coordinator->resume( $uuid, $actor ); break; case 'cancel': $run = $this->coordinator->cancel( $uuid, $actor ); if ( 'full' === $existing->profile() && null !== $this->crawl_pages ) { $this->crawl_pages->clear_pending( $existing->id(), 'run_cancelled' ); } if ( 'deep_code' === $existing->profile() && null !== $this->code_files ) { $this->code_files->clear_pending( $existing->id(), 'run_cancelled' ); } if ( 'vendor' === $existing->profile() && null !== $this->vendor_files ) { $this->vendor_files->clear_pending( $existing->id(), 'run_cancelled' ); } break; default: throw new InvalidArgumentException( 'invalid_action' ); } } catch ( InvalidArgumentException | InvalidRunTransition | StorageException $exception ) { return $this->safe_error( $exception ); } $data = $this->run_data( $run ); if ( 'full' === $existing->profile() && null !== $this->crawl_pages ) { $data['crawl_progress'] = $this->crawl_pages->progress( $existing->id() ); } return new WP_REST_Response( $data, 200 ); } /** * @param WP_REST_Request> $request REST request. * * @return WP_REST_Response|WP_Error */ public function events( WP_REST_Request $request ) { try { $events = $this->runs->events( (string) $request->get_param( 'uuid' ) ); } catch ( StorageException $exception ) { return $this->safe_error( $exception ); } return new WP_REST_Response( array( 'events' => $events ), 200 ); } /** * @return array{uuid:string,status:string,profile:string,language:string} */ private function run_data( RunRecord $run ): array { return array( 'uuid' => $run->uuid(), 'status' => $run->status(), 'profile' => $run->profile(), 'language' => $run->language(), ); } private function safe_error( \Throwable $exception ): WP_Error { $status = 'run_not_found' === $exception->getMessage() ? 404 : 409; return new WP_Error( 'px320_audit_state_conflict', __( 'The audit state changed before this request completed. Refresh the report and try again.', '320px-site-audit' ), array( 'status' => $status ) ); } private function not_found(): WP_Error { return new WP_Error( 'px320_audit_run_not_found', __( 'The requested audit report was not found.', '320px-site-audit' ), array( 'status' => 404 ) ); } }