base_prefix; $site = get_site($site_id); if (!$site || $site->archived || $site->spam || $site->deleted) { throw new \RuntimeException('The selected multisite site is archived, spam, deleted, or missing.'); } // Legacy layouts can use blogs.dir and rewritten media URLs. The file // selection below assumes uploads/ and uploads/sites// instead. if (get_site_option('ms_files_rewriting') || defined('UPLOADS') || defined('BLOGUPLOADDIR')) { throw new \RuntimeException('Legacy multisite uploads require a separate migration rule; this pull supports modern uploads directories.'); } // File traversal starts inside abspath. A relocated content directory needs // separate source roots and target path rules, which this mode does not have. if (rtrim(WP_CONTENT_DIR, '/') !== rtrim(ABSPATH, '/') . '/wp-content') { throw new \RuntimeException('A separate content directory requires a separate multisite migration rule.'); } // Ask WordPress, including upload_dir filters, without creating a directory // during an export. Use basedir, not path: path can end in the current month. $uploads = wp_upload_dir(null, false); // Accept only the layout that MultisiteFileSelection can separate by site. // A custom basedir could overlap another site's files. Site 1 is the known // overlap: its uploads/ root contains sites/, which file selection excludes. $expected_uploads = WP_CONTENT_DIR . '/uploads' . ( $site_id === 1 ? '' : '/sites/' . $site_id ); if (rtrim($uploads['basedir'], '/') !== $expected_uploads) { throw new \RuntimeException('Custom multisite uploads are not supported; observed directory: ' . $uploads['basedir']); } // Use the exporter's rules, not $wpdb->tables: registering a shared plugin // table must not authorize copying all its rows. Numbered plugin tables // have a site-prefix rule; unknown unnumbered tables still need their own. // This runs for each multisite API request, including file requests. // get_col() holds the whole database table-name list in memory; this setup // cost grows with the number of tables, despite cheap per-path file filters. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Export validation needs the current table inventory, including plugin tables added since the last request. foreach ($wpdb->get_col('SHOW TABLES') as $table) { if (strpos($table, $base_prefix) !== 0) { continue; } $suffix = substr($table, strlen($base_prefix)); $table_site_id = preg_match('/^([1-9][0-9]*)_/', $suffix, $matches) ? (int) $matches[1] : 1; $selection = new MultisiteDatabaseSelection($base_prefix, $table_site_id, $network_id); if ($table === $selection->get_user_table_name()) { continue; } if (!$selection->includes_table($table)) { throw new \RuntimeException('No multisite migration rule exists for table ' . $table . '. It may contain shared plugin data.'); } } // Distinct hosts need no exclusions. Preflight separately collects paths // for child sites below these bases; ordinary export requests do not. return [ 'site_id' => $site_id, 'network_id' => $network_id, 'base_prefix' => $base_prefix, 'abspath' => rtrim(ABSPATH, '/'), 'content_dir' => rtrim(WP_CONTENT_DIR, '/'), 'exporter_dir' => rtrim(PLUGIN_DIR, '/'), 'uploads_dir' => rtrim($uploads['basedir'], '/'), 'uploads_url' => rtrim($uploads['baseurl'], '/'), 'home_url' => get_option('home'), 'site_url' => get_option('siteurl'), 'content_url' => content_url(), 'network_content_url' => network_site_url('/wp-content'), ]; }