$this->slug(), 'risk' => 'medium', 'summary' => 'Turns the maintenance page for visitors on or off. It always auto-expires — ' . 'the site can never stay locked. Logged-in administrators keep seeing the normal site. ' . 'Use before risky changes; turn off when done.', 'annotations' => array( 'destructive' => false, 'idempotent' => true, ), // Prefill for the admin Run panel (v1.4); agents may copy it too. 'example_input' => array( 'enable' => true, 'duration_minutes' => 5 ), 'input_schema' => array( 'type' => 'object', 'required' => array( 'enable' ), 'properties' => array( 'enable' => array( 'type' => 'boolean' ), 'duration_minutes' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_DURATION_MINUTES, 'default' => self::DEFAULT_DURATION_MINUTES, ), ), ), ); } public function dry_run( $atb_input ) { if ( $this->wants_enable( $atb_input ) ) { $atb_expires_at = self::compute_expires_at( $this->requested_duration( $atb_input ), time() ); return $this->output( false, true, array( 'enabled' => true, 'expires_at' => $atb_expires_at ), $this->page_cache_warnings() ); } return $this->output( false, true, array( 'enabled' => false, 'expires_at' => null ) ); } public function run( $atb_input ) { if ( $this->wants_enable( $atb_input ) ) { return $this->enable( $this->requested_duration( $atb_input ) ); } return $this->disable(); } protected function enable( $atb_duration_minutes ) { $atb_expires_at = self::compute_expires_at( $atb_duration_minutes, time() ); update_option( self::OPTION_NAME, $atb_expires_at ); // update_option() returns false for an unchanged value, so verify // the stored state instead of trusting the return value. $atb_set = (int) get_option( self::OPTION_NAME, 0 ) === $atb_expires_at; $atb_warnings = $atb_set ? $this->page_cache_warnings() : array( 'could not save the maintenance-mode option' ); return $this->output( $atb_set, false, array( 'enabled' => $atb_set, 'expires_at' => $atb_set ? $atb_expires_at : null, ), $atb_warnings ); } protected function disable() { if ( false === get_option( self::OPTION_NAME, false ) ) { return $this->output( false, false, array( 'enabled' => false, 'expires_at' => null ) ); } delete_option( self::OPTION_NAME ); $atb_deleted = false === get_option( self::OPTION_NAME, false ); $atb_warnings = $atb_deleted ? array() : array( 'could not delete the maintenance-mode option' ); return $this->output( $atb_deleted, false, array( 'enabled' => ! $atb_deleted, 'expires_at' => null ), $atb_warnings ); } /** * The M3 finding, told to the agent instead of only to the readme * (v1.5.2, review F3): a static page cache keeps serving cached pages * and hides the maintenance page from visitors until it is purged. * Detection reuses purge-all-caches's verified 12-plugin table. * * @return string[] Zero or one warning. */ public static function page_cache_warnings() { if ( ! class_exists( 'ATB_Operation_Purge_All_Caches' ) ) { return array(); } $atb_plugin = ATB_Operation_Purge_All_Caches::detect_page_cache_plugin(); if ( '' === $atb_plugin ) { return array(); } return array( 'page cache plugin detected (' . $atb_plugin . '): visitors may keep receiving cached pages ' . 'instead of the maintenance page until the page cache is cleared — call ' . 'agent-toolbelt/purge-all-caches right after enabling', ); } protected function wants_enable( $atb_input ) { return is_array( $atb_input ) && ! empty( $atb_input['enable'] ); } protected function requested_duration( $atb_input ) { $atb_minutes = ( is_array( $atb_input ) && isset( $atb_input['duration_minutes'] ) ) ? $atb_input['duration_minutes'] : self::DEFAULT_DURATION_MINUTES; return self::clamp_duration( $atb_minutes ); } /** * Clamps a requested duration to [1, MAX_DURATION_MINUTES] (spec §3.4). * * @return int */ public static function clamp_duration( $atb_minutes ) { $atb_minutes = (int) $atb_minutes; if ( $atb_minutes < 1 ) { return 1; } if ( $atb_minutes > self::MAX_DURATION_MINUTES ) { return self::MAX_DURATION_MINUTES; } return $atb_minutes; } /** * Pure duration → expiry-timestamp math (unit-testable without touching * the options store or wall-clock time). * * @param int $atb_minutes Requested duration (clamped internally). * @param int $atb_now Reference "now" timestamp. * @return int Wall-clock expiry timestamp. */ public static function compute_expires_at( $atb_minutes, $atb_now ) { return (int) $atb_now + ( self::clamp_duration( $atb_minutes ) * MINUTE_IN_SECONDS ); } /** * Seconds left in the window for a stored option value: 0 when the * option is absent (false), malformed, or already expired. * * @param mixed $atb_stored Raw option value (expiry timestamp or false). * @param int $atb_now Reference "now" timestamp. * @return int */ public static function seconds_remaining( $atb_stored, $atb_now ) { if ( ! is_numeric( $atb_stored ) ) { return 0; } return max( 0, (int) $atb_stored - (int) $atb_now ); } /** * template_redirect callback: serves the 503 maintenance page to * front-end visitors while the window is open. Admin, login, REST, * AJAX and WP-CLI requests never reach template_redirect, so the * agent (and the site owner) can always turn the mode back off. */ public static function maybe_show_maintenance_page() { $atb_stored = get_option( self::OPTION_NAME, false ); if ( false === $atb_stored ) { return; } $atb_remaining = self::seconds_remaining( $atb_stored, time() ); if ( $atb_remaining <= 0 ) { // The window expired on its own — clean up the stale option. delete_option( self::OPTION_NAME ); return; } if ( current_user_can( 'manage_options' ) ) { return; } // The update-plugin loopback health probe must see the real page: // it decides whether an update broke the site, and a maintenance // screen would read as "broken" and trigger a needless rollback. // Presence check only — the value is never used. // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['atb_health'] ) ) { return; } if ( ! headers_sent() ) { header( 'Retry-After: ' . $atb_remaining ); } wp_die( esc_html__( 'Briefly unavailable for scheduled maintenance. Check back in a minute.', 'agent-toolbelt' ), esc_html__( 'Maintenance', 'agent-toolbelt' ), array( 'response' => 503 ) ); } } }