diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index f0f0bb97..687ead29 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -15,6 +15,9 @@ jobs: e2e: name: Playwright runs-on: ubuntu-latest + env: + # PHP 7.4 images depend on the expired Debian Bullseye package index. + WP_ENV_PHP_VERSION: "8.2" if: github.event.pull_request.draft == false steps: - uses: actions/checkout@v6 @@ -25,7 +28,7 @@ jobs: - name: Setup PHP version uses: shivammathur/setup-php@v2 with: - php-version: "7.4" + php-version: "8.2" - name: Install composer run: composer install --prefer-dist --no-progress - name: Install JS deps diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index eb20a585..3fdeeffa 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -103,3 +103,5 @@ jobs: run: composer install --prefer-dist --no-progress --no-suggest - name: Run phpunit run: composer run-script phpunit + - name: Run phpunit (multisite) + run: WP_MULTISITE=1 composer run-script phpunit diff --git a/.wp-env.json b/.wp-env.json index 8e2962ea..7de6aa0f 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -1,6 +1,6 @@ { "core": null, - "phpVersion": "7.4", + "phpVersion": "8.2", "testsEnvironment": false, "plugins": [ "." ], "config": { diff --git a/assets/js/scripts-admin.js b/assets/js/scripts-admin.js index b31978af..f1581987 100644 --- a/assets/js/scripts-admin.js +++ b/assets/js/scripts-admin.js @@ -523,7 +523,7 @@ jQuery( function( $ ) { } /** - * Installs or activates Otter and Optimole and adds the template after + * Installs or activates the selected recommended plugins and adds the template after * * @param {Object} data * @param {Function} callback @@ -626,33 +626,19 @@ jQuery( function( $ ) { * Install and activate recommended plugins if the checkboxes is checked. */ function handlePlugins() { - const optimoleCheckbox = $( '#wizard-optimole-checkbox' ); - const wpscCheckbox = $( '#wizard-wpsc-checkbox' ); + const easyMcpCheckbox = $( '#wizard-easy-mcp-checkbox' ); const otterBlockCheckbox = $( '#wizard-otter-block-checkbox' ); let promiseChain = Promise.resolve(); - if ( optimoleCheckbox.length && optimoleCheckbox.is( ':checked' ) ) { - promiseChain = promiseChain - .then(() => { - if ( ! wpmmVars.isOptimoleInstalled ) { - return installPlugin( 'optimole-wp' ).then( () => activatePlugin( 'optimole-wp' ) ); - } - - if ( ! wpmmVars.isOptimoleActive ) { - return activatePlugin( 'optimole-wp' ); - } - }); - } - - if ( wpscCheckbox.length && wpscCheckbox.is( ':checked' ) ) { + if ( easyMcpCheckbox.length && easyMcpCheckbox.is( ':checked' ) ) { promiseChain = promiseChain .then( () => { - if ( ! wpmmVars.isWPSCInstalled ) { - return installPlugin( 'wp-cloudflare-page-cache' ).then( () => activatePlugin( 'wp-cloudflare-page-cache' ) ); + if ( ! wpmmVars.isEasyMcpInstalled ) { + return installPlugin( 'easy-mcp-ai' ).then( () => activatePlugin( 'easy-mcp-ai' ) ); } - if ( ! wpmmVars.isWPSCActive ) { - return activatePlugin( wpmmVars.isWPSCProInstalled ? 'wp-super-page-cache-pro' : 'wp-cloudflare-page-cache' ); + if ( ! wpmmVars.isEasyMcpActive ) { + return activatePlugin( 'easy-mcp-ai' ); } } ); } @@ -727,11 +713,8 @@ jQuery( function( $ ) { switch ( slug ) { case 'otter-blocks': return $.get( wpmmVars.otterActivationLink ); - case 'optimole-wp': - return $.get( wpmmVars.optimoleActivationLink ); - case 'wp-super-page-cache-pro': - case 'wp-cloudflare-page-cache': - return $.get( wpmmVars.wpscActivationLink ); + case 'easy-mcp-ai': + return $.get( wpmmVars.easyMcpActivationLink ); default: break; } diff --git a/composer.json b/composer.json index b7147d78..ad2e8276 100644 --- a/composer.json +++ b/composer.json @@ -17,9 +17,8 @@ } ], "require": { - "codeinwp/themeisle-sdk": "^3.3", - "ext-json": "*", - "ext-dom": "*" + "codeinwp/themeisle-sdk": "^3.3.65", + "ext-json": "*" }, "require-dev": { "squizlabs/php_codesniffer": "^3.13", @@ -30,7 +29,7 @@ "phpunit/phpunit": "^9.6", "yoast/phpunit-polyfills": "^4.0", "phpstan/phpstan": "^2.1", - "php-stubs/wordpress-stubs": "^6.8" + "php-stubs/wordpress-stubs": "^7.0" }, "autoload": { "files": [ diff --git a/composer.lock b/composer.lock index 28eaf66a..7e82dd70 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "744ae16dff0084e7151825d4e2157447", + "content-hash": "a9912abacd1400467048e76dde8dfcd2", "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.58", + "version": "3.3.65", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82" + "reference": "f650fe856d52ce4e5754557d89ba2f3127ad54d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/d6807c0b7308e323bd77cced667dee3f2d5e6a82", - "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/f650fe856d52ce4e5754557d89ba2f3127ad54d8", + "reference": "f650fe856d52ce4e5754557d89ba2f3127ad54d8", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.58" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.65" }, - "time": "2026-07-29T08:38:52+00:00" + "time": "2026-09-29T09:35:47+00:00" } ], "packages-dev": [ @@ -452,16 +452,16 @@ }, { "name": "php-stubs/wordpress-stubs", - "version": "v6.9.4", + "version": "v7.1.0", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5" + "reference": "394570301894799e69a4aa117c9c995427e3ba04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/90a9412826b9944f93b10bf41d795b5fe68abcd5", - "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/394570301894799e69a4aa117c9c995427e3ba04", + "reference": "394570301894799e69a4aa117c9c995427e3ba04", "shasum": "" }, "conflict": { @@ -471,13 +471,13 @@ "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "nikic/php-parser": "^5.5", "php": "^7.4 || ^8.0", - "php-stubs/generator": "^0.8.6", + "php-stubs/generator": "^0.9", "phpdocumentor/reflection-docblock": "^6.0", - "phpstan/phpstan": "^2.1", + "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^9.5", "symfony/polyfill-php80": "*", - "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1", - "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.2", + "wp-coding-standards/wpcs": "3.4.1 as 2.3.0" }, "suggest": { "paragonie/sodium_compat": "Pure PHP implementation of libsodium", @@ -498,9 +498,9 @@ ], "support": { "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.9.4" + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v7.1.0" }, - "time": "2026-05-01T20:36:01+00:00" + "time": "2026-08-28T00:32:06+00:00" }, { "name": "phpcompatibility/php-compatibility", @@ -892,11 +892,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.5", + "version": "2.2.14", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", - "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", + "reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", "shasum": "" }, "require": { @@ -952,7 +952,7 @@ "type": "github" } ], - "time": "2026-07-05T06:31:06+00:00" + "time": "2026-09-12T21:39:33+00:00" }, { "name": "phpunit/php-code-coverage", @@ -2644,8 +2644,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "ext-json": "*", - "ext-dom": "*" + "ext-json": "*" }, "platform-dev": {}, "platform-overrides": { diff --git a/includes/classes/wp-maintenance-mode-abilities.php b/includes/classes/wp-maintenance-mode-abilities.php new file mode 100644 index 00000000..9993c10f --- /dev/null +++ b/includes/classes/wp-maintenance-mode-abilities.php @@ -0,0 +1,550 @@ + __( 'LightStart', 'wp-maintenance-mode' ), + 'description' => __( 'Maintenance mode, coming soon and landing page abilities.', 'wp-maintenance-mode' ), + ) + ); + } + + /** + * Register the abilities. + * + * @return void + */ + public static function register_abilities() { + wp_register_ability( + 'lightstart/get-mode', + array( + 'label' => __( 'Get maintenance mode', 'wp-maintenance-mode' ), + 'description' => __( 'Returns whether maintenance mode is enabled, the selected maintenance page and the bypass / exclusion rules.', 'wp-maintenance-mode' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'default' => array(), + 'additionalProperties' => false, + ), + 'output_schema' => self::get_mode_schema(), + 'execute_callback' => array( __CLASS__, 'get_mode' ), + 'permission_callback' => array( __CLASS__, 'can_manage_settings' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + wp_register_ability( + 'lightstart/set-mode', + array( + 'label' => __( 'Set maintenance mode', 'wp-maintenance-mode' ), + 'description' => __( 'Enables or disables maintenance mode and optionally updates the selected (existing) maintenance page and the bypass / exclusion rules. Only the provided fields are changed.', 'wp-maintenance-mode' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'enabled' => array( + 'type' => 'boolean', + 'description' => __( 'Turn maintenance mode on or off.', 'wp-maintenance-mode' ), + ), + 'page_id' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'ID of an existing published or private page to use as the maintenance page. Use 0 to clear the selection.', 'wp-maintenance-mode' ), + ), + 'frontend_roles' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'description' => __( 'Role slugs that bypass maintenance mode on the frontend. Administrators always bypass it.', 'wp-maintenance-mode' ), + ), + 'backend_roles' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'description' => __( 'Role slugs that keep access to the dashboard while maintenance mode is on.', 'wp-maintenance-mode' ), + ), + 'exclude' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'description' => __( 'Slugs, URL fragments or IP addresses excluded from maintenance mode. Replaces the current list.', 'wp-maintenance-mode' ), + ), + 'bypass_bots' => array( + 'type' => 'boolean', + 'description' => __( 'Let search engine bots bypass maintenance mode.', 'wp-maintenance-mode' ), + ), + 'dry_run' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Validate the input and return the resulting state without saving anything.', 'wp-maintenance-mode' ), + ), + ), + 'additionalProperties' => false, + ), + 'output_schema' => self::get_mode_schema( true ), + 'execute_callback' => array( __CLASS__, 'set_mode' ), + 'permission_callback' => array( __CLASS__, 'can_manage_settings' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => true, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + wp_register_ability( + 'lightstart/list-subscribers', + array( + 'label' => __( 'List subscribers', 'wp-maintenance-mode' ), + 'description' => __( 'Returns the email addresses captured by the maintenance page subscribe form, newest first, with pagination.', 'wp-maintenance-mode' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'default' => array(), + 'properties' => array( + 'page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'default' => 1, + ), + 'per_page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => self::MAX_PER_PAGE, + 'default' => 20, + ), + ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'subscribers' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'email' => array( 'type' => 'string' ), + 'insert_date' => array( 'type' => 'string' ), + ), + ), + ), + 'total' => array( 'type' => 'integer' ), + 'total_pages' => array( 'type' => 'integer' ), + 'page' => array( 'type' => 'integer' ), + 'per_page' => array( 'type' => 'integer' ), + ), + ), + 'execute_callback' => array( __CLASS__, 'list_subscribers' ), + 'permission_callback' => array( __CLASS__, 'can_manage_subscribers' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Permission check, same capability as the settings screen. + * + * @return bool + */ + public static function can_manage_settings() { + return current_user_can( wpmm_get_capability( 'settings' ) ); + } + + /** + * Permission check, same capability as the subscribers export. + * + * @return bool + */ + public static function can_manage_subscribers() { + return current_user_can( wpmm_get_capability( 'subscribers' ) ); + } + + /** + * Execute: lightstart/get-mode + * + * @return array + */ + public static function get_mode() { + return self::format_mode( self::get_settings() ); + } + + /** + * Execute: lightstart/set-mode + * + * @param mixed $input Ability input. + * @return array|WP_Error + */ + public static function set_mode( $input = array() ) { + $input = is_array( $input ) ? $input : array(); + $dry_run = isset( $input['dry_run'] ) && wp_validate_boolean( $input['dry_run'] ); + $settings = self::get_settings(); + $general = isset( $settings['general'] ) && is_array( $settings['general'] ) ? $settings['general'] : array(); + $changed = array(); + + if ( ! array_intersect( array( 'enabled', 'page_id', 'frontend_roles', 'backend_roles', 'exclude', 'bypass_bots' ), array_keys( $input ) ) ) { + return new WP_Error( 'lightstart_nothing_to_update', __( 'Provide at least one setting to change.', 'wp-maintenance-mode' ), array( 'status' => 400 ) ); + } + + $old_status = ! empty( $general['status'] ) ? 1 : 0; + $new_status = $old_status; + + if ( isset( $input['enabled'] ) ) { + if ( ! empty( $general['network_mode'] ) ) { + return new WP_Error( 'lightstart_network_mode', __( 'Maintenance mode is controlled from the network settings and cannot be changed for this site.', 'wp-maintenance-mode' ), array( 'status' => 409 ) ); + } + + $new_status = wp_validate_boolean( $input['enabled'] ) ? 1 : 0; + if ( $new_status !== $old_status ) { + $changed[] = 'enabled'; + } + } + + foreach ( + array( + 'frontend_roles' => 'frontend_role', + 'backend_roles' => 'backend_role', + ) as $field => $setting + ) { + if ( ! isset( $input[ $field ] ) ) { + continue; + } + + $roles = self::sanitize_roles( $input[ $field ] ); + if ( is_wp_error( $roles ) ) { + return $roles; + } + + $general[ $setting ] = $roles; + $changed[] = $field; + } + + if ( isset( $input['exclude'] ) ) { + if ( ! is_array( $input['exclude'] ) || count( $input['exclude'] ) > self::MAX_EXCLUDE_ITEMS ) { + /* translators: maximum number of items */ + return new WP_Error( 'lightstart_invalid_exclude', sprintf( __( 'The exclude list must be an array of at most %d items.', 'wp-maintenance-mode' ), self::MAX_EXCLUDE_ITEMS ), array( 'status' => 400 ) ); + } + + $exclude = array(); + foreach ( $input['exclude'] as $item ) { + if ( ! is_scalar( $item ) ) { + continue; + } + $item = sanitize_textarea_field( trim( (string) $item ) ); + if ( '' !== $item ) { + $exclude[] = $item; + } + } + + $general['exclude'] = $exclude; + $changed[] = 'exclude'; + } + + if ( isset( $input['bypass_bots'] ) ) { + $general['bypass_bots'] = wp_validate_boolean( $input['bypass_bots'] ) ? 1 : 0; + $changed[] = 'bypass_bots'; + } + + $previous_page_id = isset( $settings['design']['page_id'] ) ? absint( $settings['design']['page_id'] ) : 0; + $page_id = $previous_page_id; + + if ( isset( $input['page_id'] ) ) { + $page_id = absint( $input['page_id'] ); + + $valid = self::validate_page( $page_id ); + if ( is_wp_error( $valid ) ) { + return $valid; + } + + if ( $page_id !== $previous_page_id ) { + $changed[] = 'page_id'; + } + } + + $general['status'] = $new_status; + if ( 1 === $new_status && isset( $input['enabled'] ) ) { + $general['status_date'] = date( 'Y-m-d H:i:s' ); + } + + $settings['general'] = $general; + if ( isset( $input['page_id'] ) ) { + if ( ! isset( $settings['design'] ) || ! is_array( $settings['design'] ) ) { + $settings['design'] = array(); + } + $settings['design']['page_id'] = $page_id; + } + + if ( ! $dry_run ) { + if ( isset( $input['page_id'] ) ) { + wpmm_switch_selected_page( $previous_page_id, $page_id ); + } + + // network_mode is a runtime overlay of the network option, it is never stored per site. + $to_save = $settings; + if ( is_multisite() ) { + unset( $to_save['general']['network_mode'] ); + + if ( ! empty( $general['network_mode'] ) ) { + // keep the site's own status, the active one comes from the network. + $stored = wpmm_get_option( 'wpmm_settings', array() ); + $to_save['general']['status'] = ! empty( $stored['general']['status'] ) ? 1 : 0; + } + } + update_option( 'wpmm_settings', $to_save ); + + // same rule as the settings screen: purge when it is or becomes active, or gets deactivated. + if ( 1 === $old_status || 1 === $new_status ) { + wpmm_delete_cache(); + } + } + + $result = self::format_mode( $settings ); + $result['changed'] = $changed; + $result['dry_run'] = $dry_run; + + return $result; + } + + /** + * Execute: lightstart/list-subscribers + * + * @param mixed $input Ability input. + * @return array + */ + public static function list_subscribers( $input = array() ) { + global $wpdb; + + $input = is_array( $input ) ? $input : array(); + $page = isset( $input['page'] ) ? max( 1, absint( $input['page'] ) ) : 1; + $per_page = isset( $input['per_page'] ) ? min( self::MAX_PER_PAGE, max( 1, absint( $input['per_page'] ) ) ) : 20; + $total = wpmm_get_subscribers_count(); + $rows = array(); + + if ( $total > 0 ) { + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT id_subscriber, email, insert_date FROM {$wpdb->prefix}wpmm_subscribers ORDER BY id_subscriber DESC LIMIT %d OFFSET %d", + $per_page, + ( $page - 1 ) * $per_page + ), + ARRAY_A + ); + } + + $subscribers = array(); + foreach ( (array) $rows as $row ) { + $subscribers[] = array( + 'id' => (int) $row['id_subscriber'], + 'email' => (string) $row['email'], + 'insert_date' => (string) $row['insert_date'], + ); + } + + return array( + 'subscribers' => $subscribers, + 'total' => $total, + 'total_pages' => (int) ceil( $total / $per_page ), + 'page' => $page, + 'per_page' => $per_page, + ); + } + + /** + * Current settings, read fresh from the database with the network overlay applied. + * + * @return array + */ + private static function get_settings() { + $plugin = WP_Maintenance_Mode::get_instance(); + $settings = wpmm_get_option( 'wpmm_settings', array() ); + $settings = is_array( $settings ) ? $settings : array(); + + if ( ! isset( $settings['general'] ) || ! is_array( $settings['general'] ) ) { + $defaults = $plugin->default_settings(); + $settings['general'] = $defaults['general']; + } + + if ( is_multisite() ) { + $network = $plugin->get_plugin_network_settings(); + + $settings['general']['network_mode'] = ! empty( $network['general']['network_mode'] ) ? 1 : 0; + if ( $settings['general']['network_mode'] ) { + $settings['general']['status'] = ! empty( $network['general']['status'] ) ? 1 : 0; + } + } + + return $settings; + } + + /** + * Shape the settings for output. + * + * @param array $settings Plugin settings. + * @return array + */ + private static function format_mode( $settings ) { + $general = isset( $settings['general'] ) && is_array( $settings['general'] ) ? $settings['general'] : array(); + $page_id = isset( $settings['design']['page_id'] ) ? absint( $settings['design']['page_id'] ) : 0; + $status = $page_id ? get_post_status( $page_id ) : false; + + return array( + 'enabled' => ! empty( $general['status'] ), + 'status_date' => isset( $general['status_date'] ) ? (string) $general['status_date'] : '', + 'network_mode' => ! empty( $general['network_mode'] ), + 'page_id' => $page_id, + 'page_title' => $status ? get_the_title( $page_id ) : '', + 'page_status' => $status ? (string) $status : '', + 'frontend_roles' => isset( $general['frontend_role'] ) ? array_values( array_map( 'strval', (array) $general['frontend_role'] ) ) : array(), + 'backend_roles' => isset( $general['backend_role'] ) ? array_values( array_map( 'strval', (array) $general['backend_role'] ) ) : array(), + 'exclude' => isset( $general['exclude'] ) ? array_values( array_map( 'strval', (array) $general['exclude'] ) ) : array(), + 'bypass_bots' => ! empty( $general['bypass_bots'] ), + 'redirection' => isset( $general['redirection'] ) ? (string) $general['redirection'] : '', + ); + } + + /** + * Output schema shared by get-mode and set-mode. + * + * @param bool $with_changes Add the set-mode specific fields. + * @return array + */ + private static function get_mode_schema( $with_changes = false ) { + $strings = array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ); + + $properties = array( + 'enabled' => array( 'type' => 'boolean' ), + 'status_date' => array( 'type' => 'string' ), + 'network_mode' => array( 'type' => 'boolean' ), + 'page_id' => array( 'type' => 'integer' ), + 'page_title' => array( 'type' => 'string' ), + 'page_status' => array( 'type' => 'string' ), + 'frontend_roles' => $strings, + 'backend_roles' => $strings, + 'exclude' => $strings, + 'bypass_bots' => array( 'type' => 'boolean' ), + 'redirection' => array( 'type' => 'string' ), + ); + + if ( $with_changes ) { + $properties['changed'] = $strings; + $properties['dry_run'] = array( 'type' => 'boolean' ); + } + + return array( + 'type' => 'object', + 'properties' => $properties, + ); + } + + /** + * Validate role slugs against the roles offered on the settings screen. + * + * @param mixed $roles Role slugs. + * @return string[]|WP_Error + */ + private static function sanitize_roles( $roles ) { + if ( ! is_array( $roles ) ) { + return new WP_Error( 'lightstart_invalid_roles', __( 'Roles must be an array of role slugs.', 'wp-maintenance-mode' ), array( 'status' => 400 ) ); + } + + $roles = array_values( array_unique( array_map( 'sanitize_text_field', array_filter( $roles, 'is_scalar' ) ) ) ); + $unknown = array_diff( $roles, array_keys( wpmm_get_user_roles() ) ); + + if ( ! empty( $unknown ) ) { + /* translators: list of role slugs */ + return new WP_Error( 'lightstart_unknown_role', sprintf( __( 'Unknown or not selectable role(s): %s', 'wp-maintenance-mode' ), implode( ', ', $unknown ) ), array( 'status' => 400 ) ); + } + + return $roles; + } + + /** + * Validate a page the same way the settings screen dropdown limits it. + * + * @param int $page_id Page ID; 0 clears the selection. + * @return true|WP_Error + */ + private static function validate_page( $page_id ) { + if ( ! get_option( 'wpmm_new_look' ) ) { + return new WP_Error( 'lightstart_page_selection_unavailable', __( 'Selecting a maintenance page is only available with the block based (new look) maintenance page.', 'wp-maintenance-mode' ), array( 'status' => 409 ) ); + } + + if ( 0 === $page_id ) { + return true; + } + + $page = get_post( $page_id ); + if ( ! $page || 'page' !== $page->post_type || ! in_array( $page->post_status, array( 'publish', 'private' ), true ) ) { + return new WP_Error( 'lightstart_invalid_page', __( 'The page must be an existing published or private page.', 'wp-maintenance-mode' ), array( 'status' => 404 ) ); + } + + if ( ! current_user_can( 'edit_post', $page_id ) ) { + return new WP_Error( 'lightstart_cannot_edit_page', __( 'You are not allowed to edit this page.', 'wp-maintenance-mode' ), array( 'status' => 403 ) ); + } + + return true; + } + } +} diff --git a/includes/classes/wp-maintenance-mode-admin.php b/includes/classes/wp-maintenance-mode-admin.php index 3a0dc12b..aa9ccc91 100644 --- a/includes/classes/wp-maintenance-mode-admin.php +++ b/includes/classes/wp-maintenance-mode-admin.php @@ -23,6 +23,9 @@ class WP_Maintenance_Mode_Admin { * 3, 2, 1... Start! */ private function __construct() { + // The basename needs no translations, so set it here: the action link filters below use it. + $this->plugin_basename = plugin_basename( WPMM_FILE ); + // Init. add_action( 'init', array( $this, 'load_default_settings' ) ); @@ -102,7 +105,6 @@ public function load_default_settings() { $this->plugin_slug = $plugin->get_plugin_slug(); $this->plugin_settings = $plugin->get_plugin_settings(); $this->plugin_network_settings = $plugin->get_plugin_network_settings(); - $this->plugin_basename = plugin_basename( WPMM_PATH . $this->plugin_slug . '.php' ); $this->plugin_default_settings = $plugin->default_settings(); } @@ -159,29 +161,26 @@ public function enqueue_admin_scripts() { $this->plugin_slug . '-admin-script', 'wpmmVars', array( - 'ajaxURL' => admin_url( 'admin-ajax.php' ), - 'pluginURL' => add_query_arg( array( 'page' => $this->plugin_slug ), admin_url( 'admin.php' ) ), - 'ajaxNonce' => wp_create_nonce( 'ajax' ), - 'wizardNonce' => wp_create_nonce( 'wizard' ), - 'pluginInstallNonce' => wp_create_nonce( 'updates' ), - 'isOtterInstalled' => file_exists( ABSPATH . 'wp-content/plugins/otter-blocks/otter-blocks.php' ), - 'isOtterActive' => is_plugin_active( 'otter-blocks/otter-blocks.php' ), - 'isOptimoleInstalled' => file_exists( ABSPATH . 'wp-content/plugins/optimole-wp/optimole-wp.php' ), - 'isOptimoleActive' => is_plugin_active( 'optimole-wp/optimole-wp.php' ), - 'isWPSCInstalled' => file_exists( ABSPATH . 'wp-content/plugins/wp-cloudflare-page-cache/wp-cloudflare-super-page-cache.php' ) || file_exists( ABSPATH . 'wp-content/plugins/wp-super-page-cache-pro/wp-cloudflare-super-page-cache-pro.php' ), - 'isWPSCActive' => is_plugin_active( 'wp-cloudflare-page-cache/wp-cloudflare-super-page-cache.php' ) || is_plugin_active( 'wp-super-page-cache-pro/wp-cloudflare-super-page-cache-pro.php' ), - 'isWPSCProInstalled' => file_exists( ABSPATH . 'wp-content/plugins/wp-super-page-cache-pro/wp-cloudflare-super-page-cache-pro.php' ), - 'errorString' => __( 'Something went wrong, please try again.', 'wp-maintenance-mode' ), - 'loadingString' => __( 'Doing some magic...', 'wp-maintenance-mode' ), - 'importingText' => __( 'Importing', 'wp-maintenance-mode' ), - 'importDone' => __( 'Done', 'wp-maintenance-mode' ), - 'invalidEmailString' => __( 'Invalid email, please try again.', 'wp-maintenance-mode' ), - 'finishWizardStrings' => array( + 'ajaxURL' => admin_url( 'admin-ajax.php' ), + 'pluginURL' => add_query_arg( array( 'page' => $this->plugin_slug ), admin_url( 'admin.php' ) ), + 'ajaxNonce' => wp_create_nonce( 'ajax' ), + 'wizardNonce' => wp_create_nonce( 'wizard' ), + 'pluginInstallNonce' => wp_create_nonce( 'updates' ), + 'isOtterInstalled' => file_exists( ABSPATH . 'wp-content/plugins/otter-blocks/otter-blocks.php' ), + 'isOtterActive' => is_plugin_active( 'otter-blocks/otter-blocks.php' ), + 'isEasyMcpInstalled' => file_exists( ABSPATH . 'wp-content/plugins/easy-mcp-ai/easy-mcp-ai.php' ), + 'isEasyMcpActive' => is_plugin_active( 'easy-mcp-ai/easy-mcp-ai.php' ), + 'errorString' => __( 'Something went wrong, please try again.', 'wp-maintenance-mode' ), + 'loadingString' => __( 'Doing some magic...', 'wp-maintenance-mode' ), + 'importingText' => __( 'Importing', 'wp-maintenance-mode' ), + 'importDone' => __( 'Done', 'wp-maintenance-mode' ), + 'invalidEmailString' => __( 'Invalid email, please try again.', 'wp-maintenance-mode' ), + 'finishWizardStrings' => array( 'maintenance' => __( 'Your maintenance page is ready!', 'wp-maintenance-mode' ), 'coming-soon' => __( 'Your coming soon page is ready!', 'wp-maintenance-mode' ), ), - 'adminURL' => get_admin_url(), - 'otterActivationLink' => add_query_arg( + 'adminURL' => get_admin_url(), + 'otterActivationLink' => add_query_arg( array( 'action' => 'activate', 'plugin' => rawurlencode( 'otter-blocks/otter-blocks.php' ), @@ -191,48 +190,38 @@ public function enqueue_admin_scripts() { ), esc_url( network_admin_url( 'plugins.php' ) ) ), - 'optimoleActivationLink' => add_query_arg( - array( - 'action' => 'activate', - 'plugin' => rawurlencode( 'optimole-wp/optimole-wp.php' ), - 'plugin_status' => 'all', - 'paged' => '1', - '_wpnonce' => wp_create_nonce( 'activate-plugin_optimole-wp/optimole-wp.php' ), - ), - esc_url( network_admin_url( 'plugins.php' ) ) - ), - 'wpscActivationLink' => add_query_arg( + 'easyMcpActivationLink' => add_query_arg( array( 'action' => 'activate', - 'plugin' => rawurlencode( file_exists( ABSPATH . 'wp-content/plugins/wp-super-page-cache-pro/wp-cloudflare-super-page-cache-pro.php' ) ? 'wp-super-page-cache-pro/wp-cloudflare-super-page-cache-pro.php' : 'wp-cloudflare-page-cache/wp-cloudflare-super-page-cache.php' ), + 'plugin' => rawurlencode( 'easy-mcp-ai/easy-mcp-ai.php' ), 'plugin_status' => 'all', 'paged' => '1', - '_wpnonce' => wp_create_nonce( file_exists( ABSPATH . 'wp-content/plugins/wp-super-page-cache-pro/wp-cloudflare-super-page-cache-pro.php' ) ? 'activate-plugin_wp-super-page-cache-pro/wp-cloudflare-super-page-cache-pro.php' : 'activate-plugin_wp-cloudflare-page-cache/wp-cloudflare-super-page-cache.php' ), + '_wpnonce' => wp_create_nonce( 'activate-plugin_easy-mcp-ai/easy-mcp-ai.php' ), ), esc_url( network_admin_url( 'plugins.php' ) ) ), - 'modalTexts' => array( + 'modalTexts' => array( 'title' => __( 'The template has been imported!', 'wp-maintenance-mode' ), 'description' => __( 'The template has been imported to a new draft page. You can take a look and enable it from plugin settings.', 'wp-maintenance-mode' ), 'buttonPage' => __( 'Go to page', 'wp-maintenance-mode' ), 'buttonSettings' => __( 'Go to Settings', 'wp-maintenance-mode' ), ), - 'confirmModalTexts' => array( + 'confirmModalTexts' => array( 'title' => __( 'Import this template?', 'wp-maintenance-mode' ), 'description' => __( 'By importing this template, the existing content on your Maintenance Page will be replaced. Do you wish to continue?', 'wp-maintenance-mode' ), 'buttonContinue' => __( 'Continue', 'wp-maintenance-mode' ), 'buttonGoBack' => __( 'Go back', 'wp-maintenance-mode' ), ), - 'imageUploaderDefaults' => array( + 'imageUploaderDefaults' => array( 'title' => _x( 'Upload Image', 'image_uploader default title', 'wp-maintenance-mode' ), 'buttonText' => _x( 'Choose Image', 'image_uploader default button_text', 'wp-maintenance-mode' ), ), - 'skipImportStrings' => array( + 'skipImportStrings' => array( 'maintenance' => __( 'I don’t want to use a Maintenance Template', 'wp-maintenance-mode' ), 'coming-soon' => __( 'I don’t want to use a Coming Soon Template', 'wp-maintenance-mode' ), 'landing-page' => __( 'I don’t want to use a Landing Page Template', 'wp-maintenance-mode' ), ), - 'skipImportDefault' => __( 'I don’t want to use a template', 'wp-maintenance-mode' ), + 'skipImportDefault' => __( 'I don’t want to use a template', 'wp-maintenance-mode' ), ) ); @@ -717,25 +706,9 @@ public function select_page() { private function switch_selected_page( $page_id ) { $previous_page_id = isset( $this->plugin_settings['design']['page_id'] ) ? absint( $this->plugin_settings['design']['page_id'] ) : 0; - if ( $previous_page_id && $previous_page_id !== $page_id ) { - // hand the previously selected page back before abandoning it, - // otherwise nothing would ever restore it - wpmm_restore_page_state( $previous_page_id ); - } + wpmm_switch_selected_page( $previous_page_id, $page_id ); $this->plugin_settings['design']['page_id'] = $page_id; - - if ( $page_id ) { - // remember the page's original state before taking it over as a maintenance page - wpmm_record_page_state( $page_id ); - - wp_update_post( - array( - 'ID' => $page_id, - 'page_template' => 'templates/wpmm-page-template.php', - ) - ); - } } /** @@ -819,13 +792,16 @@ public function insert_template() { CSS_Handler::generate_css_file( $page_id ); } - if ( 'wizard' === $_POST['source'] ) { + if ( 'wizard' === $source ) { $this->plugin_settings['general']['status'] = 1; update_option( 'wpmm_fresh_install', false ); } update_option( 'wpmm_page_category', $category ); update_option( 'wpmm_settings', $this->plugin_settings ); + if ( 'wizard' === $source ) { + wpmm_delete_cache(); + } wp_send_json_success( array( 'pageEditURL' => get_edit_post_link( $page_id ) ) ); } diff --git a/includes/classes/wp-maintenance-mode.php b/includes/classes/wp-maintenance-mode.php index 4bc43db1..c4af1940 100644 --- a/includes/classes/wp-maintenance-mode.php +++ b/includes/classes/wp-maintenance-mode.php @@ -25,7 +25,7 @@ class WP_Maintenance_Mode { protected $plugin_basename; protected static $instance = null; - private $style_buffer; + private $style_buffer = array(); private $current_page_category; /** @@ -126,6 +126,7 @@ private function __construct() { } } + wpmm_record_show_on_front(); update_option( 'show_on_front', 'page' ); add_filter( 'pre_option_page_on_front', @@ -162,6 +163,8 @@ function ( $value ) { add_action( 'wpmm_before_scripts', array( $this, 'add_bot_extras' ) ); add_action( 'wpmm_footer', array( $this, 'add_js_files' ) ); } else { + wpmm_restore_show_on_front(); + // restore the maintenance page to its original state when maintenance mode is disabled add_action( 'init', @@ -684,6 +687,8 @@ public static function single_activate( $network_wide = false ) { * @since 2.0.0 */ public static function single_deactivate() { + wpmm_restore_show_on_front(); + // give the selected page back to the site in its original state $settings = get_option( 'wpmm_settings' ); if ( ! empty( $settings['design']['page_id'] ) ) { @@ -742,12 +747,6 @@ public function init() { ! $this->check_search_bots() && ! ( defined( 'WP_CLI' ) && WP_CLI ) ) { - if ( isset( $this->plugin_settings['design']['page_id'] ) && get_option( 'wpmm_new_look' ) ) { - define( 'IS_MAINTENANCE', true ); - include_once wpmm_get_template_path( 'maintenance.php', true ); - return; - } - // HEADER STUFF $protocol = ! empty( $_SERVER['SERVER_PROTOCOL'] ) && in_array( $_SERVER['SERVER_PROTOCOL'], array( 'HTTP/1.1', 'HTTP/1.0' ), true ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0'; $charset = get_bloginfo( 'charset' ) ? get_bloginfo( 'charset' ) : 'UTF-8'; @@ -756,6 +755,24 @@ public function init() { $backtime_seconds = $this->calculate_backtime(); $backtime = (int) apply_filters( 'wpmm_backtime', $backtime_seconds ); + $is_selected_page = isset( $this->plugin_settings['design']['page_id'] ) && get_option( 'wpmm_new_look' ); + + // send the status headers before the selected page branch, so that both maintenance flows return them + wpmm_set_nocache_constants(); + nocache_headers(); + + // the selected page template redirects other URLs to the front page, and a redirect must not tell clients to wait + if ( ! $is_selected_page || is_front_page() ) { + header( "$protocol $status_code Service Unavailable", true, $status_code ); + header( "Retry-After: $backtime" ); + } + + if ( $is_selected_page ) { + define( 'IS_MAINTENANCE', true ); + include_once wpmm_get_template_path( 'maintenance.php', true ); + return; + } + // META STUFF $title = ! empty( $this->plugin_settings['design']['title'] ) ? $this->plugin_settings['design']['title'] : get_bloginfo( 'name' ) . ' - ' . __( 'Maintenance Mode', 'wp-maintenance-mode' ); $title = apply_filters( 'wm_title', $title ); // this hook will be removed in the next versions @@ -794,13 +811,8 @@ public function init() { $countdown_start = ! empty( $this->plugin_settings['modules']['countdown_start'] ) ? $this->plugin_settings['modules']['countdown_start'] : $this->plugin_settings['general']['status_date']; $countdown_end = strtotime( $countdown_start . ' +' . $backtime_seconds . ' seconds' ); - wpmm_set_nocache_constants(); - nocache_headers(); - ob_start(); header( "Content-type: text/html; charset=$charset" ); - header( "$protocol $status_code Service Unavailable", true, $status_code ); - header( "Retry-After: $backtime" ); // load maintenance mode template include_once wpmm_get_template_path( 'maintenance.php', true ); @@ -1140,9 +1152,7 @@ public function remember_style_fse() { echo $output; - $doc = new DOMDocument(); - $doc->loadHTML( '' . $output . '' ); - $this->style_buffer = $doc->getElementsByTagName( 'style' ); + $this->style_buffer = $this->extract_style_tags( $output ); } /** @@ -1158,30 +1168,34 @@ public function add_style_fse() { $output = ob_get_contents(); ob_end_clean(); - $doc = new DOMDocument(); - $doc->loadHTML( '' . $output . '' ); - $elems = $doc->getElementsByTagName( 'style' ); - $css = ''; + $styles = $this->extract_style_tags( $output ); + $missing = array_diff( $styles, (array) $this->style_buffer ); - $common_positions = array(); + echo implode( '', $missing ); + } - foreach ( $elems as $i => $elem ) { - foreach ( $this->style_buffer as $style ) { - if ( $elems->item( $i )->C14N() == $style->C14N() ) { - $common_positions[] = $i; - } - } + /** + * Collect the inline `` tags, as they appear in the markup. + */ + private function extract_style_tags( $html ) { + if ( empty( $html ) ) { + return array(); } - foreach ( $elems as $i => $elem ) { - if ( in_array( $i, $common_positions ) ) { - continue; - } + // Comments and script bodies can hold `#is', $markup, $matches ); + + return $matches[0]; } /** diff --git a/includes/functions/helpers.php b/includes/functions/helpers.php index c174b05d..3db81c1b 100644 --- a/includes/functions/helpers.php +++ b/includes/functions/helpers.php @@ -598,6 +598,61 @@ function wpmm_restore_page_state( $page_id ) { delete_post_meta( $page_id, '_wpmm_applied_status' ); } +/** + * Record the site's front-page setting before maintenance mode changes it. + * The first recorded value is kept until maintenance mode is disabled. + * + * @return void + */ +function wpmm_record_show_on_front() { + add_option( 'wpmm_original_show_on_front', get_option( 'show_on_front', 'posts' ) ); +} + +/** + * Restore the front-page setting recorded when maintenance mode was enabled. + * + * @return void + */ +function wpmm_restore_show_on_front() { + $original_value = get_option( 'wpmm_original_show_on_front', false ); + + if ( false === $original_value ) { + return; + } + + update_option( 'show_on_front', $original_value ); + delete_option( 'wpmm_original_show_on_front' ); +} + +/** + * Switch the selected maintenance page: hand the previous page back and take + * the new one over. The caller is responsible for saving the new selection. + * + * @since 2.6.23 + * @param int $previous_page_id The currently selected page ID; 0 when none. + * @param int $page_id The newly selected page ID; 0 clears the selection. + * @return void + */ +function wpmm_switch_selected_page( $previous_page_id, $page_id ) { + if ( $previous_page_id && $previous_page_id !== $page_id ) { + // hand the previously selected page back before abandoning it, + // otherwise nothing would ever restore it + wpmm_restore_page_state( $previous_page_id ); + } + + if ( $page_id ) { + // remember the page's original state before taking it over as a maintenance page + wpmm_record_page_state( $page_id ); + + wp_update_post( + array( + 'ID' => $page_id, + 'page_template' => 'templates/wpmm-page-template.php', + ) + ); + } +} + /** * Get option page URL. */ diff --git a/tests/block-theme-styles-test.php b/tests/block-theme-styles-test.php new file mode 100644 index 00000000..0e7bcf0d --- /dev/null +++ b/tests/block-theme-styles-test.php @@ -0,0 +1,95 @@ +setAccessible( true ); + $prop->setValue( self::$frontend, array() ); + + parent::tear_down(); + } + + public function print_head_style() { + echo ''; + } + + public function print_style_lookalikes() { + echo ''; + echo ''; + } + + public function print_late_style() { + echo ''; + } + + public function test_the_head_callback_prints_the_head_markup() { + add_action( 'wp_head', array( $this, 'print_head_style' ) ); + + ob_start(); + self::$frontend->remember_style_fse(); + $head = ob_get_clean(); + + $this->assertStringContainsString( '.wpmm-head{color:red}', $head ); + } + + public function test_the_footer_callback_prints_only_the_styles_missing_from_the_head() { + add_action( 'wp_head', array( $this, 'print_head_style' ) ); + + ob_start(); + self::$frontend->remember_style_fse(); + ob_end_clean(); + + add_action( 'wp_head', array( $this, 'print_late_style' ) ); + + ob_start(); + self::$frontend->add_style_fse(); + $footer = ob_get_clean(); + + $this->assertStringContainsString( '.wpmm-late{color:blue}', $footer ); + $this->assertStringNotContainsString( '.wpmm-head{color:red}', $footer ); + } + + public function test_the_footer_callback_prints_the_styles_when_the_head_callback_never_ran() { + add_action( 'wp_head', array( $this, 'print_late_style' ) ); + + ob_start(); + self::$frontend->add_style_fse(); + $footer = ob_get_clean(); + + $this->assertStringContainsString( '.wpmm-late{color:blue}', $footer ); + } + + public function test_the_footer_callback_ignores_style_markup_inside_scripts_and_comments() { + add_action( 'wp_head', array( $this, 'print_style_lookalikes' ) ); + + ob_start(); + self::$frontend->add_style_fse(); + $footer = ob_get_clean(); + + $this->assertStringNotContainsString( '.wpmm-in-script', $footer ); + $this->assertStringNotContainsString( '.wpmm-in-comment', $footer ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index eedf3955..7d223b5f 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -34,3 +34,23 @@ function _register_module() { // Start up the WP testing environment. require $_tests_dir . '/includes/bootstrap.php'; + +/** + * Boot a fresh admin instance, the same way plugins_loaded and init do on admin requests. + * + * @return WP_Maintenance_Mode_Admin + */ +function wpmm_test_boot_admin() { + if ( ! class_exists( 'WP_Maintenance_Mode_Admin' ) ) { + require_once WPMM_CLASSES_PATH . 'wp-maintenance-mode-admin.php'; + } + + $instance = new ReflectionProperty( 'WP_Maintenance_Mode_Admin', 'instance' ); + $instance->setAccessible( true ); + $instance->setValue( null, null ); + + $admin = WP_Maintenance_Mode_Admin::get_instance(); + $admin->load_default_settings(); + + return $admin; +} diff --git a/tests/e2e/specs/maintenance-mode.spec.js b/tests/e2e/specs/maintenance-mode.spec.js index 885faafb..d70cb83f 100644 --- a/tests/e2e/specs/maintenance-mode.spec.js +++ b/tests/e2e/specs/maintenance-mode.spec.js @@ -3,11 +3,25 @@ */ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; +/** + * External dependencies + */ +import { execSync } from 'child_process'; + /** * Internal dependencies */ import { setMaintenanceMode, openAsVisitor } from '../utils.js'; +/** + * Run a WP-CLI command inside the wp-env cli container. + * + * @param {string} args WP-CLI arguments, without the leading `wp`. + */ +function wpCli( args ) { + execSync( `npx wp-env run cli wp ${ args }`, { stdio: 'pipe' } ); +} + test.describe( 'maintenance mode lifecycle', () => { test( 'the site is public while maintenance mode is off', async ( { admin, @@ -74,6 +88,63 @@ test.describe( 'maintenance mode lifecycle', () => { ).toHaveCount( 0 ); } ); + test( 'visitors get a 503 on the selected maintenance page (new look)', async ( { + admin, + page, + browser, + requestUtils, + } ) => { + const maintenancePage = await requestUtils.rest( { + path: '/wp/v2/pages', + method: 'POST', + data: { + title: 'Selected maintenance page', + status: 'publish', + content: 'Back in a moment.', + }, + } ); + + await setMaintenanceMode( admin, page, true ); + // The block-based flow has no settings UI without the wizard, so switch it on directly. + wpCli( 'option update wpmm_new_look 1' ); + wpCli( + `option patch insert wpmm_settings design page_id ${ maintenancePage.id }` + ); + + try { + const visitor = await openAsVisitor( browser ); + expect( visitor.response.status() ).toBe( 503 ); + expect( visitor.response.headers()[ 'retry-after' ] ).toBeTruthy(); + expect( visitor.response.headers()[ 'cache-control' ] ).toContain( + 'no-cache' + ); + await expect( visitor.page.locator( 'body' ) ).toContainText( + 'Back in a moment.' + ); + + // Other URLs redirect to the maintenance page without delay. + const redirect = await visitor.context.request.get( + '/sample-page/', + { maxRedirects: 0 } + ); + expect( redirect.status() ).toBe( 302 ); + expect( redirect.headers()[ 'retry-after' ] ).toBeUndefined(); + await visitor.context.close(); + } finally { + // Hand the classic flow back for the specs that follow. + wpCli( 'option update wpmm_new_look 0' ); + wpCli( 'option patch delete wpmm_settings design page_id' ); + await setMaintenanceMode( admin, page, false ); + // Active maintenance mode sets it on every request, so reset it only after disabling. + wpCli( 'option update show_on_front posts' ); + await requestUtils.rest( { + path: `/wp/v2/pages/${ maintenancePage.id }`, + method: 'DELETE', + params: { force: true }, + } ); + } + } ); + test( 'disabling maintenance mode makes the site public again', async ( { admin, page, diff --git a/tests/page-state-test.php b/tests/page-state-test.php index a7adf557..44bbca3b 100644 --- a/tests/page-state-test.php +++ b/tests/page-state-test.php @@ -106,16 +106,7 @@ public function test_user_page_template_is_restored_on_disable_and_reapplied_on_ private function apply_template( $settings, $template_slug = 'coming-soon-1', $category = 'coming-soon' ) { $this->boot_plugin( $settings ); - if ( ! class_exists( 'WP_Maintenance_Mode_Admin' ) ) { - require_once WPMM_CLASSES_PATH . 'wp-maintenance-mode-admin.php'; - } - - $instance = new ReflectionProperty( 'WP_Maintenance_Mode_Admin', 'instance' ); - $instance->setAccessible( true ); - $instance->setValue( null, null ); - - $admin = WP_Maintenance_Mode_Admin::get_instance(); - $admin->load_default_settings(); + $admin = wpmm_test_boot_admin(); $_POST = array( '_wpnonce' => wp_create_nonce( 'tab-design' ), @@ -157,16 +148,7 @@ private function apply_template( $settings, $template_slug = 'coming-soon-1', $c private function select_page( $settings, $page_id ) { $this->boot_plugin( $settings ); - if ( ! class_exists( 'WP_Maintenance_Mode_Admin' ) ) { - require_once WPMM_CLASSES_PATH . 'wp-maintenance-mode-admin.php'; - } - - $instance = new ReflectionProperty( 'WP_Maintenance_Mode_Admin', 'instance' ); - $instance->setAccessible( true ); - $instance->setValue( null, null ); - - $admin = WP_Maintenance_Mode_Admin::get_instance(); - $admin->load_default_settings(); + $admin = wpmm_test_boot_admin(); $_POST = array( '_wpnonce' => wp_create_nonce( 'tab-design' ), @@ -204,16 +186,7 @@ private function select_page( $settings, $page_id ) { private function save_design_settings( $settings, $design ) { $this->boot_plugin( $settings ); - if ( ! class_exists( 'WP_Maintenance_Mode_Admin' ) ) { - require_once WPMM_CLASSES_PATH . 'wp-maintenance-mode-admin.php'; - } - - $instance = new ReflectionProperty( 'WP_Maintenance_Mode_Admin', 'instance' ); - $instance->setAccessible( true ); - $instance->setValue( null, null ); - - $admin = WP_Maintenance_Mode_Admin::get_instance(); - $admin->load_default_settings(); + $admin = wpmm_test_boot_admin(); wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); @@ -736,13 +709,43 @@ public function test_plugin_deactivation_restores_selected_page() { ) ); + update_option( 'show_on_front', 'posts' ); + // Maintenance mode is active: the page is taken over and published. $this->boot_plugin( $this->make_settings( 1, $page_id ) ); $this->assertSame( 'publish', get_post_status( $page_id ) ); + $this->assertSame( 'page', get_option( 'show_on_front' ) ); + $this->assertSame( 'posts', get_option( 'wpmm_original_show_on_front' ) ); - // Deactivating the plugin hands the page back, private again. + // Deactivating the plugin hands both the page and Reading setting back. WP_Maintenance_Mode::single_deactivate(); $this->assertSame( 'private', get_post_status( $page_id ) ); + $this->assertSame( 'posts', get_option( 'show_on_front' ) ); + $this->assertFalse( get_option( 'wpmm_original_show_on_front', false ) ); + } + + public function test_disabling_maintenance_mode_restores_the_initial_reading_setting() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + + update_option( 'show_on_front', 'posts' ); + $this->boot_plugin( $this->make_settings( 1, $page_id ) ); + $this->assertSame( 'page', get_option( 'show_on_front' ) ); + $this->assertSame( 'posts', get_option( 'wpmm_original_show_on_front' ) ); + + // Another active request must not overwrite the recorded initial value. + $this->boot_plugin( $this->make_settings( 1, $page_id ) ); + $this->assertSame( 'posts', get_option( 'wpmm_original_show_on_front' ) ); + + $this->boot_plugin( $this->make_settings( 0, $page_id ) ); + do_action( 'init' ); + + $this->assertSame( 'posts', get_option( 'show_on_front' ) ); + $this->assertFalse( get_option( 'wpmm_original_show_on_front', false ) ); } } diff --git a/tests/plugin-action-links-test.php b/tests/plugin-action-links-test.php new file mode 100644 index 00000000..d137418e --- /dev/null +++ b/tests/plugin-action-links-test.php @@ -0,0 +1,54 @@ + $actions Row actions returned by the action links filter. + * @return void + */ + private function assert_settings_link( array $actions ) { + $this->assertArrayHasKey( 'wpmm_settings', $actions ); + $this->assertStringContainsString( 'admin.php?page=wp-maintenance-mode', $actions['wpmm_settings'] ); + $this->assertStringContainsString( '>Settings', $actions['wpmm_settings'] ); + } + + /** + * The Plugins screen shows a Settings link in the plugin row. + */ + public function test_plugin_row_has_settings_link(): void { + wpmm_test_boot_admin(); + + // WP_Plugins_List_Table filters the row actions by the plugin file. + $actions = apply_filters( 'plugin_action_links_' . plugin_basename( WPMM_FILE ), array() ); + + $this->assert_settings_link( $actions ); + } + + /** + * The Network Admin Plugins screen shows a Settings link when the plugin is network-activated. + * + * Runs only in multisite mode: WP_MULTISITE=1. + * + * @group ms-required + */ + public function test_network_plugin_row_has_settings_link(): void { + $this->skipWithoutMultisite(); + + $plugin_file = plugin_basename( WPMM_FILE ); + update_site_option( 'active_sitewide_plugins', array( $plugin_file => time() ) ); + + wpmm_test_boot_admin(); + + $actions = apply_filters( 'network_admin_plugin_action_links_' . $plugin_file, array() ); + + $this->assert_settings_link( $actions ); + } +} diff --git a/uninstall.php b/uninstall.php index 57290615..52233377 100755 --- a/uninstall.php +++ b/uninstall.php @@ -17,6 +17,7 @@ function single_uninstall() { 'wpmm_settings', 'wpmm_notice', 'wpmm_version', + 'wpmm_original_show_on_front', ); foreach ( $options_to_delete as $option ) { diff --git a/views/wizard.php b/views/wizard.php index 0c0bb67f..5a094743 100644 --- a/views/wizard.php +++ b/views/wizard.php @@ -73,55 +73,24 @@ - +
- + -

