settings = $settings; $this->templates = $templates; $this->access = $access; } /** * Hook into WordPress. Priority 10 on template_redirect runs after * WP has resolved the request but before any template output, and * template_redirect does not fire for REST/AJAX/cron requests. */ public function init() { add_action( 'template_redirect', array( $this, 'maybe_show_maintenance_page' ) ); } /** * Core gate logic. */ public function maybe_show_maintenance_page() { $this->maybe_render_admin_preview(); if ( ! $this->settings->get( 'maintenance_mode', false ) ) { return; } if ( $this->should_bypass() ) { return; } $this->render_maintenance_page(); } /** * Admin-only preview: a real, bookmarkable/refreshable URL * (home_url('/') . '?absd_um_admin_preview=1') that always renders the * maintenance page from the currently *saved* settings — regardless of * whether Maintenance Mode is actually toggled on — so an admin can * open it, save changes on the settings page, and refresh to see the * latest version. Exits on match; does nothing otherwise. */ private function maybe_render_admin_preview() { if ( empty( $_GET['absd_um_admin_preview'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, capability-gated below. return; } if ( ! current_user_can( 'manage_options' ) ) { return; } nocache_headers(); $settings = $this->settings->get_settings(); echo $this->render_template_preview( $settings, $settings['template'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already-escaped template markup, same as the real front-end render. exit; } /** * Determine whether the current request/visitor should bypass the * maintenance screen and see the normal site. * * @return bool */ private function should_bypass() { // Always allow WordPress core/system endpoints so the admin // area, login, cron and REST/AJAX keep functioning. if ( $this->is_system_request() ) { return true; } // Logged-in users who can manage the site always bypass. if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) { return true; } // Private access: token in the URL for this request, or a // previously-issued signed cookie. if ( $this->settings->get( 'private_access', false ) ) { if ( ! empty( $_GET[ ABSD_Access::QUERY_VAR ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $token = sanitize_text_field( wp_unslash( $_GET[ ABSD_Access::QUERY_VAR ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( $this->access->is_valid_token( $token ) ) { return true; } } if ( $this->access->visitor_has_bypass_cookie() ) { return true; } } /** * Final filter so other plugins/mu-plugins can add their own * bypass rules (e.g. allow specific IPs) without editing core. * * @param bool $bypass Whether to bypass the maintenance screen. */ return (bool) apply_filters( 'absd_um_bypass_maintenance', false ); } /** * Requests that must never be blocked, to avoid breaking WordPress * core functionality or creating redirect loops. * * @return bool */ private function is_system_request() { if ( is_admin() ) { return true; } if ( wp_doing_ajax() || wp_doing_cron() ) { return true; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return true; } if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { return true; } $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput $always_allow = array( 'wp-login.php', 'wp-cron.php', '/wp-json/', 'wp-admin', ); foreach ( $always_allow as $needle ) { if ( false !== strpos( $request_uri, $needle ) ) { return true; } } return false; } /** * Output the maintenance template with the correct headers and * terminate the request. */ private function render_maintenance_page() { if ( ! headers_sent() ) { status_header( 503 ); header( 'Retry-After: 3600' ); nocache_headers(); } $template_id = $this->settings->get( 'template', $this->templates->get_default_id() ); $file = $this->templates->get_template_file( $template_id ); $context = $this->build_template_context(); wp_enqueue_style( 'absd-um-base', ABSD_UM_URL . 'public/css/base.css', array(), ABSD_UM_VERSION ); wp_add_inline_style( 'absd-um-base', $this->build_inline_css( $context ) ); /** * Fires immediately before a maintenance template is rendered. * * @param string $template_id Template slug being rendered. * @param array $context Template context data. */ do_action( 'absd_um_before_render', $template_id, $context ); // Extract into local scope for simple, readable template files. // All keys are controlled by build_template_context() below. extract( $context, EXTR_SKIP ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract include $file; do_action( 'absd_um_after_render', $template_id ); exit; } /** * Render a maintenance template to an HTML string using an arbitrary * (already-sanitized, not necessarily saved) settings array. * * Used by the admin live-preview AJAX endpoint so the settings page * can show what the page will look like before the user hits Save. * Unlike render_maintenance_page(), this never sends headers, fires * the render hooks, or exits — it just returns markup. * * @param array $settings Sanitized settings array (see ABSD_Settings::sanitize_input()). * @param string $template_id Template slug to render. * @return string */ public function render_template_preview( array $settings, $template_id ) { $file = $this->templates->get_template_file( $template_id ); $context = $this->build_template_context( $settings ); wp_enqueue_style( 'absd-um-base', ABSD_UM_URL . 'public/css/base.css', array(), ABSD_UM_VERSION ); wp_add_inline_style( 'absd-um-base', $this->build_inline_css( $context ) ); // Extract into local scope for simple, readable template files. // All keys are controlled by build_template_context() below. extract( $context, EXTR_SKIP ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract ob_start(); include $file; return ob_get_clean(); } /** * Builds the per-request CSS custom properties (accent color, overlay * opacity) attached to the 'absd-um-base' handle via wp_add_inline_style() * — these two settings are chosen on the admin settings page, so they * can't be baked into the static public/css/base.css file. * * @param array $context Template context from build_template_context(). * @return string */ private function build_inline_css( array $context ) { $accent = sanitize_hex_color( $context['absd_accent_color'] ); $opacity = absint( $context['absd_overlay_opacity'] ); return sprintf( ':root{--absd-accent:%s;--absd-overlay-opacity:%s;}', $accent ? $accent : '#c9a15a', $opacity / 100 ); } /** * Build the (already-escaped-where-needed) data made available to * template files. * * @param array|null $settings Settings array to render from. Defaults * to the currently saved settings; pass an * explicit array (e.g. from the live preview * AJAX endpoint) to render unsaved values. * @return array */ private function build_template_context( array $settings = null ) { $settings = null === $settings ? $this->settings->get_settings() : wp_parse_args( $settings, ABSD_Settings::get_defaults() ); $logo_url = $settings['logo_id'] ? wp_get_attachment_image_url( $settings['logo_id'], 'medium' ) : ''; $background_url = $settings['background_id'] ? wp_get_attachment_image_url( $settings['background_id'], 'full' ) : ''; $icon_id = $this->templates->icon_exists( $settings['icon'] ?? '' ) ? $settings['icon'] : $this->templates->get_default_icon_id(); // "custom" has no SVG partial — it's a user-uploaded image instead. // Fall back to the default built-in icon if "custom" is selected // but nothing has been uploaded yet. $icon_is_custom = false; $icon_custom_url = ''; if ( 'custom' === $icon_id && ! empty( $settings['icon_custom_id'] ) ) { $custom_url = wp_get_attachment_image_url( $settings['icon_custom_id'], 'thumbnail' ); if ( $custom_url ) { $icon_is_custom = true; $icon_custom_url = $custom_url; } } $icon_file = $icon_is_custom ? '' : $this->templates->get_icon_file( 'custom' === $icon_id ? $this->templates->get_default_icon_id() : $icon_id ); return array( 'absd_heading' => $settings['heading'], 'absd_description' => $settings['description'], 'absd_small_text' => $settings['small_text'], 'absd_footer_text' => $settings['footer_text'], 'absd_retry_text' => $settings['retry_text'], 'absd_show_icon' => (bool) $settings['show_icon'], 'absd_icon_file' => $icon_file, 'absd_icon_is_custom' => $icon_is_custom, 'absd_icon_custom_url' => $icon_custom_url, 'absd_show_contact' => (bool) $settings['show_contact'], 'absd_contact_text' => $settings['contact_text'], 'absd_overlay_opacity' => absint( $settings['overlay_opacity'] ), 'absd_accent_color' => $settings['accent_color'], 'absd_logo_url' => $logo_url ? $logo_url : '', 'absd_background_url' => $background_url ? $background_url : '', ); } }