diff --git a/src/class-job-payload.php b/src/class-job-payload.php index 08fcbb1..e02efe4 100644 --- a/src/class-job-payload.php +++ b/src/class-job-payload.php @@ -174,8 +174,8 @@ private static function current_site_id(): int * During a full-network scan, get_site_url() can be filtered to a mapped, * stale, or secondary-network domain. Bootstrapping a fresh executor with * that URL can select a different routed database before switch_to_blog() - * runs. The wp_blogs domain and path are authoritative for switched-site - * scans because WordPress resolves them to the same blog the scanner read. + * runs. Prefer a uniquely owned active domain mapping. Fall back to the + * wp_blogs domain only when another blog has not claimed it. */ private static function current_site_url(): string { @@ -202,6 +202,80 @@ private static function current_site_url(): string $path = '/' . $path; } + $site_id = get_current_blog_id(); + $mapped_url = self::current_mapped_site_url($site_id, $path, $scheme === 'https'); + if ($mapped_url !== '') { + return $mapped_url; + } + + if (self::canonical_domain_is_mapped_elsewhere((string) $site->domain, $site_id)) { + return ''; + } + return $scheme . '://' . (string) $site->domain . $path; } + + /** + * Return a uniquely owned active mapping for a switched blog. + */ + private static function current_mapped_site_url(int $site_id, string $path, bool $secure): string + { + global $wpdb; + + $table = $wpdb->base_prefix . 'wu_domain_mappings'; + if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) { + return ''; + } + + $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); + if ($found !== $table) { + return ''; + } + + $sql = 'SELECT candidate.domain, candidate.secure FROM `' . $table . '` candidate ' + . 'WHERE candidate.blog_id = %d AND candidate.active = 1 ' + . 'AND NOT EXISTS (SELECT 1 FROM `' . $table . '` conflict ' + . 'WHERE conflict.domain = candidate.domain AND conflict.active = 1 ' + . 'AND conflict.blog_id <> candidate.blog_id) ' + . 'ORDER BY candidate.primary_domain DESC, candidate.id DESC LIMIT 1'; + $mapping = $wpdb->get_row($wpdb->prepare($sql, $site_id)); + if (!is_object($mapping) || empty($mapping->domain)) { + return ''; + } + + $domain = strtolower(rtrim(trim((string) $mapping->domain), '.')); + if (preg_match('/^[a-z0-9.-]+(?::[0-9]+)?$/', $domain) !== 1) { + return ''; + } + + return (!empty($mapping->secure) || $secure ? 'https://' : 'http://') . $domain . $path; + } + + /** + * Check whether a canonical domain is actively mapped to another blog. + */ + private static function canonical_domain_is_mapped_elsewhere(string $domain, int $site_id): bool + { + global $wpdb; + + $table = $wpdb->base_prefix . 'wu_domain_mappings'; + if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) { + return false; + } + + $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))); + if ($found !== $table) { + return false; + } + + $sql = 'SELECT COUNT(*) FROM `' . $table . '` ' + . 'WHERE domain = %s AND active = 1 AND blog_id <> %d'; + $conflicts = $wpdb->get_var($wpdb->prepare( + $sql, + strtolower(rtrim(trim($domain), '.')), + $site_id + )); + + return (int) $conflicts > 0; + } } diff --git a/src/class-worker-process.php b/src/class-worker-process.php index 2565f7c..75c33bb 100644 --- a/src/class-worker-process.php +++ b/src/class-worker-process.php @@ -66,6 +66,8 @@ class Worker_Process private int $last_rescan_duration = 0; private bool $is_ready = false; private string $failure_reason = ''; + /** @var array */ + private array $unroutable_sites_logged = []; public function __construct(string $wp_load, string $primary_domain, string $execute_script, string $scan_script = '') { @@ -242,6 +244,17 @@ private function mark_unready(int $worker_id, string $context, \Throwable $e): v */ private function schedule_timer(Job_Payload $payload): void { + if ($payload->site_url === '') { + if (!isset($this->unroutable_sites_logged[$payload->site_id])) { + Worker::log(sprintf( + '[SKIP][UNROUTABLE] Site %d has no uniquely owned bootstrap domain; jobs remain pending until routing metadata is repaired.', + $payload->site_id + )); + $this->unroutable_sites_logged[$payload->site_id] = true; + } + return; + } + $key = $payload->tracking_key(); if (isset($this->pending_timers[$key])) { return; diff --git a/tests/regression.php b/tests/regression.php index 4b5a01e..6dd1f77 100644 --- a/tests/regression.php +++ b/tests/regression.php @@ -127,6 +127,9 @@ public function get_error_message(): string $GLOBALS['test_site_url'] = 'https://example.test'; $GLOBALS['test_site_domain'] = 'example.test'; $GLOBALS['test_site_path'] = '/'; + $GLOBALS['test_mapping_table_exists'] = false; + $GLOBALS['test_mapping_row'] = null; + $GLOBALS['test_canonical_mapping_conflicts'] = 0; $worker_entrypoint = file_get_contents(__DIR__ . '/../bin/worker.php'); assert_true(is_string($worker_entrypoint), 'Worker entrypoint must be readable'); @@ -158,9 +161,21 @@ public function esc_like(string $text): string public function get_var(string $query): ?string { + if (str_contains($query, 'SHOW TABLES LIKE') && str_contains($query, 'wu_domain_mappings')) { + return $GLOBALS['test_mapping_table_exists'] ? 'wp_wu_domain_mappings' : null; + } + if (str_contains($query, 'COUNT(*)') && str_contains($query, 'wu_domain_mappings')) { + return (string) $GLOBALS['test_canonical_mapping_conflicts']; + } return null; } + public function get_row(string $query): ?object + { + unset($query); + return $GLOBALS['test_mapping_row']; + } + public function query(string $query) { $this->queries[] = $query; @@ -541,6 +556,39 @@ function invoke_private(object $object, string $method, array $args = []) $switched_cron_payload->site_url, 'WP-Cron factory payloads must preserve the canonical switched-site bootstrap URL' ); + $GLOBALS['test_mapping_table_exists'] = true; + $GLOBALS['test_mapping_row'] = (object) [ + 'domain' => 'translate.example.test', + 'secure' => '1', + ]; + $mapped_payload = new Job_Payload([ + 'hook' => 'mapped_site_hook', + 'timestamp' => 1710000000, + 'source' => 'action_scheduler', + 'action_id' => 47, + ]); + assert_same( + 'https://translate.example.test/translations/', + $mapped_payload->site_url, + 'Payload URL must prefer a uniquely owned active domain mapping' + ); + + $GLOBALS['test_mapping_row'] = null; + $GLOBALS['test_canonical_mapping_conflicts'] = 1; + $unroutable_payload = new Job_Payload([ + 'hook' => 'unroutable_site_hook', + 'timestamp' => 1710000000, + 'source' => 'action_scheduler', + 'action_id' => 48, + ]); + assert_same('', $unroutable_payload->site_url, 'A canonical domain mapped to another blog must fail closed'); + \Workerman\Worker::$logs = []; + invoke_private($worker, 'schedule_timer', [$unroutable_payload]); + invoke_private($worker, 'schedule_timer', [$unroutable_payload]); + assert_same(1, count(\Workerman\Worker::$logs), 'An unroutable site must be skipped with one bounded diagnostic'); + + $GLOBALS['test_mapping_table_exists'] = false; + $GLOBALS['test_canonical_mapping_conflicts'] = 0; restore_current_blog(); $GLOBALS['test_site_url'] = 'https://example.test'; $GLOBALS['test_site_domain'] = 'example.test';