boot_lite(); return; } } /** * v3.3.52+ — WordPress.org free ("lite") edition boot. * * Registers only the local audit UI and handlers. Commercial-only services, * update tooling, usage diagnostics, message polling, exporters, and extra * check classes are not shipped in this WordPress.org ZIP. */ private function boot_lite(): void { // Local DB upgrades (columns/indexes for stored runs + findings). Activator::maybe_upgrade(); // Background audit cron hooks (local wp-cron only — no external calls). // Required so a queued audit actually runs its batches to completion. (new \AetosSeo\Core\CronController($this))->register(); /** * Fires after Lite has registered core local-audit services. This runs * on admin and non-admin requests, so add-ons can attach safe shared * integrations without depending on dashboard load order. * * @param Plugin $this Lite plugin instance. */ do_action('aetos_seo_lite_core_loaded', $this); if (!is_admin()) { return; } // Cosmetic: constrain the white eagle sidebar icon to ~20px. self::register_sidebar_icon_css(); // Arabic gettext bridge (self-no-ops under an Arabic locale). \AetosSeo\Utils\TextDomainTranslator::register(); // Admin form + AJAX handlers (run an audit, view + manage findings). (new \AetosSeo\Admin\AdminPostController($this))->register(); (new \AetosSeo\Admin\AjaxController($this))->register(); // Menu + assets. Menu is edition-aware: Dashboard / Findings / // Settings + an "Upgrade to Pro" page in place of the License page. (new Menu())->register(); (new Assets())->register(); // Local-only admin notices for the public build. add_action('admin_notices', [$this, 'admin_environment_notices']); add_action('admin_init', [$this, 'reap_stuck_runs']); add_action('admin_notices', [$this, 'admin_stuck_scan_notice']); /** * Fires after the WordPress.org Lite edition has registered its admin * handlers, menu, assets, and local audit hooks. * * Aetos SEO Pro add-ons should prefer filters such as * `aetos_seo_audit_check_classes` for data/module registration. This * action is for non-critical integration setup that can safely run after * Lite is present. * * @param Plugin $this Lite plugin instance. */ do_action('aetos_seo_lite_loaded', $this); } /** * v3.3.52+ — edition-aware audit gate. The WordPress.org build always * allows local auditing. Commercial builds keep their own audit-gate logic * on the non-public branch. */ private static function audit_allowed(): bool { if (defined('AETOS_EDITION') && AETOS_EDITION === 'lite') { return true; } return true; // unreachable in Pro; the lite fallback after strip } /** * v3.3.41 — constrain the Aetos sidebar icon to 20×20 on every admin page. * * WordPress does NOT auto-constrain elements rendered inside * .wp-menu-image — it expects plugins to ship pre-sized 20×20 PNGs. If * the PNG natural size is larger (or if the browser holds an older * cached version of the asset under the same URL), the sidebar item * renders comically large and intercepts pointer events on the rest of * the menu. The robust fix is a thin CSS layer in admin_head that fires * on EVERY admin page (Aetos and non-Aetos alike) and explicitly caps * the icon dimensions. Defense in depth: the PNG ships at 40×36, the * URL carries a ?v=VERSION cache-bust, and this CSS guarantees the * rendered size no matter what bytes the browser actually pulled. */ public static function register_sidebar_icon_css(): void { add_action('admin_enqueue_scripts', static function (): void { $css = '#adminmenu li#toplevel_page_aetos-seo-dashboard .wp-menu-image img,' . '#adminmenu li.toplevel_page_aetos-seo-dashboard .wp-menu-image img' . ' { width: 20px !important; height: 20px !important;' . ' padding: 7px 0 0 0 !important; max-width: 20px !important;' . ' max-height: 20px !important; object-fit: contain !important; }'; // Inline-only style: register a src-less handle and attach the CSS // through the stylesheet queue (wp_add_inline_style) instead of // echoing a raw style tag directly into the admin head. wp_register_style('aetos-seo-menu-icon', false, [], AETOS_SEO_VERSION); wp_enqueue_style('aetos-seo-menu-icon'); wp_add_inline_style('aetos-seo-menu-icon', $css); }); } public function admin_environment_notices(): void { if (!Capabilities::can_manage()) { return; } $plugin_dir = defined('AETOS_SEO_PATH') ? (string) AETOS_SEO_PATH : ''; if ($plugin_dir === '' || !is_dir($plugin_dir)) { return; } $plugins_root = dirname(rtrim($plugin_dir, '\\/')); if (!is_dir($plugins_root)) { return; } $dirs = glob($plugins_root . DIRECTORY_SEPARATOR . 'aetos-seo*', GLOB_ONLYDIR); if (is_array($dirs)) { $dirs = array_values(array_filter($dirs, static function ($directory) { if (!is_string($directory) || $directory === '') { return false; } return is_file($directory . DIRECTORY_SEPARATOR . 'aetos-seo.php'); })); if (count($dirs) > 1) { $names = array_map(static function ($d) { return basename((string) $d); }, $dirs); echo '

'; echo esc_html__('Aetos SEO: multiple plugin folders detected. Keep only one folder to prevent activation/menu/background-scan issues:', 'aetos-seo') . ' '; echo '' . esc_html(implode(', ', $names)) . ''; echo '

'; } } // Detect malformed extraction where backslashes become part of file names. $entries = @scandir($plugin_dir); if (!is_array($entries)) { return; } foreach ($entries as $entry) { if (!is_string($entry) || $entry === '.' || $entry === '..') { continue; } if (strpos($entry, '\\') !== false && !$this->has_canonical_plugin_structure($plugin_dir)) { echo '

'; echo esc_html__('Aetos SEO: malformed plugin extraction detected and canonical files are missing. Re-upload the canonical aetos-seo plugin ZIP using Plugins > Add New.', 'aetos-seo'); echo '

'; break; } } $bom_file = $this->find_php_file_with_bom($plugin_dir); if ($bom_file !== '') { echo '

'; echo esc_html__('Aetos SEO: UTF-8 BOM detected in a PHP file. This can break AI/bootstrap requests on some hosts. Rebuild or re-upload a clean package.', 'aetos-seo') . ' '; echo '' . esc_html(str_replace($plugin_dir, '', $bom_file)) . ''; echo '

'; } } private function find_php_file_with_bom(string $plugin_dir): string { try { $it = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($plugin_dir, \FilesystemIterator::SKIP_DOTS) ); foreach ($it as $file) { if (!$file instanceof \SplFileInfo || !$file->isFile()) { continue; } if (strtolower((string) $file->getExtension()) !== 'php') { continue; } $bytes = @file_get_contents($file->getPathname(), false, null, 0, 3); if ($bytes === "\xEF\xBB\xBF") { return $file->getPathname(); } } } catch (\Throwable $e) { return ''; } return ''; } private function has_canonical_plugin_structure(string $plugin_dir): bool { // The main-file name differs per edition (Lite aetos-seo.php · Pro // aetos-seo.php). Derive it from our real basename so a valid Pro // tree is not mistaken for a malformed extraction. $main_name = defined('AETOS_SEO_BASENAME') ? basename((string) constant('AETOS_SEO_BASENAME')) : 'aetos-seo.php'; $required = [ $plugin_dir . DIRECTORY_SEPARATOR . $main_name, $plugin_dir . DIRECTORY_SEPARATOR . 'includes' . DIRECTORY_SEPARATOR . 'Core' . DIRECTORY_SEPARATOR . 'Plugin.php', $plugin_dir . DIRECTORY_SEPARATOR . 'includes' . DIRECTORY_SEPARATOR . 'Admin' . DIRECTORY_SEPARATOR . 'Pages' . DIRECTORY_SEPARATOR . 'SettingsPage.php', $plugin_dir . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'admin.css', $plugin_dir . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'brand' . DIRECTORY_SEPARATOR . 'logo.png', ]; foreach ($required as $file) { if (!is_string($file) || $file === '' || !is_readable($file)) { return false; } } return true; } private function kick_wp_cron(): void { if (function_exists('spawn_cron')) { @spawn_cron((float) microtime(true)); } } private function schedule_audit_job(int $run_id, int $delay_seconds = 5): void { $delay_seconds = max(1, $delay_seconds); // v3.3.25 — defense in depth for background execution: // 1. Action Scheduler if available (most reliable; WooCommerce ships it). // 2. WP-Cron single event as fallback (works on most hosts). // 3. Loopback ping to wp-cron.php to kick the queue immediately. // Without (1) we depend on a browser hitting the site to fire cron, // which means closing the tab can stall a long audit. if ($this->try_action_scheduler('aetos_seo_run_audit_job', [$run_id], $delay_seconds)) { return; } if (!wp_next_scheduled('aetos_seo_run_audit_job', [$run_id])) { wp_schedule_single_event(time() + $delay_seconds, 'aetos_seo_run_audit_job', [$run_id]); } $this->kick_wp_cron(); } private function schedule_crawl_batch_job(int $delay_seconds = 5): void { $delay_seconds = max(1, $delay_seconds); if ($this->try_action_scheduler('aetos_seo_run_crawl_batch_job', [], $delay_seconds)) { return; } if (!wp_next_scheduled('aetos_seo_run_crawl_batch_job')) { wp_schedule_single_event(time() + $delay_seconds, 'aetos_seo_run_crawl_batch_job'); } $this->kick_wp_cron(); } /** * v3.3.25 — schedule a job through Action Scheduler (WooCommerce's queue) * if available. Returns true if scheduled, false if AS isn't installed. * * Action Scheduler is far more reliable than WP-Cron on hosts where: * - The site has low traffic (WP-Cron fires on pageviews only). * - The host disables external cron pings. * - The host kills long PHP requests (AS uses its own loop runner). * * Customers running WooCommerce already have it; on other sites it's a * no-op and we silently fall through to WP-Cron. * * @param array $args */ private function try_action_scheduler(string $hook, array $args, int $delay_seconds): bool { if (!function_exists('as_schedule_single_action') || !function_exists('as_next_scheduled_action')) { return false; } try { // Avoid duplicate enqueuing — AS won't deduplicate automatically. $existing = as_next_scheduled_action($hook, $args, 'aetos-seo'); if ($existing !== false && $existing !== null) { return true; } as_schedule_single_action(time() + $delay_seconds, $hook, $args, 'aetos-seo'); return true; } catch (\Throwable $e) { // AS internal error — fall through to WP-Cron path. return false; } } private function maybe_bootstrap_queued_audit(int $run_id, bool $allow_direct_bootstrap = false): void { if ($run_id <= 0) { return; } $repo = new Repository(); $run = $repo->get_run($run_id); if (!$run || (string) ($run['run_type'] ?? '') !== 'audit') { return; } if ((string) ($run['status'] ?? '') !== 'queued') { return; } if (!wp_next_scheduled('aetos_seo_run_audit_job', [$run_id])) { wp_schedule_single_event(time() + 1, 'aetos_seo_run_audit_job', [$run_id]); } $this->kick_wp_cron(); if (!$allow_direct_bootstrap) { return; } $notes = $this->decode_run_notes($run); $requested_scan_mode = isset($notes['requested_scan_mode']) ? (string) $notes['requested_scan_mode'] : ''; $fresh_crawl_required = !empty($notes['fresh_crawl_required']); $crawl_source_run_id = isset($notes['crawl_source_run_id']) ? (int) $notes['crawl_source_run_id'] : 0; if ($requested_scan_mode !== 'full') { return; } // Shared-host fallback: if loopback cron is blocked, bootstrap the first step inline. if ($fresh_crawl_required && $crawl_source_run_id <= 0) { $this->cron_run_audit($run_id); $run_after_bootstrap = $repo->get_run($run_id); if ($run_after_bootstrap) { $notes = $this->decode_run_notes($run_after_bootstrap); $crawl_source_run_id = isset($notes['crawl_source_run_id']) ? (int) $notes['crawl_source_run_id'] : $crawl_source_run_id; } } // Browser heartbeat fallback: drive one crawl batch per status poll when cron is unavailable. $runner = new Runner(); $crawl_status = $runner->get_crawl_status(); $crawl_state = (string) ($crawl_status['status'] ?? 'idle'); if ($crawl_state === 'paused') { $runner->resume_crawl(); $crawl_state = 'running'; } if ($crawl_state === 'running') { $batch = $runner->crawl_next_batch(); $metrics = isset($batch['metrics']) && is_array($batch['metrics']) ? $batch['metrics'] : []; $processed = (int) ($metrics['processed'] ?? 0); $queue = (int) ($metrics['queue'] ?? 0); $target = (int) ($metrics['target_total'] ?? 0); // v3.3.40 — Phase coordination: if the audit has already moved // past the build-inventory phase (progress ≥ 50%), DON'T update // progress here. The Runner owns 50%+ — its first milestone is // exactly progress=50 ("Preparing audit pages") so we need to // use >=, not >, otherwise heartbeat-driven cron_run_crawl // would overwrite the Runner's 50 with build-phase noise and // the bar would appear stuck at 50%. // // We also check the existing phase string: any phase that // doesn't start with "Building" or "Queued" means the Runner // has taken over. $existing_run_for_progress = $repo->get_run($run_id); $existing_progress_for_phase = 0; $existing_phase_for_phase = ''; if ($existing_run_for_progress) { $existing_notes_for_phase = $this->decode_run_notes($existing_run_for_progress); $existing_progress_for_phase = isset($existing_notes_for_phase['progress_percent']) && is_numeric($existing_notes_for_phase['progress_percent']) ? (int) $existing_notes_for_phase['progress_percent'] : 0; $existing_phase_for_phase = isset($existing_notes_for_phase['phase']) ? (string) $existing_notes_for_phase['phase'] : ''; } $is_build_phase_string = stripos($existing_phase_for_phase, 'Building') !== false || stripos($existing_phase_for_phase, 'Queued') !== false || $existing_phase_for_phase === ''; $past_build_phase = $existing_progress_for_phase >= 50 && !$is_build_phase_string; // v3.3.42 — Monotonic-by-design build progress, range 5-50%. // Denominator = configured max_urls (FIXED, never shrinks), NOT // target_total/discovered which both fluctuate as the queue // drains and refills. Earlier `max(processed/target_total, // processed/(processed+queue))` was non-monotonic: both ratios // hit 1.0 when queue=0 (transient lull), then dropped when the // crawler discovered new URLs. Removing the JS latch in v3.3.41 // exposed this — the bar visibly oscillated 50→23→50. // // With a fixed denominator, `processed/max_urls` only ever // grows (numerator monotonic, denominator constant). Build // phase tops out at whatever ratio is hit when the crawler // genuinely ends; the Runner then snaps to 50% as it begins // the analyze phase. All upward transitions, zero drops. $configured_max_urls = isset($batch['metrics']['max_urls']) ? (int) $batch['metrics']['max_urls'] : 0; $denominator = $configured_max_urls > 0 ? $configured_max_urls : 1000; $build_ratio = min(1.0, $processed / max(1, $denominator)); $progress = (int) max(5, min(50, 5 + ($build_ratio * 45))); // v3.3.39 — only update progress if we're still in/before build // phase. Past 30%, the Runner owns the progress. $notes_patch = [ 'phase' => sprintf(I18n::tr('Collecting pages: %1$d read, %2$d waiting', 'جمع الصفحات: قُرئت %1$d وتنتظر %2$d'), $processed, $queue), 'requested_scan_mode' => 'full', 'fresh_crawl_required' => $fresh_crawl_required, 'crawl_source_run_id' => $crawl_source_run_id, 'last_processed' => $processed, 'last_queue' => $queue, ]; if (!$past_build_phase) { $notes_patch['progress_percent'] = $progress; } $this->update_queued_audit_notes($run_id, $notes_patch); if (!empty($batch['done'])) { $this->cron_run_audit($run_id); } return; } if ($crawl_state === 'done' || $crawl_state === 'completed' || ($crawl_state === 'idle' && $crawl_source_run_id > 0)) { $this->cron_run_audit($run_id); return; } if ($crawl_state === 'idle' && $fresh_crawl_required) { $this->cron_run_audit($run_id); } } /** * @return array */ private function decode_run_notes(array $run): array { $notes_raw = isset($run['notes']) ? (string) $run['notes'] : ''; if ($notes_raw === '') { return []; } $decoded = json_decode($notes_raw, true); return is_array($decoded) ? $decoded : []; } /** * @param array $patch */ private function update_queued_audit_notes(int $run_id, array $patch): void { if ($run_id <= 0) { return; } $repo = new Repository(); $run = $repo->get_run($run_id); if (!$run) { return; } $existing = $this->decode_run_notes($run); $previous_progress = isset($existing['progress_percent']) && is_numeric($existing['progress_percent']) ? (int) $existing['progress_percent'] : 0; $next_progress = isset($patch['progress_percent']) && is_numeric($patch['progress_percent']) ? (int) $patch['progress_percent'] : $previous_progress; $next_progress = max(0, min(99, $next_progress)); // v3.3.39 — removed the monotonic-only lock that was here. // `$next_progress = max($next_progress, min(99, $previous_progress));` // // That lock prevented the progress from ever decreasing. Combined // with the heartbeat re-firing cron_run_crawl after the audit had // already advanced past 30%, the bar would latch at the highest // value any caller ever set (typically 60% from the old build // formula) and stay there for the rest of the scan. // // Phase coordination is now enforced UPSTREAM in cron_run_crawl() // itself (skip build-phase progress updates if the audit has // already moved past 30%), so allowing the patched value through // here is safe and gives an accurate visual. $now = current_time('mysql'); $previous_processed = isset($existing['last_processed']) && is_numeric($existing['last_processed']) ? (int) $existing['last_processed'] : null; $previous_queue = isset($existing['last_queue']) && is_numeric($existing['last_queue']) ? (int) $existing['last_queue'] : null; $next_processed = isset($patch['last_processed']) && is_numeric($patch['last_processed']) ? (int) $patch['last_processed'] : $previous_processed; $next_queue = isset($patch['last_queue']) && is_numeric($patch['last_queue']) ? (int) $patch['last_queue'] : $previous_queue; $progress_moved = false; if ($next_progress > $previous_progress) { $progress_moved = true; } if ($previous_processed !== null && $next_processed !== null && $next_processed !== $previous_processed) { $progress_moved = true; } if ($previous_queue !== null && $next_queue !== null && $next_queue !== $previous_queue) { $progress_moved = true; } if (!isset($existing['last_progress_at']) || (string) $existing['last_progress_at'] === '') { $progress_moved = true; } $patch['progress_percent'] = $next_progress; $patch['updated_at'] = $now; if ($progress_moved) { $patch['last_progress_at'] = $now; } $repo->update_run($run_id, [ 'status' => 'queued', 'notes' => wp_json_encode(array_merge($existing, $patch)), ]); } /** * v2.3.2 — Self-healing reaper. Runs once per admin page load. Detects: * - Audit/crawl runs in 'running' state with no progress for > stale_seconds * - Stale lock options/transients tied to those runs * and fails the runs so the user can start a clean retry. Cheap (one query * per admin request, ~10ms) and silent unless something was actually fixed. */ public function reap_stuck_runs(): void { // Throttle: only check once every 60s per admin to avoid hammering DB. $throttle_key = 'aetos_seo_reaper_last_run'; $last = (int) get_transient($throttle_key); if ($last !== 0 && (time() - $last) < 60) { return; } set_transient($throttle_key, time(), 120); $stale_seconds = (int) apply_filters('aetos_seo_stuck_run_seconds', 900); // 15 min (new SystemHealth())->fail_stuck_runs($stale_seconds); } /** * v2.3.2 — Visible safety net. If something slipped past the reaper (e.g. * a run with very recent created_at but no batch advancing), this notice * gives the admin a one-click escape. */ public function admin_stuck_scan_notice(): void { if (!current_user_can('manage_options')) { return; } $screen = function_exists('get_current_screen') ? get_current_screen() : null; if (!$screen || strpos((string) $screen->id, 'aetos-seo') === false) { return; } $health = new SystemHealth(); if (!$health->has_stuck_runs((int) apply_filters('aetos_seo_stuck_run_seconds', 900))) { return; } $url = wp_nonce_url( admin_url('admin-post.php?action=aetos_seo_resume_scan'), 'aetos_seo_resume_scan' ); $message = I18n::tr( 'The scan is stuck. Use the button to resume it now.', 'السكان متوقف. اضغط الزر لاستئنافه فوراً.' ); $button = I18n::tr('Resume scan', 'استئناف السكان'); echo '

Aetos SEO: ' . esc_html($message) . ' ' . esc_html($button) . '

'; } /** * v2.3.2 — Resume button handler. Force-clears any stale state and * triggers a fresh combined run. */ public function handle_resume_scan(): void { if (!current_user_can('manage_options')) { wp_die('Forbidden', 403); } check_admin_referer('aetos_seo_resume_scan'); (new SystemHealth())->recover_scan_state(); // Hand control to the existing combined-run handler. wp_safe_redirect(admin_url('admin-post.php?action=aetos_seo_run_combined&_wpnonce=' . wp_create_nonce('aetos_seo_run_combined'))); exit; } private function audit_lock_key(int $run_id): string { return 'aetos_seo_audit_lock_' . max(0, $run_id); } /** * Atomic run lock. * * `wp_cache_add` is atomic on real object caches (memcached, Redis) — it * inserts ONLY if the key doesn't exist, returning false otherwise. This * defeats the get/set race where two concurrent cron+AJAX callers could * both pass an existence check and both proceed. * * Without an external object cache, we use add_option as the atomic * primitive — MySQL's UNIQUE index on option_name makes the duplicate * insert fail. Stale (TTL-expired) locks are auto-cleared on retry so a * crashed worker mid-batch can't permanently freeze the cron. */ private function acquire_audit_run_lock(int $run_id, int $ttl_seconds = 600): bool { if ($run_id <= 0) { return false; } $ttl_seconds = max(30, min(3600, $ttl_seconds)); $key = $this->audit_lock_key($run_id); // Migrate any legacy option-based lock left over from older builds. if (get_option($key) !== false) { delete_option($key); } // Path A: external object cache — true atomicity. if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) { if (!wp_cache_add($key, time(), 'aetos_seo_locks', $ttl_seconds)) { return false; } set_transient($key, time(), $ttl_seconds); return true; } // Path B: DB-backed lock via add_option (UNIQUE option_name). $option_key = '_aetos_seo_lock_' . substr(hash('sha256', $key), 0, 24); $inserted = false; if (function_exists('add_option')) { $inserted = @add_option($option_key, time(), '', 'no'); } if (!$inserted) { // Lock already held — check if it's stale. $existing = (int) get_option($option_key, 0); if ($existing > 0 && (time() - $existing) > $ttl_seconds) { delete_option($option_key); $inserted = (bool) @add_option($option_key, time(), '', 'no'); } if (!$inserted) { return false; } } set_transient($key, time(), $ttl_seconds); return true; } private function release_audit_run_lock(int $run_id): void { if ($run_id <= 0) { return; } $key = $this->audit_lock_key($run_id); if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) { wp_cache_delete($key, 'aetos_seo_locks'); } $option_key = '_aetos_seo_lock_' . substr(hash('sha256', $key), 0, 24); delete_option($option_key); delete_transient($key); // Defense-in-depth: also clean any legacy option residue. delete_option($key); } /** * Cron callback: run an audit in the background. */ public function cron_run_audit(int $run_id = 0): void { if (function_exists('wp_raise_memory_limit')) { @wp_raise_memory_limit('admin'); } @ignore_user_abort(true); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Background cron audit; default max_execution_time would abort large-site crawls. @set_time_limit(0); $run_id = (int) $run_id; if ($run_id <= 0) { return; } $repo = new Repository(); $run = $repo->get_run($run_id); if (!$run || (string) ($run['run_type'] ?? '') !== 'audit') { return; } $run_status = (string) ($run['status'] ?? ''); if ($run_status === 'completed') { return; } if (!$this->acquire_audit_run_lock($run_id)) { return; } try { $settings = Settings::get(); $notes = $this->decode_run_notes($run); $requested_scan_mode = isset($notes['requested_scan_mode']) ? (string) $notes['requested_scan_mode'] : ''; $scan_mode = in_array($requested_scan_mode, ['sample', 'full'], true) ? $requested_scan_mode : (string) ($settings['scan_mode'] ?? 'full'); if ($scan_mode === 'full') { $fresh_crawl_required = !empty($notes['fresh_crawl_required']); $linked_crawl_id = isset($notes['crawl_source_run_id']) ? (int) $notes['crawl_source_run_id'] : 0; $crawl_ready = false; if ($linked_crawl_id > 0) { $linked_crawl = $repo->get_run($linked_crawl_id); if ($linked_crawl && (string) ($linked_crawl['run_type'] ?? '') === 'crawl' && (string) ($linked_crawl['status'] ?? '') === 'completed' && $repo->count_crawl_rows($linked_crawl_id) > 0) { $crawl_ready = true; } } if (!$crawl_ready && !$fresh_crawl_required) { $latest_crawl = $repo->get_latest_completed_run('crawl'); if ($latest_crawl) { $latest_crawl_id = (int) ($latest_crawl['id'] ?? 0); if ($latest_crawl_id > 0 && $repo->count_crawl_rows($latest_crawl_id) > 0) { $crawl_ready = true; $linked_crawl_id = $latest_crawl_id; $notes['crawl_source_run_id'] = $latest_crawl_id; } } } if (!$crawl_ready) { $runner = new Runner(); $crawl_status_row = null; if ($linked_crawl_id > 0) { $crawl_status_row = $repo->get_run($linked_crawl_id); } if (!$crawl_status_row || (string) ($crawl_status_row['status'] ?? '') === 'failed') { $crawl_boot = $runner->start_crawl(); $linked_crawl_id = (int) ($crawl_boot['run_id'] ?? 0); } // v1.0.74: drive crawl forward inline. On shared hosts (Hostinger), // WP-Cron is blocked, so we process ONE batch per AJAX heartbeat poll. $cs = $runner->get_crawl_status(); $csState = (string) ($cs['status'] ?? 'idle'); if ($csState === 'paused') { $runner->resume_crawl(); $csState = 'running'; } $batch_processed = (int) ($cs['metrics']['processed'] ?? 0); $batch_queue = (int) ($cs['metrics']['queue'] ?? 0); $crawl_just_finished = false; if ($csState === 'running') { $batch = $runner->crawl_next_batch(); $metrics = isset($batch['metrics']) && is_array($batch['metrics']) ? $batch['metrics'] : []; $batch_processed = (int) ($metrics['processed'] ?? $batch_processed); $batch_queue = (int) ($metrics['queue'] ?? $batch_queue); $crawl_just_finished = !empty($batch['done']); } // Check if crawl is done (either just finished or was already done). if ($crawl_just_finished || $csState === 'done' || $csState === 'completed') { // v1.0.74: force-complete the crawl run in DB if needed. // Previous versions used recursive cron_run_audit() here which // caused infinite loops when the crawl run status wasn't 'completed'. if ($linked_crawl_id > 0) { $crawl_run_check = $repo->get_run($linked_crawl_id); if ($crawl_run_check && (string) ($crawl_run_check['status'] ?? '') !== 'completed') { $repo->update_run($linked_crawl_id, [ 'status' => 'completed', 'ended_at' => current_time('mysql'), ]); } } // Set crawl_ready = true and fall through to audit execution. // NO recursive call, NO return — just continue to the audit below. $crawl_ready = true; $notes['crawl_source_run_id'] = $linked_crawl_id; $this->update_queued_audit_notes($run_id, [ 'phase' => I18n::tr('Pages collected. Starting the checks...', 'اكتمل جمع الصفحات. بدء الفحوصات...'), 'progress_percent' => 65, 'requested_scan_mode' => 'full', 'fresh_crawl_required' => false, 'crawl_source_run_id' => $linked_crawl_id, 'last_processed' => $batch_processed, 'last_queue' => $batch_queue, ]); } // Crawl still in progress — update notes and return. Next poll // will drive the next batch. if (!$crawl_ready) { // v3.3.42 — Monotonic-by-design build progress, range // 5-50%. Uses processed/max_urls (FIXED denominator) so // the bar only ever climbs as processed grows. Mirrors // cron_run_crawl() above. See that comment block for // the regression history this replaces. $configured_max_urls = isset($batch['metrics']['max_urls']) ? (int) $batch['metrics']['max_urls'] : 0; $denominator = $configured_max_urls > 0 ? $configured_max_urls : 1000; $build_ratio = min(1.0, $batch_processed / max(1, $denominator)); $queued_progress = (int) max(5, min(50, 5 + ($build_ratio * 45))); $this->update_queued_audit_notes($run_id, [ 'phase' => sprintf(I18n::tr('Collecting pages: %1$d read, %2$d waiting', 'جمع الصفحات: قُرئت %1$d وتنتظر %2$d'), $batch_processed, $batch_queue), 'progress_percent' => $queued_progress, 'requested_scan_mode' => 'full', 'fresh_crawl_required' => $fresh_crawl_required, 'crawl_source_run_id' => $linked_crawl_id, 'last_processed' => $batch_processed, 'last_queue' => $batch_queue, ]); $this->release_audit_run_lock($run_id); $this->schedule_crawl_batch_job(1); $this->schedule_audit_job($run_id, 5); return; } } if ($linked_crawl_id > 0) { $notes['crawl_source_run_id'] = $linked_crawl_id; $notes['updated_at'] = current_time('mysql'); $repo->update_run($run_id, ['notes' => wp_json_encode($notes)]); } } $repo->update_run($run_id, ['status' => 'running']); try { $runner = new Runner(); $runner->run_full_audit($run_id); } catch (\Throwable $e) { $error_msg = sprintf( '[Aetos SEO Audit #%d] Failed: %s in %s:%d', $run_id, $e->getMessage(), $e->getFile(), $e->getLine() ); if (defined('WP_DEBUG') && WP_DEBUG) { error_log($error_msg); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded debug logging only } $trace = substr($e->getTraceAsString(), 0, 1000); if (defined('WP_DEBUG') && WP_DEBUG) { error_log('[Aetos SEO Audit #' . $run_id . '] Stack trace: ' . $trace); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded debug logging only } // v3.3.30 — preserve full error context in notes so diagnostics // can show the exact reason without needing wp-debug.log access. $detailed_notes = sprintf( "Audit failed.\nMessage: %s\nFile: %s:%d\nClass: %s\nTrace (first 1500 chars):\n%s", substr($e->getMessage(), 0, 500), $e->getFile(), $e->getLine(), get_class($e), substr($e->getTraceAsString(), 0, 1500) ); $repo->update_run($run_id, [ 'status' => 'failed', 'ended_at' => current_time('mysql'), 'notes' => $detailed_notes, ]); } } finally { $this->release_audit_run_lock($run_id); } } public function cron_run_crawl_batch(): void { $runner = new Runner(); $status = $runner->get_crawl_status(); if (($status['status'] ?? 'idle') !== 'running') { return; } $result = $runner->crawl_next_batch(); if (!empty($result['done'])) { return; } $delay_ms = isset($result['retry_after_ms']) ? (int) $result['retry_after_ms'] : 0; $delay_seconds = max(2, (int) ceil(max(0, $delay_ms) / 1000)); $this->schedule_crawl_batch_job($delay_seconds); } public function ajax_get_finding(): void { // v3.1.0 — standardized on the WP-default _ajax_nonce field so all // AJAX handlers share one convention (was 'nonce' previously). check_ajax_referer('aetos_seo_get_finding'); Capabilities::require_manage_options_ajax(); // v3.3.16 — generous rate limit even on read handlers, defends // against compromised admin sessions (Gemini PASS 6 P1). $user_id = (string) get_current_user_id(); if (!RateLimiter::allow('get_finding', $user_id, 120, 60)) { wp_send_json_error(['message' => __('Rate limit', 'aetos-seo')]); } $post = wp_unslash($_POST); $row_id = isset($post['finding_row_id']) ? absint($post['finding_row_id']) : 0; if ($row_id <= 0) { wp_send_json_error(['message' => 'Missing finding_row_id'], 400); } $repo = new Repository(); $row = $repo->get_finding_row($row_id); if (!$row) { wp_send_json_error(['message' => 'Not found'], 404); } // v3.3.43 — Repository::hydrate_finding_row already json_decodes // these columns into arrays. The previous code did // `json_decode((string) $array, true)` which coerced the array to // the literal string "Array", got NULL back, and fell through to // empty []. Same bug as FindingsPage; same fix. $evidence = is_array($row['evidence'] ?? null) ? $row['evidence'] : (json_decode((string) ($row['evidence'] ?? '{}'), true) ?: []); if (!is_array($evidence)) { $evidence = []; } $steps = is_array($row['recommendation_steps'] ?? null) ? $row['recommendation_steps'] : (json_decode((string) ($row['recommendation_steps'] ?? '[]'), true) ?: []); if (!is_array($steps)) { $steps = []; } $retest = is_array($row['retest'] ?? null) ? $row['retest'] : (json_decode((string) ($row['retest'] ?? '{}'), true) ?: []); if (!is_array($retest)) { $retest = []; } $payload = [ 'id' => (int) ($row['id'] ?? 0), 'finding_id' => (string) ($row['finding_id'] ?? ''), 'title' => (string) ($row['title'] ?? ''), 'category' => (string) ($row['category'] ?? ''), 'severity' => (string) ($row['severity'] ?? ''), 'status' => (string) ($row['status'] ?? ''), 'score_impact' => (int) ($row['score_impact'] ?? 0), 'why_it_matters' => (string) ($row['why_it_matters'] ?? ''), 'evidence' => $evidence, 'recommendation_steps' => $steps, 'retest' => $retest, ]; wp_send_json_success($payload); } public function handle_run_audit(): void { Capabilities::require_manage_options(); check_admin_referer('aetos_seo_run_audit'); // Improved rate limiting with session token $session_token = wp_get_session_token(); $rate_key = $session_token !== '' ? $session_token : 'user_' . get_current_user_id(); if (!RateLimiter::allow('run_audit', $rate_key, 1, 60)) { if (defined('WP_DEBUG') && WP_DEBUG) { error_log('[Aetos SEO] Rate limit exceeded for audit key hash: ' . substr(hash('sha256', $rate_key), 0, 12)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded debug logging only } wp_die(esc_html__('Rate limit: please try again in a minute.', 'aetos-seo')); } $repo = new Repository(); // Prevent concurrent audit (running or queued). $pending = $repo->get_running_run('audit'); if ($pending) { // If a pending run looks stale (e.g. cron timeout), mark it failed to unblock the UI. $stale_seconds = (int) apply_filters('aetos_seo_audit_stale_seconds', 45 * 60); $now_ts = time(); $created_ts = isset($pending['created_at']) ? Time::mysql_to_timestamp((string) $pending['created_at']) : 0; if ($stale_seconds > 0 && $now_ts && $created_ts && ($now_ts - $created_ts) > $stale_seconds) { $repo->update_run((int) $pending['id'], [ 'status' => 'failed', 'ended_at' => current_time('mysql'), 'notes' => 'Stale audit run auto-failed (timeout).', ]); } else { wp_safe_redirect(add_query_arg(['page' => 'aetos-seo-dashboard', 'aetos_seo_notice' => 'audit_running'], admin_url('admin.php'))); exit; } } // Create a queued run and schedule it via WP-Cron. $run_id = $repo->create_run('audit', 'queued'); if ($run_id <= 0) { wp_safe_redirect(add_query_arg(['page' => 'aetos-seo-dashboard', 'aetos_seo_notice' => 'audit_failed'], admin_url('admin.php'))); exit; } $settings = Settings::get(); $repo->update_run($run_id, [ 'notes' => wp_json_encode([ 'phase' => I18n::tr('Getting ready...', 'جارٍ التحضير...'), 'progress_percent' => 0, 'requested_scan_mode' => (string) ($settings['scan_mode'] ?? 'full'), 'fresh_crawl_required' => false, 'crawl_source_run_id' => 0, 'updated_at' => current_time('mysql'), ]), ]); $this->schedule_audit_job($run_id, 5); $this->maybe_bootstrap_queued_audit($run_id, false); wp_safe_redirect(add_query_arg([ 'page' => 'aetos-seo-dashboard', 'aetos_seo_notice' => 'audit_queued', 'run_id' => $run_id, ], admin_url('admin.php'))); exit; } /** * Dashboard site-audit button (handle_run_full_scan). * * For now this schedules the same background audit job (Runner uses settings to decide sampling/crawl limits). */ public function handle_run_full_scan(): void { Capabilities::require_manage_options(); check_admin_referer('aetos_seo_run_full_scan'); // Rate limit (per session token when available). $session_token = wp_get_session_token(); $rate_key = $session_token !== '' ? $session_token : 'user_' . get_current_user_id(); if (!RateLimiter::allow('run_full_scan', $rate_key, 1, 60)) { wp_die(esc_html__('Rate limit: please try again in a minute.', 'aetos-seo')); } $repo = new Repository(); // Prevent concurrent audit (running or queued). $pending = $repo->get_running_run('audit'); if ($pending) { // Same stale-run handling as quick audit. $stale_seconds = (int) apply_filters('aetos_seo_audit_stale_seconds', 45 * 60); $now_ts = time(); $created_ts = isset($pending['created_at']) ? Time::mysql_to_timestamp((string) $pending['created_at']) : 0; if ($stale_seconds > 0 && $now_ts && $created_ts && ($now_ts - $created_ts) > $stale_seconds) { $repo->update_run((int) $pending['id'], [ 'status' => 'failed', 'ended_at' => current_time('mysql'), 'notes' => 'Stale audit run auto-failed (timeout).', ]); } else { wp_safe_redirect(add_query_arg(['page' => 'aetos-seo-dashboard', 'aetos_seo_notice' => 'audit_running'], admin_url('admin.php'))); exit; } } // Create a queued run and schedule it via WP-Cron. $run_id = $repo->create_run('audit', 'queued'); if ($run_id <= 0) { wp_safe_redirect(add_query_arg(['page' => 'aetos-seo-dashboard', 'aetos_seo_notice' => 'audit_failed'], admin_url('admin.php'))); exit; } $repo->update_run($run_id, [ 'notes' => wp_json_encode([ 'phase' => I18n::tr('Getting ready...', 'جارٍ التحضير...'), 'progress_percent' => 0, 'requested_scan_mode' => 'full', 'fresh_crawl_required' => true, 'crawl_source_run_id' => 0, 'updated_at' => current_time('mysql'), ]), ]); $this->schedule_audit_job($run_id, 5); $this->maybe_bootstrap_queued_audit($run_id, true); wp_safe_redirect(add_query_arg([ 'page' => 'aetos-seo-dashboard', 'aetos_seo_notice' => 'audit_queued', 'run_id' => $run_id, ], admin_url('admin.php'))); exit; } /** * v2.1.1 — Combined single-button workflow: schedule a fresh crawl + * audit (the same site-audit lifecycle) from a single click. The crawl and audit * each run in their own wp_cron job; the dashboard auto-polls the audit * status until completion. */ public function handle_run_combined(): void { Capabilities::require_manage_options(); check_admin_referer('aetos_seo_run_combined'); $session_token = wp_get_session_token(); $rate_key = $session_token !== '' ? $session_token : 'user_' . get_current_user_id(); if (!RateLimiter::allow('run_combined', $rate_key, 1, 60)) { wp_die(esc_html__('Rate limit: please try again in a minute.', 'aetos-seo')); } $repo = new Repository(); // Concurrent-audit guard with stale auto-fail. $pending = $repo->get_running_run('audit'); if ($pending) { $stale_seconds = (int) apply_filters('aetos_seo_audit_stale_seconds', 45 * 60); $now_ts = time(); $created_ts = isset($pending['created_at']) ? Time::mysql_to_timestamp((string) $pending['created_at']) : 0; if ($stale_seconds > 0 && $now_ts && $created_ts && ($now_ts - $created_ts) > $stale_seconds) { $repo->update_run((int) $pending['id'], [ 'status' => 'failed', 'ended_at' => current_time('mysql'), 'notes' => 'Stale audit run auto-failed (timeout, combined trigger).', ]); } else { wp_safe_redirect(add_query_arg([ 'page' => 'aetos-seo-dashboard', 'aetos_seo_notice' => 'audit_running', ], admin_url('admin.php'))); exit; } } $run_id = $repo->create_run('audit', 'queued'); if ($run_id <= 0) { wp_safe_redirect(add_query_arg([ 'page' => 'aetos-seo-dashboard', 'aetos_seo_notice' => 'audit_failed', ], admin_url('admin.php'))); exit; } $repo->update_run($run_id, [ 'notes' => wp_json_encode([ 'phase' => I18n::tr('Getting ready...', 'جارٍ التحضير...'), 'progress_percent' => 0, 'requested_scan_mode' => 'full', 'fresh_crawl_required' => true, 'crawl_source_run_id' => 0, 'updated_at' => current_time('mysql'), ]), ]); $this->schedule_audit_job($run_id, 5); $this->maybe_bootstrap_queued_audit($run_id, true); wp_safe_redirect(add_query_arg([ 'page' => 'aetos-seo-dashboard', 'aetos_seo_notice' => 'combined_queued', 'run_id' => $run_id, ], admin_url('admin.php'))); exit; } public function ajax_audit_status(): void { Capabilities::require_manage_options_ajax(); check_ajax_referer('aetos_seo_audit_status'); $post = wp_unslash($_POST); $run_id = isset($post['run_id']) ? absint($post['run_id']) : 0; $repo = new Repository(); $run = null; if ($run_id > 0) { $run = $repo->get_run($run_id); } if (!$run) { $run = $repo->get_latest_run('audit'); } if (!$run) { wp_send_json_success([ 'status' => 'idle', 'run_id' => 0, 'message' => __('No audit runs yet.', 'aetos-seo'), ]); } $status = (string) ($run['status'] ?? ''); $rid = (int) ($run['id'] ?? 0); $overall = isset($run['overall_score']) ? (int) $run['overall_score'] : null; if ($status === 'queued' && $rid > 0) { $this->maybe_bootstrap_queued_audit($rid, true); $refreshed = $repo->get_run($rid); if ($refreshed) { $run = $refreshed; $status = (string) ($run['status'] ?? $status); $overall = isset($run['overall_score']) ? (int) $run['overall_score'] : $overall; } } $notes_raw = isset($run['notes']) ? (string) ($run['notes'] ?? '') : ''; $phase = ''; $progress_percent = null; if ($notes_raw !== '') { $decoded = json_decode($notes_raw, true); if (is_array($decoded)) { $phase = isset($decoded['phase']) ? (string) $decoded['phase'] : ''; if (isset($decoded['progress_percent']) && is_numeric($decoded['progress_percent'])) { $progress_percent = max(0, min(100, (int) $decoded['progress_percent'])); } } } $parts = []; $status_label = [ 'queued' => I18n::tr('queued', 'في الطابور'), 'running' => I18n::tr('running', 'يعمل الآن'), 'completed' => I18n::tr('completed', 'اكتمل'), 'failed' => I18n::tr('failed', 'فشل'), ][$status] ?? $status; $parts[] = sprintf('%s: %s', I18n::tr('Status', 'الحالة'), $status_label); if ($rid > 0) { $seq_disp = (new \AetosSeo\Storage\Repository())->get_run_display_number((int) $rid); $parts[] = sprintf(I18n::tr('Audit %d', 'الفحص رقم %d'), $seq_disp); } // v1.0.72: show progress for BOTH queued (crawl phase) and running (audit phase). if (($status === 'running' || $status === 'queued') && $progress_percent !== null) { $parts[] = sprintf('%s: %d%%', I18n::tr('Progress', 'التقدم'), (int) $progress_percent); } if (($status === 'running' || $status === 'queued') && $phase !== '') { $parts[] = $phase; } if ($status === 'completed' && $overall !== null) { $parts[] = sprintf(I18n::tr('Score: %d of 100', 'النتيجة: %d من 100'), $overall); } $msg = implode(' | ', $parts); if ($status === 'failed') { if ($notes_raw !== '') { $msg .= ' | ' . $notes_raw; } } wp_send_json_success([ 'status' => $status, 'run_id' => $rid, 'overall_score' => $overall, 'progress_percent' => $progress_percent, 'phase' => $phase, 'message' => $msg, ]); } public function ajax_mark_finding(): void { Capabilities::require_manage_options_ajax(); check_ajax_referer('aetos_seo_mark_finding'); // v3.3.16 — generous rate limit on the state-mutating handler // (Gemini PASS 6 P1, DeepSeek P2). 60 marks per minute is plenty // for a real admin and slows down a compromised admin session. $user_id = (string) get_current_user_id(); if (!RateLimiter::allow('mark_finding', $user_id, 60, 60)) { wp_send_json_error(['message' => __('Rate limit', 'aetos-seo')]); } $post = wp_unslash($_POST); $finding_row_id = isset($post['finding_row_id']) ? absint($post['finding_row_id']) : 0; $status = isset($post['status']) ? sanitize_key((string) $post['status']) : ''; if (!$finding_row_id || !in_array($status, ['done', 'ignored', 'open'], true)) { wp_send_json_error(['message' => 'Invalid']); } $repo = new Repository(); $ok = $repo->update_finding_status($finding_row_id, $status); // 4.0.17: return the human label so the row can show the new state // immediately instead of waiting for a page reload. wp_send_json_success(['ok' => $ok, 'status' => $status, 'status_label' => \AetosSeo\Admin\Labels::status($status)]); } /** * AJAX: Reset all plugin data (truncate tables, clear crawl state, keep settings). */ public function ajax_reset_data(): void { check_ajax_referer('aetos_seo_reset_data'); Capabilities::require_strict_manage_options_ajax(); // v3.3.16 — strict rate limit on the destructive reset handler // (DeepSeek PASS 6 P2). 3 calls per 5 minutes is plenty for legitimate // admin work and stops a misconfigured script from spamming. $user_id = (string) get_current_user_id(); if (!RateLimiter::allow('reset_data', $user_id, 3, 300)) { wp_send_json_error(['message' => __('Too many reset attempts. Wait a few minutes and try again.', 'aetos-seo')]); } $post = wp_unslash($_POST); $mode = isset($post['mode']) ? sanitize_key((string) $post['mode']) : 'scan'; $factory = $mode === 'factory'; $result = (new SystemHealth())->clear_scan_data($factory); $message_template = $factory /* translators: %d is the number of database tables truncated during a factory reset. */ ? __('Factory reset completed. Truncated %d tables.', 'aetos-seo') /* translators: %d is the number of database tables truncated during a scan-data clear. */ : __('Scan data cleared. Truncated %d tables.', 'aetos-seo'); $message = sprintf($message_template, (int) ($result['tables_truncated'] ?? 0)); if (!empty($result['tables_failed']) || !empty($result['remaining_runs'])) { wp_send_json_error([ 'message' => __('Reset did not fully complete. Open System Health and copy diagnostics.', 'aetos-seo'), 'result' => $result, ], 500); } wp_send_json_success([ 'message' => $message, 'result' => $result, ]); } }