From cb8f57a7eaa4b05405f9cce2cc2fbf166201058c Mon Sep 17 00:00:00 2001 From: Alexia Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:55:04 +0300 Subject: [PATCH 1/8] fix: preserve selected page status and template across maintenance mode lifecycle Selecting an existing published page as the maintenance page no longer leaves it private (404 for visitors) after maintenance mode is disabled or the plugin is deactivated. The page's original status and template are now recorded in post meta when the page is selected (or on first enable, for pages selected before this fix) and restored when maintenance mode is disabled and on plugin deactivation. Pages with no recorded state are left untouched instead of being forced private. Fixes #523 Co-Authored-By: Claude Fable 5 --- .../classes/wp-maintenance-mode-admin.php | 22 +++- includes/classes/wp-maintenance-mode.php | 36 +++-- includes/functions/helpers.php | 49 +++++++ tests/page-state-test.php | 123 ++++++++++++++++++ 4 files changed, 209 insertions(+), 21 deletions(-) create mode 100644 tests/page-state-test.php diff --git a/includes/classes/wp-maintenance-mode-admin.php b/includes/classes/wp-maintenance-mode-admin.php index 1f36a03..3456c3c 100644 --- a/includes/classes/wp-maintenance-mode-admin.php +++ b/includes/classes/wp-maintenance-mode-admin.php @@ -686,13 +686,21 @@ public function select_page() { die( esc_html__( 'Security check.', 'wp-maintenance-mode' ) ); } - $this->plugin_settings['design']['page_id'] = $_POST['page_id']; - wp_update_post( - array( - 'ID' => $this->plugin_settings['design']['page_id'], - 'page_template' => 'templates/wpmm-page-template.php', - ) - ); + $page_id = isset( $_POST['page_id'] ) ? absint( $_POST['page_id'] ) : 0; + + $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', + ) + ); + } update_option( 'wpmm_settings', $this->plugin_settings ); diff --git a/includes/classes/wp-maintenance-mode.php b/includes/classes/wp-maintenance-mode.php index 8933e46..ce6812a 100644 --- a/includes/classes/wp-maintenance-mode.php +++ b/includes/classes/wp-maintenance-mode.php @@ -97,12 +97,21 @@ private function __construct() { add_action( 'wp_ajax_wpmm_send_contact', array( $this, 'send_contact' ) ); add_action( 'otter_form_after_submit', array( $this, 'otter_add_subscriber' ) ); - if ( isset( $this->plugin_settings['design']['page_id'] ) && get_option( 'wpmm_new_look' ) && get_post_status( $this->plugin_settings['design']['page_id'] ) === 'private' ) { - add_filter( 'wpo_purge_all_cache_on_update', '__return_true' ); - if ( function_exists( 'wp_functionality_constants' ) ) { - wp_functionality_constants(); + if ( isset( $this->plugin_settings['design']['page_id'] ) && get_option( 'wpmm_new_look' ) ) { + // remember the page's original state so it can be restored when maintenance mode is disabled + wpmm_record_page_state( (int) $this->plugin_settings['design']['page_id'], false ); + + if ( get_post_meta( $this->plugin_settings['design']['page_id'], '_wp_page_template', true ) !== 'templates/wpmm-page-template.php' ) { + update_post_meta( $this->plugin_settings['design']['page_id'], '_wp_page_template', 'templates/wpmm-page-template.php' ); + } + + if ( get_post_status( $this->plugin_settings['design']['page_id'] ) === 'private' ) { + add_filter( 'wpo_purge_all_cache_on_update', '__return_true' ); + if ( function_exists( 'wp_functionality_constants' ) ) { + wp_functionality_constants(); + } + wp_publish_post( $this->plugin_settings['design']['page_id'] ); } - wp_publish_post( $this->plugin_settings['design']['page_id'] ); } update_option( 'show_on_front', 'page' ); @@ -141,21 +150,14 @@ function ( $value ) { add_action( 'wpmm_before_scripts', array( $this, 'add_bot_extras' ) ); add_action( 'wpmm_footer', array( $this, 'add_js_files' ) ); } else { - // make maintenance page private when maintenance mode is disabled + // restore the maintenance page to its original state when maintenance mode is disabled add_action( 'init', function() { if ( ! isset( $this->plugin_settings['design']['page_id'] ) ) { return; } - if ( get_post_status( $this->plugin_settings['design']['page_id'] ) === 'publish' ) { - wp_update_post( - array( - 'ID' => $this->plugin_settings['design']['page_id'], - 'post_status' => 'private', - ) - ); - } + wpmm_restore_page_state( (int) $this->plugin_settings['design']['page_id'] ); } ); } @@ -671,6 +673,12 @@ public static function single_activate( $network_wide = false ) { * @since 2.0.0 */ public static function single_deactivate() { + // give the selected page back to the site in its original state + $settings = get_option( 'wpmm_settings' ); + if ( ! empty( $settings['design']['page_id'] ) ) { + wpmm_restore_page_state( (int) $settings['design']['page_id'] ); + } + wpmm_delete_cache(); } diff --git a/includes/functions/helpers.php b/includes/functions/helpers.php index 40a2dbc..0695e8a 100644 --- a/includes/functions/helpers.php +++ b/includes/functions/helpers.php @@ -513,6 +513,55 @@ function sanitize_hex_color( $color ) { } } +/** + * Record the current state of the selected maintenance page so it can be + * restored when maintenance mode is disabled or the plugin is deactivated. + * + * @since 2.6.23 + * @param int $page_id page ID + * @param bool $overwrite whether to overwrite a previously recorded state + */ +function wpmm_record_page_state( $page_id, $overwrite = true ) { + if ( ! $overwrite && get_post_meta( $page_id, '_wpmm_original_status', true ) ) { + return; + } + + update_post_meta( $page_id, '_wpmm_original_status', get_post_status( $page_id ) ); + update_post_meta( $page_id, '_wpmm_original_template', get_post_meta( $page_id, '_wp_page_template', true ) ); +} + +/** + * Restore the selected maintenance page to its recorded original state. + * Pages without a recorded state are left untouched. + * + * @since 2.6.23 + * @param int $page_id page ID + */ +function wpmm_restore_page_state( $page_id ) { + $original_status = get_post_meta( $page_id, '_wpmm_original_status', true ); + + if ( empty( $original_status ) || ! get_post( $page_id ) ) { + return; + } + + if ( get_post_status( $page_id ) !== $original_status ) { + wp_update_post( + array( + 'ID' => $page_id, + 'post_status' => $original_status, + ) + ); + } + + $original_template = get_post_meta( $page_id, '_wpmm_original_template', true ); + + if ( empty( $original_template ) ) { + delete_post_meta( $page_id, '_wp_page_template' ); + } else { + update_post_meta( $page_id, '_wp_page_template', $original_template ); + } +} + /** * Get option page URL. */ diff --git a/tests/page-state-test.php b/tests/page-state-test.php new file mode 100644 index 0000000..eaa00a6 --- /dev/null +++ b/tests/page-state-test.php @@ -0,0 +1,123 @@ +setAccessible( true ); + $instance->setValue( null, null ); + + return WP_Maintenance_Mode::get_instance(); + } + + /** + * Minimal settings array for the code paths under test. + * + * @param int $status Maintenance mode status (0/1). + * @param int $page_id Selected page ID. + * @return array + */ + private function make_settings( $status, $page_id ) { + $settings = WP_Maintenance_Mode::get_instance()->default_settings(); + + $settings['general']['status'] = $status; + $settings['design']['page_id'] = $page_id; + + return $settings; + } + + public function test_disabled_mode_leaves_selected_published_page_public() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + + $this->boot_plugin( $this->make_settings( 0, $page_id ) ); + do_action( 'init' ); + + $this->assertSame( 'publish', get_post_status( $page_id ) ); + } + + public function test_enable_then_disable_returns_private_page_to_private() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'private', + ) + ); + + // Enabling maintenance mode publishes the private page so it can be served. + $this->boot_plugin( $this->make_settings( 1, $page_id ) ); + $this->assertSame( 'publish', get_post_status( $page_id ) ); + + // Disabling it puts the page back to private, not just any page to private. + $this->boot_plugin( $this->make_settings( 0, $page_id ) ); + do_action( 'init' ); + $this->assertSame( 'private', get_post_status( $page_id ) ); + } + + public function test_user_page_template_is_restored_on_disable_and_reapplied_on_enable() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + update_post_meta( $page_id, '_wp_page_template', 'custom-template.php' ); + + // What select_page() does when the user picks an existing page. + wpmm_record_page_state( $page_id ); + update_post_meta( $page_id, '_wp_page_template', 'templates/wpmm-page-template.php' ); + + // Disabling maintenance mode restores the page's own template and status. + $this->boot_plugin( $this->make_settings( 0, $page_id ) ); + do_action( 'init' ); + $this->assertSame( 'custom-template.php', get_post_meta( $page_id, '_wp_page_template', true ) ); + $this->assertSame( 'publish', get_post_status( $page_id ) ); + + // Re-enabling applies the maintenance template again. + $this->boot_plugin( $this->make_settings( 1, $page_id ) ); + $this->assertSame( 'templates/wpmm-page-template.php', get_post_meta( $page_id, '_wp_page_template', true ) ); + } + + public function test_plugin_deactivation_restores_selected_page() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + + update_option( 'wpmm_settings', $this->make_settings( 0, $page_id ) ); + wpmm_record_page_state( $page_id ); + + // Simulate the state a broken site is in: selected page left private. + wp_update_post( + array( + 'ID' => $page_id, + 'post_status' => 'private', + ) + ); + + WP_Maintenance_Mode::single_deactivate(); + + $this->assertSame( 'publish', get_post_status( $page_id ) ); + } +} From 2c25e1a6a0b324e074831ac021c106659f4001d4 Mon Sep 17 00:00:00 2001 From: Alexia Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:36:30 +0300 Subject: [PATCH 2/8] fix: never overwrite user-owned pages when applying a maintenance template Applying a gallery template while an existing site page was selected overwrote that page's content and forced it private. Templates are now written only to pages the plugin created (marked with _wpmm_generated); when a user-owned page is selected, it is handed back in its original state and the template lands on a newly created maintenance page. Recording a page's original state is now first-write-wins and the record is cleared on restore, so re-selecting a page the plugin already modified can no longer poison the saved original state. Also guards the Otter CSS_Handler call, which fataled on sites without Otter Blocks. Refs #523 Co-Authored-By: Claude Fable 5 --- .../classes/wp-maintenance-mode-admin.php | 22 ++- includes/classes/wp-maintenance-mode.php | 2 +- includes/functions/helpers.php | 15 +- tests/page-state-test.php | 150 ++++++++++++++++++ views/settings.php | 3 +- 5 files changed, 183 insertions(+), 9 deletions(-) diff --git a/includes/classes/wp-maintenance-mode-admin.php b/includes/classes/wp-maintenance-mode-admin.php index 3456c3c..ff37b3a 100644 --- a/includes/classes/wp-maintenance-mode-admin.php +++ b/includes/classes/wp-maintenance-mode-admin.php @@ -742,12 +742,25 @@ public function insert_template() { 'page_template' => 'templates/wpmm-page-template.php', ); - if ( isset( $this->plugin_settings['design']['page_id'] ) && get_post_status( $this->plugin_settings['design']['page_id'] ) && get_post_status( $this->plugin_settings['design']['page_id'] ) !== 'trash' ) { - $post_arr['ID'] = $this->plugin_settings['design']['page_id']; + $selected_page_id = isset( $this->plugin_settings['design']['page_id'] ) ? absint( $this->plugin_settings['design']['page_id'] ) : 0; + $page_exists = $selected_page_id && get_post_status( $selected_page_id ) && get_post_status( $selected_page_id ) !== 'trash'; + + if ( $page_exists && get_post_meta( $selected_page_id, '_wpmm_generated', true ) ) { + // only pages created by the plugin may be overwritten + $post_arr['ID'] = $selected_page_id; $page_id = wp_update_post( $post_arr ); } else { + if ( $page_exists ) { + // hand the user's page back before switching to a generated one + wpmm_restore_page_state( $selected_page_id ); + } + $post_arr['post_title'] = __( 'Maintenance Page', 'wp-maintenance-mode' ); $page_id = wp_insert_post( $post_arr ); + + if ( $page_id && ! $page_id instanceof WP_Error ) { + update_post_meta( $page_id, '_wpmm_generated', 1 ); + } } if ( $page_id === 0 || $page_id instanceof WP_Error ) { @@ -755,7 +768,10 @@ public function insert_template() { } $this->plugin_settings['design']['page_id'] = $page_id; - CSS_Handler::generate_css_file( $page_id ); + + if ( class_exists( 'ThemeIsle\GutenbergBlocks\CSS\CSS_Handler' ) ) { + CSS_Handler::generate_css_file( $page_id ); + } if ( 'wizard' === $_POST['source'] ) { $this->plugin_settings['general']['status'] = 1; diff --git a/includes/classes/wp-maintenance-mode.php b/includes/classes/wp-maintenance-mode.php index ce6812a..715a1fb 100644 --- a/includes/classes/wp-maintenance-mode.php +++ b/includes/classes/wp-maintenance-mode.php @@ -99,7 +99,7 @@ private function __construct() { if ( isset( $this->plugin_settings['design']['page_id'] ) && get_option( 'wpmm_new_look' ) ) { // remember the page's original state so it can be restored when maintenance mode is disabled - wpmm_record_page_state( (int) $this->plugin_settings['design']['page_id'], false ); + wpmm_record_page_state( (int) $this->plugin_settings['design']['page_id'] ); if ( get_post_meta( $this->plugin_settings['design']['page_id'], '_wp_page_template', true ) !== 'templates/wpmm-page-template.php' ) { update_post_meta( $this->plugin_settings['design']['page_id'], '_wp_page_template', 'templates/wpmm-page-template.php' ); diff --git a/includes/functions/helpers.php b/includes/functions/helpers.php index 0695e8a..f70d2fb 100644 --- a/includes/functions/helpers.php +++ b/includes/functions/helpers.php @@ -517,12 +517,15 @@ function sanitize_hex_color( $color ) { * Record the current state of the selected maintenance page so it can be * restored when maintenance mode is disabled or the plugin is deactivated. * + * A previously recorded state is never overwritten: the page may already be + * carrying plugin-made changes, and recording those would poison the original + * state. The record is cleared when the page is restored. + * * @since 2.6.23 - * @param int $page_id page ID - * @param bool $overwrite whether to overwrite a previously recorded state + * @param int $page_id page ID */ -function wpmm_record_page_state( $page_id, $overwrite = true ) { - if ( ! $overwrite && get_post_meta( $page_id, '_wpmm_original_status', true ) ) { +function wpmm_record_page_state( $page_id ) { + if ( get_post_meta( $page_id, '_wpmm_original_status', true ) ) { return; } @@ -560,6 +563,10 @@ function wpmm_restore_page_state( $page_id ) { } else { update_post_meta( $page_id, '_wp_page_template', $original_template ); } + + // clear the record so the next take-over captures the page's state afresh + delete_post_meta( $page_id, '_wpmm_original_status' ); + delete_post_meta( $page_id, '_wpmm_original_template' ); } /** diff --git a/tests/page-state-test.php b/tests/page-state-test.php index eaa00a6..5369cde 100644 --- a/tests/page-state-test.php +++ b/tests/page-state-test.php @@ -97,6 +97,156 @@ public function test_user_page_template_is_restored_on_disable_and_reapplied_on_ $this->assertSame( 'templates/wpmm-page-template.php', get_post_meta( $page_id, '_wp_page_template', true ) ); } + /** + * Boot a fresh admin instance and simulate the insert-template AJAX call. + * + * @param array $settings Value for the wpmm_settings option. + * @return void + */ + private function apply_template( $settings ) { + $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(); + + $_POST = array( + '_wpnonce' => wp_create_nonce( 'tab-design' ), + 'source' => 'tab-design', + 'template_slug' => 'coming-soon-1', + 'category' => 'coming-soon', + ); + + $die_handler = function () { + return function () { + throw new WPDieException( 'json response sent' ); + }; + }; + // Outside of admin-ajax.php, wp_send_json_*() ends with a bare `die`; + // claiming AJAX context routes it through a catchable die handler instead. + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( 'wp_die_ajax_handler', $die_handler ); + + ob_start(); + try { + $admin->insert_template(); + } catch ( WPDieException $e ) { + // wp_send_json_*() ends the request with wp_die(); expected. + } + ob_end_clean(); + + remove_filter( 'wp_doing_ajax', '__return_true' ); + remove_filter( 'wp_die_ajax_handler', $die_handler ); + $_POST = array(); + } + + public function test_applying_template_to_user_page_creates_new_page_instead_of_overwriting() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_content' => 'My real homepage content.', + ) + ); + update_post_meta( $page_id, '_wp_page_template', 'custom-template.php' ); + + // What select_page() does when the user picks an existing page. + wpmm_record_page_state( $page_id ); + update_post_meta( $page_id, '_wp_page_template', 'templates/wpmm-page-template.php' ); + + $this->apply_template( $this->make_settings( 0, $page_id ) ); + + // The user's page is handed back untouched. + $this->assertSame( 'publish', get_post_status( $page_id ) ); + $this->assertSame( 'My real homepage content.', get_post( $page_id )->post_content ); + $this->assertSame( 'custom-template.php', get_post_meta( $page_id, '_wp_page_template', true ) ); + + // The template landed on a newly created, plugin-owned page instead. + $settings = get_option( 'wpmm_settings' ); + $new_page_id = (int) $settings['design']['page_id']; + + $this->assertNotSame( $page_id, $new_page_id ); + $this->assertSame( 'private', get_post_status( $new_page_id ) ); + $this->assertNotEmpty( get_post_meta( $new_page_id, '_wpmm_generated', true ) ); + $this->assertNotEmpty( get_post( $new_page_id )->post_content ); + } + + public function test_applying_template_to_generated_page_updates_it_in_place() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'private', + ) + ); + update_post_meta( $page_id, '_wpmm_generated', 1 ); + + $this->apply_template( $this->make_settings( 0, $page_id ) ); + + $settings = get_option( 'wpmm_settings' ); + + $this->assertSame( $page_id, (int) $settings['design']['page_id'] ); + $this->assertNotEmpty( get_post( $page_id )->post_content ); + } + + public function test_recording_twice_does_not_poison_the_original_state() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + update_post_meta( $page_id, '_wp_page_template', 'custom-template.php' ); + + wpmm_record_page_state( $page_id ); + + // The plugin takes the page over... + wp_update_post( + array( + 'ID' => $page_id, + 'post_status' => 'private', + ) + ); + update_post_meta( $page_id, '_wp_page_template', 'templates/wpmm-page-template.php' ); + + // ...and the user re-selects the same page while it is in that state. + wpmm_record_page_state( $page_id ); + + wpmm_restore_page_state( $page_id ); + + $this->assertSame( 'publish', get_post_status( $page_id ) ); + $this->assertSame( 'custom-template.php', get_post_meta( $page_id, '_wp_page_template', true ) ); + } + + public function test_page_edits_after_restore_become_the_new_baseline() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + update_post_meta( $page_id, '_wp_page_template', 'custom-template.php' ); + + // First take-over / hand-back cycle. + wpmm_record_page_state( $page_id ); + update_post_meta( $page_id, '_wp_page_template', 'templates/wpmm-page-template.php' ); + wpmm_restore_page_state( $page_id ); + + // The user changes the page's template, then the plugin takes it over again. + update_post_meta( $page_id, '_wp_page_template', 'new-template.php' ); + wpmm_record_page_state( $page_id ); + update_post_meta( $page_id, '_wp_page_template', 'templates/wpmm-page-template.php' ); + wpmm_restore_page_state( $page_id ); + + $this->assertSame( 'new-template.php', get_post_meta( $page_id, '_wp_page_template', true ) ); + } + public function test_plugin_deactivation_restores_selected_page() { $page_id = self::factory()->post->create( array( diff --git a/views/settings.php b/views/settings.php index 6c53766..6260401 100644 --- a/views/settings.php +++ b/views/settings.php @@ -297,7 +297,8 @@ $will_replace = isset( $this->plugin_settings['design']['page_id'] ) && ! ( ! get_post( $this->plugin_settings['design']['page_id'] ) || empty( trim( get_post( $this->plugin_settings['design']['page_id'] )->post_content ) ) || - get_post( $this->plugin_settings['design']['page_id'] )->post_status === 'trash' ); + get_post( $this->plugin_settings['design']['page_id'] )->post_status === 'trash' ) && + get_post_meta( (int) $this->plugin_settings['design']['page_id'], '_wpmm_generated', true ); foreach ( $categories as $category => $label ) { $templates = list_files( WPMM_TEMPLATES_PATH . $category . '/', 1 ); From 941315acc66cc3b968d94dc80bfe70a8947402ac Mon Sep 17 00:00:00 2001 From: Alexia Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:49:13 +0300 Subject: [PATCH 3/8] fix: harden maintenance page bootstrap and template import Skip the enable-mode page takeover when the selected page no longer exists (previously wrote orphan post meta), and reject template imports whose slug/category do not resolve to a bundled template instead of creating an empty maintenance page. Template slug and category are sanitized before being used in a filesystem path. Refs #523 Co-Authored-By: Claude Fable 5 --- .../classes/wp-maintenance-mode-admin.php | 16 +++++++-- includes/classes/wp-maintenance-mode.php | 2 +- tests/page-state-test.php | 33 +++++++++++++++++-- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/includes/classes/wp-maintenance-mode-admin.php b/includes/classes/wp-maintenance-mode-admin.php index ff37b3a..5a95842 100644 --- a/includes/classes/wp-maintenance-mode-admin.php +++ b/includes/classes/wp-maintenance-mode-admin.php @@ -729,9 +729,19 @@ public function insert_template() { die( esc_html__( 'Security check.', 'wp-maintenance-mode' ) ); } - $template_slug = $_POST['template_slug']; - $category = $_POST['category']; - $template = json_decode( file_get_contents( WPMM_TEMPLATES_PATH . $category . '/' . $template_slug . '/blocks-export.json' ) ); + $template_slug = isset( $_POST['template_slug'] ) ? basename( sanitize_text_field( $_POST['template_slug'] ) ) : ''; + $category = isset( $_POST['category'] ) ? basename( sanitize_text_field( $_POST['category'] ) ) : ''; + $template_file = WPMM_TEMPLATES_PATH . $category . '/' . $template_slug . '/blocks-export.json'; + + if ( '' === $template_slug || '' === $category || ! is_readable( $template_file ) ) { + wp_send_json_error( array( 'error' => 'Unknown template' ) ); + } + + $template = json_decode( file_get_contents( $template_file ) ); + + if ( empty( $template->content ) ) { + wp_send_json_error( array( 'error' => 'Unknown template' ) ); + } $blocks = str_replace( '\n', '', $template->content ); diff --git a/includes/classes/wp-maintenance-mode.php b/includes/classes/wp-maintenance-mode.php index 715a1fb..5775aa3 100644 --- a/includes/classes/wp-maintenance-mode.php +++ b/includes/classes/wp-maintenance-mode.php @@ -97,7 +97,7 @@ private function __construct() { add_action( 'wp_ajax_wpmm_send_contact', array( $this, 'send_contact' ) ); add_action( 'otter_form_after_submit', array( $this, 'otter_add_subscriber' ) ); - if ( isset( $this->plugin_settings['design']['page_id'] ) && get_option( 'wpmm_new_look' ) ) { + if ( isset( $this->plugin_settings['design']['page_id'] ) && get_option( 'wpmm_new_look' ) && get_post_status( $this->plugin_settings['design']['page_id'] ) ) { // remember the page's original state so it can be restored when maintenance mode is disabled wpmm_record_page_state( (int) $this->plugin_settings['design']['page_id'] ); diff --git a/tests/page-state-test.php b/tests/page-state-test.php index 5369cde..af26e0f 100644 --- a/tests/page-state-test.php +++ b/tests/page-state-test.php @@ -103,7 +103,7 @@ public function test_user_page_template_is_restored_on_disable_and_reapplied_on_ * @param array $settings Value for the wpmm_settings option. * @return void */ - private function apply_template( $settings ) { + private function apply_template( $settings, $template_slug = 'coming-soon-1', $category = 'coming-soon' ) { $this->boot_plugin( $settings ); if ( ! class_exists( 'WP_Maintenance_Mode_Admin' ) ) { @@ -120,8 +120,8 @@ private function apply_template( $settings ) { $_POST = array( '_wpnonce' => wp_create_nonce( 'tab-design' ), 'source' => 'tab-design', - 'template_slug' => 'coming-soon-1', - 'category' => 'coming-soon', + 'template_slug' => $template_slug, + 'category' => $category, ); $die_handler = function () { @@ -195,6 +195,33 @@ public function test_applying_template_to_generated_page_updates_it_in_place() { $this->assertNotEmpty( get_post( $page_id )->post_content ); } + public function test_applying_unknown_template_is_rejected() { + $pages_before = count( get_posts( array( 'post_type' => 'page', 'post_status' => 'any', 'numberposts' => -1 ) ) ); + + $this->apply_template( $this->make_settings( 0, 0 ), '../../nonexistent', 'coming-soon' ); + + $settings = get_option( 'wpmm_settings' ); + $pages_after = count( get_posts( array( 'post_type' => 'page', 'post_status' => 'any', 'numberposts' => -1 ) ) ); + + $this->assertSame( 0, (int) $settings['design']['page_id'], 'selection must not change' ); + $this->assertSame( $pages_before, $pages_after, 'no page must be created' ); + } + + public function test_enabled_mode_with_deleted_page_writes_nothing() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + wp_delete_post( $page_id, true ); + + $this->boot_plugin( $this->make_settings( 1, $page_id ) ); + + $this->assertFalse( metadata_exists( 'post', $page_id, '_wp_page_template' ) ); + $this->assertFalse( metadata_exists( 'post', $page_id, '_wpmm_original_status' ) ); + } + public function test_recording_twice_does_not_poison_the_original_state() { $page_id = self::factory()->post->create( array( From 820e2b3364a4ced56bb9cb93952cb3e091c977ba Mon Sep 17 00:00:00 2001 From: Alexia Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:56:53 +0300 Subject: [PATCH 4/8] fix: never resurrect or re-trash a trashed maintenance page selection Restoring the selected page's recorded state now leaves trashed pages alone (trashing is always user intent; the plugin never trashes), and enabling maintenance mode with a trashed page selected records nothing, so untrashing the page later cannot snap it back to trash. Refs #523 Co-Authored-By: Claude Fable 5 --- includes/classes/wp-maintenance-mode.php | 4 ++- includes/functions/helpers.php | 5 ++++ tests/page-state-test.php | 33 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/includes/classes/wp-maintenance-mode.php b/includes/classes/wp-maintenance-mode.php index 5775aa3..d98553a 100644 --- a/includes/classes/wp-maintenance-mode.php +++ b/includes/classes/wp-maintenance-mode.php @@ -97,7 +97,9 @@ private function __construct() { add_action( 'wp_ajax_wpmm_send_contact', array( $this, 'send_contact' ) ); add_action( 'otter_form_after_submit', array( $this, 'otter_add_subscriber' ) ); - if ( isset( $this->plugin_settings['design']['page_id'] ) && get_option( 'wpmm_new_look' ) && get_post_status( $this->plugin_settings['design']['page_id'] ) ) { + $maintenance_page_status = isset( $this->plugin_settings['design']['page_id'] ) ? get_post_status( $this->plugin_settings['design']['page_id'] ) : false; + + if ( $maintenance_page_status && $maintenance_page_status !== 'trash' && get_option( 'wpmm_new_look' ) ) { // remember the page's original state so it can be restored when maintenance mode is disabled wpmm_record_page_state( (int) $this->plugin_settings['design']['page_id'] ); diff --git a/includes/functions/helpers.php b/includes/functions/helpers.php index f70d2fb..765a168 100644 --- a/includes/functions/helpers.php +++ b/includes/functions/helpers.php @@ -547,6 +547,11 @@ function wpmm_restore_page_state( $page_id ) { return; } + // a trashed page reflects user intent; the plugin never trashes, so leave it alone + if ( get_post_status( $page_id ) === 'trash' ) { + return; + } + if ( get_post_status( $page_id ) !== $original_status ) { wp_update_post( array( diff --git a/tests/page-state-test.php b/tests/page-state-test.php index af26e0f..91f30ed 100644 --- a/tests/page-state-test.php +++ b/tests/page-state-test.php @@ -222,6 +222,39 @@ public function test_enabled_mode_with_deleted_page_writes_nothing() { $this->assertFalse( metadata_exists( 'post', $page_id, '_wpmm_original_status' ) ); } + public function test_trashed_page_is_not_resurrected_on_disable() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + + wpmm_record_page_state( $page_id ); + + // The user deliberately trashes the page while it is selected. + wp_trash_post( $page_id ); + + $this->boot_plugin( $this->make_settings( 0, $page_id ) ); + do_action( 'init' ); + + $this->assertSame( 'trash', get_post_status( $page_id ) ); + } + + public function test_enabled_mode_with_trashed_page_records_nothing() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + wp_trash_post( $page_id ); + + $this->boot_plugin( $this->make_settings( 1, $page_id ) ); + + $this->assertFalse( metadata_exists( 'post', $page_id, '_wpmm_original_status' ) ); + } + public function test_recording_twice_does_not_poison_the_original_state() { $page_id = self::factory()->post->create( array( From f79bbc554abbfbe5087fb79f40e23419b0dc3be1 Mon Sep 17 00:00:00 2001 From: Alexia Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:00:45 +0300 Subject: [PATCH 5/8] test: cover the full issue #523 workflow through the select-page handler Selecting an existing published page via the real AJAX handler, enabling and disabling maintenance mode must leave the page public with its original content and template. Verified to fail against the unfixed code. Refs #523 Co-Authored-By: Claude Fable 5 --- tests/page-state-test.php | 75 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/page-state-test.php b/tests/page-state-test.php index 91f30ed..41080e8 100644 --- a/tests/page-state-test.php +++ b/tests/page-state-test.php @@ -147,6 +147,81 @@ private function apply_template( $settings, $template_slug = 'coming-soon-1', $c $_POST = array(); } + /** + * Simulate the select-page AJAX call the Design tab dropdown fires. + * + * @param array $settings Value for the wpmm_settings option. + * @param int $page_id Page being selected. + * @return void + */ + 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(); + + $_POST = array( + '_wpnonce' => wp_create_nonce( 'tab-design' ), + 'page_id' => (string) $page_id, + ); + + $die_handler = function () { + return function () { + throw new WPDieException( 'json response sent' ); + }; + }; + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( 'wp_die_ajax_handler', $die_handler ); + + ob_start(); + try { + $admin->select_page(); + } catch ( WPDieException $e ) { + // wp_send_json_*() ends the request with wp_die(); expected. + } + ob_end_clean(); + + remove_filter( 'wp_doing_ajax', '__return_true' ); + remove_filter( 'wp_die_ajax_handler', $die_handler ); + $_POST = array(); + } + + public function test_issue_523_workflow_leaves_selected_homepage_public() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_content' => 'Real homepage content.', + ) + ); + + // 1. The user selects their existing homepage in Design > Select page. + $this->select_page( $this->make_settings( 0, 0 ), $page_id ); + + $settings = get_option( 'wpmm_settings' ); + $this->assertSame( $page_id, (int) $settings['design']['page_id'] ); + + // 2. Maintenance mode is enabled... + $this->boot_plugin( $this->make_settings( 1, $page_id ) ); + + // 3. ...then disabled, and another request runs the init callback. + $this->boot_plugin( $this->make_settings( 0, $page_id ) ); + do_action( 'init' ); + + // The homepage is publicly available in its original state (issue #523). + $this->assertSame( 'publish', get_post_status( $page_id ) ); + $this->assertSame( 'Real homepage content.', get_post( $page_id )->post_content ); + $this->assertSame( '', get_post_meta( $page_id, '_wp_page_template', true ) ); + } + public function test_applying_template_to_user_page_creates_new_page_instead_of_overwriting() { $page_id = self::factory()->post->create( array( From 41961beb4360e545a7ffe7f0d664e809b28da4b8 Mon Sep 17 00:00:00 2001 From: Alexia Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:09:29 +0300 Subject: [PATCH 6/8] ci: run PHPUnit on PHP 7.4, required by the latest WordPress test suite The job installed the latest WordPress test suite (currently 7.0.1, which requires PHP 7.4+) on PHP 7.2, so the suite aborted before running any test. The phpunit:7.5.20 tool pin was unused: composer run-script resolves vendor/bin/phpunit from the lockfile. Co-Authored-By: Claude Fable 5 --- .github/workflows/test-php.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index 03801ea..1dbaabb 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -51,9 +51,9 @@ jobs: - name: Setup PHP version uses: shivammathur/setup-php@v2 with: - php-version: '7.2' + php-version: '7.4' extensions: simplexml, mysql - tools: phpunit:7.5.20, phpunit-polyfills + tools: phpunit-polyfills - name: Checkout source code uses: actions/checkout@v2 - name: Install Subversion From 383dfda2a5b42d8b9cb47888d4a1b6fc8188a52d Mon Sep 17 00:00:00 2001 From: Alexia Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:47:02 +0300 Subject: [PATCH 7/8] test: drop the issue number from the test name and comments Co-Authored-By: Claude Fable 5 --- tests/page-state-test.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/page-state-test.php b/tests/page-state-test.php index 41080e8..e6c7e08 100644 --- a/tests/page-state-test.php +++ b/tests/page-state-test.php @@ -1,6 +1,6 @@ post->create( array( 'post_type' => 'page', @@ -216,7 +216,7 @@ public function test_issue_523_workflow_leaves_selected_homepage_public() { $this->boot_plugin( $this->make_settings( 0, $page_id ) ); do_action( 'init' ); - // The homepage is publicly available in its original state (issue #523). + // The homepage is publicly available in its original state. $this->assertSame( 'publish', get_post_status( $page_id ) ); $this->assertSame( 'Real homepage content.', get_post( $page_id )->post_content ); $this->assertSame( '', get_post_meta( $page_id, '_wp_page_template', true ) ); From e8a06d6b3812dbce5fb5d8581d8ef8c867ebaaf0 Mon Sep 17 00:00:00 2001 From: Alexia Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:09:15 +0300 Subject: [PATCH 8/8] fix: restore abandoned selections, clean up trashed pages, record atomically Addresses the Copilot review on #524: - select_page() restores the previously selected page before switching away from it, so the old page never keeps the maintenance template - restoring a trashed page keeps it in the trash but hands back the template and clears the record, so untrashing brings it back clean - recording claims the record with add_post_meta( ..., true ) so a racing request cannot capture the plugin-modified state as original Co-Authored-By: Claude Fable 5 --- .../classes/wp-maintenance-mode-admin.php | 8 +++ includes/functions/helpers.php | 24 ++++--- tests/page-state-test.php | 71 +++++++++++++++++++ 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/includes/classes/wp-maintenance-mode-admin.php b/includes/classes/wp-maintenance-mode-admin.php index 5a95842..b8c4424 100644 --- a/includes/classes/wp-maintenance-mode-admin.php +++ b/includes/classes/wp-maintenance-mode-admin.php @@ -688,6 +688,14 @@ public function select_page() { $page_id = isset( $_POST['page_id'] ) ? absint( $_POST['page_id'] ) : 0; + $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 ); + } + $this->plugin_settings['design']['page_id'] = $page_id; if ( $page_id ) { diff --git a/includes/functions/helpers.php b/includes/functions/helpers.php index 765a168..8e31af5 100644 --- a/includes/functions/helpers.php +++ b/includes/functions/helpers.php @@ -525,12 +525,21 @@ function sanitize_hex_color( $color ) { * @param int $page_id page ID */ function wpmm_record_page_state( $page_id ) { - if ( get_post_meta( $page_id, '_wpmm_original_status', true ) ) { + $status = get_post_status( $page_id ); + + if ( ! $status ) { + return; + } + + $template = get_post_meta( $page_id, '_wp_page_template', true ); + + // the unique flag makes the status key a first-write-wins claim on the + // record, so a racing request cannot save the plugin-modified state + if ( ! add_post_meta( $page_id, '_wpmm_original_status', $status, true ) ) { return; } - update_post_meta( $page_id, '_wpmm_original_status', get_post_status( $page_id ) ); - update_post_meta( $page_id, '_wpmm_original_template', get_post_meta( $page_id, '_wp_page_template', true ) ); + update_post_meta( $page_id, '_wpmm_original_template', $template ); } /** @@ -547,12 +556,9 @@ function wpmm_restore_page_state( $page_id ) { return; } - // a trashed page reflects user intent; the plugin never trashes, so leave it alone - if ( get_post_status( $page_id ) === 'trash' ) { - return; - } - - if ( get_post_status( $page_id ) !== $original_status ) { + // a trashed page reflects user intent; the plugin never trashes, so keep the + // status but still hand back the template and clear the record below + if ( get_post_status( $page_id ) !== 'trash' && get_post_status( $page_id ) !== $original_status ) { wp_update_post( array( 'ID' => $page_id, diff --git a/tests/page-state-test.php b/tests/page-state-test.php index e6c7e08..a3b78ac 100644 --- a/tests/page-state-test.php +++ b/tests/page-state-test.php @@ -222,6 +222,77 @@ public function test_select_page_workflow_leaves_selected_homepage_public() { $this->assertSame( '', get_post_meta( $page_id, '_wp_page_template', true ) ); } + public function test_changing_selection_restores_the_previously_selected_page() { + $first_page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + update_post_meta( $first_page_id, '_wp_page_template', 'custom-template.php' ); + + $second_page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + + $this->select_page( $this->make_settings( 0, 0 ), $first_page_id ); + $this->assertSame( 'templates/wpmm-page-template.php', get_post_meta( $first_page_id, '_wp_page_template', true ) ); + + $this->select_page( $this->make_settings( 0, $first_page_id ), $second_page_id ); + + // The first page is handed back with its own template, and its record is cleared. + $this->assertSame( 'custom-template.php', get_post_meta( $first_page_id, '_wp_page_template', true ) ); + $this->assertFalse( metadata_exists( 'post', $first_page_id, '_wpmm_original_status' ) ); + + $settings = get_option( 'wpmm_settings' ); + $this->assertSame( $second_page_id, (int) $settings['design']['page_id'] ); + } + + public function test_clearing_selection_restores_the_previously_selected_page() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'private', + ) + ); + + $this->select_page( $this->make_settings( 0, 0 ), $page_id ); + + // Simulate the state an active maintenance mode leaves the page in. + wp_publish_post( $page_id ); + + $this->select_page( $this->make_settings( 0, $page_id ), 0 ); + + $this->assertSame( 'private', get_post_status( $page_id ) ); + $this->assertFalse( metadata_exists( 'post', $page_id, '_wpmm_original_status' ) ); + } + + public function test_restoring_a_trashed_page_keeps_trash_but_clears_template_and_record() { + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + update_post_meta( $page_id, '_wp_page_template', 'custom-template.php' ); + + wpmm_record_page_state( $page_id ); + update_post_meta( $page_id, '_wp_page_template', 'templates/wpmm-page-template.php' ); + + // The user deliberately trashes the page, then the plugin hands it back. + wp_trash_post( $page_id ); + wpmm_restore_page_state( $page_id ); + + // The status is untouched, but the page no longer carries plugin state. + $this->assertSame( 'trash', get_post_status( $page_id ) ); + $this->assertSame( 'custom-template.php', get_post_meta( $page_id, '_wp_page_template', true ) ); + $this->assertFalse( metadata_exists( 'post', $page_id, '_wpmm_original_status' ) ); + $this->assertFalse( metadata_exists( 'post', $page_id, '_wpmm_original_template' ) ); + } + public function test_applying_template_to_user_page_creates_new_page_instead_of_overwriting() { $page_id = self::factory()->post->create( array(