%3$s %4$s', - __( 'Templates would have pre-optimized images and all of your website’s images would be delivered via Amazon Cloudfront CDN, resulting in an ≈ 80% increase in speed.', 'wp-maintenance-mode' ), - esc_url( 'https://wordpress.org/plugins/optimole-wp/' ), - 'Optimole', - __( 'plugin will be installed and activated.', 'wp-maintenance-mode' ) - ), - wpmm_translated_string_allowed_html() - ); - ?> -

-
- - -
-
- - - - - -
-

- %3$s %4$s', - __( 'Speed up your pages by 60-80% with intelligent caching. Achieve faster load times and better search rankings automatically, with', 'wp-maintenance-mode' ), - esc_url( 'https://wordpress.org/plugins/wp-cloudflare-page-cache/' ), - __( 'Super Page Cache', 'wp-maintenance-mode' ), - __( 'plugin installed and activated automatically.', 'wp-maintenance-mode' ) + // translators: %s is the Easy MCP AI plugin link. + __( 'Ask Claude or ChatGPT to put your site into maintenance mode, bring it back live, update your pages or check new sign-ups. The free %s plugin will be installed and activated.', 'wp-maintenance-mode' ), + 'Easy MCP AI' ), wpmm_translated_string_allowed_html() ); diff --git a/wp-maintenance-mode.php b/wp-maintenance-mode.php index a8926163..bb8338c9 100755 --- a/wp-maintenance-mode.php +++ b/wp-maintenance-mode.php @@ -65,6 +65,12 @@ add_action( 'plugins_loaded', array( 'WP_Maintenance_Mode', 'get_instance' ) ); +/** + * ABILITIES API + */ +require_once WPMM_CLASSES_PATH . 'wp-maintenance-mode-abilities.php'; +WP_Maintenance_Mode_Abilities::init(); + /** * DASHBOARD */ @@ -85,6 +91,26 @@ function () { } ); +add_filter( + 'wp_maintenance_mode_ai_connect_metadata', + function () { + return array( + 'name' => 'LightStart', + 'notice_cases' => array( + __( 'turn maintenance mode on or off', 'wp-maintenance-mode' ), + __( 'change who can bypass it', 'wp-maintenance-mode' ), + __( 'list your subscribers', 'wp-maintenance-mode' ), + ), + 'prompts' => array( + __( 'Turn on LightStart maintenance mode and let editors keep access.', 'wp-maintenance-mode' ), + __( 'Add my sales page to the maintenance-mode bypass list so the campaign stays live.', 'wp-maintenance-mode' ), + __( 'Give me the emails collected by my maintenance page since Monday.', 'wp-maintenance-mode' ), + ), + 'ability_prefix' => 'lightstart', + ); + } +); + /** * Filter products array. *