self::ID, 'name' => __( 'Site Scan', 'forensix-digital' ), 'description' => __( 'Finds broken links, missing images and files, redirect problems, mixed content and references to old domains, and shows where each one can be fixed.', 'forensix-digital' ), 'category' => 'scan', 'edition' => Edition::FREE, 'features' => array( 'F-SCAN-01' => Edition::FREE, 'F-SCAN-02' => Edition::FREE, 'F-SCAN-03' => Edition::FREE, 'F-SCAN-04' => Edition::FREE, 'F-SCAN-05' => Edition::FREE, 'F-SCAN-06' => Edition::FREE, 'F-SCAN-07' => Edition::FREE, ), 'deactivation_effects' => array( __( 'A running scan stops.', 'forensix-digital' ), __( 'Existing findings stay visible, but no new scans run.', 'forensix-digital' ), ), 'factory' => static fn( Container $c ): self => new self( $c->get( Settings::class ), $c->get( JobRunner::class ), $c->get( FindingStore::class ), $c->get( Notifier::class ), $c->get( Http::class ), new ScanQueue() ), ) ); } /** * Hooks the app while it is active. */ public function register(): void { $this->settings->register( self::ID, self::defaults(), array( self::class, 'sanitize_settings' ) ); $this->runner->register_type( self::JOB_TYPE, self::ID, fn(): ScanHandler => $this->handler() ); ( new Annotator() )->maybe_register(); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_action( 'forensix_job_failed', array( $this, 'job_failed' ), 10, 4 ); } /** * Nothing to set up; the tables belong to the core. */ public function activate(): void {} /** * Stops a running scan and removes the scan token. */ public function deactivate(): void { $scan = $this->store->latest_scan( self::ID ); if ( null !== $scan && FindingStore::SCAN_RUNNING === $scan['status'] ) { $this->runner->cancel( $scan['job_id'] ); $this->store->finish_scan( $scan['id'], FindingStore::SCAN_CANCELLED, array( 'coverage' => $this->queue->coverage( $scan['id'] ) ) ); } ScanToken::revoke(); remove_action( 'forensix_job_failed', array( $this, 'job_failed' ) ); } /** * Removes the scan inventory. Findings and scans are removed by the core. */ public function delete_data(): void { $this->queue->delete_all(); ScanToken::revoke(); } /** * Default settings. * * @return array */ public static function defaults(): array { return array( 'max_urls' => self::MAX_URLS, 'check_external' => true, 'include_noindex' => false, 'old_domains' => array(), ); } /** * Previous domains the user entered. * * @return string[] */ public function old_domains(): array { return OldDomainSearch::sanitize_domains( $this->settings->get( self::ID, 'old_domains', array() ), self::hosts() ); } /** * Cleans settings before they are saved. * * @param array $values Raw values. * @return array */ public static function sanitize_settings( array $values ): array { if ( array_key_exists( 'max_urls', $values ) ) { // The same range applies to every site. The ceiling only protects the server and the database. $values['max_urls'] = max( 10, min( self::URL_CEILING, (int) $values['max_urls'] ) ); } if ( array_key_exists( 'old_domains', $values ) ) { $values['old_domains'] = OldDomainSearch::sanitize_domains( $values['old_domains'], self::hosts() ); } foreach ( array( 'check_external', 'include_noindex' ) as $key ) { if ( array_key_exists( $key, $values ) ) { $values[ $key ] = (bool) $values[ $key ]; } } return $values; } /** * Hosts of the site itself, lower case. * * @return string[] */ public static function hosts(): array { $hosts = array(); foreach ( array( home_url(), site_url() ) as $url ) { $host = wp_parse_url( $url, PHP_URL_HOST ); if ( is_string( $host ) ) { $hosts[] = strtolower( $host ); } } return array_values( array_unique( $hosts ) ); } /** * The checks every page runs through. Other apps add theirs with the forensix_scan_checks filter. * * @return Check[] */ public function checks(): array { $hosts = self::hosts(); $external = (bool) $this->settings->get( self::ID, 'check_external', true ); $checks = array( new BrokenLinkCheck( $hosts, $external ), new MissingFileCheck( $hosts, $external ), new RedirectCheck( $hosts, $external ), new MixedContentCheck( $hosts, $external ), ); /** * Filters the checks of a site scan. * * @param Check[] $checks Checks. */ $filtered = apply_filters( 'forensix_scan_checks', $checks ); if ( ! is_array( $filtered ) ) { return $checks; } $valid = array(); foreach ( $filtered as $check ) { if ( $check instanceof Check ) { $valid[] = $check; } } return $valid; } /** * Checks of stored content that run after the pages. Other apps add theirs with the * forensix_scan_stored_checks filter. * * @return StoredContentCheck[] */ public function stored_checks(): array { $checks = array( new OldDomainSearch( self::ID, $this->store, $this->old_domains() ) ); /** * Filters the stored-content checks of a site scan. * * @param StoredContentCheck[] $checks Checks. */ $filtered = (array) apply_filters( 'forensix_scan_stored_checks', $checks ); return array_values( array_filter( $filtered, static fn( $c ): bool => $c instanceof StoredContentCheck ) ); } /** * Builds the job handler. */ public function handler(): ScanHandler { return new ScanHandler( self::ID, $this->store, $this->queue, $this->http, $this->checks(), self::hosts(), array( 'max_urls' => (int) $this->settings->get( self::ID, 'max_urls', self::MAX_URLS ), /** * Filters whether pages marked noindex are scanned. SEO Basics turns this on to report them. * * @param bool $include Setting of the site scan. */ 'include_noindex' => (bool) apply_filters( 'forensix_scan_include_noindex', (bool) $this->settings->get( self::ID, 'include_noindex', false ) ), /** * Filters the pause between page requests of a scan, in seconds. * * @param float $seconds Pause. */ 'pause' => (float) apply_filters( 'forensix_scan_pause', 0.2 ), ), $this->stored_checks() ); } /** * Starts a scan unless one is running. * * @return array|\WP_Error Status, or an error when a scan is running. */ public function start(): array|\WP_Error { $running = $this->running(); if ( null !== $running ) { return new \WP_Error( 'scan_running', __( 'A scan is already running.', 'forensix-digital' ), array( 'status' => 409 ) ); } $this->notifier->remove( self::FAIL_NOTICE ); $scan_id = $this->store->start_scan( self::ID ); $job_id = $this->runner->start( self::JOB_TYPE, array( 'scan_id' => $scan_id ) ); $this->store->set_scan_job( $scan_id, $job_id ); return $this->status(); } /** * Cancels the running scan. Findings keep their status, because the scan did not look everywhere. */ public function cancel(): bool { $scan = $this->running(); if ( null === $scan ) { return false; } $this->runner->cancel( $scan['job_id'] ); $this->store->finish_scan( $scan['id'], FindingStore::SCAN_CANCELLED, array( 'coverage' => $this->queue->coverage( $scan['id'] ) ) ); return true; } /** * Latest scan with progress and coverage. * * @return array */ public function status(): array { $scan = $this->running() ?? $this->store->latest_scan( self::ID ); if ( null === $scan ) { return array( 'scan' => null, 'running' => false, ); } $job = $scan['job_id'] > 0 ? $this->runner->find( $scan['job_id'] ) : null; $running = FindingStore::SCAN_RUNNING === $scan['status']; return array( 'scan' => $scan, 'running' => $running, 'phase' => null !== $job ? (string) ( $job->state['phase'] ?? 'start' ) : '', 'progress' => null !== $job ? $job->progress : 0, 'total' => null !== $job ? $job->total : 0, 'coverage' => $this->queue->coverage( $scan['id'] ), 'error' => null !== $job && Job::FAILED === $job->status ? $job->error : '', ); } /** * Marks a scan failed when its job failed for good. Hooked to forensix_job_failed. * * @param int $job_id Job id. * @param string $type Job type. * @param string $app App id. * @param string $message Reason. */ public function job_failed( $job_id, $type, $app, $message ): void { if ( self::JOB_TYPE !== $type ) { return; } $scan = $this->store->latest_scan( self::ID ); if ( null === $scan || (int) $job_id !== $scan['job_id'] || FindingStore::SCAN_RUNNING !== $scan['status'] ) { return; } $this->store->finish_scan( $scan['id'], FindingStore::SCAN_FAILED, array( 'coverage' => $this->queue->coverage( $scan['id'] ), 'error' => (string) $message, ) ); /* translators: %s: reason. */ $this->notifier->add( self::FAIL_NOTICE, sprintf( __( 'The site scan stopped: %s', 'forensix-digital' ), (string) $message ), 'error', self::ID ); } /** * The running scan. A scan whose job ended without closing it (for example after the plugin was * deactivated) is closed as failed here, so it never blocks new scans. * * @return array{id: int, app: string, status: string, job_id: int, stats: array, started_at: string, finished_at: string|null}|null */ private function running(): ?array { $scan = $this->store->latest_scan( self::ID ); if ( null === $scan || FindingStore::SCAN_RUNNING !== $scan['status'] ) { return null; } $job = $scan['job_id'] > 0 ? $this->runner->find( $scan['job_id'] ) : null; if ( null === $job || $job->is_finished() ) { $this->store->finish_scan( $scan['id'], FindingStore::SCAN_FAILED, array( 'coverage' => $this->queue->coverage( $scan['id'] ) ) ); return null; } return $scan; } /** * REST routes of the app (forensix/v1/scan/...). */ public function register_routes(): void { $can = static fn(): bool => current_user_can( Capabilities::VIEW_FINDINGS ); register_rest_route( 'forensix/v1', '/scan/status', array( 'methods' => 'GET', 'callback' => fn(): \WP_REST_Response => new \WP_REST_Response( $this->status() ), 'permission_callback' => $can, ) ); register_rest_route( 'forensix/v1', '/scan/start', array( 'methods' => 'POST', 'callback' => function () { $result = $this->start(); return is_wp_error( $result ) ? $result : new \WP_REST_Response( $result ); }, 'permission_callback' => $can, ) ); register_rest_route( 'forensix/v1', '/scan/cancel', array( 'methods' => 'POST', 'callback' => fn(): \WP_REST_Response => new \WP_REST_Response( array( 'cancelled' => $this->cancel(), 'status' => $this->status(), ) ), 'permission_callback' => $can, ) ); register_rest_route( 'forensix/v1', '/scan/settings', array( array( 'methods' => 'GET', 'callback' => fn(): \WP_REST_Response => new \WP_REST_Response( $this->present_settings() ), 'permission_callback' => $can, ), array( 'methods' => 'POST', 'callback' => array( $this, 'save_settings' ), 'permission_callback' => static fn(): bool => current_user_can( Capabilities::MANAGE_SETTINGS ), 'args' => array( 'old_domains' => array( 'type' => array( 'string', 'array' ) ), 'check_external' => array( 'type' => 'boolean' ), 'include_noindex' => array( 'type' => 'boolean' ), 'max_urls' => array( 'type' => 'integer' ), ), ), ) ); register_rest_route( 'forensix/v1', '/scan/coverage', array( 'methods' => 'GET', 'callback' => array( $this, 'coverage' ), 'permission_callback' => $can, 'args' => array( 'status' => array( 'type' => 'string', 'enum' => array( '', ScanQueue::PENDING, ScanQueue::SCANNED, ScanQueue::SKIPPED, ScanQueue::FAILED ), 'default' => '', ), 'page' => array( 'type' => 'integer', 'minimum' => 1, 'default' => 1, ), ), ) ); } /** * Scan settings with suggested old domains. * * @return array */ public function present_settings(): array { $values = $this->settings->all( self::ID ); $values['old_domains'] = $this->old_domains(); return array( 'values' => $values, 'suggestions' => array_values( array_diff( OldDomainSearch::suggestions( self::hosts() ), $values['old_domains'] ) ), 'can_edit' => current_user_can( Capabilities::MANAGE_SETTINGS ), 'url_ceiling' => self::URL_CEILING, ); } /** * Saves scan settings. * * @param \WP_REST_Request $request Request. */ public function save_settings( \WP_REST_Request $request ): \WP_REST_Response { $values = array(); foreach ( array_keys( self::defaults() ) as $key ) { if ( null !== $request->get_param( $key ) ) { $values[ $key ] = $request->get_param( $key ); } } $this->settings->update( self::ID, $values ); return new \WP_REST_Response( $this->present_settings() ); } /** * Pages of the latest scan's inventory. * * @param \WP_REST_Request $request Request. */ public function coverage( \WP_REST_Request $request ): \WP_REST_Response { $scan = $this->store->latest_scan( self::ID ); if ( null === $scan ) { return new \WP_REST_Response( array( 'items' => array(), 'total' => 0, ) ); } return new \WP_REST_Response( $this->queue->urls( $scan['id'], (string) $request->get_param( 'status' ), (int) $request->get_param( 'page' ) ) ); } }