$active->id ) ); } $config = Settings::all(); $id = Repository::create_scan( $type, $trigger, $config ); Repository::update_scan( $id, array( 'status' => 'running', 'stage' => self::job( $type )->first_stage(), 'started_at' => Db::now(), 'heartbeat_at' => Db::now(), ) ); Lock::release( 'start_' . $type, $token ); Cron::ensure_queue(); Logger::log( 'info', sprintf( 'Started %s scan #%d (%s).', $type, $id, $trigger ) ); return $id; } /** * Change scan state. * * @param int $id Scan ID. * @param string $action pause|resume|cancel|retry. * @return true|\WP_Error */ public static function control( $id, $action ) { $scan = Repository::get_scan( $id ); if ( ! $scan ) { return new \WP_Error( 'siteaudithub_not_found', __( 'Scan not found.', 'siteaudithub' ) ); } switch ( $action ) { case 'pause': if ( ! in_array( $scan->status, array( 'queued', 'running' ), true ) ) { return new \WP_Error( 'siteaudithub_state', __( 'Only running scans can be paused.', 'siteaudithub' ) ); } Repository::update_scan( $id, array( 'status' => 'paused' ) ); break; case 'resume': if ( 'paused' !== $scan->status ) { return new \WP_Error( 'siteaudithub_state', __( 'Only paused scans can be resumed.', 'siteaudithub' ) ); } Repository::update_scan( $id, array( 'status' => 'running', 'error_message' => '', 'heartbeat_at' => Db::now(), ) ); Cron::ensure_queue(); break; case 'cancel': if ( ! in_array( $scan->status, Repository::ACTIVE_STATUSES, true ) ) { return new \WP_Error( 'siteaudithub_state', __( 'This scan is not active.', 'siteaudithub' ) ); } Repository::update_scan( $id, array( 'status' => 'cancelled', 'finished_at' => Db::now(), ) ); break; case 'retry': if ( 'site' !== $scan->scan_type || ! in_array( $scan->status, array( 'completed', 'paused', 'running' ), true ) ) { return new \WP_Error( 'siteaudithub_state', __( 'Failed URLs can be retried on site audits that are completed or in progress.', 'siteaudithub' ) ); } $latest = Repository::latest_completed( 'site' ); if ( 'completed' === $scan->status && $latest && $latest->id !== $scan->id ) { return new \WP_Error( 'siteaudithub_state', __( 'Only the most recent completed audit can be retried. Start a new scan instead.', 'siteaudithub' ) ); } if ( Repository::active_scan( 'site' ) && 'completed' === $scan->status ) { return new \WP_Error( 'siteaudithub_active', __( 'Another site audit is in progress.', 'siteaudithub' ) ); } $reset = Repository::reset_failed_items( $id ); if ( ! $reset ) { return new \WP_Error( 'siteaudithub_nothing', __( 'There are no failed or inaccessible URLs to retry.', 'siteaudithub' ) ); } // Failures are re-evaluated; site-wide analysis and later stages re-run. Repository::delete_findings( $id, 'health' ); Repository::delete_findings( $id, 'plugins' ); self::delete_sitewide_findings( $id ); $state = $scan->state; unset( $state['analyze_step'] ); Repository::update_scan( $id, array( 'status' => 'running', 'stage' => 'crawl', 'state' => $state, 'finished_at' => null, 'heartbeat_at' => Db::now(), ) ); Cron::ensure_queue(); break; default: return new \WP_Error( 'siteaudithub_action', __( 'Unknown action.', 'siteaudithub' ) ); } Logger::log( 'info', sprintf( 'Scan #%d: %s.', $id, $action ) ); return true; } /** * Remove findings produced by the site-wide analysis stage (before a re-run). * * @param int $id Scan ID. */ private static function delete_sitewide_findings( $id ) { global $wpdb; $table = Db::table( 'findings' ); $checks = Site_Job::SITEWIDE_CHECKS; $in = implode( ',', array_fill( 0, count( $checks ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $in holds only %s placeholders, one per value. $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE scan_id = %d AND check_id IN ($in)", array_merge( array( $table, $id ), $checks ) ) ); } /** * Process work for a scan within a time budget. Safe to call concurrently: * a per-scan lock guarantees only one worker runs at a time. * * @param int $id Scan ID. * @param int $budget Seconds. * @return array Status payload. */ public static function tick( $id, $budget = 0 ) { $scan = Repository::get_scan( $id ); if ( ! $scan ) { return array( 'error' => 'not_found' ); } if ( ! in_array( $scan->status, array( 'queued', 'running' ), true ) ) { return self::status_payload( $scan ); } $budget = $budget > 0 ? $budget : (int) Settings::get( 'time_budget' ); $max = (int) ini_get( 'max_execution_time' ); if ( $max > 0 ) { $budget = min( $budget, max( 3, (int) floor( $max * 0.6 ) ) ); } $budget = max( 3, min( 50, $budget ) ); $token = Lock::acquire( 'worker_' . $id, $budget + 60 ); if ( ! $token ) { $payload = self::status_payload( $scan ); $payload['busy'] = true; return $payload; } $deadline = microtime( true ) + $budget; $job = self::job( $scan->scan_type ); if ( function_exists( 'wp_raise_memory_limit' ) ) { wp_raise_memory_limit( 'admin' ); } try { if ( 'queued' === $scan->status ) { $scan->status = 'running'; Repository::update_scan( $id, array( 'status' => 'running' ) ); } Repository::recover_stale_items( $id ); $guard = 0; while ( microtime( true ) < $deadline && $guard++ < 500 ) { $fresh = Repository::get_scan( $id ); if ( ! $fresh || 'running' !== $fresh->status ) { break; // Paused/cancelled by the user mid-tick. } $scan = $fresh; $more = $job->step( $scan, $deadline ); Repository::update_scan( $id, array( 'heartbeat_at' => Db::now() ) ); if ( ! $more ) { break; } } } catch ( \Throwable $e ) { // Pause instead of failing permanently so the admin can resume after fixing the cause. Logger::log( 'error', sprintf( 'Scan #%d error in stage %s: %s', $id, $scan->stage, $e->getMessage() ) ); Repository::update_scan( $id, array( 'status' => 'paused', 'error_message' => sprintf( /* translators: %s: stage name */ __( 'The scan was paused after an unexpected error in the "%s" stage. Check Help → Diagnostics, then resume.', 'siteaudithub' ), $scan->stage ), ) ); } Lock::release( 'worker_' . $id, $token ); return self::status_payload( Repository::get_scan( $id ) ); } /** * Process all runnable scans (cron entry point). * * @param int $budget Seconds. */ public static function process_queue( $budget = 0 ) { $scans = Repository::runnable_scans(); if ( empty( $scans ) ) { Cron::clear_queue(); return; } $per = $budget > 0 ? max( 3, (int) floor( $budget / count( $scans ) ) ) : 0; foreach ( $scans as $scan ) { self::tick( $scan->id, $per ); } } /** * Progress percentage based on real stage/item counts. * * @param object $scan Scan. * @return int */ public static function progress( $scan ) { if ( 'completed' === $scan->status ) { return 100; } return self::job( $scan->scan_type )->progress( $scan ); } /** * JSON-ready status for the UI. * * @param object|null $scan Scan. * @return array */ public static function status_payload( $scan ) { if ( ! $scan ) { return array( 'error' => 'not_found' ); } $job = self::job( $scan->scan_type ); return array( 'id' => (int) $scan->id, 'type' => $scan->scan_type, 'status' => $scan->status, 'stage' => $scan->stage, 'stage_label' => $job->stage_label( $scan->stage ), 'progress' => self::progress( $scan ), 'total' => (int) $scan->total_items, 'processed' => (int) $scan->processed_items, 'failed' => (int) $scan->failed_items, 'findings' => (int) $scan->findings_count, 'error' => (string) $scan->error_message, 'started_at' => $scan->started_at, 'heartbeat' => $scan->heartbeat_at, ); } /** * Shared finalization: change status, summary, scores, notifications. * * @param object $scan Scan. * @param array $summary Extra summary data. */ public static function complete( $scan, array $summary ) { Repository::mark_recurring( $scan->id, $scan->prev_scan_id ); $matrix = Repository::finding_matrix( $scan->id ); $changes = Repository::change_counts( $scan->id ); $total = 0; foreach ( $matrix as $sev ) { $total += array_sum( $sev ); } $summary['matrix'] = $matrix; $summary['changes'] = $changes; $summary['resolved'] = Repository::resolved_findings( $scan->id, $scan->prev_scan_id, 0, true ); $summary['scores'] = Scoring::for_scan( $scan->id ); Repository::update_scan( $scan->id, array( 'status' => 'completed', 'stage' => 'done', 'finished_at' => Db::now(), 'findings_count' => $total, 'summary' => $summary, ) ); Logger::log( 'info', sprintf( 'Completed %s scan #%d with %d findings.', $scan->scan_type, $scan->id, $total ) ); /** * Fires when a scan completes. * * @param int $scan_id Scan ID. */ do_action( 'siteaudithub_scan_completed', (int) $scan->id ); } }