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 '';
}
/**
* 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,
]);
}
}