the table its rows are read from, * null for the shared users tables (read from the job's copy, Naming::shape()). A single site's are its own. * * @return array */ public static function sources(Db $db, Scope $scope): array { $out = []; $all = $db->tables(); $own = $scope->is_subsite() ? SubsiteTables::of($scope->prefix, $scope->base_prefix, $all) : $all; foreach ($own as $t) { $out[self::name($scope, $t)] = $t; } if ($scope->is_subsite()) { foreach (self::USER_TABLES as $suffix) { $out[$scope->base_prefix . $suffix] = null; } } ksort($out); return $out; } /** A subsite table's name in single-site shape ("wp_3_posts" → "wp_posts"). */ public static function name(Scope $scope, string $table): string { return strpos($table, $scope->prefix) === 0 ? $scope->base_prefix . substr($table, strlen($scope->prefix)) : $table; } /** * The subsite's users and their meta, copied into internal tables (Naming::shape()) for the job tagged $tag: * everyone with a role on the subsite, the authors of its posts and comments, and the network's super admins * (as administrators where they have no role). Meta of other subsites and of the network stays behind. * * @return array{users: string, usermeta: string} the internal tables to read instead of the shared ones */ public static function materialize(Db $db, Scope $scope, string $tag): array { $wpdb = $db->wpdb(); $base = $scope->base_prefix; $users = Naming::shape($base . 'users', $tag); $meta = Naming::shape($base . 'usermeta', $tag); $caps = $scope->prefix . 'capabilities'; $level = $scope->prefix . 'user_level'; $admins = self::super_admins($db); $in = self::who($db, $scope); $db->raw('DROP TABLE IF EXISTS ' . Db::ident($users)); $db->raw('DROP TABLE IF EXISTS ' . Db::ident($meta)); $db->raw('CREATE TABLE ' . Db::ident($users) . ' LIKE ' . Db::ident($base . 'users')); $db->raw('CREATE TABLE ' . Db::ident($meta) . ' LIKE ' . Db::ident($base . 'usermeta')); $db->query('INSERT INTO ' . Db::ident($users) . ' SELECT u.* FROM ' . Db::ident($base . 'users') . " u WHERE {$in}"); // Keys without the base prefix are the person's own (names, settings); prefixed ones belong to a site. $base_like = $wpdb->esc_like($base) . '%'; $own = $scope->prefix === $base ? $wpdb->prepare('(m.meta_key NOT REGEXP %s)', '^' . preg_quote($base, '/') . '[0-9]+_') : $wpdb->prepare('(m.meta_key NOT LIKE %s OR m.meta_key LIKE %s)', $base_like, $wpdb->esc_like($scope->prefix) . '%'); $db->query('INSERT INTO ' . Db::ident($meta) . ' SELECT m.* FROM ' . Db::ident($base . 'usermeta') . ' m JOIN ' . Db::ident($users) . " u ON u.ID = m.user_id WHERE {$own} AND m.meta_key NOT IN ('primary_blog', 'source_domain')"); foreach ([$caps => serialize(['administrator' => true]), $level => '10'] as $key => $value) { $db->query($wpdb->prepare('INSERT INTO ' . Db::ident($meta) . ' (user_id, meta_key, meta_value) SELECT u.ID, %s, %s FROM ' . Db::ident($users) // phpcs:ignore WordPress.DB.PreparedSQL -- table names quoted by Db::ident(), values prepared . " u WHERE u.user_login IN ({$admins}) AND NOT EXISTS (SELECT 1 FROM " . Db::ident($base . 'usermeta') . ' x WHERE x.user_id = u.ID AND x.meta_key = %s)', $key, $value, $caps)); // phpcs:ignore WordPress.DB.PreparedSQL -- tables quoted by Db::ident(); $admins prepared one by one in super_admins() } return ['users' => $users, 'usermeta' => $meta]; } /** * The people who leave with the subsite (as {login, email}): everyone with a role on it, the authors of its posts * and comments, and the network's super admins. A single site's are all its users. At most $limit, in the order a * user merge takes them (by id). * * @return array{users: array[], total: int} */ public static function user_list(Db $db, Scope $scope, int $limit = 5000): array { $table = Db::ident($scope->base_prefix . 'users'); $where = $scope->is_subsite() ? self::who($db, $scope) : '1 = 1'; $total = (int) $db->var("SELECT COUNT(*) FROM {$table} u WHERE {$where}"); $rows = $db->results("SELECT u.user_login, u.user_email FROM {$table} u WHERE {$where} ORDER BY u.ID LIMIT " . max(1, $limit)); return ['users' => array_map(static fn ($r) => ['login' => (string) $r->user_login, 'email' => (string) $r->user_email], $rows), 'total' => $total]; } /** The SQL condition (on "u", the users table) for who goes along with a subsite. */ private static function who(Db $db, Scope $scope): string { $wpdb = $db->wpdb(); $who = [$wpdb->prepare('SELECT user_id FROM ' . Db::ident($scope->base_prefix . 'usermeta') . ' WHERE meta_key = %s', $scope->prefix . 'capabilities')]; // phpcs:ignore WordPress.DB.PreparedSQL -- table names quoted by Db::ident(), values prepared foreach ([['posts', 'post_author'], ['comments', 'user_id']] as [$suffix, $column]) { if ($db->table_exists($scope->prefix . $suffix)) { $who[] = 'SELECT ' . Db::ident($column) . ' FROM ' . Db::ident($scope->prefix . $suffix) . ' WHERE ' . Db::ident($column) . ' > 0'; } } return '(' . implode(' OR ', array_map(static fn ($q) => "u.ID IN ({$q})", $who)) . ' OR u.user_login IN (' . self::super_admins($db) . '))'; } /** The network's super admins' logins, as an SQL list. */ private static function super_admins(Db $db): string { $logins = array_values(array_filter((array) get_super_admins(), 'is_string')); return $logins === [] ? "''" : implode(',', array_map(static fn ($l) => $db->wpdb()->prepare('%s', $l), $logins)); } /** * About how big the subsite's share of a shared users table is ($table: "{base}users" or "{base}usermeta"): its * people with a role there and their meta rows. For the table list only; the copy decides what goes. * * @return array{rows: int, bytes: int} */ public static function count_users(Db $db, Scope $scope, string $table): array { $wpdb = $db->wpdb(); $meta = Db::ident($scope->base_prefix . 'usermeta'); $who = $wpdb->prepare("SELECT user_id FROM {$meta} WHERE meta_key = %s", $scope->prefix . 'capabilities'); // phpcs:ignore WordPress.DB.PreparedSQL -- $meta quoted by Db::ident() $rows = $table === $scope->base_prefix . 'users' ? (int) $db->var("SELECT COUNT(*) FROM ({$who}) w") : (int) $db->var("SELECT COUNT(*) FROM {$meta} WHERE user_id IN ({$who})"); return ['rows' => $rows, 'bytes' => 0]; } /** The job's copy of the users tables goes (materialize()). */ public static function drop(Db $db, string $tag): void { foreach (self::USER_TABLES as $suffix) { $db->raw('DROP TABLE IF EXISTS ' . Db::ident(Naming::shape($db->prefix() . $suffix, $tag))); } } /** * The column rewrite for $out (a table's name where it goes, under the prefix $to — the base prefix by default): * this site's prefix in option names and user meta keys becomes $to, and a subsite's options table's * active_plugins also lists the plugins the network activates for every site (SiteRelay among them), so the copy * runs what the subsite ran. Null when nothing needs rewriting. */ public static function rewrite(Scope $scope, string $out, ?string $to = null): ?callable { $to = $to ?? $scope->base_prefix; $prefix = PrefixRewriter::for_table($out, $scope->prefix, $to); if (!$scope->is_subsite() || $out !== $to . 'options') { return $prefix; } $network = Network::active_plugins(true); return static function (string $column, string $value, $row = null) use ($prefix, $network): string { if ($prefix !== null) { $value = $prefix($column, $value); } if ($column !== 'option_value' || !is_object($row) || ($row->option_name ?? '') !== 'active_plugins' || $network === []) { return $value; } $plugins = maybe_unserialize($value); $plugins = is_array($plugins) ? array_values($plugins) : []; return serialize(array_values(array_unique(array_merge($plugins, $network)))); }; } }