Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions src/class-job-payload.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
13 changes: 13 additions & 0 deletions src/class-worker-process.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ class Worker_Process
private int $last_rescan_duration = 0;
private bool $is_ready = false;
private string $failure_reason = '';
/** @var array<int, true> */
private array $unroutable_sites_logged = [];

public function __construct(string $wp_load, string $primary_domain, string $execute_script, string $scan_script = '')
{
Expand Down Expand Up @@ -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;
Expand Down
48 changes: 48 additions & 0 deletions tests/regression.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand Down