├── index.php ├── assets ├── fonts │ ├── materialdesignicons-webfont.eot │ ├── materialdesignicons-webfont.ttf │ ├── materialdesignicons-webfont.woff │ └── materialdesignicons-webfont.woff2 └── js │ ├── admin-app.js │ └── axios.min.js ├── vendor ├── composer │ ├── autoload_namespaces.php │ ├── autoload_psr4.php │ ├── autoload_classmap.php │ ├── LICENSE │ ├── autoload_real.php │ ├── autoload_static.php │ └── ClassLoader.php └── autoload.php ├── composer.json ├── wp-freighter.php ├── manifest.json ├── license ├── uninstall.php ├── app ├── Dev │ └── AssetFetcher.php ├── Updater.php ├── Sites.php ├── CLI.php ├── Configurations.php ├── Site.php └── Run.php ├── readme.md ├── changelog.md └── templates └── admin-wp-freighter.php /index.php: -------------------------------------------------------------------------------- 1 | array($baseDir . '/app'), 10 | ); 11 | -------------------------------------------------------------------------------- /vendor/composer/autoload_classmap.php: -------------------------------------------------------------------------------- 1 | $vendorDir . '/composer/InstalledVersions.php', 10 | 'WPFreighter\\CLI' => $baseDir . '/app/CLI.php', 11 | 'WPFreighter\\Configurations' => $baseDir . '/app/Configurations.php', 12 | 'WPFreighter\\Dev\\AssetFetcher' => $baseDir . '/app/Dev/AssetFetcher.php', 13 | 'WPFreighter\\Run' => $baseDir . '/app/Run.php', 14 | 'WPFreighter\\Site' => $baseDir . '/app/Site.php', 15 | 'WPFreighter\\Sites' => $baseDir . '/app/Sites.php', 16 | 'WPFreighter\\Updater' => $baseDir . '/app/Updater.php', 17 | ); 18 | -------------------------------------------------------------------------------- /vendor/autoload.php: -------------------------------------------------------------------------------- 1 | Austin Ginder", 5 | "author_profile" : "https://profiles.wordpress.org/austinginder", 6 | "donate_link" : "https://github.com/sponsors/austinginder", 7 | "version" : "1.5.1", 8 | "download_url" : "https://github.com/WPFreighter/wp-freighter/releases/download/v1.5.1/wp-freighter.zip", 9 | "requires" : "5.4", 10 | "tested" : "6.9", 11 | "requires_php" : "7.0", 12 | "added" : "2020-09-10 02:10:00", 13 | "last_updated" : "2025-12-13 10:00:00", 14 | "homepage" : "https://wpfreighter.com", 15 | "sections" : { 16 | "description" : "Multi-tenant mode for WordPress.", 17 | "installation" : "Install and activate the plugin.", 18 | "changelog" : "Refer to official changelog for more details." 19 | }, 20 | "banners" : { 21 | "low" : "https://wpfreighter.com/images/wp-freighter-banner-772x250.webp", 22 | "high" : "https://wpfreighter.com/images/wp-freighter-banner-1544x500.webp" 23 | } 24 | } -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Austin Ginder 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /vendor/composer/LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Copyright (c) Nils Adermann, Jordi Boggiano 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished 9 | to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /uninstall.php: -------------------------------------------------------------------------------- 1 | register(true); 33 | 34 | return $loader; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /vendor/composer/autoload_static.php: -------------------------------------------------------------------------------- 1 | 11 | array ( 12 | 'WPFreighter\\' => 12, 13 | ), 14 | ); 15 | 16 | public static $prefixDirsPsr4 = array ( 17 | 'WPFreighter\\' => 18 | array ( 19 | 0 => __DIR__ . '/../..' . '/app', 20 | ), 21 | ); 22 | 23 | public static $classMap = array ( 24 | 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', 25 | 'WPFreighter\\CLI' => __DIR__ . '/../..' . '/app/CLI.php', 26 | 'WPFreighter\\Configurations' => __DIR__ . '/../..' . '/app/Configurations.php', 27 | 'WPFreighter\\Dev\\AssetFetcher' => __DIR__ . '/../..' . '/app/Dev/AssetFetcher.php', 28 | 'WPFreighter\\Run' => __DIR__ . '/../..' . '/app/Run.php', 29 | 'WPFreighter\\Site' => __DIR__ . '/../..' . '/app/Site.php', 30 | 'WPFreighter\\Sites' => __DIR__ . '/../..' . '/app/Sites.php', 31 | 'WPFreighter\\Updater' => __DIR__ . '/../..' . '/app/Updater.php', 32 | ); 33 | 34 | public static function getInitializer(ClassLoader $loader) 35 | { 36 | return \Closure::bind(function () use ($loader) { 37 | $loader->prefixLengthsPsr4 = ComposerStaticInit68be0100d7d9e80e822c421ddd2b7c98::$prefixLengthsPsr4; 38 | $loader->prefixDirsPsr4 = ComposerStaticInit68be0100d7d9e80e822c421ddd2b7c98::$prefixDirsPsr4; 39 | $loader->classMap = ComposerStaticInit68be0100d7d9e80e822c421ddd2b7c98::$classMap; 40 | 41 | }, null, ClassLoader::class); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/Dev/AssetFetcher.php: -------------------------------------------------------------------------------- 1 | '3.5.22', 12 | 'vuetify' => '3.10.5', 13 | 'axios' => '1.13.2', 14 | 'mdi' => '7.4.47', 15 | ]; 16 | 17 | $files = [ 18 | // JS 19 | 'js/vue.min.js' => "https://cdn.jsdelivr.net/npm/vue@{$versions['vue']}/dist/vue.global.prod.js", 20 | 'js/vuetify.min.js' => "https://cdn.jsdelivr.net/npm/vuetify@{$versions['vuetify']}/dist/vuetify.min.js", 21 | 'js/axios.min.js' => "https://cdn.jsdelivr.net/npm/axios@{$versions['axios']}/dist/axios.min.js", 22 | 23 | // CSS 24 | 'css/vuetify.min.css' => "https://cdn.jsdelivr.net/npm/vuetify@{$versions['vuetify']}/dist/vuetify.min.css", 25 | 'css/materialdesignicons.min.css' => "https://cdn.jsdelivr.net/npm/@mdi/font@{$versions['mdi']}/css/materialdesignicons.min.css", 26 | ]; 27 | 28 | // MDI Fonts (Must match the filenames referenced in the CSS) 29 | $fonts = [ 30 | 'materialdesignicons-webfont.eot', 31 | 'materialdesignicons-webfont.ttf', 32 | 'materialdesignicons-webfont.woff', 33 | 'materialdesignicons-webfont.woff2', 34 | ]; 35 | 36 | foreach ( $fonts as $font ) { 37 | $files["fonts/{$font}"] = "https://cdn.jsdelivr.net/npm/@mdi/font@{$versions['mdi']}/fonts/{$font}"; 38 | } 39 | 40 | echo "📦 Fetching assets...\n"; 41 | 42 | foreach ( $files as $local_path => $url ) { 43 | $dest = $base_dir . '/' . $local_path; 44 | $dir = dirname( $dest ); 45 | 46 | if ( ! is_dir( $dir ) ) { 47 | mkdir( $dir, 0755, true ); 48 | } 49 | 50 | echo " Downloading: $local_path ... "; 51 | 52 | $content = @file_get_contents( $url ); 53 | 54 | if ( $content === false ) { 55 | echo "\033[31mFAILED\033[0m (Check URL or version)\n"; 56 | continue; 57 | } 58 | 59 | file_put_contents( $dest, $content ); 60 | echo "\033[32mOK\033[0m\n"; 61 | } 62 | 63 | echo "✨ Assets updated successfully.\n"; 64 | } 65 | } -------------------------------------------------------------------------------- /app/Updater.php: -------------------------------------------------------------------------------- 1 | plugin_slug = dirname ( plugin_basename( __DIR__ ) ); 21 | $this->version = WP_FREIGHTER_VERSION; 22 | $this->cache_key = 'wpfreighter_updater'; 23 | $this->cache_allowed = false; 24 | 25 | add_filter( 'plugins_api', [ $this, 'info' ], 20, 3 ); 26 | add_filter( 'site_transient_update_plugins', [ $this, 'update' ] ); 27 | add_action( 'upgrader_process_complete', [ $this, 'purge' ], 10, 2 ); 28 | 29 | } 30 | 31 | public function request(){ 32 | // Get the local manifest as a fallback. 33 | $manifest_file = dirname(__DIR__) . "/manifest.json"; 34 | $local_manifest = null; 35 | if ( file_exists($manifest_file) ) { 36 | $local_manifest = json_decode( file_get_contents( $manifest_file ) ); 37 | } 38 | 39 | // If local manifest fails to load, create a default object to prevent errors. 40 | if ( ! is_object( $local_manifest ) ) { 41 | $local_manifest = new \stdClass(); 42 | } 43 | 44 | // Attempt to get the remote manifest from the transient cache. 45 | $remote = get_transient( $this->cache_key ); 46 | 47 | if( false === $remote || ! $this->cache_allowed ) { 48 | $remote_response = wp_remote_get( 'https://raw.githubusercontent.com/WPFreighter/wp-freighter/master/manifest.json', [ 49 | 'timeout' => 30, 50 | 'headers' => [ 'Accept' => 'application/json' ] 51 | ] 52 | ); 53 | 54 | // If the remote request fails, return the modified local manifest. 55 | if ( is_wp_error( $remote_response ) || 200 !== wp_remote_retrieve_response_code( $remote_response ) || empty( wp_remote_retrieve_body( $remote_response ) ) ) { 56 | return $local_manifest; 57 | } 58 | 59 | $remote = json_decode( wp_remote_retrieve_body( $remote_response ) ); 60 | set_transient( $this->cache_key, $remote, DAY_IN_SECONDS ); 61 | } 62 | 63 | // If a valid remote object is retrieved, return it. 64 | if ( is_object( $remote ) ) { 65 | return $remote; 66 | } 67 | 68 | // Fallback to the local manifest if remote data is invalid. 69 | return $local_manifest; 70 | } 71 | 72 | function info( $response, $action, $args ) { 73 | if ( 'plugin_information' !== $action || empty( $args->slug ) || $this->plugin_slug !== $args->slug ) { 74 | return $response; 75 | } 76 | 77 | $remote = $this->request(); 78 | if ( ! $remote ) { return $response; } 79 | 80 | $response = new \stdClass(); 81 | $response->name = $remote->name; 82 | $response->slug = $remote->slug; 83 | $response->version = $remote->version; 84 | $response->tested = $remote->tested; 85 | $response->requires = $remote->requires; 86 | $response->author = $remote->author; 87 | $response->author_profile = $remote->author_profile; 88 | $response->donate_link = $remote->donate_link; 89 | $response->homepage = $remote->homepage; 90 | $response->download_link = $remote->download_url; 91 | $response->trunk = $remote->download_url; 92 | $response->requires_php = $remote->requires_php; 93 | $response->last_updated = $remote->last_updated; 94 | 95 | $response->sections = [ 96 | 'description' => $remote->sections->description, 97 | 'installation' => $remote->sections->installation, 98 | 'changelog' => $remote->sections->changelog 99 | ]; 100 | 101 | if ( ! empty( $remote->banners ) ) { 102 | $response->banners = [ 103 | 'low' => $remote->banners->low, 104 | 'high' => $remote->banners->high 105 | ]; 106 | } 107 | return $response; 108 | } 109 | 110 | public function update( $transient ) { 111 | if ( empty($transient->checked ) ) { return $transient; } 112 | 113 | $remote = $this->request(); 114 | if ( $remote && isset($remote->version) && version_compare( $this->version, $remote->version, '<' ) ) { 115 | $response = new \stdClass(); 116 | $response->slug = $this->plugin_slug; 117 | $response->plugin = "{$this->plugin_slug}/{$this->plugin_slug}.php"; 118 | $response->new_version = $remote->version; 119 | $response->package = $remote->download_url; 120 | $response->tested = $remote->tested; 121 | $response->requires_php = $remote->requires_php; 122 | $transient->response[ $response->plugin ] = $response; 123 | } 124 | return $transient; 125 | } 126 | 127 | public function purge( $upgrader, $options ) { 128 | 129 | if ( 'update' === $options['action'] && 'plugin' === $options[ 'type' ] ) { 130 | // refresh configuration 131 | ( new Configurations )->refresh_configs(); 132 | } 133 | 134 | if ( $this->cache_allowed && 'update' === $options['action'] && 'plugin' === $options[ 'type' ] ) { 135 | delete_transient( $this->cache_key ); 136 | } 137 | } 138 | 139 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # WP Freighter 2 | 3 | **Multi-tenant mode for WordPress.** 4 | 5 | WP Freighter allows you to efficiently run many WordPress sites from a single WordPress installation. It modifies your WordPress configuration to support multiple database prefixes and content directories, allowing for instant provisioning and lightweight management. 6 | 7 | ## Features 8 | 9 | * **Lightning Fast Provisioning ⚡** - Spin up new environments in seconds. Only database tables are created or cloned. 10 | * **Flexible File Isolation** - Choose how your sites share files: 11 | * **Shared:** All sites share plugins, themes, and uploads (Single `/wp-content/`). 12 | * **Hybrid:** Shared plugins and themes, but unique uploads for every site (Standardized stack, unique content). 13 | * **Dedicated:** Completely unique `/wp-content/` directory for every site (Full isolation). 14 | * **One-Click Cloning** - Clone your main site or *any* existing tenant site to a new environment. Perfect for staging or testing. 15 | * **Domain Mapping** - Map unique custom domains to specific tenant sites or use the parent domain for easy access. 16 | * **Magic Login** - Generate one-time auto-login links to jump between site dashboards instantly. 17 | * **Zero-Config Sandbox** - Safely troubleshoot maintenance issues by cloning your live site to a sandbox piggybacked onto your existing installation. 18 | * **Secure Context Switching** - Intelligent session management ensures admins can move between sites securely. 19 | 20 | --- 21 | 22 | ## Installation 23 | 24 | 1. Download the `wp-freighter.zip` release. 25 | 2. Upload to your WordPress installation via **Plugins -> Add New -> Upload Plugin**. 26 | 3. Activate the plugin. 27 | 4. Navigate to **Tools -> WP Freighter** to configure your environment. 28 | 29 | ### Configuration Note 30 | WP Freighter attempts to create a bootstrap file at `/wp-content/freighter.php` and modify your `wp-config.php` to function. 31 | 32 | If your host restricts file permissions: 33 | 1. The plugin will provide the exact code snippets you need. 34 | 2. You will need to manually create the bootstrap file and/or edit `wp-config.php`. 35 | 36 | --- 37 | 38 | ## WP-CLI Integration 39 | 40 | WP Freighter includes a robust CLI interface for managing sites via the terminal. 41 | 42 | ### Global Management 43 | ```bash 44 | # View system info, current mode, and site count 45 | wp freighter info 46 | 47 | # List all tenant sites 48 | wp freighter list 49 | 50 | # Update file storage mode (shared|hybrid|dedicated) 51 | wp freighter files set dedicated 52 | 53 | # Toggle domain mapping (on|off) 54 | wp freighter domain set on 55 | ``` 56 | 57 | ### Site Management 58 | ```bash 59 | # Create a new empty site 60 | wp freighter create --title="My New Site" --name="Client A" --domain="client-a.test" 61 | 62 | # Clone the main site to a new staging environment 63 | wp freighter clone main --name="Staging" 64 | 65 | # Clone a specific tenant site (ID 2) to a new site 66 | wp freighter clone 2 --name="Dev Copy" 67 | 68 | # Generate a magic login URL for Site ID 3 69 | wp freighter login 3 70 | 71 | # Delete a site (Confirmation required) 72 | wp freighter delete 4 73 | ``` 74 | 75 | --- 76 | 77 | ## Developer API (`WPFreighter\Site`) 78 | 79 | You can programmatically manage tenant sites using the `WPFreighter\Site` class. 80 | 81 | ### Create a Site 82 | ```php 83 | $args = [ 84 | 'title' => 'New Project', 85 | 'name' => 'Project Alpha', // Internal label 86 | 'domain' => 'project-alpha.com', // Optional 87 | 'username' => 'admin', 88 | 'email' => 'admin@example.com', 89 | 'password' => 'secure_password_123' // Optional, auto-generated if omitted 90 | ]; 91 | 92 | $site = \WPFreighter\Site::create( $args ); 93 | 94 | if ( is_wp_error( $site ) ) { 95 | // Handle error 96 | } else { 97 | echo "Created site ID: " . $site['stacked_site_id']; 98 | } 99 | ``` 100 | 101 | ### Clone a Site 102 | ```php 103 | // Clone Main Site 104 | $staging = \WPFreighter\Site::clone( 'main', [ 'name' => 'Staging Environment' ] ); 105 | 106 | // Clone Tenant Site ID 5 107 | $copy = \WPFreighter\Site::clone( 5, [ 'name' => 'Copy of Site 5' ] ); 108 | ``` 109 | 110 | ### Generate Login Link 111 | ```php 112 | // Get a one-time login URL for Site ID 2 113 | $login_url = \WPFreighter\Site::login( 2 ); 114 | 115 | // Redirect to a specific page after login 116 | $edit_url = \WPFreighter\Site::login( 2, 'post-new.php' ); 117 | ``` 118 | 119 | ### Delete a Site 120 | ```php 121 | \WPFreighter\Site::delete( 4 ); 122 | ``` 123 | 124 | --- 125 | 126 | ## Architecture & Modes 127 | 128 | WP Freighter works by dynamically swapping the `$table_prefix` and directory constants based on the requested domain or a secure admin cookie. It offers three distinct file modes to suit your workflow: 129 | 130 | ### 1. Shared Mode 131 | * **Structure:** Single `/wp-content/` directory. 132 | * **Behavior:** All sites share the exact same plugins, themes, and media library. 133 | * **Best for:** Multilingual networks or brand variations using the exact same assets. 134 | 135 | ### 2. Hybrid Mode 136 | * **Structure:** Shared `/plugins/` and `/themes/`. Unique uploads stored in `/content//uploads/`. 137 | * **Behavior:** You manage one set of plugins for all sites, but every site has its own media library. 138 | * **Best for:** Agencies managing multiple client sites with a standardized software stack but unique content. 139 | 140 | ### 3. Dedicated Mode 141 | * **Structure:** Completely unique `/wp-content/` directory stored in `/content//`. 142 | * **Behavior:** Each site has its own plugins, themes, and uploads. 143 | * **Best for:** True multi-tenancy, snapshots, and distinct staging environments where you need to test plugin updates in isolation. 144 | 145 | ## Known Limitations ⚠️ 146 | 147 | * **`wp-config.php` Access:** The plugin requires write access to `wp-config.php` and `wp-content/`. If your host prevents this, manual configuration is required. 148 | * **Root Files:** Files in the root directory (like `robots.txt` or `.htaccess`) are shared across all sites. 149 | * **Cron Jobs:** WP-Cron relies on traffic to trigger. For low-traffic tenant sites, consider setting up system cron jobs triggered via WP-CLI. 150 | 151 | ## License 152 | 153 | MIT License -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## **1.5.1** - 2025-12-13 4 | ### Fixed 5 | - **WP CLI:** Fixed issue with detecting domain mapping 6 | 7 | ## **1.5.0** - 2025-12-10 8 | ### Added 9 | - **Dark Mode Support:** Added full support for dark mode. The interface now automatically respects system preferences (`prefers-color-scheme`) and includes a manual toggle in the toolbar to switch between light and dark themes. 10 | - **Vue 3 & Vuetify 3:** Completely upgraded the admin dashboard dependencies to Vue.js 3.5.22 and Vuetify 3.10.5. This modernization improves rendering performance and ensures long-term compatibility. 11 | - **Self-Healing Logic:** Implemented `Site::ensure_freighter()`, which automatically restores the plugin files and forces activation on a tenant site before context switching. This prevents errors if the plugin was manually deleted or deactivated in a child environment. 12 | - **New CLI Command:** Added `wp freighter regenerate`. This utility allows administrators to manually trigger a refresh of the `wp-content/freighter.php` bootstrap file and `wp-config.php` adjustments via the command line. 13 | 14 | ### Changed 15 | - **Terminology:** Updated UI and CLI text to refer to sites as "Tenant Sites" instead of "Stacked Sites" for better clarity. 16 | - **UI Visuals:** Refined input fields, tables, and overlays to use transparent backgrounds, allowing the plugin interface to blend seamlessly with the native WordPress Admin color scheme. 17 | - **Cache Busting:** Introduced `WP_FREIGHTER_VERSION` constant to ensure admin assets are properly refreshed in browser caches immediately after an update. 18 | 19 | ### Fixed 20 | - **Bootstrap Location:** Updated the detection logic for `freighter.php` to use `ABSPATH` instead of `WP_CONTENT_DIR`, resolving path resolution issues on specific hosting configurations. 21 | 22 | ## **1.4.0** - 2025-12-09 23 | ### Added 24 | - **Bootstrap Architecture:** Introduced `wp-content/freighter.php` to handle site loading logic. This significantly reduces the footprint within `wp-config.php` to a single `require_once` line, making the configuration more robust and easier to read. 25 | - **Local Dependencies:** Removed reliance on external CDNs (delivr.net) for frontend assets. Vue.js, Vuetify, and Axios are now bundled locally within the plugin, improving privacy, compliance (GDPR), and offline development capabilities. 26 | - **Manual Configuration UI:** Added a dedicated interface for environments with strict file permissions. If the plugin cannot write to `wp-config.php` or create the bootstrap file, it now provides the exact code snippets and a "Copy to Clipboard" feature for manual installation. 27 | - **Session Gatekeeper:** Implemented stricter security logic for cookie-based context switching. The plugin now actively verifies that a user is authenticated or in the process of logging in before allowing access to a tenant site via cookies. 28 | - **"Login to Main" Button:** Added a quick-access button in the toolbar to return to the main parent site dashboard when viewing a tenant site with domain mapping enabled. 29 | 30 | ### Changed 31 | - **Context Switching:** Refactored the context switching logic to force an immediate header reload when setting the `stacked_site_id` cookie. This resolves issues where the context switch would occasionally fail on the first attempt. 32 | - **Asset Management:** Added a developer utility (`WPFreighter\Dev\AssetFetcher`) to programmatically fetch and update frontend vendor libraries. 33 | 34 | ### Fixed 35 | - **Cookie Reliability:** Fixed an issue where the site ID cookie wasn't being set early enough during the request lifecycle for specific hosting configurations. 36 | 37 | ## **1.3.0** - 2025-12-07 38 | ### Added 39 | - **Hybrid Mode:** Introduced a new "Hybrid" file mode that shares plugins and themes across all sites while keeping the `uploads` folder unique for each site. Perfect for agencies managing standardized stacks with different media libraries. 40 | - **WP-CLI Integration:** Added robust CLI commands (`wp freighter`) to manage sites, clone environments, toggle file modes, and handle domain mapping via the terminal. 41 | - **Developer API:** Introduced the `WPFreighter\Site` class, allowing developers to programmatically create, clone, login, and delete tenant sites. 42 | - **Environment Support:** Added support for the `STACKED_SITE_ID` environment variable to enable context switching in CLI and server environments. 43 | - **Object Cache Compatibility:** Now modifies `WP_CACHE_KEY_SALT` in `wp-config.php` to ensure unique object caching for every tenant site. 44 | - **Storage Stats:** Added directory size calculation to the delete site dialog, providing warnings for dedicated content deletion. 45 | 46 | ### Changed 47 | - **Architecture Refactor:** Moved core logic from `Run.php` into dedicated `Site` and `CLI` models for better maintainability. 48 | - **Admin Assets:** Migrated inline Vue.js logic from the PHP template to a dedicated `admin-app.js` file. 49 | - **Kinsta Compatibility:** Enhanced support for copying Kinsta-specific `mu-plugins` when creating sites in dedicated mode. 50 | 51 | ### Fixed 52 | - **Database Hardening:** Implemented `$wpdb->prepare()` across all database write operations to prevent SQL injection vulnerabilities. 53 | - **Input Sanitization:** Added strict input sanitization (`sanitize_text_field`, `sanitize_user`) to all REST API endpoints. 54 | 55 | ## **v1.2.0** - 2025-12-05 56 | ### Added 57 | - **REST API Implementation:** Completely replaced legacy `admin-ajax` calls with secure WordPress REST API endpoints (`wp-freighter/v1`) for better stability and permission handling. 58 | - **Clone Specific Sites:** Added functionality to clone any specific tenant site directly from the site list, not just the main site. 59 | - **Enhanced Clone Dialog:** New UI dialog allowing users to define the Label or Domain immediately when cloning a site. 60 | - **Smart Defaults:** The "New Site" form now pre-fills the current user's email and username, and automatically generates a secure random password. 61 | - **Manifest Support:** Added `manifest.json` for standardized update checking. 62 | 63 | ### Changed 64 | - **Frontend Dependencies:** Updated Vue.js to v2.7.16, Vuetify to v2.6.13, and Axios to v1.13.2. 65 | - **Updater Logic:** Migrated the update checker to pull release data directly from the GitHub repository (`raw.githubusercontent.com`) instead of the previous proprietary endpoint. 66 | - **Admin Bar:** The "Exit WP Freighter" button now utilizes a Javascript-based REST API call for a smoother exit transition. 67 | 68 | ### Fixed 69 | - **Theme Upgrader Noise:** Implemented a silent skin for the `Theme_Upgrader` to prevent HTML output from breaking JSON responses when installing default themes on new sites. 70 | - **Fallback Logic:** The updater now falls back to a local manifest file if the remote check fails. 71 | 72 | ## **v1.1.2** - 2023-05-04 73 | ### Changed 74 | - Site cloning will copy current source content folder over to new `/content//`. 75 | - Refresh WP Freighter configs on plugin update. 76 | 77 | ### Fixed 78 | - PHP 8 warnings and errors. 79 | 80 | ## **v1.1.1** - 2023-03-18 81 | ### Fixed 82 | - PHP 8 issues. 83 | 84 | ## **v1.1** - 2022-12-28 85 | ### Added 86 | - **Free for everyone:** Removed EDD integration. 87 | - **Automatic Updates:** Integrated with Github release. 88 | - Settings link to plugin page. 89 | 90 | ## **v1.0.2** - 2021-08-21 91 | ### Fixed 92 | - Bad logic where configurations weren't always regenerated after sites changed. 93 | - Various PHP warnings. 94 | 95 | ## **v1.0.1** - 2020-09-18 96 | ### Added 97 | - Automatic installation of default theme when creating new sites if needed. 98 | - Overlay loader while new sites are installing. 99 | - Fields for domain or label on the new site dialog. 100 | 101 | ### Changed 102 | - Compatibility for alternative `wp-config.php` location. 103 | - Force HTTPS in urls. 104 | 105 | ### Fixed 106 | - Inconsistent response of sites array. 107 | 108 | ## **v1.0.0** - 2020-09-10 109 | ### Added 110 | - Initial release of WP Freighter. 111 | - Ability to add or remove tenant sites with database prefix `stacked_#_`. 112 | - Clone existing site to new database prefix. 113 | - Add new empty site to new database prefix. 114 | - Domain mapping off or on. 115 | - Files shared or dedicated. -------------------------------------------------------------------------------- /app/Sites.php: -------------------------------------------------------------------------------- 1 | prefix; 13 | $db_prefix_primary = ( defined( 'TABLE_PREFIX' ) ? TABLE_PREFIX : $db_prefix ); 14 | 15 | if ( $db_prefix_primary == "TABLE_PREFIX" ) { 16 | $db_prefix_primary = $db_prefix; 17 | } 18 | 19 | $this->db_prefix_primary = $db_prefix_primary; 20 | $stacked_sites = $wpdb->get_results("select option_value from {$db_prefix_primary}options where option_name = 'stacked_sites'"); 21 | $stacked_sites = empty( $stacked_sites ) ? "" : maybe_unserialize( $stacked_sites[0]->option_value ); 22 | if ( empty( $stacked_sites ) ) { 23 | $stacked_sites = []; 24 | } 25 | $this->sites = $stacked_sites; 26 | } 27 | 28 | public function get() { 29 | return array_values( $this->sites ); 30 | } 31 | 32 | public static function fetch( $stacked_site_id = "" ) { 33 | global $wpdb; 34 | $db_prefix = $wpdb->prefix; 35 | $db_prefix_primary = ( defined( 'TABLE_PREFIX' ) ? TABLE_PREFIX : $db_prefix ); 36 | 37 | if ( $db_prefix_primary == "TABLE_PREFIX" ) { 38 | $db_prefix_primary = $db_prefix; 39 | } 40 | 41 | $stacked_sites = $wpdb->get_results("select option_value from {$db_prefix_primary}options where option_name = 'stacked_sites'"); 42 | $stacked_sites = empty( $stacked_sites ) ? "" : maybe_unserialize( $stacked_sites[0]->option_value ); 43 | if ( empty( $stacked_sites ) ) { 44 | $stacked_sites = []; 45 | } 46 | if ( empty( $stacked_site_id ) ) { return array_values( $stacked_sites ); } 47 | foreach( $stacked_sites as $site ) { if ( $site['stacked_site_id'] == $stacked_site_id ) { return $site; } } 48 | } 49 | 50 | public function domain_mappings() { 51 | $sites = (object) $this->sites; 52 | $domain_mappings = []; 53 | foreach( $sites as $site ) { 54 | $domain_mappings[ $site['stacked_site_id'] ] = $site['domain']; 55 | } 56 | return $domain_mappings; 57 | } 58 | 59 | public function get_json() { 60 | $stacked_sites = $this->sites; 61 | if ( empty( $stacked_sites ) ) { 62 | $stacked_sites = []; 63 | } 64 | return json_encode( array_values( $stacked_sites ) ); 65 | } 66 | 67 | public function update( $sites ) { 68 | global $wpdb; 69 | $configurations = ( new Configurations )->get(); 70 | 71 | // Refresh domain mappings when enabled 72 | if ( $configurations->domain_mapping == "on" ) { 73 | foreach( $sites as $site ) { 74 | $site_id = (int) $site['stacked_site_id']; // Cast to integer for safety 75 | 76 | if ( ! empty( $site["domain"] ) ) { 77 | // Use a variable for the URL to pass to prepare() 78 | $url = 'https://' . $site["domain"]; 79 | 80 | // Table names cannot be prepared, so we build them using the safe integer ID 81 | $table = "stacked_{$site_id}_options"; 82 | 83 | // Use prepare() for the values 84 | $wpdb->query( $wpdb->prepare( 85 | "UPDATE {$table} set option_value = %s where option_name = 'siteurl'", 86 | $url 87 | ) ); 88 | $wpdb->query( $wpdb->prepare( 89 | "UPDATE {$table} set option_value = %s where option_name = 'home'", 90 | $url 91 | ) ); 92 | } 93 | } 94 | } 95 | 96 | // Turn domain mappings off when not used 97 | if ( $configurations->domain_mapping == "off" ) { 98 | // Use get_var() directly for cleaner code 99 | $primary_site_url = $wpdb->get_var( "SELECT option_value from {$this->db_prefix_primary}options where option_name = 'siteurl'" ); 100 | $primary_home = $wpdb->get_var( "SELECT option_value from {$this->db_prefix_primary}options where option_name = 'home'" ); 101 | 102 | foreach( $sites as $site ) { 103 | $site_id = (int) $site['stacked_site_id']; 104 | $table = "stacked_{$site_id}_options"; 105 | 106 | $wpdb->query( $wpdb->prepare( 107 | "UPDATE {$table} set option_value = %s where option_name = 'siteurl'", 108 | $primary_site_url 109 | ) ); 110 | $wpdb->query( $wpdb->prepare( 111 | "UPDATE {$table} set option_value = %s where option_name = 'home'", 112 | $primary_home 113 | ) ); 114 | } 115 | } 116 | 117 | $sites_serialize = serialize( $sites ); 118 | 119 | // Secure the check for existing options 120 | $exists = $wpdb->get_var( $wpdb->prepare( 121 | "SELECT option_id from {$this->db_prefix_primary}options where option_name = %s", 122 | 'stacked_sites' 123 | ) ); 124 | 125 | // Secure INSERT and UPDATE 126 | if ( ! $exists ) { 127 | $wpdb->query( $wpdb->prepare( 128 | "INSERT INTO {$this->db_prefix_primary}options ( option_name, option_value) VALUES ( %s, %s )", 129 | 'stacked_sites', 130 | $sites_serialize 131 | ) ); 132 | } else { 133 | $wpdb->query( $wpdb->prepare( 134 | "UPDATE {$this->db_prefix_primary}options set option_value = %s where option_name = %s", 135 | $sites_serialize, 136 | 'stacked_sites' 137 | ) ); 138 | } 139 | } 140 | 141 | public static function get_directory_size( $path ) { 142 | $bytestotal = 0; 143 | $path = realpath( $path ); 144 | if ( $path !== false && $path != '' && file_exists( $path ) ) { 145 | foreach ( new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $path, \FilesystemIterator::SKIP_DOTS ) ) as $object ) { 146 | $bytestotal += $object->getSize(); 147 | } 148 | } 149 | return $bytestotal; 150 | } 151 | 152 | public static function format_size( $bytes ) { 153 | $units = [ 'B', 'KB', 'MB', 'GB', 'TB' ]; 154 | $bytes = max( $bytes, 0 ); 155 | $pow = floor( ( $bytes ? log( $bytes ) : 0 ) / log( 1024 ) ); 156 | $pow = min( $pow, count( $units ) - 1 ); 157 | $bytes /= pow( 1024, $pow ); 158 | return round( $bytes, 2 ) . ' ' . $units[ $pow ]; 159 | } 160 | 161 | public static function delete_directory( $dir ) { 162 | if ( ! file_exists( $dir ) ) { 163 | return true; 164 | } 165 | 166 | if ( is_link( $dir ) ) { 167 | return unlink( $dir ); 168 | } 169 | 170 | if ( ! is_dir( $dir ) ) { 171 | return unlink( $dir ); 172 | } 173 | 174 | foreach ( scandir( $dir ) as $item ) { 175 | if ( $item == '.' || $item == '..' ) { 176 | continue; 177 | } 178 | 179 | if ( ! self::delete_directory( $dir . DIRECTORY_SEPARATOR . $item ) ) { 180 | return false; 181 | } 182 | } 183 | 184 | return rmdir( $dir ); 185 | } 186 | 187 | public static function copy_recursive( $source, $dest ) { 188 | if ( ! file_exists( $source ) ) { 189 | return; 190 | } 191 | if ( ! file_exists( $dest ) ) { 192 | mkdir( $dest, 0777, true ); 193 | } 194 | 195 | foreach ( 196 | $iterator = new \RecursiveIteratorIterator( 197 | new \RecursiveDirectoryIterator( $source, \RecursiveDirectoryIterator::SKIP_DOTS ), 198 | \RecursiveIteratorIterator::SELF_FIRST 199 | ) as $item 200 | ) { 201 | $subPathName = $iterator->getSubPathname(); 202 | $destination_file = $dest . DIRECTORY_SEPARATOR . $subPathName; 203 | 204 | if ( $item->isLink() ) { 205 | $target = readlink( $item->getPathname() ); 206 | @symlink( $target, $destination_file ); 207 | } elseif ( $item->isDir() ) { 208 | if ( ! file_exists( $destination_file ) ) { 209 | mkdir( $destination_file ); 210 | } 211 | } else { 212 | copy( $item, $destination_file ); 213 | } 214 | } 215 | } 216 | 217 | } -------------------------------------------------------------------------------- /app/CLI.php: -------------------------------------------------------------------------------- 1 | get(); 24 | // 2. Get Site List & Count 25 | $sites = ( new Sites )->get(); 26 | $count = count( $sites ); 27 | 28 | // 3. Get Main Site URL safely 29 | $db_prefix = $wpdb->prefix; 30 | $db_prefix_primary = ( defined( 'TABLE_PREFIX' ) ? TABLE_PREFIX : $db_prefix ); 31 | if ( $db_prefix_primary == "TABLE_PREFIX" ) { 32 | $db_prefix_primary = $db_prefix; 33 | } 34 | 35 | $main_site_url = $wpdb->get_var( "SELECT option_value FROM {$db_prefix_primary}options WHERE option_name = 'home'" ); 36 | // 4. Output Details 37 | WP_CLI::line( "Main site: " . $main_site_url ); 38 | // 5. Check for Environment Variable 39 | $current_env_id = getenv( 'STACKED_SITE_ID' ); 40 | if ( ! $current_env_id && isset( $_SERVER['STACKED_SITE_ID'] ) ) { 41 | $current_env_id = $_SERVER['STACKED_SITE_ID']; 42 | } 43 | if ( $current_env_id !== false && $current_env_id !== '' ) { 44 | $site_details = "{$current_env_id} (Not found)"; 45 | foreach ( $sites as $site ) { 46 | if ( $site['stacked_site_id'] == $current_env_id ) { 47 | // Build label dynamically 48 | $parts = []; 49 | if ( ! empty( $site['name'] ) ) { 50 | $parts[] = "name: " . $site['name']; 51 | } 52 | if ( ! empty( $site['domain'] ) ) { 53 | $parts[] = "domain: " . $site['domain']; 54 | } 55 | 56 | $site_details = $site['stacked_site_id']; 57 | if ( ! empty( $parts ) ) { 58 | $site_details .= " (" . implode( ', ', $parts ) . ")"; 59 | } 60 | break; 61 | } 62 | } 63 | WP_CLI::line( "Current Site: " . $site_details ); 64 | } 65 | 66 | WP_CLI::line( "Current Domain Mapping: " . WP_CLI::colorize( "%G" . $configs->domain_mapping . "%n" ) ); 67 | WP_CLI::line( "Current Files Mode: " . WP_CLI::colorize( "%G" . $configs->files . "%n" ) ); 68 | WP_CLI::line( "Site Count: " . $count ); 69 | } 70 | 71 | /** 72 | * Get or set the files mode. 73 | * 74 | * ## OPTIONS 75 | * 76 | * 77 | * : The action to perform (get|set). 78 | * 79 | * [] 80 | * : The mode to set (shared|hybrid|dedicated). Required for 'set'. 81 | * 82 | * ## EXAMPLES 83 | * 84 | * wp freighter files get 85 | * wp freighter files set dedicated 86 | */ 87 | public function files( $args, $assoc_args ) { 88 | list( $action ) = $args; 89 | $mode = isset( $args[1] ) ? $args[1] : null; 90 | 91 | if ( ! in_array( $action, [ 'get', 'set' ] ) ) { 92 | WP_CLI::error( "Invalid action. Use 'get' or 'set'." ); 93 | } 94 | 95 | $configs = ( new Configurations )->get(); 96 | if ( 'get' === $action ) { 97 | WP_CLI::line( "Current Files Mode: " . WP_CLI::colorize( "%G" . $configs->files . "%n" ) ); 98 | return; 99 | } 100 | 101 | if ( 'set' === $action ) { 102 | $valid_modes = [ 'shared', 'hybrid', 'dedicated' ]; 103 | if ( ! in_array( $mode, $valid_modes ) ) { 104 | WP_CLI::error( "Invalid mode. Available options: " . implode( ', ', $valid_modes ) ); 105 | } 106 | 107 | $data = (array) $configs; 108 | $data['files'] = $mode; 109 | 110 | ( new Configurations )->update( $data ); 111 | WP_CLI::success( "Files mode updated to '{$mode}' and wp-config.php refreshed." ); 112 | } 113 | } 114 | 115 | /** 116 | * Get or set the domain mapping mode. 117 | * 118 | * ## OPTIONS 119 | * 120 | * 121 | * : The action to perform (get|set). 122 | * 123 | * [] 124 | * : The status to set (on|off). Required for 'set'. 125 | * 126 | * ## EXAMPLES 127 | * 128 | * wp freighter domain get 129 | * wp freighter domain set on 130 | */ 131 | public function domain( $args, $assoc_args ) { 132 | list( $action ) = $args; 133 | $status = isset( $args[1] ) ? $args[1] : null; 134 | 135 | if ( ! in_array( $action, [ 'get', 'set' ] ) ) { 136 | WP_CLI::error( "Invalid action. Use 'get' or 'set'." ); 137 | } 138 | 139 | $configs = ( new Configurations )->get(); 140 | if ( 'get' === $action ) { 141 | WP_CLI::line( "Current Domain Mapping: " . WP_CLI::colorize( "%G" . $configs->domain_mapping . "%n" ) ); 142 | return; 143 | } 144 | 145 | if ( 'set' === $action ) { 146 | $valid_status = [ 'on', 'off' ]; 147 | if ( ! in_array( $status, $valid_status ) ) { 148 | WP_CLI::error( "Invalid status. Available options: on, off" ); 149 | } 150 | 151 | $data = (array) $configs; 152 | $data['domain_mapping'] = $status; 153 | ( new Configurations )->update( $data ); 154 | WP_CLI::success( "Domain mapping updated to '{$status}'." ); 155 | } 156 | } 157 | 158 | /** 159 | * List all tenant sites. 160 | * 161 | * ## EXAMPLES 162 | * 163 | * wp freighter list 164 | * 165 | * @subcommand list 166 | */ 167 | public function list_sites( $args, $assoc_args ) { 168 | $sites = ( new Sites )->get(); 169 | $configs = ( new Configurations )->get(); 170 | 171 | if ( empty( $sites ) ) { 172 | WP_CLI::line( "No tenant sites found." ); 173 | return; 174 | } 175 | 176 | // Prepare all possible data points 177 | $display_data = array_map( function( $site ) { 178 | return [ 179 | 'ID' => $site['stacked_site_id'], 180 | 'Name' => $site['name'], 181 | 'Domain' => $site['domain'], 182 | 'Content' => "content/{$site['stacked_site_id']}", 183 | 'Uploads' => "content/{$site['stacked_site_id']}/uploads", 184 | 'Created' => date( 'Y-m-d H:i:s', $site['created_at'] ), 185 | ]; 186 | }, $sites ); 187 | // Build columns dynamically based on configurations 188 | $fields = [ 'ID' ]; 189 | // Toggle Name vs Domain 190 | if ( $configs->domain_mapping === 'on' ) { 191 | $fields[] = 'Domain'; 192 | } else { 193 | $fields[] = 'Name'; 194 | } 195 | 196 | if ( $configs->files === 'dedicated' ) { 197 | $fields[] = 'Content'; 198 | } 199 | 200 | // Show Uploads path if in Hybrid mode 201 | if ( $configs->files === 'hybrid' ) { 202 | $fields[] = 'Uploads'; 203 | } 204 | 205 | $fields[] = 'Created'; 206 | 207 | WP_CLI\Utils\format_items( 'table', $display_data, $fields ); 208 | } 209 | 210 | /** 211 | * Create a new tenant site. 212 | * 213 | * ## OPTIONS 214 | * 215 | * [--title=] 216 | * : Site title. Default: "New Site" 217 | * 218 | * [--name=<name>] 219 | * : Label for the site. 220 | * 221 | * [--domain=<domain>] 222 | * : Domain for the site. 223 | * 224 | * [--username=<username>] 225 | * : Admin username. Default: "admin" 226 | * 227 | * [--email=<email>] 228 | * : Admin email. Default: "admin@example.com" 229 | * 230 | * [--password=<password>] 231 | * : Admin password. If not set, one will be generated. 232 | * 233 | * ## EXAMPLES 234 | * 235 | * wp freighter add --title="My Sandbox" --name="Sandbox" 236 | * 237 | * @alias create 238 | */ 239 | public function add( $args, $assoc_args ) { 240 | 241 | // Fix: Suppress "Undefined array key HTTP_HOST" warnings in wp_install 242 | if ( ! isset( $_SERVER['HTTP_HOST'] ) ) { 243 | $_SERVER['HTTP_HOST'] = 'cli.wpfreighter.localhost'; 244 | } 245 | 246 | WP_CLI::line( "Creating site..." ); 247 | // Delegate directly to the Site model 248 | $result = Site::create( $assoc_args ); 249 | if ( is_wp_error( $result ) ) { 250 | WP_CLI::error( "Failed to create site: " . $result->get_error_message() ); 251 | } 252 | 253 | WP_CLI::success( "Site created successfully." ); 254 | WP_CLI::line( "ID: " . $result['stacked_site_id'] ); 255 | // Only show password if we generated it or user provided it (it's in assoc_args if provided) 256 | if ( isset( $assoc_args['password'] ) ) { 257 | WP_CLI::line( "Password: " . $assoc_args['password'] ); 258 | } else { 259 | WP_CLI::line( "Password: (auto-generated)" ); 260 | } 261 | } 262 | 263 | /** 264 | * Delete a tenant site. 265 | * 266 | * ## OPTIONS 267 | * 268 | * <id> 269 | * : The Tenant Site ID to delete. 270 | * 271 | * [--yes] 272 | * : Skip confirmation. 273 | * 274 | * ## EXAMPLES 275 | * 276 | * wp freighter delete 2 277 | */ 278 | public function delete( $args, $assoc_args ) { 279 | list( $site_id ) = $args; 280 | WP_CLI::confirm( "Are you sure you want to delete Site ID {$site_id}? This will drop tables and delete files.", $assoc_args ); 281 | // Delegate directly to the Site model 282 | $success = Site::delete( $site_id ); 283 | if ( $success ) { 284 | WP_CLI::success( "Site {$site_id} deleted." ); 285 | } else { 286 | WP_CLI::error( "Failed to delete site {$site_id}. Site may not exist." ); 287 | } 288 | } 289 | 290 | /** 291 | * Clone an existing site (or main site). 292 | * 293 | * ## OPTIONS 294 | * 295 | * <source-id> 296 | * : The Source ID to clone. Use 'main' for the primary site. 297 | * 298 | * [--name=<name>] 299 | * : New site label. 300 | * 301 | * [--domain=<domain>] 302 | * : New site domain. 303 | * 304 | * ## EXAMPLES 305 | * 306 | * wp freighter clone main --name="Staging" 307 | * wp freighter clone 2 --name="Dev Copy" 308 | */ 309 | public function clone_site( $args, $assoc_args ) { 310 | list( $source_id ) = $args; 311 | $clone_args = [ 312 | 'name' => isset( $assoc_args['name'] ) ? $assoc_args['name'] : '', 313 | 'domain' => isset( $assoc_args['domain'] ) ? $assoc_args['domain'] : '', 314 | ]; 315 | 316 | WP_CLI::line( "Cloning site ID '{$source_id}'..." ); 317 | // Delegate directly to the Site model 318 | $result = Site::clone( $source_id, $clone_args ); 319 | if ( is_wp_error( $result ) ) { 320 | WP_CLI::error( "Clone failed: " . $result->get_error_message() ); 321 | } 322 | 323 | WP_CLI::success( "Clone complete. New Site ID: " . $result['stacked_site_id'] ); 324 | } 325 | 326 | /** 327 | * Generate a magic login URL for a specific site. 328 | * 329 | * ## OPTIONS 330 | * 331 | * <id> 332 | * : The Tenant Site ID to login to. Use 'main' for the primary site. 333 | * 334 | * [--url-only] 335 | * : Output only the URL (useful for piping to other commands/browsers). 336 | * 337 | * ## EXAMPLES 338 | * 339 | * wp freighter login 2 340 | * wp freighter login main 341 | * open $(wp freighter login 2 --url-only) 342 | */ 343 | public function login( $args, $assoc_args ) { 344 | list( $site_id ) = $args; 345 | $url_only = isset( $assoc_args['url-only'] ); 346 | 347 | // Validate Site ID Exists (unless it is 'main') 348 | if ( 'main' !== $site_id ) { 349 | $site = Site::get( $site_id ); 350 | if ( empty( $site ) ) { 351 | WP_CLI::error( "Site ID '{$site_id}' not found." ); 352 | } 353 | } 354 | 355 | // Delegate to Site Model 356 | $login_url = Site::login( $site_id ); 357 | if ( is_wp_error( $login_url ) ) { 358 | WP_CLI::error( "Failed to generate login URL: " . $login_url->get_error_message() ); 359 | } 360 | 361 | if ( $url_only ) { 362 | WP_CLI::line( $login_url ); 363 | } else { 364 | WP_CLI::success( "Magic login URL generated:" ); 365 | WP_CLI::line( $login_url ); 366 | } 367 | } 368 | 369 | /** 370 | * Regenerate the WP Freighter configuration files. 371 | * 372 | * ## EXAMPLES 373 | * 374 | * wp freighter regenerate 375 | */ 376 | public function regenerate( $args, $assoc_args ) { 377 | ( new Configurations )->refresh_configs(); 378 | WP_CLI::success( "Configuration files regenerated." ); 379 | } 380 | 381 | } -------------------------------------------------------------------------------- /assets/js/admin-app.js: -------------------------------------------------------------------------------- 1 | const { createApp } = Vue; 2 | const { createVuetify } = Vuetify; 3 | const vuetify = createVuetify({ 4 | theme: { 5 | defaultTheme: 'light', 6 | themes: { 7 | light: { 8 | colors: { 9 | primary: '#0073aa', 10 | secondary: '#424242', 11 | accent: '#82B1FF', 12 | error: '#FF5252', 13 | info: '#2196F3', 14 | success: '#4CAF50', 15 | warning: '#FFC107', 16 | background: '#FFFFFF', 17 | surface: '#FFFFFF', 18 | } 19 | }, 20 | dark: { 21 | dark: true, 22 | colors: { 23 | primary: '#72aee6', // WP Admin Dark Mode Blue 24 | secondary: '#424242', 25 | surface: '#1e1e1e', 26 | background: '#121212', 27 | error: '#CF6679', 28 | } 29 | } 30 | }, 31 | }, 32 | }); 33 | 34 | createApp({ 35 | data() { 36 | return { 37 | configurations: wpFreighterSettings.configurations || {}, 38 | stacked_sites: wpFreighterSettings.stacked_sites || [], 39 | current_site_id: wpFreighterSettings.current_site_id || "", 40 | response: "", 41 | snackbar: false, 42 | snackbarText: "", 43 | new_site: { 44 | name: "", 45 | domain: "", 46 | title: "", 47 | email: wpFreighterSettings.currentUser.email, 48 | username: wpFreighterSettings.currentUser.username, 49 | password: Math.random().toString(36).slice(-10), 50 | show: false, 51 | valid: true 52 | }, 53 | clone_site: { 54 | show: false, 55 | valid: true, 56 | source_id: null, 57 | source_name: "", 58 | name: "", 59 | domain: "" 60 | }, 61 | delete_site: { 62 | show: false, 63 | id: null, 64 | has_dedicated_content: false, 65 | path: "", 66 | size: "" 67 | }, 68 | pending_changes: false, 69 | loading: false, 70 | headers: [ 71 | { title: '', key: 'stacked_site_id', sortable: false }, 72 | { title: 'ID', key: 'id' }, 73 | { title: 'Label', key: 'name' }, 74 | { title: 'Domain', key: 'domain' }, 75 | { title: 'Created At', key: 'created_at', headerProps: { class: 'd-none d-md-table-cell' } }, 76 | { title: '', key: 'actions', align: "end", sortable: false } 77 | ], 78 | }; 79 | }, 80 | mounted() { 81 | // Load Dark Mode preference 82 | const savedTheme = localStorage.getItem('wpFreighterTheme'); 83 | if (savedTheme) { 84 | this.$vuetify.theme.global.name = savedTheme; 85 | } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { 86 | this.$vuetify.theme.global.name = 'dark'; 87 | } 88 | 89 | // Apply background immediately 90 | this.updateBodyBackground(); 91 | }, 92 | watch: { 93 | // Watch for theme changes to update WordPress admin background 94 | '$vuetify.theme.global.name'() { 95 | this.updateBodyBackground(); 96 | } 97 | }, 98 | methods: { 99 | toggleTheme() { 100 | const current = this.$vuetify.theme.global.name; 101 | const next = current === 'light' ? 'dark' : 'light'; 102 | this.$vuetify.theme.global.name = next; 103 | localStorage.setItem('wpFreighterTheme', next); 104 | }, 105 | updateBodyBackground() { 106 | const isDark = this.$vuetify.theme.global.name === 'dark'; 107 | const wpContent = document.querySelector('#wpcontent'); 108 | if (wpContent) { 109 | // #121212 matches the Vuetify Dark theme background defined above 110 | wpContent.style.backgroundColor = isDark ? '#121212' : ''; 111 | } 112 | }, 113 | pretty_timestamp( date ) { 114 | let d = new Date(0); 115 | d.setUTCSeconds(date); 116 | return d.toLocaleTimeString( "en-us", { 117 | "weekday": "short", 118 | "year": "numeric", 119 | "month": "short", 120 | "day": "numeric", 121 | "hour": "2-digit", 122 | "minute": "2-digit" 123 | }); 124 | }, 125 | pretty_timestamp_mysql( date ) { 126 | let d = new Date( date ); 127 | return d.toLocaleDateString( "en-us", { 128 | "weekday": "short", 129 | "year": "numeric", 130 | "month": "short", 131 | "day": "numeric", 132 | }); 133 | }, 134 | copyToClipboard( text ) { 135 | if ( !navigator.clipboard ) { 136 | this.snackbarText = "Clipboard API not supported via non-secure context."; 137 | this.snackbar = true; 138 | return; 139 | } 140 | 141 | navigator.clipboard.writeText( text ).then( () => { 142 | this.snackbarText = "Code copied to clipboard."; 143 | this.snackbar = true; 144 | }, ( err ) => { 145 | this.snackbarText = "Failed to copy: " + err; 146 | this.snackbar = true; 147 | }); 148 | }, 149 | generatePassword() { 150 | return Math.random().toString(36).slice(-10); 151 | }, 152 | getNewSiteDefaults() { 153 | return { 154 | name: "", 155 | domain: "", 156 | title: "", 157 | email: wpFreighterSettings.currentUser.email, 158 | username: wpFreighterSettings.currentUser.username, 159 | password: this.generatePassword(), 160 | show: false, 161 | valid: true 162 | }; 163 | }, 164 | changeForm() { 165 | this.pending_changes = true; 166 | }, 167 | cloneSite( stacked_site_id ) { 168 | let proceed = confirm( `Clone site ${stacked_site_id} to a new tenant website?` ); 169 | if ( ! proceed ) { 170 | return; 171 | } 172 | this.loading = true; 173 | axios.post( wpFreighterSettings.root + 'sites/clone', { 174 | 'source_id': stacked_site_id 175 | }) 176 | .then( response => { 177 | this.stacked_sites = response.data; 178 | this.loading = false; 179 | }) 180 | .catch( error => { 181 | this.loading = false; 182 | console.log( error ); 183 | }); 184 | }, 185 | openCloneDialog( item ) { 186 | this.clone_site.source_id = item.stacked_site_id; 187 | if ( this.configurations.domain_mapping == 'on' ) { 188 | this.clone_site.source_name = item.domain ? item.domain : 'Site ' + item.stacked_site_id; 189 | } else { 190 | this.clone_site.source_name = item.name ? item.name : 'Site ' + item.stacked_site_id; 191 | } 192 | 193 | if ( this.configurations.domain_mapping == 'off' ) { 194 | this.clone_site.name = item.name ? item.name + " (Clone)" : ""; 195 | this.clone_site.domain = ""; 196 | } else { 197 | this.clone_site.name = ""; 198 | this.clone_site.domain = ""; 199 | } 200 | this.clone_site.show = true; 201 | }, 202 | openCloneMainDialog() { 203 | this.clone_site.source_id = 'main'; 204 | this.clone_site.source_name = "Main Site"; 205 | this.clone_site.name = ""; 206 | this.clone_site.domain = ""; 207 | this.clone_site.show = true; 208 | }, 209 | processClone() { 210 | this.loading = true; 211 | this.clone_site.show = false; 212 | 213 | axios.post( wpFreighterSettings.root + 'sites/clone', { 214 | 'source_id': this.clone_site.source_id, 215 | 'name': this.clone_site.name, 216 | 'domain': this.clone_site.domain 217 | }) 218 | .then( response => { 219 | this.stacked_sites = response.data; 220 | this.loading = false; 221 | this.clone_site.source_id = null; 222 | this.clone_site.name = ""; 223 | this.clone_site.domain = ""; 224 | }) 225 | .catch( error => { 226 | this.loading = false; 227 | console.log( error ); 228 | }); 229 | }, 230 | deleteSite( stacked_site_id ) { 231 | this.loading = true; 232 | this.delete_site.id = stacked_site_id; 233 | 234 | axios.post( wpFreighterSettings.root + 'sites/stats', { 235 | 'site_id': stacked_site_id 236 | }) 237 | .then( response => { 238 | this.delete_site.has_dedicated_content = response.data.has_dedicated_content; 239 | this.delete_site.path = response.data.path; 240 | this.delete_site.size = response.data.size; 241 | 242 | this.loading = false; 243 | this.delete_site.show = true; 244 | }) 245 | .catch( error => { 246 | this.loading = false; 247 | console.log( error ); 248 | if ( confirm( `Delete site ${stacked_site_id}?` ) ) { 249 | this.confirmDelete(); 250 | } 251 | }); 252 | }, 253 | confirmDelete() { 254 | this.delete_site.show = false; 255 | this.loading = true; 256 | 257 | axios.post( wpFreighterSettings.root + 'sites/delete', { 258 | 'site_id': this.delete_site.id, 259 | } ) 260 | .then( response => { 261 | // Check for redirect URL (Magic Login) 262 | if ( response.data.url ) { 263 | window.location.href = response.data.url; 264 | return; 265 | } 266 | 267 | // Fallback for deleting other sites (not the current one) 268 | if ( this.delete_site.id == wpFreighterSettings.current_site_id ) { 269 | location.reload(); 270 | return; 271 | } 272 | 273 | this.stacked_sites = response.data; 274 | this.loading = false; 275 | this.snackbarText = "Site deleted successfully."; 276 | this.snackbar = true; 277 | }) 278 | .catch( error => { 279 | this.loading = false; 280 | console.log( error ); 281 | }); 282 | }, 283 | saveConfigurations() { 284 | axios.post( wpFreighterSettings.root + 'configurations', { 285 | sites: this.stacked_sites, 286 | configurations: this.configurations, 287 | } ) 288 | .then( response => { 289 | this.configurations = response.data; 290 | this.pending_changes = false; 291 | this.snackbarText = "Configurations saved."; 292 | this.snackbar = true; 293 | }) 294 | .catch( error => { 295 | console.log( error ); 296 | }); 297 | }, 298 | switchTo( stacked_site_id ) { 299 | axios.post( wpFreighterSettings.root + 'switch', { 300 | 'site_id': stacked_site_id, 301 | } ) 302 | .then( response => { 303 | if ( response.data.url ) { 304 | window.location.href = response.data.url; 305 | } else { 306 | location.reload(); 307 | } 308 | }) 309 | .catch( error => { 310 | console.log( error ); 311 | }); 312 | }, 313 | autoLogin( item ) { 314 | this.loading = true; 315 | axios.post( wpFreighterSettings.root + 'sites/autologin', { 316 | 'site_id': item.stacked_site_id 317 | }) 318 | .then( response => { 319 | this.loading = false; 320 | if ( response.data.url ) { 321 | window.open( response.data.url, '_blank' ); 322 | } 323 | }) 324 | .catch( error => { 325 | this.loading = false; 326 | this.snackbarText = "Autologin failed: " + (error.response?.data?.message || error.message); 327 | this.snackbar = true; 328 | console.log( error ); 329 | }); 330 | }, 331 | loginToMain() { 332 | this.loading = true; 333 | axios.post( wpFreighterSettings.root + 'sites/autologin', { 334 | 'site_id': 'main' 335 | }) 336 | .then( response => { 337 | this.loading = false; 338 | if ( response.data.url ) { 339 | window.location.href = response.data.url; 340 | } 341 | }) 342 | .catch( error => { 343 | this.loading = false; 344 | this.snackbarText = "Login failed: " + (error.response?.data?.message || error.message); 345 | this.snackbar = true; 346 | console.log( error ); 347 | }); 348 | }, 349 | newSite() { 350 | this.$refs.form.validate().then(result => { 351 | if (!result.valid) { 352 | return; 353 | } 354 | let proceed = confirm( "Create a new tenant website?" ); 355 | if ( ! proceed ) { 356 | return; 357 | } 358 | this.loading = true; 359 | this.new_site.show = false; 360 | 361 | axios.post( wpFreighterSettings.root + 'sites', this.new_site ) 362 | .then( response => { 363 | this.stacked_sites = response.data; 364 | this.loading = false; 365 | this.new_site = this.getNewSiteDefaults(); 366 | }) 367 | .catch( error => { 368 | this.loading = false; 369 | console.log( error ); 370 | }); 371 | }); 372 | }, 373 | cloneExisting() { 374 | let proceed = confirm( "Clone existing site to a new tenant website?" ); 375 | if ( ! proceed ) { 376 | return; 377 | } 378 | axios.post( wpFreighterSettings.root + 'sites/clone' ) 379 | .then( response => { 380 | this.stacked_sites = response.data; 381 | }) 382 | .catch( error => { 383 | console.log( error ); 384 | }); 385 | } 386 | } 387 | }).use(vuetify).mount('#app'); -------------------------------------------------------------------------------- /app/Configurations.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace WPFreighter; 4 | 5 | class Configurations { 6 | 7 | protected $configurations = []; 8 | protected $db_prefix_primary = ""; 9 | 10 | public function __construct() { 11 | global $wpdb; 12 | $db_prefix = $wpdb->prefix; 13 | $db_prefix_primary = ( defined( 'TABLE_PREFIX' ) ? TABLE_PREFIX : $db_prefix ); 14 | if ( $db_prefix_primary == "TABLE_PREFIX" ) { 15 | $db_prefix_primary = $db_prefix; 16 | } 17 | $this->db_prefix_primary = $db_prefix_primary; 18 | $configurations = $wpdb->get_results("select option_value from {$this->db_prefix_primary}options where option_name = 'stacked_configurations'"); 19 | $configurations = empty ( $configurations ) ? "" : maybe_unserialize( $configurations[0]->option_value ); 20 | if ( empty( $configurations ) ) { 21 | $configurations = [ 22 | "files" => "shared", 23 | "domain_mapping" => "off", 24 | ]; 25 | } 26 | $this->configurations = $configurations; 27 | } 28 | 29 | public function get() { 30 | $configs = (array) $this->configurations; 31 | $configs['errors'] = []; 32 | 33 | // Use absolute path to ensure we look in root wp-content, not the tenant site's content dir 34 | $bootstrap_path = ABSPATH . 'wp-content/freighter.php'; 35 | 36 | // Lazy Init 37 | if ( ! file_exists( $bootstrap_path ) ) { 38 | $this->refresh_configs(); 39 | } 40 | 41 | // Error 1 Check 42 | if ( ! file_exists( $bootstrap_path ) ) { 43 | $configs['errors']['manual_bootstrap_required'] = $this->get_bootstrap_content(); 44 | } 45 | 46 | // Error 2 Check 47 | if ( empty( $configs['errors']['manual_bootstrap_required'] ) ) { 48 | if ( file_exists( ABSPATH . "wp-config.php" ) ) { 49 | $wp_config_file = ABSPATH . "wp-config.php"; 50 | } elseif ( file_exists( dirname( ABSPATH ) . '/wp-config.php' ) ) { 51 | $wp_config_file = dirname( ABSPATH ) . '/wp-config.php'; 52 | } 53 | 54 | $wp_config_content = ( isset( $wp_config_file ) && file_exists( $wp_config_file ) ) 55 | ? file_get_contents( $wp_config_file ) 56 | : ''; 57 | // Look for the unique filename 58 | if ( strpos( $wp_config_content, 'wp-content/freighter.php' ) === false ) { 59 | // Return as array for Vue compatibility 60 | $configs['errors']['manual_config_required'] = [ 61 | "if ( file_exists( dirname( __FILE__ ) . '/wp-content/freighter.php' ) ) { require_once( dirname( __FILE__ ) . '/wp-content/freighter.php' ); }" 62 | ]; 63 | } 64 | } 65 | 66 | return (object) $configs; 67 | } 68 | 69 | public function get_json() { 70 | $configurations = $this->configurations; 71 | if ( empty( $configurations ) ) { 72 | $configurations = [ 73 | "files" => "shared", 74 | "domain_mapping" => "off", 75 | ]; 76 | } 77 | return json_encode( $configurations ); 78 | } 79 | 80 | public function update_config( $key, $value ) { 81 | global $wpdb; 82 | $this->configurations[ $key ] = $value; 83 | $configurations_serialize = serialize( $this->configurations ); 84 | $exists = $wpdb->get_var( $wpdb->prepare( 85 | "SELECT option_id from {$this->db_prefix_primary}options where option_name = %s", 86 | 'stacked_configurations' 87 | ) ); 88 | if ( ! $exists ) { 89 | $wpdb->query( $wpdb->prepare( 90 | "INSERT INTO {$this->db_prefix_primary}options ( option_name, option_value) VALUES ( %s, %s )", 91 | 'stacked_configurations', 92 | $configurations_serialize 93 | ) ); 94 | } else { 95 | $wpdb->query( $wpdb->prepare( 96 | "UPDATE {$this->db_prefix_primary}options set option_value = %s where option_name = %s", 97 | $configurations_serialize, 98 | 'stacked_configurations' 99 | ) ); 100 | } 101 | } 102 | 103 | public function update( $configurations ) { 104 | global $wpdb; 105 | // 1. Sanitize: Remove 'errors' so we don't save dynamic checks to DB 106 | $data = (array) $configurations; 107 | if ( isset( $data['errors'] ) ) { 108 | unset( $data['errors'] ); 109 | } 110 | 111 | $this->configurations = $data; 112 | $configurations_serialize = serialize( $this->configurations ); 113 | $exists = $wpdb->get_var( $wpdb->prepare( 114 | "SELECT option_id from {$this->db_prefix_primary}options where option_name = %s", 115 | 'stacked_configurations' 116 | ) ); 117 | if ( ! $exists ) { 118 | $wpdb->query( $wpdb->prepare( 119 | "INSERT INTO {$this->db_prefix_primary}options ( option_name, option_value) VALUES ( %s, %s )", 120 | 'stacked_configurations', 121 | $configurations_serialize 122 | ) ); 123 | } else { 124 | $wpdb->query( $wpdb->prepare( 125 | "UPDATE {$this->db_prefix_primary}options set option_value = %s where option_name = %s", 126 | $configurations_serialize, 127 | 'stacked_configurations' 128 | ) ); 129 | } 130 | self::refresh_configs(); 131 | } 132 | 133 | public function domain_mapping() { 134 | $configurations = self::get(); 135 | if ( $configurations->domain_mapping == "on" ) { 136 | return true; 137 | } 138 | return false; 139 | } 140 | 141 | public function refresh_configs() { 142 | global $wpdb; 143 | // 1. Generate & Write Bootstrap File 144 | $bootstrap_content = $this->get_bootstrap_content(); 145 | $bootstrap_path = ABSPATH . 'wp-content/freighter.php'; 146 | 147 | // Attempt write 148 | $bootstrap_written = @file_put_contents( $bootstrap_path, $bootstrap_content ); 149 | // Fallback check: maybe it already exists and is valid? 150 | if ( ! $bootstrap_written ) { 151 | if ( file_exists( $bootstrap_path ) && md5_file( $bootstrap_path ) === md5( $bootstrap_content ) ) { 152 | $bootstrap_written = true; 153 | } 154 | } 155 | 156 | if ( ! $bootstrap_written ) { 157 | return; 158 | } 159 | 160 | // 2. The Clean One-Liner (No Comment) 161 | $lines_to_add = [ 162 | "if ( file_exists( dirname( __FILE__ ) . '/wp-content/freighter.php' ) ) { require_once( dirname( __FILE__ ) . '/wp-content/freighter.php' ); }" 163 | ]; 164 | // 3. Update wp-config.php 165 | if ( file_exists( ABSPATH . "wp-config.php" ) ) { 166 | $wp_config_file = ABSPATH . "wp-config.php"; 167 | } elseif ( file_exists( dirname( ABSPATH ) . '/wp-config.php' ) ) { 168 | $wp_config_file = dirname( ABSPATH ) . '/wp-config.php'; 169 | } else { 170 | return; 171 | } 172 | 173 | if ( is_writable( $wp_config_file ) ) { 174 | $wp_config_content = file_get_contents( $wp_config_file ); 175 | $working = preg_split( '/\R/', $wp_config_content ); 176 | // Clean OLD logic and NEW logic 177 | $working = $this->clean_wp_config_lines( $working ); 178 | // Find insertion point ($table_prefix) 179 | $table_prefix_line = 0; 180 | foreach( $working as $key => $line ) { 181 | if ( strpos( $line, '$table_prefix' ) !== false ) { 182 | $table_prefix_line = $key; 183 | break; 184 | } 185 | } 186 | 187 | // Insert new one-liner 188 | $updated = array_merge( 189 | array_slice( $working, 0, $table_prefix_line + 1, true ), 190 | $lines_to_add, 191 | array_slice( $working, $table_prefix_line + 1, count( $working ), true ) 192 | ); 193 | file_put_contents( $wp_config_file, implode( PHP_EOL, $updated ) ); 194 | } 195 | } 196 | 197 | /** 198 | * Generates the dynamic PHP code for wp-content/freighter.php 199 | */ 200 | private function get_bootstrap_content() { 201 | global $wpdb; 202 | $configurations = (object) $this->configurations; 203 | 204 | // Fetch URL cleanly directly from DB 205 | $site_url = $wpdb->get_var( "SELECT option_value FROM {$this->db_prefix_primary}options WHERE option_name = 'siteurl'" ); 206 | $site_url = str_replace( ["https://", "http://"], "", $site_url ); 207 | 208 | // Prepare Mappings Array 209 | $mapping_php = '$stacked_mappings = [];'; 210 | if ( $configurations->domain_mapping == "on" ) { 211 | $domain_mappings = ( new Sites )->domain_mappings(); 212 | // Using var_export ensures the array is written as valid PHP code 213 | $export = var_export( $domain_mappings, true ); 214 | $mapping_php = "\$stacked_mappings = $export;"; 215 | } 216 | 217 | // Logic Blocks based on File Mode 218 | $mode_logic = ""; 219 | // --- DEDICATED MODE --- 220 | if ( $configurations->files == 'dedicated' ) { 221 | $mode_logic = <<<PHP 222 | if ( ! empty( \$stacked_site_id ) ) { 223 | \$table_prefix = "stacked_{\$stacked_site_id}_"; 224 | define( 'WP_CONTENT_URL', "https://" . ( isset(\$stacked_home) ? \$stacked_home : '$site_url' ) . "/content/{\$stacked_site_id}" ); 225 | define( 'WP_CONTENT_DIR', ABSPATH . "content/{\$stacked_site_id}" ); 226 | // Define URLs for non-mapped sites (cookie based) 227 | if ( empty( \$stacked_home ) ) { 228 | define( 'WP_HOME', "https://$site_url" ); 229 | define( 'WP_SITEURL', "https://$site_url" ); 230 | } 231 | } 232 | PHP; 233 | } 234 | 235 | // --- HYBRID MODE --- 236 | if ( $configurations->files == 'hybrid' ) { 237 | $mode_logic = <<<PHP 238 | if ( ! empty( \$stacked_site_id ) ) { 239 | \$table_prefix = "stacked_{\$stacked_site_id}_"; 240 | define( 'UPLOADS', "content/{\$stacked_site_id}/uploads" ); 241 | 242 | if ( empty( \$stacked_home ) ) { 243 | define( 'WP_HOME', "https://$site_url" ); 244 | define( 'WP_SITEURL', "https://$site_url" ); 245 | } 246 | } 247 | PHP; 248 | } 249 | 250 | // --- SHARED MODE --- 251 | if ( $configurations->files == 'shared' ) { 252 | $mode_logic = <<<PHP 253 | if ( ! empty( \$stacked_site_id ) ) { 254 | \$table_prefix = "stacked_{\$stacked_site_id}_"; 255 | if ( empty( \$stacked_home ) ) { 256 | define( 'WP_HOME', "https://$site_url" ); 257 | define( 'WP_SITEURL', "https://$site_url" ); 258 | } 259 | } 260 | PHP; 261 | } 262 | 263 | // Return the Full File Content 264 | return <<<EOD 265 | <?php 266 | /** 267 | * WP Freighter Bootstrap 268 | * 269 | * Auto-generated file. Do not edit directly. 270 | * Settings are managed via WP Admin -> Tools -> WP Freighter. 271 | */ 272 | 273 | // 1. Define Mappings 274 | $mapping_php 275 | 276 | // 2. Identify Tenant Site ID 277 | \$stacked_site_id = ( isset( \$_COOKIE[ "stacked_site_id" ] ) ? \$_COOKIE[ "stacked_site_id" ] : "" ); 278 | // [GATEKEEPER] Enforce strict access control for cookie-based access 279 | if ( ! empty( \$stacked_site_id ) && isset( \$_COOKIE['stacked_site_id'] ) ) { 280 | 281 | // Whitelist login page (so Magic Login can function) 282 | \$is_login = ( isset( \$_SERVER['SCRIPT_NAME'] ) && strpos( \$_SERVER['SCRIPT_NAME'], 'wp-login.php' ) !== false ); 283 | // Check for WordPress Auth Cookie (Raw Check) 284 | // We check if *any* cookie starts with 'wordpress_logged_in_' because we can't validate the hash yet. 285 | \$has_auth_cookie = false; 286 | foreach ( \$_COOKIE as \$key => \$value ) { 287 | if ( strpos( \$key, 'wordpress_logged_in_' ) === 0 ) { 288 | \$has_auth_cookie = true; 289 | break; 290 | } 291 | } 292 | 293 | // If not logging in, and no auth cookie is present, REVOKE ACCESS immediately. 294 | if ( ! \$is_login && ! \$has_auth_cookie ) { 295 | setcookie( 'stacked_site_id', '', time() - 3600, '/' ); 296 | unset( \$_COOKIE['stacked_site_id'] ); 297 | \$stacked_site_id = ""; // Revert to Main Site context for this request 298 | } 299 | } 300 | 301 | // CLI Support 302 | if ( defined( 'WP_CLI' ) && WP_CLI ) { 303 | \$env_id = getenv( 'STACKED_SITE_ID' ); 304 | if ( ! \$env_id && isset( \$_SERVER['STACKED_SITE_ID'] ) ) { \$env_id = \$_SERVER['STACKED_SITE_ID']; } 305 | if ( ! \$env_id && isset( \$_ENV['STACKED_SITE_ID'] ) ) { \$env_id = \$_ENV['STACKED_SITE_ID']; } 306 | if ( \$env_id ) { \$stacked_site_id = \$env_id; } 307 | } 308 | 309 | // Domain Mapping Detection 310 | if ( isset( \$_SERVER['HTTP_HOST'] ) && in_array( \$_SERVER['HTTP_HOST'], \$stacked_mappings ) ) { 311 | \$found_id = array_search( \$_SERVER['HTTP_HOST'], \$stacked_mappings ); 312 | if ( \$found_id ) { 313 | \$stacked_site_id = \$found_id; 314 | \$stacked_home = \$stacked_mappings[ \$found_id ]; 315 | } 316 | } 317 | 318 | if ( ! empty( \$stacked_site_id ) && empty( \$stacked_home ) && isset( \$stacked_mappings[ \$stacked_site_id ] ) ) { 319 | \$stacked_home = \$stacked_mappings[ \$stacked_site_id ]; 320 | } 321 | 322 | // 3. Apply Configuration Logic 323 | if ( ! empty( \$stacked_site_id ) ) { 324 | // Save original prefix if needed later 325 | if ( ! defined( 'TABLE_PREFIX' ) && isset( \$table_prefix ) ) { 326 | define( 'TABLE_PREFIX', \$table_prefix ); 327 | } 328 | 329 | $mode_logic 330 | 331 | // Ensure Object Caching is unique per site 332 | if ( ! defined( 'WP_CACHE_KEY_SALT' ) ) { 333 | define( 'WP_CACHE_KEY_SALT', \$stacked_site_id ); 334 | } 335 | } 336 | EOD; 337 | } 338 | 339 | /** 340 | * Cleaning Logic 341 | */ 342 | private function clean_wp_config_lines( $lines ) { 343 | $is_legacy_block = false; 344 | foreach( $lines as $key => $line ) { 345 | 346 | // 1. Remove the One-Liner (Matches by unique filename) 347 | if ( strpos( $line, 'wp-content/freighter.php' ) !== false ) { 348 | unset( $lines[ $key ] ); 349 | continue; 350 | } 351 | 352 | // 2. Remove Legacy Multi-line Blocks (Keep this to clean old versions) 353 | if ( strpos( $line, '/* WP Freighter */' ) !== false ) { 354 | if ( strpos( $line, 'require_once' ) === false ) { 355 | $is_legacy_block = true; 356 | } 357 | unset( $lines[ $key ] ); 358 | continue; 359 | } 360 | 361 | if ( $is_legacy_block || strpos( $line, '$stacked_site_id' ) !== false || strpos( $line, '$stacked_mappings' ) !== false ) { 362 | 363 | unset( $lines[ $key ] ); 364 | if ( trim( $line ) === '}' || trim( $line ) === '' ) { 365 | $is_legacy_block = false; 366 | } 367 | } 368 | } 369 | return $lines; 370 | } 371 | 372 | } -------------------------------------------------------------------------------- /vendor/composer/ClassLoader.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of Composer. 5 | * 6 | * (c) Nils Adermann <naderman@naderman.de> 7 | * Jordi Boggiano <j.boggiano@seld.be> 8 | * 9 | * For the full copyright and license information, please view the LICENSE 10 | * file that was distributed with this source code. 11 | */ 12 | 13 | namespace Composer\Autoload; 14 | 15 | /** 16 | * ClassLoader implements a PSR-0, PSR-4 and classmap class loader. 17 | * 18 | * $loader = new \Composer\Autoload\ClassLoader(); 19 | * 20 | * // register classes with namespaces 21 | * $loader->add('Symfony\Component', __DIR__.'/component'); 22 | * $loader->add('Symfony', __DIR__.'/framework'); 23 | * 24 | * // activate the autoloader 25 | * $loader->register(); 26 | * 27 | * // to enable searching the include path (eg. for PEAR packages) 28 | * $loader->setUseIncludePath(true); 29 | * 30 | * In this example, if you try to use a class in the Symfony\Component 31 | * namespace or one of its children (Symfony\Component\Console for instance), 32 | * the autoloader will first look for the class under the component/ 33 | * directory, and it will then fallback to the framework/ directory if not 34 | * found before giving up. 35 | * 36 | * This class is loosely based on the Symfony UniversalClassLoader. 37 | * 38 | * @author Fabien Potencier <fabien@symfony.com> 39 | * @author Jordi Boggiano <j.boggiano@seld.be> 40 | * @see https://www.php-fig.org/psr/psr-0/ 41 | * @see https://www.php-fig.org/psr/psr-4/ 42 | */ 43 | class ClassLoader 44 | { 45 | /** @var \Closure(string):void */ 46 | private static $includeFile; 47 | 48 | /** @var string|null */ 49 | private $vendorDir; 50 | 51 | // PSR-4 52 | /** 53 | * @var array<string, array<string, int>> 54 | */ 55 | private $prefixLengthsPsr4 = array(); 56 | /** 57 | * @var array<string, list<string>> 58 | */ 59 | private $prefixDirsPsr4 = array(); 60 | /** 61 | * @var list<string> 62 | */ 63 | private $fallbackDirsPsr4 = array(); 64 | 65 | // PSR-0 66 | /** 67 | * List of PSR-0 prefixes 68 | * 69 | * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2'))) 70 | * 71 | * @var array<string, array<string, list<string>>> 72 | */ 73 | private $prefixesPsr0 = array(); 74 | /** 75 | * @var list<string> 76 | */ 77 | private $fallbackDirsPsr0 = array(); 78 | 79 | /** @var bool */ 80 | private $useIncludePath = false; 81 | 82 | /** 83 | * @var array<string, string> 84 | */ 85 | private $classMap = array(); 86 | 87 | /** @var bool */ 88 | private $classMapAuthoritative = false; 89 | 90 | /** 91 | * @var array<string, bool> 92 | */ 93 | private $missingClasses = array(); 94 | 95 | /** @var string|null */ 96 | private $apcuPrefix; 97 | 98 | /** 99 | * @var array<string, self> 100 | */ 101 | private static $registeredLoaders = array(); 102 | 103 | /** 104 | * @param string|null $vendorDir 105 | */ 106 | public function __construct($vendorDir = null) 107 | { 108 | $this->vendorDir = $vendorDir; 109 | self::initializeIncludeClosure(); 110 | } 111 | 112 | /** 113 | * @return array<string, list<string>> 114 | */ 115 | public function getPrefixes() 116 | { 117 | if (!empty($this->prefixesPsr0)) { 118 | return call_user_func_array('array_merge', array_values($this->prefixesPsr0)); 119 | } 120 | 121 | return array(); 122 | } 123 | 124 | /** 125 | * @return array<string, list<string>> 126 | */ 127 | public function getPrefixesPsr4() 128 | { 129 | return $this->prefixDirsPsr4; 130 | } 131 | 132 | /** 133 | * @return list<string> 134 | */ 135 | public function getFallbackDirs() 136 | { 137 | return $this->fallbackDirsPsr0; 138 | } 139 | 140 | /** 141 | * @return list<string> 142 | */ 143 | public function getFallbackDirsPsr4() 144 | { 145 | return $this->fallbackDirsPsr4; 146 | } 147 | 148 | /** 149 | * @return array<string, string> Array of classname => path 150 | */ 151 | public function getClassMap() 152 | { 153 | return $this->classMap; 154 | } 155 | 156 | /** 157 | * @param array<string, string> $classMap Class to filename map 158 | * 159 | * @return void 160 | */ 161 | public function addClassMap(array $classMap) 162 | { 163 | if ($this->classMap) { 164 | $this->classMap = array_merge($this->classMap, $classMap); 165 | } else { 166 | $this->classMap = $classMap; 167 | } 168 | } 169 | 170 | /** 171 | * Registers a set of PSR-0 directories for a given prefix, either 172 | * appending or prepending to the ones previously set for this prefix. 173 | * 174 | * @param string $prefix The prefix 175 | * @param list<string>|string $paths The PSR-0 root directories 176 | * @param bool $prepend Whether to prepend the directories 177 | * 178 | * @return void 179 | */ 180 | public function add($prefix, $paths, $prepend = false) 181 | { 182 | $paths = (array) $paths; 183 | if (!$prefix) { 184 | if ($prepend) { 185 | $this->fallbackDirsPsr0 = array_merge( 186 | $paths, 187 | $this->fallbackDirsPsr0 188 | ); 189 | } else { 190 | $this->fallbackDirsPsr0 = array_merge( 191 | $this->fallbackDirsPsr0, 192 | $paths 193 | ); 194 | } 195 | 196 | return; 197 | } 198 | 199 | $first = $prefix[0]; 200 | if (!isset($this->prefixesPsr0[$first][$prefix])) { 201 | $this->prefixesPsr0[$first][$prefix] = $paths; 202 | 203 | return; 204 | } 205 | if ($prepend) { 206 | $this->prefixesPsr0[$first][$prefix] = array_merge( 207 | $paths, 208 | $this->prefixesPsr0[$first][$prefix] 209 | ); 210 | } else { 211 | $this->prefixesPsr0[$first][$prefix] = array_merge( 212 | $this->prefixesPsr0[$first][$prefix], 213 | $paths 214 | ); 215 | } 216 | } 217 | 218 | /** 219 | * Registers a set of PSR-4 directories for a given namespace, either 220 | * appending or prepending to the ones previously set for this namespace. 221 | * 222 | * @param string $prefix The prefix/namespace, with trailing '\\' 223 | * @param list<string>|string $paths The PSR-4 base directories 224 | * @param bool $prepend Whether to prepend the directories 225 | * 226 | * @throws \InvalidArgumentException 227 | * 228 | * @return void 229 | */ 230 | public function addPsr4($prefix, $paths, $prepend = false) 231 | { 232 | $paths = (array) $paths; 233 | if (!$prefix) { 234 | // Register directories for the root namespace. 235 | if ($prepend) { 236 | $this->fallbackDirsPsr4 = array_merge( 237 | $paths, 238 | $this->fallbackDirsPsr4 239 | ); 240 | } else { 241 | $this->fallbackDirsPsr4 = array_merge( 242 | $this->fallbackDirsPsr4, 243 | $paths 244 | ); 245 | } 246 | } elseif (!isset($this->prefixDirsPsr4[$prefix])) { 247 | // Register directories for a new namespace. 248 | $length = strlen($prefix); 249 | if ('\\' !== $prefix[$length - 1]) { 250 | throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); 251 | } 252 | $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; 253 | $this->prefixDirsPsr4[$prefix] = $paths; 254 | } elseif ($prepend) { 255 | // Prepend directories for an already registered namespace. 256 | $this->prefixDirsPsr4[$prefix] = array_merge( 257 | $paths, 258 | $this->prefixDirsPsr4[$prefix] 259 | ); 260 | } else { 261 | // Append directories for an already registered namespace. 262 | $this->prefixDirsPsr4[$prefix] = array_merge( 263 | $this->prefixDirsPsr4[$prefix], 264 | $paths 265 | ); 266 | } 267 | } 268 | 269 | /** 270 | * Registers a set of PSR-0 directories for a given prefix, 271 | * replacing any others previously set for this prefix. 272 | * 273 | * @param string $prefix The prefix 274 | * @param list<string>|string $paths The PSR-0 base directories 275 | * 276 | * @return void 277 | */ 278 | public function set($prefix, $paths) 279 | { 280 | if (!$prefix) { 281 | $this->fallbackDirsPsr0 = (array) $paths; 282 | } else { 283 | $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths; 284 | } 285 | } 286 | 287 | /** 288 | * Registers a set of PSR-4 directories for a given namespace, 289 | * replacing any others previously set for this namespace. 290 | * 291 | * @param string $prefix The prefix/namespace, with trailing '\\' 292 | * @param list<string>|string $paths The PSR-4 base directories 293 | * 294 | * @throws \InvalidArgumentException 295 | * 296 | * @return void 297 | */ 298 | public function setPsr4($prefix, $paths) 299 | { 300 | if (!$prefix) { 301 | $this->fallbackDirsPsr4 = (array) $paths; 302 | } else { 303 | $length = strlen($prefix); 304 | if ('\\' !== $prefix[$length - 1]) { 305 | throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); 306 | } 307 | $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; 308 | $this->prefixDirsPsr4[$prefix] = (array) $paths; 309 | } 310 | } 311 | 312 | /** 313 | * Turns on searching the include path for class files. 314 | * 315 | * @param bool $useIncludePath 316 | * 317 | * @return void 318 | */ 319 | public function setUseIncludePath($useIncludePath) 320 | { 321 | $this->useIncludePath = $useIncludePath; 322 | } 323 | 324 | /** 325 | * Can be used to check if the autoloader uses the include path to check 326 | * for classes. 327 | * 328 | * @return bool 329 | */ 330 | public function getUseIncludePath() 331 | { 332 | return $this->useIncludePath; 333 | } 334 | 335 | /** 336 | * Turns off searching the prefix and fallback directories for classes 337 | * that have not been registered with the class map. 338 | * 339 | * @param bool $classMapAuthoritative 340 | * 341 | * @return void 342 | */ 343 | public function setClassMapAuthoritative($classMapAuthoritative) 344 | { 345 | $this->classMapAuthoritative = $classMapAuthoritative; 346 | } 347 | 348 | /** 349 | * Should class lookup fail if not found in the current class map? 350 | * 351 | * @return bool 352 | */ 353 | public function isClassMapAuthoritative() 354 | { 355 | return $this->classMapAuthoritative; 356 | } 357 | 358 | /** 359 | * APCu prefix to use to cache found/not-found classes, if the extension is enabled. 360 | * 361 | * @param string|null $apcuPrefix 362 | * 363 | * @return void 364 | */ 365 | public function setApcuPrefix($apcuPrefix) 366 | { 367 | $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null; 368 | } 369 | 370 | /** 371 | * The APCu prefix in use, or null if APCu caching is not enabled. 372 | * 373 | * @return string|null 374 | */ 375 | public function getApcuPrefix() 376 | { 377 | return $this->apcuPrefix; 378 | } 379 | 380 | /** 381 | * Registers this instance as an autoloader. 382 | * 383 | * @param bool $prepend Whether to prepend the autoloader or not 384 | * 385 | * @return void 386 | */ 387 | public function register($prepend = false) 388 | { 389 | spl_autoload_register(array($this, 'loadClass'), true, $prepend); 390 | 391 | if (null === $this->vendorDir) { 392 | return; 393 | } 394 | 395 | if ($prepend) { 396 | self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders; 397 | } else { 398 | unset(self::$registeredLoaders[$this->vendorDir]); 399 | self::$registeredLoaders[$this->vendorDir] = $this; 400 | } 401 | } 402 | 403 | /** 404 | * Unregisters this instance as an autoloader. 405 | * 406 | * @return void 407 | */ 408 | public function unregister() 409 | { 410 | spl_autoload_unregister(array($this, 'loadClass')); 411 | 412 | if (null !== $this->vendorDir) { 413 | unset(self::$registeredLoaders[$this->vendorDir]); 414 | } 415 | } 416 | 417 | /** 418 | * Loads the given class or interface. 419 | * 420 | * @param string $class The name of the class 421 | * @return true|null True if loaded, null otherwise 422 | */ 423 | public function loadClass($class) 424 | { 425 | if ($file = $this->findFile($class)) { 426 | $includeFile = self::$includeFile; 427 | $includeFile($file); 428 | 429 | return true; 430 | } 431 | 432 | return null; 433 | } 434 | 435 | /** 436 | * Finds the path to the file where the class is defined. 437 | * 438 | * @param string $class The name of the class 439 | * 440 | * @return string|false The path if found, false otherwise 441 | */ 442 | public function findFile($class) 443 | { 444 | // class map lookup 445 | if (isset($this->classMap[$class])) { 446 | return $this->classMap[$class]; 447 | } 448 | if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { 449 | return false; 450 | } 451 | if (null !== $this->apcuPrefix) { 452 | $file = apcu_fetch($this->apcuPrefix.$class, $hit); 453 | if ($hit) { 454 | return $file; 455 | } 456 | } 457 | 458 | $file = $this->findFileWithExtension($class, '.php'); 459 | 460 | // Search for Hack files if we are running on HHVM 461 | if (false === $file && defined('HHVM_VERSION')) { 462 | $file = $this->findFileWithExtension($class, '.hh'); 463 | } 464 | 465 | if (null !== $this->apcuPrefix) { 466 | apcu_add($this->apcuPrefix.$class, $file); 467 | } 468 | 469 | if (false === $file) { 470 | // Remember that this class does not exist. 471 | $this->missingClasses[$class] = true; 472 | } 473 | 474 | return $file; 475 | } 476 | 477 | /** 478 | * Returns the currently registered loaders keyed by their corresponding vendor directories. 479 | * 480 | * @return array<string, self> 481 | */ 482 | public static function getRegisteredLoaders() 483 | { 484 | return self::$registeredLoaders; 485 | } 486 | 487 | /** 488 | * @param string $class 489 | * @param string $ext 490 | * @return string|false 491 | */ 492 | private function findFileWithExtension($class, $ext) 493 | { 494 | // PSR-4 lookup 495 | $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; 496 | 497 | $first = $class[0]; 498 | if (isset($this->prefixLengthsPsr4[$first])) { 499 | $subPath = $class; 500 | while (false !== $lastPos = strrpos($subPath, '\\')) { 501 | $subPath = substr($subPath, 0, $lastPos); 502 | $search = $subPath . '\\'; 503 | if (isset($this->prefixDirsPsr4[$search])) { 504 | $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); 505 | foreach ($this->prefixDirsPsr4[$search] as $dir) { 506 | if (file_exists($file = $dir . $pathEnd)) { 507 | return $file; 508 | } 509 | } 510 | } 511 | } 512 | } 513 | 514 | // PSR-4 fallback dirs 515 | foreach ($this->fallbackDirsPsr4 as $dir) { 516 | if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { 517 | return $file; 518 | } 519 | } 520 | 521 | // PSR-0 lookup 522 | if (false !== $pos = strrpos($class, '\\')) { 523 | // namespaced class name 524 | $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1) 525 | . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR); 526 | } else { 527 | // PEAR-like class name 528 | $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext; 529 | } 530 | 531 | if (isset($this->prefixesPsr0[$first])) { 532 | foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) { 533 | if (0 === strpos($class, $prefix)) { 534 | foreach ($dirs as $dir) { 535 | if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { 536 | return $file; 537 | } 538 | } 539 | } 540 | } 541 | } 542 | 543 | // PSR-0 fallback dirs 544 | foreach ($this->fallbackDirsPsr0 as $dir) { 545 | if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { 546 | return $file; 547 | } 548 | } 549 | 550 | // PSR-0 include paths. 551 | if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { 552 | return $file; 553 | } 554 | 555 | return false; 556 | } 557 | 558 | /** 559 | * @return void 560 | */ 561 | private static function initializeIncludeClosure() 562 | { 563 | if (self::$includeFile !== null) { 564 | return; 565 | } 566 | 567 | /** 568 | * Scope isolated include. 569 | * 570 | * Prevents access to $this/self from included files. 571 | * 572 | * @param string $file 573 | * @return void 574 | */ 575 | self::$includeFile = \Closure::bind(static function($file) { 576 | include $file; 577 | }, null, null); 578 | } 579 | } 580 | -------------------------------------------------------------------------------- /app/Site.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace WPFreighter; 4 | 5 | class Site { 6 | 7 | /** 8 | * Get a specific site by ID. 9 | */ 10 | public static function get( $site_id ) { 11 | return Sites::fetch( $site_id ); 12 | } 13 | 14 | /** 15 | * Safety Check: Ensure WP Freighter is installed and active on the target. 16 | */ 17 | public static function ensure_freighter( $site_id ) { 18 | global $wpdb; 19 | 20 | $configs = ( new Configurations )->get(); 21 | 22 | // Only needed for dedicated file modes where plugins aren't shared 23 | if ( $configs->files !== 'dedicated' ) { 24 | return; 25 | } 26 | 27 | // 1. Filesystem Check 28 | // Source is the current plugin directory 29 | $source = dirname( __DIR__ ); 30 | $dest = ABSPATH . "content/$site_id/plugins/wp-freighter"; 31 | 32 | if ( ! file_exists( $dest ) ) { 33 | if ( ! file_exists( dirname( $dest ) ) ) { 34 | mkdir( dirname( $dest ), 0777, true ); 35 | } 36 | Sites::copy_recursive( $source, $dest ); 37 | } 38 | 39 | // 2. Database Activation Check 40 | $prefix = "stacked_{$site_id}_"; 41 | $table = "{$prefix}options"; 42 | 43 | // Check if table exists to avoid errors on corrupted sites 44 | if ( $wpdb->get_var( "SHOW TABLES LIKE '$table'" ) != $table ) { 45 | return; 46 | } 47 | 48 | // Fetch active_plugins raw to avoid context switching issues 49 | $active_plugins_raw = $wpdb->get_var( "SELECT option_value FROM $table WHERE option_name = 'active_plugins'" ); 50 | $active_plugins = empty( $active_plugins_raw ) ? [] : maybe_unserialize( $active_plugins_raw ); 51 | 52 | if ( ! is_array( $active_plugins ) ) { 53 | $active_plugins = []; 54 | } 55 | 56 | // If not active, activate it 57 | if ( ! in_array( 'wp-freighter/wp-freighter.php', $active_plugins ) ) { 58 | $active_plugins[] = 'wp-freighter/wp-freighter.php'; 59 | sort( $active_plugins ); 60 | 61 | $new_value = serialize( $active_plugins ); 62 | 63 | // Check if row exists to Decide Update vs Insert 64 | $row_exists = $wpdb->get_var( "SELECT option_id FROM $table WHERE option_name = 'active_plugins'" ); 65 | 66 | if ( $row_exists ) { 67 | $wpdb->query( $wpdb->prepare( "UPDATE $table SET option_value = %s WHERE option_name = 'active_plugins'", $new_value ) ); 68 | } else { 69 | $wpdb->query( $wpdb->prepare( "INSERT INTO $table (option_name, option_value) VALUES ('active_plugins', %s)", $new_value ) ); 70 | } 71 | } 72 | } 73 | 74 | /** 75 | * Create a new tenant site. 76 | */ 77 | public static function create( $args ) { 78 | global $wpdb, $table_prefix; 79 | $defaults = [ 80 | 'title' => 'New Site', 81 | 'name' => 'New Site', 82 | 'domain' => '', 83 | 'username' => 'admin', 84 | 'email' => 'admin@example.com', 85 | 'password' => wp_generate_password(), 86 | ]; 87 | 88 | $data = (object) array_merge( $defaults, $args ); 89 | 90 | // 1. Calculate New ID 91 | $stacked_sites = ( new Sites )->get(); 92 | $current_stacked_ids = array_column( $stacked_sites, "stacked_site_id" ); 93 | $site_id = ( empty( $current_stacked_ids ) ? 1 : (int) max( $current_stacked_ids ) + 1 ); 94 | 95 | // 2. Prepare Database 96 | $original_prefix = $table_prefix; 97 | $primary_prefix = self::get_primary_prefix(); 98 | $new_table_prefix = "stacked_{$site_id}_"; 99 | 100 | // Cleanup existing tables 101 | $tables = array_column( $wpdb->get_results("show tables"), "Tables_in_". DB_NAME ); 102 | foreach ( $tables as $table ) { 103 | if ( strpos( $table, $new_table_prefix ) === 0 ) { 104 | $wpdb->query( "DROP TABLE IF EXISTS $table" ); 105 | } 106 | } 107 | 108 | // 3. Prepare Site Data 109 | // NOTE: We queue the data here but DO NOT save it yet to avoid "Table doesn't exist" errors. 110 | $stacked_sites[] = [ 111 | "stacked_site_id" => $site_id, 112 | "created_at" => time(), 113 | "name" => sanitize_text_field( $data->name ), 114 | "domain" => sanitize_text_field( $data->domain ) 115 | ]; 116 | 117 | // 4. Handle Filesystem (Dedicated/Hybrid) 118 | $files_mode = ( new Configurations )->get()->files; 119 | if ( in_array( $files_mode, [ 'dedicated', 'hybrid' ] ) ) { 120 | $content_path = ABSPATH . "content/$site_id"; 121 | 122 | if ( ! file_exists( "$content_path/uploads/" ) ) { 123 | mkdir( "$content_path/uploads/", 0777, true ); 124 | } 125 | 126 | if ( $files_mode == 'dedicated' ) { 127 | if ( ! file_exists( "$content_path/themes/" ) ) mkdir( "$content_path/themes/", 0777, true ); 128 | if ( ! file_exists( "$content_path/plugins/" ) ) mkdir( "$content_path/plugins/", 0777, true ); 129 | // Kinsta Compatibility 130 | self::copy_kinsta_assets( $site_id ); 131 | } 132 | } 133 | 134 | // 5. Install WordPress 135 | try { 136 | // Context Switch 137 | $table_prefix = $new_table_prefix; 138 | wp_set_wpdb_vars(); 139 | 140 | require_once( ABSPATH . 'wp-admin/includes/upgrade.php' ); 141 | 142 | ob_start(); 143 | wp_install( $data->title, $data->username, $data->email, true, '', wp_slash( $data->password ), "en" ); 144 | ob_end_clean(); 145 | 146 | // Update Site URLs if domain provided 147 | if ( ! empty ( $data->domain ) ) { 148 | $url = 'https://' . sanitize_text_field( $data->domain ); 149 | update_option( 'siteurl', $url ); 150 | update_option( 'home', $url ); 151 | } 152 | 153 | // Activate WP Freighter on the new site 154 | update_option( 'active_plugins', [ 'wp-freighter/wp-freighter.php' ] ); 155 | 156 | // Fix Permissions (Copy roles from main site) 157 | $wpdb->query( "UPDATE {$new_table_prefix}options set `option_name` = 'stacked_{$site_id}_user_roles' WHERE `option_name` = '{$primary_prefix}user_roles'" ); 158 | 159 | // Install Default Theme if Dedicated 160 | if ( $files_mode == "dedicated" ) { 161 | $default_theme_path = ABSPATH . "content/$site_id/themes/" . WP_DEFAULT_THEME ."/"; 162 | 163 | if ( ! file_exists( $default_theme_path ) ) { 164 | include_once ABSPATH . 'wp-admin/includes/theme.php'; 165 | 166 | // Prevent updates during install 167 | remove_action( 'upgrader_process_complete', [ 'Language_Pack_Upgrader', 'async_upgrade' ], 20 ); 168 | remove_action( 'upgrader_process_complete', 'wp_version_check', 10 ); 169 | remove_action( 'upgrader_process_complete', 'wp_update_plugins', 10 ); 170 | remove_action( 'upgrader_process_complete', 'wp_update_themes', 10 ); 171 | $skin = self::get_silent_skin(); 172 | $upgrader = new \Theme_Upgrader( $skin ); 173 | $api = themes_api( 'theme_information', [ 'slug' => WP_DEFAULT_THEME, 'fields' => [ 'sections' => false ] ] ); 174 | if ( ! is_wp_error( $api ) ) { 175 | $upgrader->run( [ 176 | 'package' => $api->download_link, 177 | 'destination' => $default_theme_path, 178 | 'clear_destination' => false, 179 | 'clear_working' => true, 180 | 'hook_extra' => [ 'type' => 'theme', 'action' => 'install' ], 181 | ] ); 182 | } 183 | } 184 | } 185 | 186 | } catch ( \Exception $e ) { 187 | return new \WP_Error( 'install_failed', $e->getMessage() ); 188 | } finally { 189 | // Restore Context 190 | $table_prefix = $original_prefix; 191 | wp_set_wpdb_vars(); 192 | } 193 | 194 | ( new Sites )->update( $stacked_sites ); 195 | ( new Configurations )->refresh_configs(); 196 | 197 | return self::get( $site_id ); 198 | } 199 | 200 | /** 201 | * Delete a tenant site. 202 | */ 203 | public static function delete( $site_id ) { 204 | global $wpdb; 205 | 206 | $site_id = (int) $site_id; 207 | $sites = ( new Sites )->get(); 208 | $found = false; 209 | 210 | foreach( $sites as $key => $site ) { 211 | if ( $site['stacked_site_id'] == $site_id ) { 212 | unset( $sites[$key] ); 213 | $found = true; 214 | } 215 | } 216 | 217 | if ( ! $found ) return false; 218 | 219 | ( new Sites )->update( array_values( $sites ) ); 220 | 221 | $prefix = "stacked_{$site_id}_"; 222 | $tables = array_column( $wpdb->get_results("show tables"), "Tables_in_". DB_NAME ); 223 | foreach ( $tables as $table ) { 224 | if ( strpos( $table, $prefix ) === 0 ) { 225 | $wpdb->query( "DROP TABLE IF EXISTS $table" ); 226 | } 227 | } 228 | 229 | $content_path = ABSPATH . "content/$site_id/"; 230 | if ( file_exists( $content_path ) ) { 231 | Sites::delete_directory( $content_path ); 232 | } 233 | 234 | ( new Configurations )->refresh_configs(); 235 | 236 | return true; 237 | } 238 | 239 | /** 240 | * Clone a site. 241 | */ 242 | public static function clone( $source_id, $args = [] ) { 243 | global $wpdb; 244 | 245 | // 1. Determine Paths and Prefixes 246 | if ( $source_id && $source_id !== 'main' ) { 247 | $source_path = ABSPATH . "content/$source_id"; 248 | $source_prefix = "stacked_{$source_id}_"; 249 | $source_site = self::get( $source_id ); 250 | $source_name = $source_site['name']; 251 | } else { 252 | $source_path = ABSPATH . 'wp-content'; 253 | $source_prefix = self::get_primary_prefix(); 254 | $source_name = "Main Site"; 255 | } 256 | 257 | $new_name = ! empty( $args['name'] ) ? $args['name'] : "$source_name (Clone)"; 258 | $new_domain = ! empty( $args['domain'] ) ? $args['domain'] : ""; 259 | 260 | // 2. Get New ID 261 | $sites = ( new Sites )->get(); 262 | $ids = array_column( $sites, "stacked_site_id" ); 263 | $new_id = ( empty( $ids ) ? 1 : (int) max( $ids ) + 1 ); 264 | 265 | // 3. Duplicate Tables 266 | $new_prefix = "stacked_{$new_id}_"; 267 | $tables = array_column( $wpdb->get_results("show tables"), "Tables_in_". DB_NAME ); 268 | 269 | foreach ( $tables as $table ) { 270 | if ( strpos( $table, $source_prefix ) !== 0 ) continue; 271 | 272 | $suffix = substr( $table, strlen( $source_prefix ) ); 273 | $new_table_name = $new_prefix . $suffix; 274 | 275 | $wpdb->query( "DROP TABLE IF EXISTS $new_table_name" ); 276 | $wpdb->query( "CREATE TABLE $new_table_name LIKE $table" ); 277 | $wpdb->query( "INSERT INTO $new_table_name SELECT * FROM $table" ); 278 | 279 | if ( $suffix == "options" ) { 280 | $wpdb->query( "UPDATE $new_table_name set `option_name` = 'stacked_{$new_id}_user_roles' WHERE `option_name` = '{$source_prefix}user_roles'" ); 281 | } 282 | if ( $suffix == "usermeta" ) { 283 | $wpdb->query( "UPDATE $new_table_name set `meta_key` = 'stacked_{$new_id}_capabilities' WHERE `meta_key` = '{$source_prefix}capabilities'" ); 284 | $wpdb->query( "UPDATE $new_table_name set `meta_key` = 'stacked_{$new_id}_user_level' WHERE `meta_key` = '{$source_prefix}user_level'" ); 285 | } 286 | } 287 | 288 | // 4. Register Site 289 | $sites[] = [ 290 | "stacked_site_id" => $new_id, 291 | "created_at" => time(), 292 | "name" => $new_name, 293 | "domain" => $new_domain 294 | ]; 295 | ( new Sites )->update( $sites ); 296 | 297 | // 5. Duplicate Files (If Dedicated) 298 | if ( ( new Configurations )->get()->files == "dedicated" ) { 299 | Sites::copy_recursive( $source_path, ABSPATH . "content/$new_id" ); 300 | self::copy_kinsta_assets( $new_id ); 301 | } 302 | 303 | // 6. Update Domain in DB 304 | if ( ! empty ( $new_domain ) ) { 305 | $url = 'https://' . $new_domain; 306 | $wpdb->query( $wpdb->prepare("UPDATE stacked_{$new_id}_options set option_value = %s where option_name = 'siteurl'", $url ) ); 307 | $wpdb->query( $wpdb->prepare("UPDATE stacked_{$new_id}_options set option_value = %s where option_name = 'home'", $url ) ); 308 | } 309 | 310 | ( new Configurations )->refresh_configs(); 311 | 312 | return self::get( $new_id ); 313 | } 314 | 315 | /** 316 | * Update site details. 317 | */ 318 | public static function update( $site_id, $args ) { 319 | global $wpdb; 320 | $sites = ( new Sites )->get(); 321 | $found = false; 322 | 323 | foreach( $sites as &$site ) { 324 | if ( $site['stacked_site_id'] == $site_id ) { 325 | if ( isset( $args['name'] ) ) $site['name'] = $args['name']; 326 | if ( isset( $args['domain'] ) ) $site['domain'] = $args['domain']; 327 | 328 | if ( isset( $args['domain'] ) ) { 329 | $url = 'https://' . $args['domain']; 330 | $wpdb->query( $wpdb->prepare("UPDATE stacked_{$site_id}_options set option_value = %s where option_name = 'siteurl'", $url ) ); 331 | $wpdb->query( $wpdb->prepare("UPDATE stacked_{$site_id}_options set option_value = %s where option_name = 'home'", $url ) ); 332 | } 333 | 334 | $found = true; 335 | break; 336 | } 337 | } 338 | 339 | if ( $found ) { 340 | ( new Sites )->update( $sites ); 341 | return self::get( $site_id ); 342 | } 343 | 344 | return false; 345 | } 346 | 347 | /** 348 | * Generate an auto-login URL. 349 | */ 350 | public static function login( $site_id, $redirect_to = '' ) { 351 | global $wpdb; 352 | 353 | // 1. Determine Prefix 354 | if ( 'main' === $site_id ) { 355 | $prefix = self::get_primary_prefix(); 356 | } else { 357 | $site_id = (int) $site_id; 358 | $prefix = "stacked_{$site_id}_"; 359 | } 360 | 361 | $meta_table = $prefix . "usermeta"; 362 | $cap_key = $prefix . "capabilities"; 363 | 364 | // 2. Find an Admin user 365 | $user_id = $wpdb->get_var( "SELECT user_id FROM $meta_table WHERE meta_key = '$cap_key' AND meta_value LIKE '%administrator%' LIMIT 1" ); 366 | 367 | if ( ! $user_id ) return new \WP_Error( 'no_admin', 'No administrator found.' ); 368 | 369 | // 3. Set Token 370 | $token = sha1( wp_generate_password() ); 371 | $existing = $wpdb->get_var( $wpdb->prepare( "SELECT umeta_id FROM $meta_table WHERE user_id = %d AND meta_key = 'captaincore_login_token'", $user_id ) ); 372 | 373 | if ( $existing ) { 374 | $wpdb->query( $wpdb->prepare( "UPDATE $meta_table SET meta_value = %s WHERE umeta_id = %d", $token, $existing ) ); 375 | } else { 376 | $wpdb->query( $wpdb->prepare( "INSERT INTO $meta_table (user_id, meta_key, meta_value) VALUES (%d, 'captaincore_login_token', %s)", $user_id, $token ) ); 377 | } 378 | 379 | // 4. Ensure Helper 380 | if ( 'main' !== $site_id ) { 381 | self::ensure_helper_plugin( $site_id ); 382 | } 383 | 384 | // 5. Build URL 385 | $site_url = $wpdb->get_var( "SELECT option_value FROM {$prefix}options WHERE option_name = 'siteurl'" ); 386 | $site_url = rtrim( $site_url, '/' ); 387 | 388 | $query_args = [ 389 | 'user_id' => $user_id, 390 | 'captaincore_login_token' => $token 391 | ]; 392 | 393 | if ( 'main' !== $site_id ) { 394 | $query_args['stacked_site_id'] = $site_id; 395 | } 396 | 397 | // Append redirect_to if provided 398 | if ( ! empty( $redirect_to ) ) { 399 | $query_args['redirect_to'] = $redirect_to; 400 | } 401 | 402 | return $site_url . '/wp-login.php?' . http_build_query( $query_args ); 403 | } 404 | 405 | /** 406 | * HELPER: Ensure the auth helper plugin exists. 407 | */ 408 | private static function ensure_helper_plugin( $site_id ) { 409 | $mu_dir = ABSPATH . "content/$site_id/mu-plugins"; 410 | if ( ! file_exists( $mu_dir ) ) mkdir( $mu_dir, 0777, true ); 411 | 412 | $mu_file = "$mu_dir/captaincore-helper.php"; 413 | if ( ! file_exists( $mu_file ) ) { 414 | // Simplified helper plugin content 415 | $plugin_content = <<<'EOD' 416 | <?php 417 | /** 418 | * Plugin Name: CaptainCore Helper 419 | */ 420 | function captaincore_login_handle_token() { 421 | global $pagenow; 422 | if ( 'wp-login.php' !== $pagenow || empty( $_GET['user_id'] ) || empty( $_GET['captaincore_login_token'] ) ) return; 423 | 424 | $user = get_user_by( 'id', (int) $_GET['user_id'] ); 425 | if ( ! $user ) wp_die( 'Invalid User' ); 426 | 427 | $token = get_user_meta( $user->ID, 'captaincore_login_token', true ); 428 | if ( ! hash_equals( $token, $_GET['captaincore_login_token'] ) ) wp_die( 'Invalid Token' ); 429 | 430 | delete_user_meta( $user->ID, 'captaincore_login_token' ); 431 | wp_set_auth_cookie( $user->ID, 1 ); 432 | wp_safe_redirect( admin_url() ); 433 | exit; 434 | } 435 | add_action( 'init', 'captaincore_login_handle_token' ); 436 | add_filter( 'auto_plugin_update_send_email', '__return_false' ); 437 | add_filter( 'auto_theme_update_send_email', '__return_false' ); 438 | EOD; 439 | file_put_contents( $mu_file, $plugin_content ); 440 | } 441 | } 442 | 443 | /** 444 | * HELPER: Kinsta Assets Copy 445 | */ 446 | private static function copy_kinsta_assets( $stacked_site_id ) { 447 | // Only run if using dedicated files 448 | if ( ( new Configurations )->get()->files != "dedicated" ) { 449 | return; 450 | } 451 | 452 | $mu_plugins_source = ABSPATH . 'wp-content/mu-plugins'; 453 | $mu_plugins_dest = ABSPATH . "content/$stacked_site_id/mu-plugins"; 454 | 455 | if ( file_exists( "$mu_plugins_source/kinsta-mu-plugins.php" ) ) { 456 | if ( ! file_exists( $mu_plugins_dest ) ) mkdir( $mu_plugins_dest, 0777, true ); 457 | copy( "$mu_plugins_source/kinsta-mu-plugins.php", "$mu_plugins_dest/kinsta-mu-plugins.php" ); 458 | } 459 | 460 | if ( file_exists( "$mu_plugins_source/kinsta-mu-plugins" ) ) { 461 | if ( ! file_exists( $mu_plugins_dest ) ) mkdir( $mu_plugins_dest, 0777, true ); 462 | Sites::copy_recursive( "$mu_plugins_source/kinsta-mu-plugins", "$mu_plugins_dest/kinsta-mu-plugins" ); 463 | } 464 | } 465 | 466 | /** 467 | * HELPER: Get Primary Prefix 468 | */ 469 | private static function get_primary_prefix() { 470 | global $wpdb; 471 | $db_prefix = $wpdb->prefix; 472 | $db_prefix_primary = ( defined( 'TABLE_PREFIX' ) ? TABLE_PREFIX : $db_prefix ); 473 | if ( $db_prefix_primary == "TABLE_PREFIX" ) { 474 | $db_prefix_primary = $db_prefix; 475 | } 476 | return $db_prefix_primary; 477 | } 478 | 479 | /** 480 | * HELPER: Get Silent Upgrader Skin 481 | */ 482 | private static function get_silent_skin() { 483 | if ( ! class_exists( 'WP_Upgrader_Skin' ) ) { 484 | require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; 485 | } 486 | return new class extends \WP_Upgrader_Skin { 487 | public function feedback( $string, ...$args ) { /* Silence */ } 488 | public function header() { /* Silence */ } 489 | public function footer() { /* Silence */ } 490 | public function error( $errors ) { /* Silence */ } 491 | }; 492 | } 493 | } -------------------------------------------------------------------------------- /app/Run.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace WPFreighter; 4 | 5 | class Run { 6 | 7 | public function __construct() { 8 | if ( isset( $_GET['stacked_site_id'] ) && isset( $_GET['captaincore_login_token'] ) ) { 9 | 10 | $target_id = (int) $_GET['stacked_site_id']; 11 | $current_cookie = isset( $_COOKIE['stacked_site_id'] ) ? (int) $_COOKIE['stacked_site_id'] : null; 12 | // If the cookie is missing or incorrect, set it and force a reload. 13 | if ( $current_cookie !== $target_id ) { 14 | $cookie_path = defined( 'SITECOOKIEPATH' ) ? SITECOOKIEPATH : '/'; 15 | $cookie_domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : ''; 16 | // Set the cookie for the bootstrap file (freighter.php) to find 17 | setcookie( 'stacked_site_id', $target_id, time() + 31536000, $cookie_path, $cookie_domain ); 18 | // Reload immediately so freighter.php runs with the new cookie 19 | // and switches the DB prefix before the mu-plugin runs. 20 | header( "Location: " . $_SERVER['REQUEST_URI'] ); 21 | exit; 22 | } 23 | } 24 | if ( defined( 'WP_FREIGHTER_DEV_MODE' ) ) { 25 | add_filter('https_ssl_verify', '__return_false'); 26 | add_filter('https_local_ssl_verify', '__return_false'); 27 | add_filter('http_request_host_is_external', '__return_true'); 28 | } 29 | if ( defined( 'WP_CLI' ) && WP_CLI ) { 30 | \WP_CLI::add_command( 'freighter', 'WPFreighter\CLI' ); 31 | } 32 | add_action( 'admin_bar_menu', [ $this, 'admin_toolbar' ], 100 ); 33 | add_action( 'admin_menu', [ $this, 'admin_menu' ] ); 34 | add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); 35 | add_action( 'rest_api_init', [ $this, 'register_rest_endpoints' ] ); 36 | add_action( 'init', [ $this, 'handle_auto_login' ] ); 37 | register_activation_hook( plugin_dir_path( __DIR__ ) . "wp-freighter.php", [ $this, 'activate' ] ); 38 | register_deactivation_hook( plugin_dir_path( __DIR__ ) . "wp-freighter.php", [ $this, 'deactivate' ] ); 39 | $plugin_file = dirname ( plugin_basename( __DIR__ ) ) . "/wp-freighter.php" ; 40 | add_filter( "plugin_action_links_{$plugin_file}", [ $this, 'settings_link' ] ); 41 | } 42 | 43 | public function settings_link( $links ) { 44 | $settings_link = "<a href='/wp-admin/tools.php?page=wp-freighter'>" . __( 'Settings' ) . "</a>"; 45 | array_unshift( $links, $settings_link ); 46 | return $links; 47 | } 48 | 49 | public function register_rest_endpoints() { 50 | $namespace = 'wp-freighter/v1'; 51 | $routes = [ 52 | '/sites' => [ 53 | 'GET' => 'get_sites', 54 | 'POST' => 'new_site' 55 | ], 56 | '/sites/delete' => [ 57 | 'POST' => 'delete_site' 58 | ], 59 | '/sites/clone' => [ 60 | 'POST' => 'clone_existing' 61 | ], 62 | '/sites/stats' => [ 63 | 'POST' => 'get_site_stats' 64 | ], 65 | '/sites/autologin' => [ 66 | 'POST' => 'auto_login' 67 | ], 68 | '/configurations' => [ 69 | 'POST' => 'save_configurations' 70 | ], 71 | '/switch' => [ 72 | 'POST' => 'switch_to' 73 | ], 74 | '/exit' => [ 75 | 'POST' => 'exit_freighter' 76 | ], 77 | ]; 78 | foreach ( $routes as $route => $methods ) { 79 | foreach ( $methods as $method => $callback ) { 80 | register_rest_route( $namespace, $route, [ 81 | 'methods' => $method, 82 | 'callback' => [ $this, $callback ], 83 | 'permission_callback' => [ $this, 'permissions_check' ] 84 | ]); 85 | } 86 | } 87 | } 88 | 89 | public function permissions_check() { 90 | return current_user_can( 'manage_options' ); 91 | } 92 | 93 | // --- REST API Callbacks --- 94 | 95 | public function get_sites( $request ) { 96 | $sites = ( new Sites )->get(); 97 | return empty( $sites ) ? [] : $sites; 98 | } 99 | 100 | public function new_site( $request ) { 101 | $params = $request->get_json_params(); 102 | // Sanitize incoming parameters 103 | $clean_params = []; 104 | $clean_params['title'] = isset($params['title']) ? sanitize_text_field($params['title']) : 'New Site'; 105 | $clean_params['name'] = isset($params['name']) ? sanitize_text_field($params['name']) : 'New Site'; 106 | 107 | // For domains, we sanitize as text to allow partials/localhost, but strip harmful chars 108 | $clean_params['domain'] = isset($params['domain']) ? sanitize_text_field($params['domain']) : ''; 109 | 110 | $clean_params['username'] = isset($params['username']) ? sanitize_user($params['username']) : 'admin'; 111 | $clean_params['email'] = isset($params['email']) ? sanitize_email($params['email']) : ''; 112 | // Passwords should be kept raw for complexity, but only if they are set 113 | $clean_params['password'] = isset($params['password']) ? $params['password'] : ''; 114 | 115 | // Delegate to Site Model with clean data 116 | $result = Site::create( $clean_params ); 117 | if ( is_wp_error( $result ) ) { 118 | return $result; 119 | } 120 | 121 | // Return full list for UI update 122 | return ( new Sites )->get(); 123 | } 124 | 125 | public function delete_site( $request ) { 126 | $params = $request->get_json_params(); 127 | $site_id = (int) $params['site_id']; 128 | 129 | // Delegate to Site Model 130 | Site::delete( $site_id ); 131 | 132 | // If we are deleting the site we are currently viewing, kill the session cookie 133 | if ( isset( $_COOKIE['stacked_site_id'] ) && $_COOKIE['stacked_site_id'] == $site_id ) { 134 | return $this->exit_freighter( $request ); 135 | } 136 | 137 | return ( new Sites )->get(); 138 | } 139 | 140 | public function clone_existing( $request ) { 141 | $params = $request->get_json_params(); 142 | // Allow 'main' or an integer ID 143 | $source_id = isset( $params['source_id'] ) ? $params['source_id'] : 'main'; 144 | if ( $source_id !== 'main' ) { 145 | $source_id = (int) $source_id; 146 | } 147 | 148 | $args = [ 149 | 'name' => isset( $params['name'] ) ? sanitize_text_field( $params['name'] ) : '', 150 | 'domain' => isset( $params['domain'] ) ? sanitize_text_field( $params['domain'] ) : '', 151 | ]; 152 | 153 | $result = Site::clone( $source_id, $args ); 154 | if ( is_wp_error( $result ) ) { 155 | return $result; 156 | } 157 | 158 | return ( new Sites )->get(); 159 | } 160 | 161 | public function auto_login( $request ) { 162 | $params = $request->get_json_params(); 163 | // Allow 'main' or an integer ID 164 | $site_id = isset( $params['site_id'] ) ? $params['site_id'] : ''; 165 | if ( $site_id !== 'main' ) { 166 | $site_id = (int) $site_id; 167 | } 168 | 169 | // Delegate to Site Model 170 | $login_url = Site::login( $site_id ); 171 | if ( is_wp_error( $login_url ) ) { 172 | return $login_url; 173 | } 174 | 175 | return [ 'url' => $login_url ]; 176 | } 177 | 178 | public function save_configurations( $request ) { 179 | $params = $request->get_json_params(); 180 | $sites_data = isset($params['sites']) ? $params['sites'] : []; 181 | $configs_data = isset($params['configurations']) ? $params['configurations'] : []; 182 | // Use Sites class for bulk update 183 | ( new Sites )->update( $sites_data ); 184 | ( new Configurations )->update( $configs_data ); 185 | 186 | return ( new Configurations )->get(); 187 | } 188 | 189 | public function get_site_stats( $request ) { 190 | $params = $request->get_json_params(); 191 | $site_id = (int) $params['site_id']; 192 | 193 | $path = ABSPATH . "content/$site_id/"; 194 | 195 | if ( file_exists( $path ) ) { 196 | $bytes = Sites::get_directory_size( $path ); 197 | $relative_path = str_replace( ABSPATH, '', $path ); 198 | return [ 199 | 'has_dedicated_content' => true, 200 | 'path' => $relative_path, 201 | 'size' => Sites::format_size( $bytes ) 202 | ]; 203 | } 204 | 205 | return [ 206 | 'has_dedicated_content' => false, 207 | 'path' => '', 208 | 'size' => 0 209 | ]; 210 | } 211 | 212 | // --- Session / Cookie Management --- 213 | 214 | public function switch_to( $request ) { 215 | $params = $request->get_json_params(); 216 | $site_id = (int) $params['site_id']; 217 | 218 | // 0. Safety Check: Ensure Freighter is installed/active on target 219 | Site::ensure_freighter( $site_id ); 220 | 221 | // 1. Set Cookie for Context Switch 222 | setcookie( 'stacked_site_id', $site_id, time() + 31536000, '/' ); 223 | $_COOKIE[ "stacked_site_id" ] = $site_id; 224 | 225 | // 2. Generate Magic Login URL for the Target Site 226 | // We use Site::login which generates the token in the target DB 227 | $login_url = Site::login( $site_id ); 228 | if ( is_wp_error( $login_url ) ) { 229 | return [ 'success' => false, 'message' => $login_url->get_error_message() ]; 230 | } 231 | 232 | return [ 'success' => true, 'site_id' => $site_id, 'url' => $login_url ]; 233 | } 234 | 235 | public function exit_freighter( $request ) { 236 | // 1. Generate Login URL for Main Site with Redirection 237 | $login_url = Site::login( 'main', 'wp-admin/tools.php?page=wp-freighter' ); 238 | // 2. Clear Session Cookie 239 | setcookie( 'stacked_site_id', null, -1, '/'); 240 | unset( $_COOKIE[ "stacked_site_id" ] ); 241 | 242 | // 3. Return URL 243 | if ( is_wp_error( $login_url ) ) { 244 | return [ 'success' => true ]; 245 | } 246 | return [ 'success' => true, 'url' => $login_url ]; 247 | } 248 | 249 | // --- Admin Interface --- 250 | 251 | public function admin_toolbar( $admin_bar ) { 252 | if ( ! current_user_can( 'manage_options' ) ) { 253 | return; 254 | } 255 | $domain_mapping = ( new Configurations )->domain_mapping(); 256 | if ( $domain_mapping ) { 257 | return; 258 | } 259 | $stacked_sites = ( new Sites )->get(); 260 | $stacked_site_id = empty ( $_COOKIE[ "stacked_site_id" ] ) ? "" : $_COOKIE[ "stacked_site_id" ]; 261 | $item = null; 262 | foreach( $stacked_sites as $stacked_site ) { 263 | if ( $stacked_site_id == $stacked_site['stacked_site_id'] ) { 264 | $item = $stacked_site; 265 | break; 266 | } 267 | } 268 | 269 | if ( ! empty( $stacked_site_id ) && $item ) { 270 | $label = ( $item['name'] ? "{$item['name']} - " : "" ) . wp_date( "M j, Y, g:i a", $item['created_at'] ); 271 | $admin_bar->add_menu( [ 272 | 'id' => 'wp-freighter', 273 | 'title' => '<span class="ab-icon dashicons dashicons-welcome-view-site"></span> <span style="font-size: 0.8em !important;background-color: #fff;color: #000;padding: 1px 4px;border-radius: 2px;margin-left: 2px;position:relative;top:-2px">' . $label .'</span>', 274 | 'href' => '/wp-admin/tools.php?page=wp-freighter', 275 | ] ); 276 | $admin_bar->add_menu( [ 277 | 'id' => 'wp-freighter-exit', 278 | 'title' => '<span class="ab-icon dashicons dashicons-backup"></span>Exit WP Freighter', 279 | 'href' => '#', 280 | 'meta' => [ 'onclick' => 'fetch( "' . esc_url( get_rest_url( null, 'wp-freighter/v1/exit' ) ) . '", { method: "POST", headers: { "X-WP-Nonce": "' . wp_create_nonce( 'wp_rest' ) . '" } } ).then( res => res.json() ).then( data => { if ( data.url ) { window.location.href = data.url; } else { window.location.reload(); } } ); return false;' ] 281 | ] ); 282 | } 283 | if ( empty( $stacked_site_id ) ) { 284 | $admin_bar->add_menu( [ 285 | 'id' => 'wp-freighter-enter', 286 | 'title' => '<span class="ab-icon dashicons dashicons-welcome-view-site"></span>View Tenant Sites', 287 | 'href' => '/wp-admin/tools.php?page=wp-freighter', 288 | ] ); 289 | } 290 | } 291 | 292 | public function enqueue_assets( $hook ) { 293 | if ( 'tools_page_wp-freighter' !== $hook ) { 294 | return; 295 | } 296 | 297 | // Define the root URL for the plugin assets 298 | $plugin_url = plugin_dir_url( dirname( __DIR__ ) . '/wp-freighter.php' ); 299 | // 1. Enqueue Local CSS 300 | wp_enqueue_style( 'vuetify', $plugin_url . 'assets/css/vuetify.min.css', [], '3.10.5' ); 301 | wp_enqueue_style( 'mdi', $plugin_url . 'assets/css/materialdesignicons.min.css', [], '7.4.47' ); 302 | 303 | // 2. Enqueue Local JS 304 | wp_enqueue_script( 'axios', $plugin_url . 'assets/js/axios.min.js', [], '1.13.2', true ); 305 | wp_enqueue_script( 'vue', $plugin_url . 'assets/js/vue.min.js', [], '3.5.22', true ); 306 | wp_enqueue_script( 'vuetify', $plugin_url . 'assets/js/vuetify.min.js', [ 'vue' ], '3.10.5', true ); 307 | // 3. Enqueue App Logic 308 | wp_enqueue_script( 'wp-freighter-app', $plugin_url . 'assets/js/admin-app.js', [ 'vuetify', 'axios' ], WP_FREIGHTER_VERSION, true ); 309 | 310 | // 4. Determine Current Site ID (Cookie vs Global) 311 | // Check for global variable set by domain mapping/bootstrap 312 | global $stacked_site_id; 313 | $current_id = isset( $_COOKIE['stacked_site_id'] ) ? sanitize_text_field( $_COOKIE['stacked_site_id'] ) : ''; 314 | 315 | if ( ! empty( $stacked_site_id ) ) { 316 | $current_id = $stacked_site_id; 317 | } 318 | 319 | // 5. Localize Data 320 | $data = [ 321 | 'root' => esc_url_raw( rest_url( 'wp-freighter/v1/' ) ), 322 | 'nonce' => wp_create_nonce( 'wp_rest' ), 323 | 'current_site_id' => $current_id, 324 | 'currentUser' => [ 325 | 'username' => wp_get_current_user()->user_login, 326 | 'email' => wp_get_current_user()->user_email, 327 | ], 328 | 'configurations' => ( new Configurations )->get(), 329 | 'stacked_sites' => ( new Sites )->get(), 330 | ]; 331 | wp_localize_script( 'wp-freighter-app', 'wpFreighterSettings', $data ); 332 | 333 | // 6. Set Axios Defaults 334 | wp_add_inline_script( 'wp-freighter-app', "axios.defaults.headers.common['X-WP-Nonce'] = wpFreighterSettings.nonce; axios.defaults.headers.common['Content-Type'] = 'application/json';", 'after' ); 335 | } 336 | 337 | public function admin_menu() { 338 | if ( current_user_can( 'manage_options' ) ) { 339 | add_management_page( "WP Freighter", "WP Freighter", "manage_options", "wp-freighter", array( $this, 'admin_view' ) ); 340 | } 341 | } 342 | 343 | public function admin_view() { 344 | require_once plugin_dir_path( __DIR__ ) . '/templates/admin-wp-freighter.php'; 345 | } 346 | 347 | // --- Lifecycle Methods (Activation/Deactivation) --- 348 | 349 | public function activate() { 350 | // Add default configurations right after $table_prefix 351 | $lines_to_add = [ 352 | '', 353 | '/* WP Freighter */', 354 | '$stacked_site_id = ( isset( $_COOKIE[ "stacked_site_id" ] ) ? $_COOKIE[ "stacked_site_id" ] : "" );', 355 | 'if ( defined( \'WP_CLI\' ) && WP_CLI ) { $stacked_site_id = getenv( \'STACKED_SITE_ID\' ); }', 356 | 'if ( ! empty( $stacked_site_id ) ) { define( \'TABLE_PREFIX\', $table_prefix ); $table_prefix = "stacked_{$stacked_site_id}_"; }', 357 | ]; 358 | 359 | $wp_config_file = $this->get_wp_config_path(); 360 | if ( empty ( $wp_config_file ) ) { 361 | ( new Configurations )->update_config( "unable_to_save", $lines_to_add ); 362 | return; 363 | } 364 | 365 | $wp_config_content = file_get_contents( $wp_config_file ); 366 | $working = preg_split( '/\R/', $wp_config_content ); 367 | // Clean existing freighter configs 368 | $working = $this->clean_wp_config_lines( $working ); 369 | // Comment out manually set WP_HOME or WP_SITEURL 370 | foreach( $working as $key => $line ) { 371 | if ( strpos( $line, "define('WP_HOME'" ) === 0 || strpos( $line, "define('WP_SITEURL'" ) === 0 ) { 372 | $working[ $key ] = "//$line"; 373 | } 374 | } 375 | 376 | // Append WP_CACHE_KEY_SALT with unique identifier if found 377 | foreach( $working as $key => $line ) { 378 | if ( strpos( $line, "define('WP_CACHE_KEY_SALT', '" ) === 0 ) { 379 | $working[ $key ] = str_replace( "define('WP_CACHE_KEY_SALT', '", "define('WP_CACHE_KEY_SALT', \$stacked_site_id . '", $line ); 380 | } 381 | } 382 | 383 | $table_prefix_line = 0; 384 | foreach( $working as $key => $line ) { 385 | if ( strpos( $line, '$table_prefix' ) !== false ) { 386 | $table_prefix_line = $key; 387 | break; 388 | } 389 | } 390 | 391 | // Cleanup empty lines after prefix 392 | if ( isset( $working[ $table_prefix_line + 1 ] ) && $working[ $table_prefix_line + 1 ] == "" && isset( $working[ $table_prefix_line + 2 ] ) && $working[ $table_prefix_line + 2 ] == "" ) { 393 | unset( $working[ $table_prefix_line + 1 ] ); 394 | } 395 | 396 | // Insert new lines 397 | $updated = array_merge( array_slice( $working, 0, $table_prefix_line + 1, true ), $lines_to_add, array_slice( $working, $table_prefix_line + 1, count( $working ), true ) ); 398 | // Save 399 | $results = @file_put_contents( $wp_config_file, implode( PHP_EOL, $updated ) ); 400 | if ( empty( $results ) ) { 401 | ( new Configurations )->update_config( "unable_to_save", $lines_to_add ); 402 | } 403 | } 404 | 405 | public function deactivate() { 406 | $wp_config_file = $this->get_wp_config_path(); 407 | if ( empty ( $wp_config_file ) ) { 408 | return; 409 | } 410 | 411 | $wp_config_content = file_get_contents( $wp_config_file ); 412 | $working = preg_split( '/\R/', $wp_config_content ); 413 | // Remove WP Freighter configs 414 | $working = $this->clean_wp_config_lines( $working ); 415 | @file_put_contents( $wp_config_file, implode( PHP_EOL, $working ) ); 416 | } 417 | 418 | public function handle_auto_login() { 419 | global $pagenow; 420 | // Standard Token Verification 421 | if ( 'wp-login.php' !== $pagenow || empty( $_GET['user_id'] ) || empty( $_GET['captaincore_login_token'] ) ) { 422 | return; 423 | } 424 | 425 | $user = get_user_by( 'id', (int) $_GET['user_id'] ); 426 | if ( ! $user ) { 427 | wp_die( 'Invalid User' ); 428 | } 429 | 430 | $token = get_user_meta( $user->ID, 'captaincore_login_token', true ); 431 | if ( ! hash_equals( $token, $_GET['captaincore_login_token'] ) ) { 432 | wp_die( 'Invalid one-time login token.' ); 433 | } 434 | 435 | delete_user_meta( $user->ID, 'captaincore_login_token' ); 436 | wp_set_auth_cookie( $user->ID, 1 ); 437 | 438 | $redirect_to = ! empty( $_GET['redirect_to'] ) ? $_GET['redirect_to'] : admin_url(); 439 | wp_safe_redirect( $redirect_to ); 440 | exit; 441 | } 442 | 443 | // --- Helpers --- 444 | 445 | private function get_wp_config_path() { 446 | if ( file_exists( ABSPATH . "wp-config.php" ) ) { 447 | return ABSPATH . "wp-config.php"; 448 | } 449 | if ( file_exists( dirname( ABSPATH ) . '/wp-config.php' ) ) { 450 | return dirname( ABSPATH ) . '/wp-config.php'; 451 | } 452 | return false; 453 | } 454 | 455 | private function clean_wp_config_lines( $lines ) { 456 | foreach( $lines as $key => $line ) { 457 | if ( strpos( $line, '/* WP Freighter */' ) !== false || strpos( $line, 'stacked_site_id' ) !== false || strpos( $line, '$stacked_mappings' ) !== false ) { 458 | unset( $lines[ $key ] ); 459 | } 460 | } 461 | return $lines; 462 | } 463 | 464 | } -------------------------------------------------------------------------------- /templates/admin-wp-freighter.php: -------------------------------------------------------------------------------- 1 | <style> 2 | [v-cloak] > * { 3 | display:none; 4 | } 5 | [v-cloak]::before { 6 | display: block; 7 | position: relative; 8 | left: 0%; 9 | top: 0%; 10 | max-width: 1000px; 11 | margin:auto; 12 | padding-bottom: 10em; 13 | } 14 | body #app { 15 | line-height: initial; 16 | } 17 | 18 | /* --- VISUAL RESTORATION OVERRIDES --- */ 19 | 20 | /* Force Vuetify app wrapper to be transparent so WP Admin background shows */ 21 | .v-application { 22 | background: transparent !important; 23 | } 24 | 25 | /* Tighten up table inputs to match V2 look */ 26 | .v-data-table .v-field__input, .v-input input, .v-input input:focus, .v-field__append-inner, .v-field.v-field--variant-underlined .v-field__append-inner { 27 | padding: 0px; 28 | } 29 | .v-data-table .v-input__details { 30 | display: none !important; 31 | } 32 | 33 | /* Restore button icon sizing */ 34 | .v-data-table .v-btn--icon.v-btn--density-default { 35 | width: 28px; 36 | height: 28px; 37 | } 38 | .v-text-field input, .v-text-field input:focus { 39 | border: 0px; 40 | box-shadow: none; 41 | } 42 | .v-field--variant-plain .v-label.v-field-label, .v-field--variant-underlined .v-label.v-field-label { 43 | top: 4px; 44 | } 45 | #app input[type="text"], 46 | #app input[type="email"], 47 | #app input[type="password"], 48 | .v-data-table .v-field__input, .v-input input { 49 | background-color: transparent !important; 50 | color: inherit !important; 51 | box-shadow: none !important; 52 | } 53 | </style> 54 | 55 | <div id="app" v-cloak> 56 | <v-app class="bg-transparent"> 57 | <v-main> 58 | <v-container fluid class="pa-0"> 59 | <v-row> 60 | <v-col cols="12" class="mt-5 pr-8"> 61 | <v-card rounded="0"> 62 | 63 | <v-overlay v-model="loading" class="align-center justify-center" z-index="5"> 64 | <v-progress-circular size="64" color="primary" indeterminate></v-progress-circular> 65 | </v-overlay> 66 | 67 | <v-toolbar flat color="surface"> 68 | <v-toolbar-title class="font-weight-bold">WP Freighter</v-toolbar-title> 69 | <v-spacer></v-spacer> 70 | <v-btn v-if="configurations.domain_mapping == 'on' && current_site_id != ''" color="secondary" variant="flat" class="mr-2 mt-1" size="small" @click="loginToMain()"> 71 | <v-icon start>mdi-login-variant</v-icon> <span class="d-none d-sm-inline">Login to main site</span> 72 | </v-btn> 73 | <v-btn color="secondary" variant="flat" class="mr-2" size="small" @click="openCloneMainDialog()"> 74 | <v-icon start>mdi-content-copy</v-icon> <span class="d-none d-sm-inline">Clone main site</span> 75 | </v-btn> 76 | <v-dialog v-model="new_site.show" persistent max-width="600px" :transition="false"> 77 | <template v-slot:activator="{ props }"> 78 | <v-btn variant="flat" color="secondary" class="mr-1" size="small" v-bind="props"> 79 | <v-icon start>mdi-plus</v-icon> <span class="d-none d-sm-inline">Add new empty site</span> 80 | </v-btn> 81 | </template> 82 | <v-card> 83 | <v-card-title>New Site</v-card-title> 84 | <v-card-text> 85 | <v-form ref="form" v-model="new_site.valid"> 86 | <v-container> 87 | <v-row> 88 | <v-col cols="12" sm="6" md="6" v-show="configurations.domain_mapping == 'off'"> 89 | <v-text-field v-model="new_site.name" label="Label" variant="underlined"></v-text-field> 90 | </v-col> 91 | <v-col cols="12" sm="6" md="6" v-show="configurations.domain_mapping == 'on'"> 92 | <v-text-field v-model="new_site.domain" label="Domain" variant="underlined"></v-text-field> 93 | </v-col> 94 | <v-col cols="12" sm="6" md="6"> 95 | <v-text-field v-model="new_site.title" label="Title*" :rules="[ value => !!value || 'Required.' ]" variant="underlined"></v-text-field> 96 | </v-col> 97 | <v-col cols="12" sm="6" md="6"> 98 | <v-text-field v-model="new_site.email" label="Email*" :rules="[ value => !!value || 'Required.' ]" variant="underlined"></v-text-field> 99 | </v-col> 100 | <v-col cols="12" sm="6" md="6"> 101 | <v-text-field v-model="new_site.username" label="Username*" :rules="[ value => !!value || 'Required.' ]" variant="underlined"></v-text-field> 102 | </v-col> 103 | <v-col cols="12" sm="6" md="6"> 104 | <v-text-field 105 | v-model="new_site.password" 106 | label="Password*" 107 | type="text" 108 | append-inner-icon="mdi-refresh" 109 | @click:append-inner="new_site.password = generatePassword()" 110 | hide-details 111 | :rules="[ value => !!value || 'Required.' ]" 112 | variant="underlined" 113 | ></v-text-field> 114 | </v-col> 115 | </v-row> 116 | </v-container> 117 | <small class="text-caption">*indicates required field</small> 118 | </v-form> 119 | </v-card-text> 120 | <v-card-actions> 121 | <v-spacer></v-spacer> 122 | <v-btn color="primary" variant="text" @click="new_site.show = false">Close</v-btn> 123 | <v-btn color="primary" variant="text" @click="newSite()">Create new site</v-btn> 124 | </v-card-actions> 125 | </v-card> 126 | </v-dialog> 127 | 128 | <v-tooltip location="bottom"> 129 | <template v-slot:activator="{ props }"> 130 | <v-btn icon variant="text" @click="toggleTheme" class="mr-2" v-bind="props"> 131 | <v-icon>{{ $vuetify.theme.global.name === 'dark' ? 'mdi-weather-sunny' : 'mdi-weather-night' }}</v-icon> 132 | </v-btn> 133 | </template> 134 | <span>Toggle theme</span> 135 | </v-tooltip> 136 | </v-toolbar> 137 | 138 | <v-card-text> 139 | <v-alert type="error" variant="outlined" v-if="configurations.errors && configurations.errors.manual_bootstrap_required"> 140 | <h3 class="text-h6 mb-2">Permission Error: Unable to create bootstrap file</h3> 141 | <p>WP Freighter cannot write to your <code>wp-content</code> directory. You must manually create this file to enable your tenant sites.</p> 142 | <p class="mt-2"><strong>1. Create a new file:</strong><br><code>/wp-content/freighter.php</code></p> 143 | <p><strong>2. Paste the following code into it:</strong></p> 144 | <v-textarea variant="outlined" readonly :model-value="configurations.errors.manual_bootstrap_required" height="300px" class="mt-2" style="font-family: monospace; font-size: 12px;"></v-textarea> 145 | <v-row class="mt-2"> 146 | <v-col> 147 | <v-btn color="error" @click="saveConfigurations()"> 148 | <v-icon start>mdi-refresh</v-icon> I have created the file 149 | </v-btn> 150 | </v-col> 151 | <v-col class="text-right"> 152 | <v-btn variant="text" size="small" @click="copyToClipboard(configurations.errors.manual_bootstrap_required)">Copy to Clipboard</v-btn> 153 | </v-col> 154 | </v-row> 155 | </v-alert> 156 | 157 | <v-alert type="warning" variant="outlined" v-if="configurations.errors && !configurations.errors.manual_bootstrap_required && configurations.errors.manual_config_required"> 158 | <h3 class="text-h6 mb-2">Setup Required: Update wp-config.php</h3> 159 | <p>WP Freighter cannot write to your <code>wp-config.php</code> file. Please add the following snippet manually.</p> 160 | <p class="mt-2">Place this code directly <strong>after</strong> the line: <code>$table_prefix = 'wp_';</code></p> 161 | <v-card class="my-3 grey-lighten-4" variant="outlined"> 162 | <v-card-text style="font-family: monospace;"> 163 | <div v-for="line in configurations.errors.manual_config_required">{{ line }}</div> 164 | </v-card-text> 165 | </v-card> 166 | <v-btn color="warning" @click="saveConfigurations()"> 167 | <v-icon start>mdi-check</v-icon> I have updated wp-config.php 168 | </v-btn> 169 | </v-alert> 170 | 171 | <div class="text-subtitle text-medium-emphasis mb-2 ml-2">Tenant Sites</div> 172 | 173 | <v-data-table 174 | :headers="headers" 175 | :items="stacked_sites" 176 | :items-per-page="-1" 177 | hide-default-footer 178 | > 179 | <template v-slot:headers="{ columns, isSorted, getSortIcon, toggleSort }"> 180 | <tr> 181 | <th></th> 182 | <th class="cursor-pointer font-weight-bold" @click="toggleSort(columns.find(c => c.key === 'id'))"> 183 | ID 184 | <v-icon v-if="isSorted(columns.find(c => c.key === 'id'))" :icon="getSortIcon(columns.find(c => c.key === 'id'))"></v-icon> 185 | </th> 186 | <th v-show="configurations.domain_mapping == 'off'" class="cursor-pointer font-weight-bold" @click="toggleSort(columns.find(c => c.key === 'name'))"> 187 | Label 188 | <v-icon v-if="isSorted(columns.find(c => c.key === 'name'))" :icon="getSortIcon(columns.find(c => c.key === 'name'))"></v-icon> 189 | </th> 190 | <th v-show="configurations.domain_mapping == 'on'" class="cursor-pointer font-weight-bold" @click="toggleSort(columns.find(c => c.key === 'domain'))"> 191 | Domain Mapping 192 | <v-icon v-if="isSorted(columns.find(c => c.key === 'domain'))" :icon="getSortIcon(columns.find(c => c.key === 'domain'))"></v-icon> 193 | </th> 194 | 195 | <th v-if="configurations.files == 'dedicated'" class="font-weight-bold d-none d-md-table-cell">Files</th> 196 | <th v-if="configurations.files == 'hybrid'" class="font-weight-bold d-none d-md-table-cell">Uploads</th> 197 | <th class="cursor-pointer font-weight-bold d-none d-md-table-cell" @click="toggleSort(columns.find(c => c.key === 'created_at'))"> 198 | Created At 199 | <v-icon v-if="isSorted(columns.find(c => c.key === 'created_at'))" :icon="getSortIcon(columns.find(c => c.key === 'created_at'))"></v-icon> 200 | </th> 201 | <th></th> 202 | </tr> 203 | </template> 204 | <template v-slot:item="{ item }"> 205 | <tr> 206 | <td width="122px" class="pa-2"> 207 | <v-btn v-if="configurations.domain_mapping == 'off'" size="small" color="primary" class="text-white" elevation="0" @click="switchTo( item.stacked_site_id )">Switch To</v-btn> 208 | <v-btn v-else color="primary" :href="`//${item.domain}`" size="small" target="_new" variant="flat" class="text-white"> 209 | <v-icon size="small" start>mdi-open-in-new</v-icon> Open 210 | </v-btn> 211 | </td> 212 | <td width="58px" class="text-caption"> 213 | <code>{{ item.stacked_site_id }}</code> 214 | </td> 215 | <td v-if="configurations.domain_mapping == 'off'"> 216 | <v-text-field v-model="item.name" density="compact" hide-details variant="underlined" hide-details color="primary" @input="changeForm()"></v-text-field> 217 | </td> 218 | <td v-if="configurations.domain_mapping == 'on'"> 219 | <v-text-field v-model="item.domain" density="compact" hide-details variant="underlined" hide-details color="primary" @input="changeForm()"></v-text-field> 220 | </td> 221 | 222 | <td width="160px" class="d-none d-md-table-cell" v-if="configurations.files == 'dedicated'"> 223 | <code>/content/{{ item.stacked_site_id }}/</code> 224 | </td> 225 | <td width="200px" class="d-none d-md-table-cell" v-if="configurations.files == 'hybrid'"> 226 | <code>/content/{{ item.stacked_site_id }}/uploads/</code> 227 | </td> 228 | <td width="235px" class="d-none d-md-table-cell">{{ pretty_timestamp( item.created_at ) }}</td> 229 | 230 | <td width="150px" class="text-right"> 231 | <v-tooltip location="bottom" v-if="configurations.domain_mapping == 'on'"> 232 | <template v-slot:activator="{ props }"> 233 | <v-btn icon="mdi-login-variant" variant="text" color="grey-darken-1" @click="autoLogin( item )" v-bind="props"></v-btn> 234 | </template> 235 | <span>Magic Autologin</span> 236 | </v-tooltip> 237 | 238 | <v-tooltip location="bottom"> 239 | <template v-slot:activator="{ props }"> 240 | <v-btn icon="mdi-content-copy" variant="text" color="grey-darken-1" @click="openCloneDialog( item )" v-bind="props"></v-btn> 241 | </template> 242 | <span>Clone site</span> 243 | </v-tooltip> 244 | 245 | <v-tooltip location="bottom"> 246 | <template v-slot:activator="{ props }"> 247 | <v-btn icon="mdi-delete" variant="text" color="grey-darken-1" @click="deleteSite( item.stacked_site_id )" v-bind="props"></v-btn> 248 | </template> 249 | <span>Delete site</span> 250 | </v-tooltip> 251 | </td> 252 | </tr> 253 | </template> 254 | <template v-slot:no-data> 255 | <div class="text-center grey--text pa-4"> 256 | You have no tenant sites. 257 | </div> 258 | </template> 259 | </v-data-table> 260 | 261 | <div class="text-subtitle-2 text-medium-emphasis mt-6 mb-2" id="files">Files</div> 262 | <v-radio-group v-model="configurations.files" @change="changeForm()" density="compact"> 263 | <v-row dense> 264 | <v-col cols="12" sm="3" md="2"> 265 | <v-radio value="shared" color="primary"> 266 | <template v-slot:label><strong class="text-body-1 text-high-emphasis">Shared</strong></template> 267 | </v-radio> 268 | </v-col> 269 | <v-col cols="12" sm="9" md="10" class="d-flex align-center text-body-2 pt-0 pt-sm-3"> 270 | <div>Single <code class="mx-1">/wp-content/</code> folder. Any file changes to plugins, themes and uploads will affect all sites.</div> 271 | </v-col> 272 | </v-row> 273 | <v-row dense> 274 | <v-col cols="12" sm="3" md="2"> 275 | <v-radio value="hybrid" color="primary"> 276 | <template v-slot:label><strong class="text-body-1 text-high-emphasis">Hybrid</strong></template> 277 | </v-radio> 278 | </v-col> 279 | <v-col cols="12" sm="9" md="10" class="d-flex align-center text-body-2 pt-0 pt-sm-3"> 280 | <div>Shared <code class="mx-1">plugins</code> and <code class="mx-1">themes</code>, but unique <code class="mx-1">uploads</code> folder stored under <code class="mx-1">/content/(site-id)/uploads/</code>.</div> 281 | </v-col> 282 | </v-row> 283 | <v-row dense> 284 | <v-col cols="12" sm="3" md="2"> 285 | <v-radio value="dedicated" color="primary"> 286 | <template v-slot:label><strong class="text-body-1 text-high-emphasis">Dedicated</strong></template> 287 | </v-radio> 288 | </v-col> 289 | <v-col cols="12" sm="9" md="10" class="d-flex align-center text-body-2 pt-0 pt-sm-3"> 290 | <div>Each site will have its unique <code class="mx-1">/wp-content/</code> folder stored under <code class="mx-1">/content/(site-id)/</code>.</div> 291 | </v-col> 292 | </v-row> 293 | </v-radio-group> 294 | 295 | <div class="text-subtitle-2 text-medium-emphasis mt-4 mb-2" id="domain-mapping">Domain Mapping</div> 296 | <v-radio-group v-model="configurations.domain_mapping" @change="changeForm()" density="compact"> 297 | <v-row dense> 298 | <v-col cols="12" sm="3" md="2"> 299 | <v-radio value="off" color="primary"> 300 | <template v-slot:label><strong class="text-body-1 text-high-emphasis">Off</strong></template> 301 | </v-radio> 302 | </v-col> 303 | <v-col cols="12" sm="9" md="10" class="d-flex align-center text-body-2 pt-0 pt-sm-3"> 304 | <div>Easy option - Only logged in users can view tenant sites. Each site will share existing URL and SSL.</div> 305 | </v-col> 306 | </v-row> 307 | <v-row dense> 308 | <v-col cols="12" sm="3" md="2"> 309 | <v-radio value="on" color="primary"> 310 | <template v-slot:label><strong class="text-body-1 text-high-emphasis">On</strong></template> 311 | </v-radio> 312 | </v-col> 313 | <v-col cols="12" sm="9" md="10" class="d-flex align-center text-body-2 pt-0 pt-sm-3"> 314 | <div>Manual setup - DNS updates, domain mapping and SSL installation need to completed with your host provider.</div> 315 | </v-col> 316 | </v-row> 317 | </v-radio-group> 318 | 319 | <div class="mt-6"> 320 | <v-btn color="primary" class="text-white" elevation="0" @click="saveConfigurations()">Save Configurations</v-btn> 321 | <v-chip class="mx-3" v-if="pending_changes" color="warning" label size="small">Unsaved configurations pending</v-chip> 322 | {{ response }} 323 | </div> 324 | </v-card-text> 325 | </v-card> 326 | </v-col> 327 | </v-row> 328 | </v-container> 329 | 330 | <v-dialog v-model="clone_site.show" persistent max-width="600px" :transition="false"> 331 | <v-card> 332 | <v-card-title>Clone Site</v-card-title> 333 | <v-card-text> 334 | <v-form ref="clone_form" v-model="clone_site.valid"> 335 | <v-container> 336 | <v-row> 337 | <v-col cols="12"> 338 | <p>You are about to clone <strong>{{ clone_site.source_name }}</strong>.</p> 339 | </v-col> 340 | <v-col cols="12" v-if="configurations.domain_mapping == 'off'"> 341 | <v-text-field 342 | v-model="clone_site.name" 343 | label="New Site Label" 344 | hint="Enter a name for the cloned site" 345 | variant="underlined" 346 | persistent-hint 347 | ></v-text-field> 348 | </v-col> 349 | <v-col cols="12" v-if="configurations.domain_mapping == 'on'"> 350 | <v-text-field 351 | v-model="clone_site.domain" 352 | label="New Domain" 353 | placeholder="example.com" 354 | hint="Enter the domain for the cloned site" 355 | variant="underlined" 356 | persistent-hint 357 | ></v-text-field> 358 | </v-col> 359 | </v-row> 360 | </v-container> 361 | </v-form> 362 | </v-card-text> 363 | <v-card-actions> 364 | <v-spacer></v-spacer> 365 | <v-btn color="grey" variant="text" @click="clone_site.show = false">Cancel</v-btn> 366 | <v-btn color="primary" variant="text" @click="processClone()">Confirm Clone</v-btn> 367 | </v-card-actions> 368 | </v-card> 369 | </v-dialog> 370 | 371 | <v-dialog v-model="delete_site.show" persistent max-width="500px" :transition="false"> 372 | <v-card> 373 | <v-card-title class="text-h5">Delete Site?</v-card-title> 374 | <v-card-text> 375 | <p>Are you sure you want to delete this site? This action cannot be undone.</p> 376 | 377 | <v-alert v-if="delete_site.has_dedicated_content" color="primary" density="compact" variant="text" icon="mdi-folder-alert" class="mt-3"> 378 | <strong>Dedicated Content Folder Detected</strong><br/> 379 | The following directory and its contents will be permanently deleted: 380 | <div class="mt-2 mb-1"><code style="font-size:11px">{{ delete_site.path }}</code></div> 381 | <div>Estimated Storage: <strong>{{ delete_site.size }}</strong></div> 382 | </v-alert> 383 | </v-card-text> 384 | <v-card-actions> 385 | <v-spacer></v-spacer> 386 | <v-btn color="grey" variant="text" @click="delete_site.show = false">Cancel</v-btn> 387 | <v-btn color="error" variant="text" @click="confirmDelete()">Permanently Delete</v-btn> 388 | </v-card-actions> 389 | </v-card> 390 | </v-dialog> 391 | 392 | <v-snackbar v-model="snackbar" :timeout="2000" color="primary" location="bottom right"> 393 | {{ snackbarText }} 394 | </v-snackbar> 395 | </v-main> 396 | </v-app> 397 | </div> -------------------------------------------------------------------------------- /assets/js/axios.min.js: -------------------------------------------------------------------------------- 1 | /*! Axios v1.13.2 Copyright (c) 2025 Matt Zabriskie and contributors */ 2 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).axios=t()}(this,(function(){"use strict";function e(e){var r,n;function o(r,n){try{var a=e[r](n),u=a.value,s=u instanceof t;Promise.resolve(s?u.v:u).then((function(t){if(s){var n="return"===r?"return":"next";if(!u.k||t.done)return o(n,t);t=e[n](t).value}i(a.done?"return":"normal",t)}),(function(e){o("throw",e)}))}catch(e){i("throw",e)}}function i(e,t){switch(e){case"return":r.resolve({value:t,done:!0});break;case"throw":r.reject(t);break;default:r.resolve({value:t,done:!1})}(r=r.next)?o(r.key,r.arg):n=null}this._invoke=function(e,t){return new Promise((function(i,a){var u={key:e,arg:t,resolve:i,reject:a,next:null};n?n=n.next=u:(r=n=u,o(e,t))}))},"function"!=typeof e.return&&(this.return=void 0)}function t(e,t){this.v=e,this.k=t}function r(e){var r={},n=!1;function o(r,o){return n=!0,o=new Promise((function(t){t(e[r](o))})),{done:!1,value:new t(o,1)}}return r["undefined"!=typeof Symbol&&Symbol.iterator||"@@iterator"]=function(){return this},r.next=function(e){return n?(n=!1,e):o("next",e)},"function"==typeof e.throw&&(r.throw=function(e){if(n)throw n=!1,e;return o("throw",e)}),"function"==typeof e.return&&(r.return=function(e){return n?(n=!1,e):o("return",e)}),r}function n(e){var t,r,n,i=2;for("undefined"!=typeof Symbol&&(r=Symbol.asyncIterator,n=Symbol.iterator);i--;){if(r&&null!=(t=e[r]))return t.call(e);if(n&&null!=(t=e[n]))return new o(t.call(e));r="@@asyncIterator",n="@@iterator"}throw new TypeError("Object is not async iterable")}function o(e){function t(e){if(Object(e)!==e)return Promise.reject(new TypeError(e+" is not an object."));var t=e.done;return Promise.resolve(e.value).then((function(e){return{value:e,done:t}}))}return o=function(e){this.s=e,this.n=e.next},o.prototype={s:null,n:null,next:function(){return t(this.n.apply(this.s,arguments))},return:function(e){var r=this.s.return;return void 0===r?Promise.resolve({value:e,done:!0}):t(r.apply(this.s,arguments))},throw:function(e){var r=this.s.return;return void 0===r?Promise.reject(e):t(r.apply(this.s,arguments))}},new o(e)}function i(e){return new t(e,0)}function a(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,n)}return r}function u(e){for(var t=1;t<arguments.length;t++){var r=null!=arguments[t]?arguments[t]:{};t%2?a(Object(r),!0).forEach((function(t){m(e,t,r[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(r)):a(Object(r)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(r,t))}))}return e}function s(){s=function(){return t};var e,t={},r=Object.prototype,n=r.hasOwnProperty,o=Object.defineProperty||function(e,t,r){e[t]=r.value},i="function"==typeof Symbol?Symbol:{},a=i.iterator||"@@iterator",u=i.asyncIterator||"@@asyncIterator",c=i.toStringTag||"@@toStringTag";function f(e,t,r){return Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}),e[t]}try{f({},"")}catch(e){f=function(e,t,r){return e[t]=r}}function l(e,t,r,n){var i=t&&t.prototype instanceof m?t:m,a=Object.create(i.prototype),u=new P(n||[]);return o(a,"_invoke",{value:k(e,r,u)}),a}function p(e,t,r){try{return{type:"normal",arg:e.call(t,r)}}catch(e){return{type:"throw",arg:e}}}t.wrap=l;var d="suspendedStart",h="executing",v="completed",y={};function m(){}function b(){}function g(){}var w={};f(w,a,(function(){return this}));var E=Object.getPrototypeOf,O=E&&E(E(L([])));O&&O!==r&&n.call(O,a)&&(w=O);var S=g.prototype=m.prototype=Object.create(w);function x(e){["next","throw","return"].forEach((function(t){f(e,t,(function(e){return this._invoke(t,e)}))}))}function R(e,t){function r(o,i,a,u){var s=p(e[o],e,i);if("throw"!==s.type){var c=s.arg,f=c.value;return f&&"object"==typeof f&&n.call(f,"__await")?t.resolve(f.__await).then((function(e){r("next",e,a,u)}),(function(e){r("throw",e,a,u)})):t.resolve(f).then((function(e){c.value=e,a(c)}),(function(e){return r("throw",e,a,u)}))}u(s.arg)}var i;o(this,"_invoke",{value:function(e,n){function o(){return new t((function(t,o){r(e,n,t,o)}))}return i=i?i.then(o,o):o()}})}function k(t,r,n){var o=d;return function(i,a){if(o===h)throw new Error("Generator is already running");if(o===v){if("throw"===i)throw a;return{value:e,done:!0}}for(n.method=i,n.arg=a;;){var u=n.delegate;if(u){var s=T(u,n);if(s){if(s===y)continue;return s}}if("next"===n.method)n.sent=n._sent=n.arg;else if("throw"===n.method){if(o===d)throw o=v,n.arg;n.dispatchException(n.arg)}else"return"===n.method&&n.abrupt("return",n.arg);o=h;var c=p(t,r,n);if("normal"===c.type){if(o=n.done?v:"suspendedYield",c.arg===y)continue;return{value:c.arg,done:n.done}}"throw"===c.type&&(o=v,n.method="throw",n.arg=c.arg)}}}function T(t,r){var n=r.method,o=t.iterator[n];if(o===e)return r.delegate=null,"throw"===n&&t.iterator.return&&(r.method="return",r.arg=e,T(t,r),"throw"===r.method)||"return"!==n&&(r.method="throw",r.arg=new TypeError("The iterator does not provide a '"+n+"' method")),y;var i=p(o,t.iterator,r.arg);if("throw"===i.type)return r.method="throw",r.arg=i.arg,r.delegate=null,y;var a=i.arg;return a?a.done?(r[t.resultName]=a.value,r.next=t.nextLoc,"return"!==r.method&&(r.method="next",r.arg=e),r.delegate=null,y):a:(r.method="throw",r.arg=new TypeError("iterator result is not an object"),r.delegate=null,y)}function j(e){var t={tryLoc:e[0]};1 in e&&(t.catchLoc=e[1]),2 in e&&(t.finallyLoc=e[2],t.afterLoc=e[3]),this.tryEntries.push(t)}function A(e){var t=e.completion||{};t.type="normal",delete t.arg,e.completion=t}function P(e){this.tryEntries=[{tryLoc:"root"}],e.forEach(j,this),this.reset(!0)}function L(t){if(t||""===t){var r=t[a];if(r)return r.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var o=-1,i=function r(){for(;++o<t.length;)if(n.call(t,o))return r.value=t[o],r.done=!1,r;return r.value=e,r.done=!0,r};return i.next=i}}throw new TypeError(typeof t+" is not iterable")}return b.prototype=g,o(S,"constructor",{value:g,configurable:!0}),o(g,"constructor",{value:b,configurable:!0}),b.displayName=f(g,c,"GeneratorFunction"),t.isGeneratorFunction=function(e){var t="function"==typeof e&&e.constructor;return!!t&&(t===b||"GeneratorFunction"===(t.displayName||t.name))},t.mark=function(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,g):(e.__proto__=g,f(e,c,"GeneratorFunction")),e.prototype=Object.create(S),e},t.awrap=function(e){return{__await:e}},x(R.prototype),f(R.prototype,u,(function(){return this})),t.AsyncIterator=R,t.async=function(e,r,n,o,i){void 0===i&&(i=Promise);var a=new R(l(e,r,n,o),i);return t.isGeneratorFunction(r)?a:a.next().then((function(e){return e.done?e.value:a.next()}))},x(S),f(S,c,"Generator"),f(S,a,(function(){return this})),f(S,"toString",(function(){return"[object Generator]"})),t.keys=function(e){var t=Object(e),r=[];for(var n in t)r.push(n);return r.reverse(),function e(){for(;r.length;){var n=r.pop();if(n in t)return e.value=n,e.done=!1,e}return e.done=!0,e}},t.values=L,P.prototype={constructor:P,reset:function(t){if(this.prev=0,this.next=0,this.sent=this._sent=e,this.done=!1,this.delegate=null,this.method="next",this.arg=e,this.tryEntries.forEach(A),!t)for(var r in this)"t"===r.charAt(0)&&n.call(this,r)&&!isNaN(+r.slice(1))&&(this[r]=e)},stop:function(){this.done=!0;var e=this.tryEntries[0].completion;if("throw"===e.type)throw e.arg;return this.rval},dispatchException:function(t){if(this.done)throw t;var r=this;function o(n,o){return u.type="throw",u.arg=t,r.next=n,o&&(r.method="next",r.arg=e),!!o}for(var i=this.tryEntries.length-1;i>=0;--i){var a=this.tryEntries[i],u=a.completion;if("root"===a.tryLoc)return o("end");if(a.tryLoc<=this.prev){var s=n.call(a,"catchLoc"),c=n.call(a,"finallyLoc");if(s&&c){if(this.prev<a.catchLoc)return o(a.catchLoc,!0);if(this.prev<a.finallyLoc)return o(a.finallyLoc)}else if(s){if(this.prev<a.catchLoc)return o(a.catchLoc,!0)}else{if(!c)throw new Error("try statement without catch or finally");if(this.prev<a.finallyLoc)return o(a.finallyLoc)}}}},abrupt:function(e,t){for(var r=this.tryEntries.length-1;r>=0;--r){var o=this.tryEntries[r];if(o.tryLoc<=this.prev&&n.call(o,"finallyLoc")&&this.prev<o.finallyLoc){var i=o;break}}i&&("break"===e||"continue"===e)&&i.tryLoc<=t&&t<=i.finallyLoc&&(i=null);var a=i?i.completion:{};return a.type=e,a.arg=t,i?(this.method="next",this.next=i.finallyLoc,y):this.complete(a)},complete:function(e,t){if("throw"===e.type)throw e.arg;return"break"===e.type||"continue"===e.type?this.next=e.arg:"return"===e.type?(this.rval=this.arg=e.arg,this.method="return",this.next="end"):"normal"===e.type&&t&&(this.next=t),y},finish:function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var r=this.tryEntries[t];if(r.finallyLoc===e)return this.complete(r.completion,r.afterLoc),A(r),y}},catch:function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var r=this.tryEntries[t];if(r.tryLoc===e){var n=r.completion;if("throw"===n.type){var o=n.arg;A(r)}return o}}throw new Error("illegal catch attempt")},delegateYield:function(t,r,n){return this.delegate={iterator:L(t),resultName:r,nextLoc:n},"next"===this.method&&(this.arg=e),y}},t}function c(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var n=r.call(e,t||"default");if("object"!=typeof n)return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:String(t)}function f(e){return f="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},f(e)}function l(t){return function(){return new e(t.apply(this,arguments))}}function p(e,t,r,n,o,i,a){try{var u=e[i](a),s=u.value}catch(e){return void r(e)}u.done?t(s):Promise.resolve(s).then(n,o)}function d(e){return function(){var t=this,r=arguments;return new Promise((function(n,o){var i=e.apply(t,r);function a(e){p(i,n,o,a,u,"next",e)}function u(e){p(i,n,o,a,u,"throw",e)}a(void 0)}))}}function h(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function v(e,t){for(var r=0;r<t.length;r++){var n=t[r];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,c(n.key),n)}}function y(e,t,r){return t&&v(e.prototype,t),r&&v(e,r),Object.defineProperty(e,"prototype",{writable:!1}),e}function m(e,t,r){return(t=c(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}function b(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var r=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=r){var n,o,i,a,u=[],s=!0,c=!1;try{if(i=(r=r.call(e)).next,0===t){if(Object(r)!==r)return;s=!1}else for(;!(s=(n=i.call(r)).done)&&(u.push(n.value),u.length!==t);s=!0);}catch(e){c=!0,o=e}finally{try{if(!s&&null!=r.return&&(a=r.return(),Object(a)!==a))return}finally{if(c)throw o}}return u}}(e,t)||w(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function g(e){return function(e){if(Array.isArray(e))return E(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||w(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function w(e,t){if(e){if("string"==typeof e)return E(e,t);var r=Object.prototype.toString.call(e).slice(8,-1);return"Object"===r&&e.constructor&&(r=e.constructor.name),"Map"===r||"Set"===r?Array.from(e):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?E(e,t):void 0}}function E(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=new Array(t);r<t;r++)n[r]=e[r];return n}function O(e,t){return function(){return e.apply(t,arguments)}}e.prototype["function"==typeof Symbol&&Symbol.asyncIterator||"@@asyncIterator"]=function(){return this},e.prototype.next=function(e){return this._invoke("next",e)},e.prototype.throw=function(e){return this._invoke("throw",e)},e.prototype.return=function(e){return this._invoke("return",e)};var S,x=Object.prototype.toString,R=Object.getPrototypeOf,k=Symbol.iterator,T=Symbol.toStringTag,j=(S=Object.create(null),function(e){var t=x.call(e);return S[t]||(S[t]=t.slice(8,-1).toLowerCase())}),A=function(e){return e=e.toLowerCase(),function(t){return j(t)===e}},P=function(e){return function(t){return f(t)===e}},L=Array.isArray,N=P("undefined");function C(e){return null!==e&&!N(e)&&null!==e.constructor&&!N(e.constructor)&&F(e.constructor.isBuffer)&&e.constructor.isBuffer(e)}var _=A("ArrayBuffer");var U=P("string"),F=P("function"),B=P("number"),D=function(e){return null!==e&&"object"===f(e)},I=function(e){if("object"!==j(e))return!1;var t=R(e);return!(null!==t&&t!==Object.prototype&&null!==Object.getPrototypeOf(t)||T in e||k in e)},q=A("Date"),M=A("File"),z=A("Blob"),H=A("FileList"),J=A("URLSearchParams"),W=b(["ReadableStream","Request","Response","Headers"].map(A),4),K=W[0],V=W[1],G=W[2],X=W[3];function $(e,t){var r,n,o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},i=o.allOwnKeys,a=void 0!==i&&i;if(null!=e)if("object"!==f(e)&&(e=[e]),L(e))for(r=0,n=e.length;r<n;r++)t.call(null,e[r],r,e);else{if(C(e))return;var u,s=a?Object.getOwnPropertyNames(e):Object.keys(e),c=s.length;for(r=0;r<c;r++)u=s[r],t.call(null,e[u],u,e)}}function Y(e,t){if(C(e))return null;t=t.toLowerCase();for(var r,n=Object.keys(e),o=n.length;o-- >0;)if(t===(r=n[o]).toLowerCase())return r;return null}var Q="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:global,Z=function(e){return!N(e)&&e!==Q};var ee,te=(ee="undefined"!=typeof Uint8Array&&R(Uint8Array),function(e){return ee&&e instanceof ee}),re=A("HTMLFormElement"),ne=function(e){var t=Object.prototype.hasOwnProperty;return function(e,r){return t.call(e,r)}}(),oe=A("RegExp"),ie=function(e,t){var r=Object.getOwnPropertyDescriptors(e),n={};$(r,(function(r,o){var i;!1!==(i=t(r,o,e))&&(n[o]=i||r)})),Object.defineProperties(e,n)};var ae,ue,se,ce,fe=A("AsyncFunction"),le=(ae="function"==typeof setImmediate,ue=F(Q.postMessage),ae?setImmediate:ue?(se="axios@".concat(Math.random()),ce=[],Q.addEventListener("message",(function(e){var t=e.source,r=e.data;t===Q&&r===se&&ce.length&&ce.shift()()}),!1),function(e){ce.push(e),Q.postMessage(se,"*")}):function(e){return setTimeout(e)}),pe="undefined"!=typeof queueMicrotask?queueMicrotask.bind(Q):"undefined"!=typeof process&&process.nextTick||le,de={isArray:L,isArrayBuffer:_,isBuffer:C,isFormData:function(e){var t;return e&&("function"==typeof FormData&&e instanceof FormData||F(e.append)&&("formdata"===(t=j(e))||"object"===t&&F(e.toString)&&"[object FormData]"===e.toString()))},isArrayBufferView:function(e){return"undefined"!=typeof ArrayBuffer&&ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer&&_(e.buffer)},isString:U,isNumber:B,isBoolean:function(e){return!0===e||!1===e},isObject:D,isPlainObject:I,isEmptyObject:function(e){if(!D(e)||C(e))return!1;try{return 0===Object.keys(e).length&&Object.getPrototypeOf(e)===Object.prototype}catch(e){return!1}},isReadableStream:K,isRequest:V,isResponse:G,isHeaders:X,isUndefined:N,isDate:q,isFile:M,isBlob:z,isRegExp:oe,isFunction:F,isStream:function(e){return D(e)&&F(e.pipe)},isURLSearchParams:J,isTypedArray:te,isFileList:H,forEach:$,merge:function e(){for(var t=Z(this)&&this||{},r=t.caseless,n=t.skipUndefined,o={},i=function(t,i){var a=r&&Y(o,i)||i;I(o[a])&&I(t)?o[a]=e(o[a],t):I(t)?o[a]=e({},t):L(t)?o[a]=t.slice():n&&N(t)||(o[a]=t)},a=0,u=arguments.length;a<u;a++)arguments[a]&&$(arguments[a],i);return o},extend:function(e,t,r){var n=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},o=n.allOwnKeys;return $(t,(function(t,n){r&&F(t)?e[n]=O(t,r):e[n]=t}),{allOwnKeys:o}),e},trim:function(e){return e.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")},stripBOM:function(e){return 65279===e.charCodeAt(0)&&(e=e.slice(1)),e},inherits:function(e,t,r,n){e.prototype=Object.create(t.prototype,n),e.prototype.constructor=e,Object.defineProperty(e,"super",{value:t.prototype}),r&&Object.assign(e.prototype,r)},toFlatObject:function(e,t,r,n){var o,i,a,u={};if(t=t||{},null==e)return t;do{for(i=(o=Object.getOwnPropertyNames(e)).length;i-- >0;)a=o[i],n&&!n(a,e,t)||u[a]||(t[a]=e[a],u[a]=!0);e=!1!==r&&R(e)}while(e&&(!r||r(e,t))&&e!==Object.prototype);return t},kindOf:j,kindOfTest:A,endsWith:function(e,t,r){e=String(e),(void 0===r||r>e.length)&&(r=e.length),r-=t.length;var n=e.indexOf(t,r);return-1!==n&&n===r},toArray:function(e){if(!e)return null;if(L(e))return e;var t=e.length;if(!B(t))return null;for(var r=new Array(t);t-- >0;)r[t]=e[t];return r},forEachEntry:function(e,t){for(var r,n=(e&&e[k]).call(e);(r=n.next())&&!r.done;){var o=r.value;t.call(e,o[0],o[1])}},matchAll:function(e,t){for(var r,n=[];null!==(r=e.exec(t));)n.push(r);return n},isHTMLForm:re,hasOwnProperty:ne,hasOwnProp:ne,reduceDescriptors:ie,freezeMethods:function(e){ie(e,(function(t,r){if(F(e)&&-1!==["arguments","caller","callee"].indexOf(r))return!1;var n=e[r];F(n)&&(t.enumerable=!1,"writable"in t?t.writable=!1:t.set||(t.set=function(){throw Error("Can not rewrite read-only method '"+r+"'")}))}))},toObjectSet:function(e,t){var r={},n=function(e){e.forEach((function(e){r[e]=!0}))};return L(e)?n(e):n(String(e).split(t)),r},toCamelCase:function(e){return e.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,(function(e,t,r){return t.toUpperCase()+r}))},noop:function(){},toFiniteNumber:function(e,t){return null!=e&&Number.isFinite(e=+e)?e:t},findKey:Y,global:Q,isContextDefined:Z,isSpecCompliantForm:function(e){return!!(e&&F(e.append)&&"FormData"===e[T]&&e[k])},toJSONObject:function(e){var t=new Array(10);return function e(r,n){if(D(r)){if(t.indexOf(r)>=0)return;if(C(r))return r;if(!("toJSON"in r)){t[n]=r;var o=L(r)?[]:{};return $(r,(function(t,r){var i=e(t,n+1);!N(i)&&(o[r]=i)})),t[n]=void 0,o}}return r}(e,0)},isAsyncFn:fe,isThenable:function(e){return e&&(D(e)||F(e))&&F(e.then)&&F(e.catch)},setImmediate:le,asap:pe,isIterable:function(e){return null!=e&&F(e[k])}};function he(e,t,r,n,o){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=(new Error).stack,this.message=e,this.name="AxiosError",t&&(this.code=t),r&&(this.config=r),n&&(this.request=n),o&&(this.response=o,this.status=o.status?o.status:null)}de.inherits(he,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:de.toJSONObject(this.config),code:this.code,status:this.status}}});var ve=he.prototype,ye={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach((function(e){ye[e]={value:e}})),Object.defineProperties(he,ye),Object.defineProperty(ve,"isAxiosError",{value:!0}),he.from=function(e,t,r,n,o,i){var a=Object.create(ve);de.toFlatObject(e,a,(function(e){return e!==Error.prototype}),(function(e){return"isAxiosError"!==e}));var u=e&&e.message?e.message:"Error",s=null==t&&e?e.code:t;return he.call(a,u,s,r,n,o),e&&null==a.cause&&Object.defineProperty(a,"cause",{value:e,configurable:!0}),a.name=e&&e.name||"Error",i&&Object.assign(a,i),a};function me(e){return de.isPlainObject(e)||de.isArray(e)}function be(e){return de.endsWith(e,"[]")?e.slice(0,-2):e}function ge(e,t,r){return e?e.concat(t).map((function(e,t){return e=be(e),!r&&t?"["+e+"]":e})).join(r?".":""):t}var we=de.toFlatObject(de,{},null,(function(e){return/^is[A-Z]/.test(e)}));function Ee(e,t,r){if(!de.isObject(e))throw new TypeError("target must be an object");t=t||new FormData;var n=(r=de.toFlatObject(r,{metaTokens:!0,dots:!1,indexes:!1},!1,(function(e,t){return!de.isUndefined(t[e])}))).metaTokens,o=r.visitor||c,i=r.dots,a=r.indexes,u=(r.Blob||"undefined"!=typeof Blob&&Blob)&&de.isSpecCompliantForm(t);if(!de.isFunction(o))throw new TypeError("visitor must be a function");function s(e){if(null===e)return"";if(de.isDate(e))return e.toISOString();if(de.isBoolean(e))return e.toString();if(!u&&de.isBlob(e))throw new he("Blob is not supported. Use a Buffer instead.");return de.isArrayBuffer(e)||de.isTypedArray(e)?u&&"function"==typeof Blob?new Blob([e]):Buffer.from(e):e}function c(e,r,o){var u=e;if(e&&!o&&"object"===f(e))if(de.endsWith(r,"{}"))r=n?r:r.slice(0,-2),e=JSON.stringify(e);else if(de.isArray(e)&&function(e){return de.isArray(e)&&!e.some(me)}(e)||(de.isFileList(e)||de.endsWith(r,"[]"))&&(u=de.toArray(e)))return r=be(r),u.forEach((function(e,n){!de.isUndefined(e)&&null!==e&&t.append(!0===a?ge([r],n,i):null===a?r:r+"[]",s(e))})),!1;return!!me(e)||(t.append(ge(o,r,i),s(e)),!1)}var l=[],p=Object.assign(we,{defaultVisitor:c,convertValue:s,isVisitable:me});if(!de.isObject(e))throw new TypeError("data must be an object");return function e(r,n){if(!de.isUndefined(r)){if(-1!==l.indexOf(r))throw Error("Circular reference detected in "+n.join("."));l.push(r),de.forEach(r,(function(r,i){!0===(!(de.isUndefined(r)||null===r)&&o.call(t,r,de.isString(i)?i.trim():i,n,p))&&e(r,n?n.concat(i):[i])})),l.pop()}}(e),t}function Oe(e){var t={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(e).replace(/[!'()~]|%20|%00/g,(function(e){return t[e]}))}function Se(e,t){this._pairs=[],e&&Ee(e,this,t)}var xe=Se.prototype;function Re(e){return encodeURIComponent(e).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+")}function ke(e,t,r){if(!t)return e;var n=r&&r.encode||Re;de.isFunction(r)&&(r={serialize:r});var o,i=r&&r.serialize;if(o=i?i(t,r):de.isURLSearchParams(t)?t.toString():new Se(t,r).toString(n)){var a=e.indexOf("#");-1!==a&&(e=e.slice(0,a)),e+=(-1===e.indexOf("?")?"?":"&")+o}return e}xe.append=function(e,t){this._pairs.push([e,t])},xe.toString=function(e){var t=e?function(t){return e.call(this,t,Oe)}:Oe;return this._pairs.map((function(e){return t(e[0])+"="+t(e[1])}),"").join("&")};var Te=function(){function e(){h(this,e),this.handlers=[]}return y(e,[{key:"use",value:function(e,t,r){return this.handlers.push({fulfilled:e,rejected:t,synchronous:!!r&&r.synchronous,runWhen:r?r.runWhen:null}),this.handlers.length-1}},{key:"eject",value:function(e){this.handlers[e]&&(this.handlers[e]=null)}},{key:"clear",value:function(){this.handlers&&(this.handlers=[])}},{key:"forEach",value:function(e){de.forEach(this.handlers,(function(t){null!==t&&e(t)}))}}]),e}(),je={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},Ae={isBrowser:!0,classes:{URLSearchParams:"undefined"!=typeof URLSearchParams?URLSearchParams:Se,FormData:"undefined"!=typeof FormData?FormData:null,Blob:"undefined"!=typeof Blob?Blob:null},protocols:["http","https","file","blob","url","data"]},Pe="undefined"!=typeof window&&"undefined"!=typeof document,Le="object"===("undefined"==typeof navigator?"undefined":f(navigator))&&navigator||void 0,Ne=Pe&&(!Le||["ReactNative","NativeScript","NS"].indexOf(Le.product)<0),Ce="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope&&"function"==typeof self.importScripts,_e=Pe&&window.location.href||"http://localhost",Ue=u(u({},Object.freeze({__proto__:null,hasBrowserEnv:Pe,hasStandardBrowserWebWorkerEnv:Ce,hasStandardBrowserEnv:Ne,navigator:Le,origin:_e})),Ae);function Fe(e){function t(e,r,n,o){var i=e[o++];if("__proto__"===i)return!0;var a=Number.isFinite(+i),u=o>=e.length;return i=!i&&de.isArray(n)?n.length:i,u?(de.hasOwnProp(n,i)?n[i]=[n[i],r]:n[i]=r,!a):(n[i]&&de.isObject(n[i])||(n[i]=[]),t(e,r,n[i],o)&&de.isArray(n[i])&&(n[i]=function(e){var t,r,n={},o=Object.keys(e),i=o.length;for(t=0;t<i;t++)n[r=o[t]]=e[r];return n}(n[i])),!a)}if(de.isFormData(e)&&de.isFunction(e.entries)){var r={};return de.forEachEntry(e,(function(e,n){t(function(e){return de.matchAll(/\w+|\[(\w*)]/g,e).map((function(e){return"[]"===e[0]?"":e[1]||e[0]}))}(e),n,r,0)})),r}return null}var Be={transitional:je,adapter:["xhr","http","fetch"],transformRequest:[function(e,t){var r,n=t.getContentType()||"",o=n.indexOf("application/json")>-1,i=de.isObject(e);if(i&&de.isHTMLForm(e)&&(e=new FormData(e)),de.isFormData(e))return o?JSON.stringify(Fe(e)):e;if(de.isArrayBuffer(e)||de.isBuffer(e)||de.isStream(e)||de.isFile(e)||de.isBlob(e)||de.isReadableStream(e))return e;if(de.isArrayBufferView(e))return e.buffer;if(de.isURLSearchParams(e))return t.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),e.toString();if(i){if(n.indexOf("application/x-www-form-urlencoded")>-1)return function(e,t){return Ee(e,new Ue.classes.URLSearchParams,u({visitor:function(e,t,r,n){return Ue.isNode&&de.isBuffer(e)?(this.append(t,e.toString("base64")),!1):n.defaultVisitor.apply(this,arguments)}},t))}(e,this.formSerializer).toString();if((r=de.isFileList(e))||n.indexOf("multipart/form-data")>-1){var a=this.env&&this.env.FormData;return Ee(r?{"files[]":e}:e,a&&new a,this.formSerializer)}}return i||o?(t.setContentType("application/json",!1),function(e,t,r){if(de.isString(e))try{return(t||JSON.parse)(e),de.trim(e)}catch(e){if("SyntaxError"!==e.name)throw e}return(r||JSON.stringify)(e)}(e)):e}],transformResponse:[function(e){var t=this.transitional||Be.transitional,r=t&&t.forcedJSONParsing,n="json"===this.responseType;if(de.isResponse(e)||de.isReadableStream(e))return e;if(e&&de.isString(e)&&(r&&!this.responseType||n)){var o=!(t&&t.silentJSONParsing)&&n;try{return JSON.parse(e,this.parseReviver)}catch(e){if(o){if("SyntaxError"===e.name)throw he.from(e,he.ERR_BAD_RESPONSE,this,null,this.response);throw e}}}return e}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:Ue.classes.FormData,Blob:Ue.classes.Blob},validateStatus:function(e){return e>=200&&e<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};de.forEach(["delete","get","head","post","put","patch"],(function(e){Be.headers[e]={}}));var De=Be,Ie=de.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),qe=Symbol("internals");function Me(e){return e&&String(e).trim().toLowerCase()}function ze(e){return!1===e||null==e?e:de.isArray(e)?e.map(ze):String(e)}function He(e,t,r,n,o){return de.isFunction(n)?n.call(this,t,r):(o&&(t=r),de.isString(t)?de.isString(n)?-1!==t.indexOf(n):de.isRegExp(n)?n.test(t):void 0:void 0)}var Je=function(e,t){function r(e){h(this,r),e&&this.set(e)}return y(r,[{key:"set",value:function(e,t,r){var n=this;function o(e,t,r){var o=Me(t);if(!o)throw new Error("header name must be a non-empty string");var i=de.findKey(n,o);(!i||void 0===n[i]||!0===r||void 0===r&&!1!==n[i])&&(n[i||t]=ze(e))}var i=function(e,t){return de.forEach(e,(function(e,r){return o(e,r,t)}))};if(de.isPlainObject(e)||e instanceof this.constructor)i(e,t);else if(de.isString(e)&&(e=e.trim())&&!/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(e.trim()))i(function(e){var t,r,n,o={};return e&&e.split("\n").forEach((function(e){n=e.indexOf(":"),t=e.substring(0,n).trim().toLowerCase(),r=e.substring(n+1).trim(),!t||o[t]&&Ie[t]||("set-cookie"===t?o[t]?o[t].push(r):o[t]=[r]:o[t]=o[t]?o[t]+", "+r:r)})),o}(e),t);else if(de.isObject(e)&&de.isIterable(e)){var a,u,s,c={},f=function(e,t){var r="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!r){if(Array.isArray(e)||(r=w(e))||t&&e&&"number"==typeof e.length){r&&(e=r);var n=0,o=function(){};return{s:o,n:function(){return n>=e.length?{done:!0}:{done:!1,value:e[n++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,a=!0,u=!1;return{s:function(){r=r.call(e)},n:function(){var e=r.next();return a=e.done,e},e:function(e){u=!0,i=e},f:function(){try{a||null==r.return||r.return()}finally{if(u)throw i}}}}(e);try{for(f.s();!(s=f.n()).done;){var l=s.value;if(!de.isArray(l))throw TypeError("Object iterator must return a key-value pair");c[u=l[0]]=(a=c[u])?de.isArray(a)?[].concat(g(a),[l[1]]):[a,l[1]]:l[1]}}catch(e){f.e(e)}finally{f.f()}i(c,t)}else null!=e&&o(t,e,r);return this}},{key:"get",value:function(e,t){if(e=Me(e)){var r=de.findKey(this,e);if(r){var n=this[r];if(!t)return n;if(!0===t)return function(e){for(var t,r=Object.create(null),n=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;t=n.exec(e);)r[t[1]]=t[2];return r}(n);if(de.isFunction(t))return t.call(this,n,r);if(de.isRegExp(t))return t.exec(n);throw new TypeError("parser must be boolean|regexp|function")}}}},{key:"has",value:function(e,t){if(e=Me(e)){var r=de.findKey(this,e);return!(!r||void 0===this[r]||t&&!He(0,this[r],r,t))}return!1}},{key:"delete",value:function(e,t){var r=this,n=!1;function o(e){if(e=Me(e)){var o=de.findKey(r,e);!o||t&&!He(0,r[o],o,t)||(delete r[o],n=!0)}}return de.isArray(e)?e.forEach(o):o(e),n}},{key:"clear",value:function(e){for(var t=Object.keys(this),r=t.length,n=!1;r--;){var o=t[r];e&&!He(0,this[o],o,e,!0)||(delete this[o],n=!0)}return n}},{key:"normalize",value:function(e){var t=this,r={};return de.forEach(this,(function(n,o){var i=de.findKey(r,o);if(i)return t[i]=ze(n),void delete t[o];var a=e?function(e){return e.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(function(e,t,r){return t.toUpperCase()+r}))}(o):String(o).trim();a!==o&&delete t[o],t[a]=ze(n),r[a]=!0})),this}},{key:"concat",value:function(){for(var e,t=arguments.length,r=new Array(t),n=0;n<t;n++)r[n]=arguments[n];return(e=this.constructor).concat.apply(e,[this].concat(r))}},{key:"toJSON",value:function(e){var t=Object.create(null);return de.forEach(this,(function(r,n){null!=r&&!1!==r&&(t[n]=e&&de.isArray(r)?r.join(", "):r)})),t}},{key:Symbol.iterator,value:function(){return Object.entries(this.toJSON())[Symbol.iterator]()}},{key:"toString",value:function(){return Object.entries(this.toJSON()).map((function(e){var t=b(e,2);return t[0]+": "+t[1]})).join("\n")}},{key:"getSetCookie",value:function(){return this.get("set-cookie")||[]}},{key:Symbol.toStringTag,get:function(){return"AxiosHeaders"}}],[{key:"from",value:function(e){return e instanceof this?e:new this(e)}},{key:"concat",value:function(e){for(var t=new this(e),r=arguments.length,n=new Array(r>1?r-1:0),o=1;o<r;o++)n[o-1]=arguments[o];return n.forEach((function(e){return t.set(e)})),t}},{key:"accessor",value:function(e){var t=(this[qe]=this[qe]={accessors:{}}).accessors,r=this.prototype;function n(e){var n=Me(e);t[n]||(!function(e,t){var r=de.toCamelCase(" "+t);["get","set","has"].forEach((function(n){Object.defineProperty(e,n+r,{value:function(e,r,o){return this[n].call(this,t,e,r,o)},configurable:!0})}))}(r,e),t[n]=!0)}return de.isArray(e)?e.forEach(n):n(e),this}}]),r}();Je.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]),de.reduceDescriptors(Je.prototype,(function(e,t){var r=e.value,n=t[0].toUpperCase()+t.slice(1);return{get:function(){return r},set:function(e){this[n]=e}}})),de.freezeMethods(Je);var We=Je;function Ke(e,t){var r=this||De,n=t||r,o=We.from(n.headers),i=n.data;return de.forEach(e,(function(e){i=e.call(r,i,o.normalize(),t?t.status:void 0)})),o.normalize(),i}function Ve(e){return!(!e||!e.__CANCEL__)}function Ge(e,t,r){he.call(this,null==e?"canceled":e,he.ERR_CANCELED,t,r),this.name="CanceledError"}function Xe(e,t,r){var n=r.config.validateStatus;r.status&&n&&!n(r.status)?t(new he("Request failed with status code "+r.status,[he.ERR_BAD_REQUEST,he.ERR_BAD_RESPONSE][Math.floor(r.status/100)-4],r.config,r.request,r)):e(r)}function $e(e,t){e=e||10;var r,n=new Array(e),o=new Array(e),i=0,a=0;return t=void 0!==t?t:1e3,function(u){var s=Date.now(),c=o[a];r||(r=s),n[i]=u,o[i]=s;for(var f=a,l=0;f!==i;)l+=n[f++],f%=e;if((i=(i+1)%e)===a&&(a=(a+1)%e),!(s-r<t)){var p=c&&s-c;return p?Math.round(1e3*l/p):void 0}}}function Ye(e,t){var r,n,o=0,i=1e3/t,a=function(t){var i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:Date.now();o=i,r=null,n&&(clearTimeout(n),n=null),e.apply(void 0,g(t))};return[function(){for(var e=Date.now(),t=e-o,u=arguments.length,s=new Array(u),c=0;c<u;c++)s[c]=arguments[c];t>=i?a(s,e):(r=s,n||(n=setTimeout((function(){n=null,a(r)}),i-t)))},function(){return r&&a(r)}]}de.inherits(Ge,he,{__CANCEL__:!0});var Qe=function(e,t){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:3,n=0,o=$e(50,250);return Ye((function(r){var i=r.loaded,a=r.lengthComputable?r.total:void 0,u=i-n,s=o(u);n=i;var c=m({loaded:i,total:a,progress:a?i/a:void 0,bytes:u,rate:s||void 0,estimated:s&&a&&i<=a?(a-i)/s:void 0,event:r,lengthComputable:null!=a},t?"download":"upload",!0);e(c)}),r)},Ze=function(e,t){var r=null!=e;return[function(n){return t[0]({lengthComputable:r,total:e,loaded:n})},t[1]]},et=function(e){return function(){for(var t=arguments.length,r=new Array(t),n=0;n<t;n++)r[n]=arguments[n];return de.asap((function(){return e.apply(void 0,r)}))}},tt=Ue.hasStandardBrowserEnv?function(e,t){return function(r){return r=new URL(r,Ue.origin),e.protocol===r.protocol&&e.host===r.host&&(t||e.port===r.port)}}(new URL(Ue.origin),Ue.navigator&&/(msie|trident)/i.test(Ue.navigator.userAgent)):function(){return!0},rt=Ue.hasStandardBrowserEnv?{write:function(e,t,r,n,o,i,a){if("undefined"!=typeof document){var u=["".concat(e,"=").concat(encodeURIComponent(t))];de.isNumber(r)&&u.push("expires=".concat(new Date(r).toUTCString())),de.isString(n)&&u.push("path=".concat(n)),de.isString(o)&&u.push("domain=".concat(o)),!0===i&&u.push("secure"),de.isString(a)&&u.push("SameSite=".concat(a)),document.cookie=u.join("; ")}},read:function(e){if("undefined"==typeof document)return null;var t=document.cookie.match(new RegExp("(?:^|; )"+e+"=([^;]*)"));return t?decodeURIComponent(t[1]):null},remove:function(e){this.write(e,"",Date.now()-864e5,"/")}}:{write:function(){},read:function(){return null},remove:function(){}};function nt(e,t,r){var n=!/^([a-z][a-z\d+\-.]*:)?\/\//i.test(t);return e&&(n||0==r)?function(e,t){return t?e.replace(/\/?\/$/,"")+"/"+t.replace(/^\/+/,""):e}(e,t):t}var ot=function(e){return e instanceof We?u({},e):e};function it(e,t){t=t||{};var r={};function n(e,t,r,n){return de.isPlainObject(e)&&de.isPlainObject(t)?de.merge.call({caseless:n},e,t):de.isPlainObject(t)?de.merge({},t):de.isArray(t)?t.slice():t}function o(e,t,r,o){return de.isUndefined(t)?de.isUndefined(e)?void 0:n(void 0,e,0,o):n(e,t,0,o)}function i(e,t){if(!de.isUndefined(t))return n(void 0,t)}function a(e,t){return de.isUndefined(t)?de.isUndefined(e)?void 0:n(void 0,e):n(void 0,t)}function s(r,o,i){return i in t?n(r,o):i in e?n(void 0,r):void 0}var c={url:i,method:i,data:i,baseURL:a,transformRequest:a,transformResponse:a,paramsSerializer:a,timeout:a,timeoutMessage:a,withCredentials:a,withXSRFToken:a,adapter:a,responseType:a,xsrfCookieName:a,xsrfHeaderName:a,onUploadProgress:a,onDownloadProgress:a,decompress:a,maxContentLength:a,maxBodyLength:a,beforeRedirect:a,transport:a,httpAgent:a,httpsAgent:a,cancelToken:a,socketPath:a,responseEncoding:a,validateStatus:s,headers:function(e,t,r){return o(ot(e),ot(t),0,!0)}};return de.forEach(Object.keys(u(u({},e),t)),(function(n){var i=c[n]||o,a=i(e[n],t[n],n);de.isUndefined(a)&&i!==s||(r[n]=a)})),r}var at,ut=function(e){var t=it({},e),r=t.data,n=t.withXSRFToken,o=t.xsrfHeaderName,i=t.xsrfCookieName,a=t.headers,u=t.auth;if(t.headers=a=We.from(a),t.url=ke(nt(t.baseURL,t.url,t.allowAbsoluteUrls),e.params,e.paramsSerializer),u&&a.set("Authorization","Basic "+btoa((u.username||"")+":"+(u.password?unescape(encodeURIComponent(u.password)):""))),de.isFormData(r))if(Ue.hasStandardBrowserEnv||Ue.hasStandardBrowserWebWorkerEnv)a.setContentType(void 0);else if(de.isFunction(r.getHeaders)){var s=r.getHeaders(),c=["content-type","content-length"];Object.entries(s).forEach((function(e){var t=b(e,2),r=t[0],n=t[1];c.includes(r.toLowerCase())&&a.set(r,n)}))}if(Ue.hasStandardBrowserEnv&&(n&&de.isFunction(n)&&(n=n(t)),n||!1!==n&&tt(t.url))){var f=o&&i&&rt.read(i);f&&a.set(o,f)}return t},st="undefined"!=typeof XMLHttpRequest&&function(e){return new Promise((function(t,r){var n,o,i,a,u,s=ut(e),c=s.data,f=We.from(s.headers).normalize(),l=s.responseType,p=s.onUploadProgress,d=s.onDownloadProgress;function h(){a&&a(),u&&u(),s.cancelToken&&s.cancelToken.unsubscribe(n),s.signal&&s.signal.removeEventListener("abort",n)}var v=new XMLHttpRequest;function y(){if(v){var n=We.from("getAllResponseHeaders"in v&&v.getAllResponseHeaders());Xe((function(e){t(e),h()}),(function(e){r(e),h()}),{data:l&&"text"!==l&&"json"!==l?v.response:v.responseText,status:v.status,statusText:v.statusText,headers:n,config:e,request:v}),v=null}}if(v.open(s.method.toUpperCase(),s.url,!0),v.timeout=s.timeout,"onloadend"in v?v.onloadend=y:v.onreadystatechange=function(){v&&4===v.readyState&&(0!==v.status||v.responseURL&&0===v.responseURL.indexOf("file:"))&&setTimeout(y)},v.onabort=function(){v&&(r(new he("Request aborted",he.ECONNABORTED,e,v)),v=null)},v.onerror=function(t){var n=new he(t&&t.message?t.message:"Network Error",he.ERR_NETWORK,e,v);n.event=t||null,r(n),v=null},v.ontimeout=function(){var t=s.timeout?"timeout of "+s.timeout+"ms exceeded":"timeout exceeded",n=s.transitional||je;s.timeoutErrorMessage&&(t=s.timeoutErrorMessage),r(new he(t,n.clarifyTimeoutError?he.ETIMEDOUT:he.ECONNABORTED,e,v)),v=null},void 0===c&&f.setContentType(null),"setRequestHeader"in v&&de.forEach(f.toJSON(),(function(e,t){v.setRequestHeader(t,e)})),de.isUndefined(s.withCredentials)||(v.withCredentials=!!s.withCredentials),l&&"json"!==l&&(v.responseType=s.responseType),d){var m=b(Qe(d,!0),2);i=m[0],u=m[1],v.addEventListener("progress",i)}if(p&&v.upload){var g=b(Qe(p),2);o=g[0],a=g[1],v.upload.addEventListener("progress",o),v.upload.addEventListener("loadend",a)}(s.cancelToken||s.signal)&&(n=function(t){v&&(r(!t||t.type?new Ge(null,e,v):t),v.abort(),v=null)},s.cancelToken&&s.cancelToken.subscribe(n),s.signal&&(s.signal.aborted?n():s.signal.addEventListener("abort",n)));var w,E,O=(w=s.url,(E=/^([-+\w]{1,25})(:?\/\/|:)/.exec(w))&&E[1]||"");O&&-1===Ue.protocols.indexOf(O)?r(new he("Unsupported protocol "+O+":",he.ERR_BAD_REQUEST,e)):v.send(c||null)}))},ct=function(e,t){var r=(e=e?e.filter(Boolean):[]).length;if(t||r){var n,o=new AbortController,i=function(e){if(!n){n=!0,u();var t=e instanceof Error?e:this.reason;o.abort(t instanceof he?t:new Ge(t instanceof Error?t.message:t))}},a=t&&setTimeout((function(){a=null,i(new he("timeout ".concat(t," of ms exceeded"),he.ETIMEDOUT))}),t),u=function(){e&&(a&&clearTimeout(a),a=null,e.forEach((function(e){e.unsubscribe?e.unsubscribe(i):e.removeEventListener("abort",i)})),e=null)};e.forEach((function(e){return e.addEventListener("abort",i)}));var s=o.signal;return s.unsubscribe=function(){return de.asap(u)},s}},ft=s().mark((function e(t,r){var n,o,i;return s().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:if(n=t.byteLength,r&&!(n<r)){e.next=5;break}return e.next=4,t;case 4:return e.abrupt("return");case 5:o=0;case 6:if(!(o<n)){e.next=13;break}return i=o+r,e.next=10,t.slice(o,i);case 10:o=i,e.next=6;break;case 13:case"end":return e.stop()}}),e)})),lt=function(){var e=l(s().mark((function e(t,o){var a,u,c,f,l,p;return s().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:a=!1,u=!1,e.prev=2,f=n(pt(t));case 4:return e.next=6,i(f.next());case 6:if(!(a=!(l=e.sent).done)){e.next=12;break}return p=l.value,e.delegateYield(r(n(ft(p,o))),"t0",9);case 9:a=!1,e.next=4;break;case 12:e.next=18;break;case 14:e.prev=14,e.t1=e.catch(2),u=!0,c=e.t1;case 18:if(e.prev=18,e.prev=19,!a||null==f.return){e.next=23;break}return e.next=23,i(f.return());case 23:if(e.prev=23,!u){e.next=26;break}throw c;case 26:return e.finish(23);case 27:return e.finish(18);case 28:case"end":return e.stop()}}),e,null,[[2,14,18,28],[19,,23,27]])})));return function(t,r){return e.apply(this,arguments)}}(),pt=function(){var e=l(s().mark((function e(t){var o,a,u,c;return s().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:if(!t[Symbol.asyncIterator]){e.next=3;break}return e.delegateYield(r(n(t)),"t0",2);case 2:return e.abrupt("return");case 3:o=t.getReader(),e.prev=4;case 5:return e.next=7,i(o.read());case 7:if(a=e.sent,u=a.done,c=a.value,!u){e.next=12;break}return e.abrupt("break",16);case 12:return e.next=14,c;case 14:e.next=5;break;case 16:return e.prev=16,e.next=19,i(o.cancel());case 19:return e.finish(16);case 20:case"end":return e.stop()}}),e,null,[[4,,16,20]])})));return function(t){return e.apply(this,arguments)}}(),dt=function(e,t,r,n){var o,i=lt(e,t),a=0,u=function(e){o||(o=!0,n&&n(e))};return new ReadableStream({pull:function(e){return d(s().mark((function t(){var n,o,c,f,l;return s().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return t.prev=0,t.next=3,i.next();case 3:if(n=t.sent,o=n.done,c=n.value,!o){t.next=10;break}return u(),e.close(),t.abrupt("return");case 10:f=c.byteLength,r&&(l=a+=f,r(l)),e.enqueue(new Uint8Array(c)),t.next=19;break;case 15:throw t.prev=15,t.t0=t.catch(0),u(t.t0),t.t0;case 19:case"end":return t.stop()}}),t,null,[[0,15]])})))()},cancel:function(e){return u(e),i.return()}},{highWaterMark:2})},ht=de.isFunction,vt={Request:(at=de.global).Request,Response:at.Response},yt=de.global,mt=yt.ReadableStream,bt=yt.TextEncoder,gt=function(e){try{for(var t=arguments.length,r=new Array(t>1?t-1:0),n=1;n<t;n++)r[n-1]=arguments[n];return!!e.apply(void 0,r)}catch(e){return!1}},wt=function(e){var t=e=de.merge.call({skipUndefined:!0},vt,e),r=t.fetch,n=t.Request,o=t.Response,i=r?ht(r):"function"==typeof fetch,a=ht(n),c=ht(o);if(!i)return!1;var f,l=i&&ht(mt),p=i&&("function"==typeof bt?(f=new bt,function(e){return f.encode(e)}):function(){var e=d(s().mark((function e(t){return s().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return e.t0=Uint8Array,e.next=3,new n(t).arrayBuffer();case 3:return e.t1=e.sent,e.abrupt("return",new e.t0(e.t1));case 5:case"end":return e.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}()),h=a&&l&>((function(){var e=!1,t=new n(Ue.origin,{body:new mt,method:"POST",get duplex(){return e=!0,"half"}}).headers.has("Content-Type");return e&&!t})),v=c&&l&>((function(){return de.isReadableStream(new o("").body)})),y={stream:v&&function(e){return e.body}};i&&["text","arrayBuffer","blob","formData","stream"].forEach((function(e){!y[e]&&(y[e]=function(t,r){var n=t&&t[e];if(n)return n.call(t);throw new he("Response type '".concat(e,"' is not supported"),he.ERR_NOT_SUPPORT,r)})}));var m=function(){var e=d(s().mark((function e(t){var r;return s().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:if(null!=t){e.next=2;break}return e.abrupt("return",0);case 2:if(!de.isBlob(t)){e.next=4;break}return e.abrupt("return",t.size);case 4:if(!de.isSpecCompliantForm(t)){e.next=9;break}return r=new n(Ue.origin,{method:"POST",body:t}),e.next=8,r.arrayBuffer();case 8:case 15:return e.abrupt("return",e.sent.byteLength);case 9:if(!de.isArrayBufferView(t)&&!de.isArrayBuffer(t)){e.next=11;break}return e.abrupt("return",t.byteLength);case 11:if(de.isURLSearchParams(t)&&(t+=""),!de.isString(t)){e.next=16;break}return e.next=15,p(t);case 16:case"end":return e.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}(),g=function(){var e=d(s().mark((function e(t,r){var n;return s().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return n=de.toFiniteNumber(t.getContentLength()),e.abrupt("return",null==n?m(r):n);case 2:case"end":return e.stop()}}),e)})));return function(t,r){return e.apply(this,arguments)}}();return function(){var e=d(s().mark((function e(t){var i,c,f,l,p,d,m,w,E,O,S,x,R,k,T,j,A,P,L,N,C,_,U,F,B,D,I,q,M,z,H,J,W,K,V,G;return s().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:if(i=ut(t),c=i.url,f=i.method,l=i.data,p=i.signal,d=i.cancelToken,m=i.timeout,w=i.onDownloadProgress,E=i.onUploadProgress,O=i.responseType,S=i.headers,x=i.withCredentials,R=void 0===x?"same-origin":x,k=i.fetchOptions,T=r||fetch,O=O?(O+"").toLowerCase():"text",j=ct([p,d&&d.toAbortSignal()],m),A=null,P=j&&j.unsubscribe&&function(){j.unsubscribe()},e.prev=6,e.t0=E&&h&&"get"!==f&&"head"!==f,!e.t0){e.next=13;break}return e.next=11,g(S,l);case 11:e.t1=L=e.sent,e.t0=0!==e.t1;case 13:if(!e.t0){e.next=17;break}N=new n(c,{method:"POST",body:l,duplex:"half"}),de.isFormData(l)&&(C=N.headers.get("content-type"))&&S.setContentType(C),N.body&&(_=Ze(L,Qe(et(E))),U=b(_,2),F=U[0],B=U[1],l=dt(N.body,65536,F,B));case 17:return de.isString(R)||(R=R?"include":"omit"),D=a&&"credentials"in n.prototype,I=u(u({},k),{},{signal:j,method:f.toUpperCase(),headers:S.normalize().toJSON(),body:l,duplex:"half",credentials:D?R:void 0}),A=a&&new n(c,I),e.next=23,a?T(A,k):T(c,I);case 23:return q=e.sent,M=v&&("stream"===O||"response"===O),v&&(w||M&&P)&&(z={},["status","statusText","headers"].forEach((function(e){z[e]=q[e]})),H=de.toFiniteNumber(q.headers.get("content-length")),J=w&&Ze(H,Qe(et(w),!0))||[],W=b(J,2),K=W[0],V=W[1],q=new o(dt(q.body,65536,K,(function(){V&&V(),P&&P()})),z)),O=O||"text",e.next=29,y[de.findKey(y,O)||"text"](q,t);case 29:return G=e.sent,!M&&P&&P(),e.next=33,new Promise((function(e,r){Xe(e,r,{data:G,headers:We.from(q.headers),status:q.status,statusText:q.statusText,config:t,request:A})}));case 33:return e.abrupt("return",e.sent);case 36:if(e.prev=36,e.t2=e.catch(6),P&&P(),!e.t2||"TypeError"!==e.t2.name||!/Load failed|fetch/i.test(e.t2.message)){e.next=41;break}throw Object.assign(new he("Network Error",he.ERR_NETWORK,t,A),{cause:e.t2.cause||e.t2});case 41:throw he.from(e.t2,e.t2&&e.t2.code,t,A);case 42:case"end":return e.stop()}}),e,null,[[6,36]])})));return function(t){return e.apply(this,arguments)}}()},Et=new Map,Ot=function(e){for(var t,r,n=e&&e.env||{},o=n.fetch,i=[n.Request,n.Response,o],a=i.length,u=Et;a--;)t=i[a],void 0===(r=u.get(t))&&u.set(t,r=a?new Map:wt(n)),u=r;return r};Ot();var St={http:null,xhr:st,fetch:{get:Ot}};de.forEach(St,(function(e,t){if(e){try{Object.defineProperty(e,"name",{value:t})}catch(e){}Object.defineProperty(e,"adapterName",{value:t})}}));var xt=function(e){return"- ".concat(e)},Rt=function(e){return de.isFunction(e)||null===e||!1===e};var kt={getAdapter:function(e,t){for(var r,n,o=(e=de.isArray(e)?e:[e]).length,i={},a=0;a<o;a++){var u=void 0;if(n=r=e[a],!Rt(r)&&void 0===(n=St[(u=String(r)).toLowerCase()]))throw new he("Unknown adapter '".concat(u,"'"));if(n&&(de.isFunction(n)||(n=n.get(t))))break;i[u||"#"+a]=n}if(!n){var s=Object.entries(i).map((function(e){var t=b(e,2),r=t[0],n=t[1];return"adapter ".concat(r," ")+(!1===n?"is not supported by the environment":"is not available in the build")}));throw new he("There is no suitable adapter to dispatch the request "+(o?s.length>1?"since :\n"+s.map(xt).join("\n"):" "+xt(s[0]):"as no adapter specified"),"ERR_NOT_SUPPORT")}return n},adapters:St};function Tt(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new Ge(null,e)}function jt(e){return Tt(e),e.headers=We.from(e.headers),e.data=Ke.call(e,e.transformRequest),-1!==["post","put","patch"].indexOf(e.method)&&e.headers.setContentType("application/x-www-form-urlencoded",!1),kt.getAdapter(e.adapter||De.adapter,e)(e).then((function(t){return Tt(e),t.data=Ke.call(e,e.transformResponse,t),t.headers=We.from(t.headers),t}),(function(t){return Ve(t)||(Tt(e),t&&t.response&&(t.response.data=Ke.call(e,e.transformResponse,t.response),t.response.headers=We.from(t.response.headers))),Promise.reject(t)}))}var At="1.13.2",Pt={};["object","boolean","number","function","string","symbol"].forEach((function(e,t){Pt[e]=function(r){return f(r)===e||"a"+(t<1?"n ":" ")+e}}));var Lt={};Pt.transitional=function(e,t,r){function n(e,t){return"[Axios v1.13.2] Transitional option '"+e+"'"+t+(r?". "+r:"")}return function(r,o,i){if(!1===e)throw new he(n(o," has been removed"+(t?" in "+t:"")),he.ERR_DEPRECATED);return t&&!Lt[o]&&(Lt[o]=!0,console.warn(n(o," has been deprecated since v"+t+" and will be removed in the near future"))),!e||e(r,o,i)}},Pt.spelling=function(e){return function(t,r){return console.warn("".concat(r," is likely a misspelling of ").concat(e)),!0}};var Nt={assertOptions:function(e,t,r){if("object"!==f(e))throw new he("options must be an object",he.ERR_BAD_OPTION_VALUE);for(var n=Object.keys(e),o=n.length;o-- >0;){var i=n[o],a=t[i];if(a){var u=e[i],s=void 0===u||a(u,i,e);if(!0!==s)throw new he("option "+i+" must be "+s,he.ERR_BAD_OPTION_VALUE)}else if(!0!==r)throw new he("Unknown option "+i,he.ERR_BAD_OPTION)}},validators:Pt},Ct=Nt.validators,_t=function(){function e(t){h(this,e),this.defaults=t||{},this.interceptors={request:new Te,response:new Te}}var t;return y(e,[{key:"request",value:(t=d(s().mark((function e(t,r){var n,o;return s().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return e.prev=0,e.next=3,this._request(t,r);case 3:return e.abrupt("return",e.sent);case 6:if(e.prev=6,e.t0=e.catch(0),e.t0 instanceof Error){n={},Error.captureStackTrace?Error.captureStackTrace(n):n=new Error,o=n.stack?n.stack.replace(/^.+\n/,""):"";try{e.t0.stack?o&&!String(e.t0.stack).endsWith(o.replace(/^.+\n.+\n/,""))&&(e.t0.stack+="\n"+o):e.t0.stack=o}catch(e){}}throw e.t0;case 10:case"end":return e.stop()}}),e,this,[[0,6]])}))),function(e,r){return t.apply(this,arguments)})},{key:"_request",value:function(e,t){"string"==typeof e?(t=t||{}).url=e:t=e||{};var r=t=it(this.defaults,t),n=r.transitional,o=r.paramsSerializer,i=r.headers;void 0!==n&&Nt.assertOptions(n,{silentJSONParsing:Ct.transitional(Ct.boolean),forcedJSONParsing:Ct.transitional(Ct.boolean),clarifyTimeoutError:Ct.transitional(Ct.boolean)},!1),null!=o&&(de.isFunction(o)?t.paramsSerializer={serialize:o}:Nt.assertOptions(o,{encode:Ct.function,serialize:Ct.function},!0)),void 0!==t.allowAbsoluteUrls||(void 0!==this.defaults.allowAbsoluteUrls?t.allowAbsoluteUrls=this.defaults.allowAbsoluteUrls:t.allowAbsoluteUrls=!0),Nt.assertOptions(t,{baseUrl:Ct.spelling("baseURL"),withXsrfToken:Ct.spelling("withXSRFToken")},!0),t.method=(t.method||this.defaults.method||"get").toLowerCase();var a=i&&de.merge(i.common,i[t.method]);i&&de.forEach(["delete","get","head","post","put","patch","common"],(function(e){delete i[e]})),t.headers=We.concat(a,i);var u=[],s=!0;this.interceptors.request.forEach((function(e){"function"==typeof e.runWhen&&!1===e.runWhen(t)||(s=s&&e.synchronous,u.unshift(e.fulfilled,e.rejected))}));var c,f=[];this.interceptors.response.forEach((function(e){f.push(e.fulfilled,e.rejected)}));var l,p=0;if(!s){var d=[jt.bind(this),void 0];for(d.unshift.apply(d,u),d.push.apply(d,f),l=d.length,c=Promise.resolve(t);p<l;)c=c.then(d[p++],d[p++]);return c}l=u.length;for(var h=t;p<l;){var v=u[p++],y=u[p++];try{h=v(h)}catch(e){y.call(this,e);break}}try{c=jt.call(this,h)}catch(e){return Promise.reject(e)}for(p=0,l=f.length;p<l;)c=c.then(f[p++],f[p++]);return c}},{key:"getUri",value:function(e){return ke(nt((e=it(this.defaults,e)).baseURL,e.url,e.allowAbsoluteUrls),e.params,e.paramsSerializer)}}]),e}();de.forEach(["delete","get","head","options"],(function(e){_t.prototype[e]=function(t,r){return this.request(it(r||{},{method:e,url:t,data:(r||{}).data}))}})),de.forEach(["post","put","patch"],(function(e){function t(t){return function(r,n,o){return this.request(it(o||{},{method:e,headers:t?{"Content-Type":"multipart/form-data"}:{},url:r,data:n}))}}_t.prototype[e]=t(),_t.prototype[e+"Form"]=t(!0)}));var Ut=_t,Ft=function(){function e(t){if(h(this,e),"function"!=typeof t)throw new TypeError("executor must be a function.");var r;this.promise=new Promise((function(e){r=e}));var n=this;this.promise.then((function(e){if(n._listeners){for(var t=n._listeners.length;t-- >0;)n._listeners[t](e);n._listeners=null}})),this.promise.then=function(e){var t,r=new Promise((function(e){n.subscribe(e),t=e})).then(e);return r.cancel=function(){n.unsubscribe(t)},r},t((function(e,t,o){n.reason||(n.reason=new Ge(e,t,o),r(n.reason))}))}return y(e,[{key:"throwIfRequested",value:function(){if(this.reason)throw this.reason}},{key:"subscribe",value:function(e){this.reason?e(this.reason):this._listeners?this._listeners.push(e):this._listeners=[e]}},{key:"unsubscribe",value:function(e){if(this._listeners){var t=this._listeners.indexOf(e);-1!==t&&this._listeners.splice(t,1)}}},{key:"toAbortSignal",value:function(){var e=this,t=new AbortController,r=function(e){t.abort(e)};return this.subscribe(r),t.signal.unsubscribe=function(){return e.unsubscribe(r)},t.signal}}],[{key:"source",value:function(){var t;return{token:new e((function(e){t=e})),cancel:t}}}]),e}(),Bt=Ft;var Dt={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511,WebServerIsDown:521,ConnectionTimedOut:522,OriginIsUnreachable:523,TimeoutOccurred:524,SslHandshakeFailed:525,InvalidSslCertificate:526};Object.entries(Dt).forEach((function(e){var t=b(e,2),r=t[0],n=t[1];Dt[n]=r}));var It=Dt;var qt=function e(t){var r=new Ut(t),n=O(Ut.prototype.request,r);return de.extend(n,Ut.prototype,r,{allOwnKeys:!0}),de.extend(n,r,null,{allOwnKeys:!0}),n.create=function(r){return e(it(t,r))},n}(De);return qt.Axios=Ut,qt.CanceledError=Ge,qt.CancelToken=Bt,qt.isCancel=Ve,qt.VERSION=At,qt.toFormData=Ee,qt.AxiosError=he,qt.Cancel=qt.CanceledError,qt.all=function(e){return Promise.all(e)},qt.spread=function(e){return function(t){return e.apply(null,t)}},qt.isAxiosError=function(e){return de.isObject(e)&&!0===e.isAxiosError},qt.mergeConfig=it,qt.AxiosHeaders=We,qt.formToJSON=function(e){return Fe(de.isHTMLForm(e)?new FormData(e):e)},qt.getAdapter=kt.getAdapter,qt.HttpStatusCode=It,qt.default=qt,qt})); 3 | //# sourceMappingURL=axios.min.js.map 4 | --------------------------------------------------------------------------------