self::ID, 'name' => __( 'Maintenance mode', 'forensix-digital' ), 'description' => __( 'Shows visitors a "we will be right back" or "coming soon" page while you work on the site. Everyone who is signed in still sees the real site, and your legal pages stay public.', 'forensix-digital' ), 'category' => 'tools', 'edition' => Edition::FREE, 'features' => array( 'C-06' => Edition::FREE, ), 'deactivation_effects' => array( __( 'The site is open to everyone again, even if maintenance mode was switched on.', 'forensix-digital' ), __( 'Your saved texts stay.', 'forensix-digital' ), ), 'factory' => static fn(): self => new self(), ) ); } /** * Settings service with the scope declared. */ private static function settings(): Settings { $settings = Plugin::container()->get( Settings::class ); $settings->register( MaintenanceConfig::SCOPE, MaintenanceConfig::defaults(), array( MaintenanceConfig::class, 'sanitize' ) ); return $settings; } /** * Hooks the app while it is active. */ public function register(): void { add_action( 'template_redirect', array( $this, 'close_site' ), 0 ); add_action( 'admin_bar_menu', array( $this, 'admin_bar' ), 100 ); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_filter( 'forensix_admin_screens', array( $this, 'add_screen' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_media' ) ); } /** * Loads the media library picker on the maintenance screen, for the logo and the image. */ public function enqueue_media(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only decides which scripts load. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; if ( 'forensix-maintenance' === $page && current_user_can( Capabilities::MANAGE_SETTINGS ) ) { wp_enqueue_media(); } } /** * Answers visitors with the closed page when the mode is on. */ public function close_site(): void { $values = self::settings()->all( MaintenanceConfig::SCOPE ); // People who manage the site can look at the closed page through ?forensix_closed_preview=1, even while it is off. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only preview for the owner. $preview = isset( $_GET['forensix_closed_preview'] ) && current_user_can( Capabilities::MANAGE_SETTINGS ); if ( ! $preview && ( ! self::closed_now( $values ) || self::request_passes() ) ) { return; } if ( $preview ) { $values['enabled'] = true; } elseif ( self::may_pass( $values ) ) { // A visitor who passes without being signed in must not fill a page cache with the real site. if ( ! is_user_logged_in() ) { self::no_cache(); } return; } if ( ! $preview && self::page_is_public( $values ) ) { return; } $coming_soon = MaintenanceConfig::MODE_COMING_SOON === $values['mode']; self::no_cache(); if ( ! $coming_soon ) { /** * Seconds after which search engines should try again (the Retry-After header). * * @param int $seconds Seconds, one hour by default. * @param array $values Saved settings. */ $retry = (int) apply_filters( 'forensix_maintenance_retry_after', 3600, $values ); header( 'Retry-After: ' . max( 60, $retry ) ); } else { header( 'X-Robots-Tag: noindex, nofollow' ); } if ( (int) $values['page_id'] > 0 && $this->show_own_page( (int) $values['page_id'], $coming_soon ) ) { exit; } status_header( $coming_soon ? 200 : 503 ); header( 'Content-Type: text/html; charset=utf-8' ); /** * Extra parts of the built-in closed page, for example a countdown or a signup form. * * @param array $parts HTML for the head ("head") and below the message ("after"). Must be escaped. * @param array $values Saved settings. */ $parts = (array) apply_filters( 'forensix_maintenance_page_parts', self::empty_parts(), $values ); $extra = array( 'logo' => self::image_url( (int) $values['logo_id'], 'medium' ), 'image' => self::image_url( (int) $values['image_id'], 'full' ), 'image_style' => $values['image_style'], 'legal' => ! empty( $values['legal_public'] ) ? self::legal_links() : array(), 'head' => (string) ( $parts['head'] ?? '' ), 'after' => '' !== (string) ( $parts['after'] ?? '' ) ? '
' . (string) $parts['after'] . '
' : '', ); foreach ( array_keys( MaintenanceConfig::COLOURS ) as $key ) { $extra[ $key ] = $values[ $key ]; } $extra['styles'] = self::printed_styles( MaintenancePage::css( $extra ), $values ); $page = MaintenancePage::html( get_bloginfo( 'name' ), MaintenanceConfig::title( $values ), MaintenanceConfig::message( $values ), get_bloginfo( 'language' ), $coming_soon, $extra ); echo $page; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped inside MaintenancePage; add-on parts are escaped by the add-on. exit; } /** * Puts the page's CSS into the WordPress style queue and returns the printed tags. The closed * page is a document of its own without the theme, so it prints only its own handles. * * @param string $css The page's CSS. * @param array $values Saved settings. */ private static function printed_styles( string $css, array $values ): string { wp_register_style( self::STYLE, false, array(), FORENSIX_VERSION ); wp_add_inline_style( self::STYLE, $css ); /** * Runs before the closed page prints its styles. Add-ons register their own styles here, * or add CSS to the fxd-maintenance handle with wp_add_inline_style(). * * @param array $values Saved settings. */ do_action( 'forensix_maintenance_enqueue_styles', $values ); /** * Style handles the closed page prints. * * @param string[] $handles Handles. * @param array $values Saved settings. */ $handles = array_values( array_filter( (array) apply_filters( 'forensix_maintenance_style_handles', array( self::STYLE ), $values ), 'is_string' ) ); ob_start(); wp_print_styles( $handles ); return (string) ob_get_clean(); } /** * Empty add-on parts of the built-in page. * * @return array */ private static function empty_parts(): array { return array( 'head' => '', 'after' => '', ); } /** * Whether visitors get the closed page right now. Add-ons can add a schedule. * * @param array $values Saved settings. */ public static function closed_now( array $values ): bool { if ( empty( $values['enabled'] ) ) { return false; } /** * Whether the site is closed right now while the switch is on. * * @param bool $closed True by default. * @param array $values Saved settings. */ return (bool) apply_filters( 'forensix_maintenance_closed', true, $values ); } /** * Requests that are never answered with the closed page: REST, AJAX, cron, XML-RPC, robots.txt. */ private static function request_passes(): bool { if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || is_robots() ) { return true; } return ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ); } /** * Tells caching plugins and browsers not to keep this response. */ private static function no_cache(): void { if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Standard constant read by caching plugins. } nocache_headers(); } /** * Whether this visitor still sees the real site: anyone signed in, an allowed address, or * whatever an add-on allows (for example a preview link). * * @param array $values Saved settings. */ private static function may_pass( array $values ): bool { if ( is_user_logged_in() ) { return true; } $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( '' !== $ip && in_array( $ip, (array) $values['allowed_ips'], true ) ) { return true; } /** * Whether a visitor who is not signed in may see the real site. * * @param bool $pass False by default. * @param array $values Saved settings. */ return (bool) apply_filters( 'forensix_maintenance_may_pass', false, $values ); } /** * Whether the requested page stays public: legal pages and the pages on the allowed list. * * @param array $values Saved settings. */ private static function page_is_public( array $values ): bool { if ( ! is_page() ) { return false; } $id = (int) get_queried_object_id(); if ( in_array( $id, array_map( 'intval', (array) $values['allowed_pages'] ), true ) ) { return true; } return ! empty( $values['legal_public'] ) && in_array( $id, self::legal_page_ids(), true ); } /** * Published legal pages: the WordPress privacy page, the WooCommerce terms page and pages * with a usual legal address (imprint, terms, privacy, withdrawal) in English, German and Spanish. * * @return int[] */ public static function legal_page_ids(): array { $ids = array(); foreach ( array( (int) get_option( 'wp_page_for_privacy_policy' ), (int) get_option( 'woocommerce_terms_page_id' ) ) as $id ) { if ( $id > 0 && 'publish' === get_post_status( $id ) ) { $ids[] = $id; } } /** * Page slugs that count as legal pages and stay public while the site is closed. * * @param string[] $slugs Slugs. */ $slugs = (array) apply_filters( 'forensix_maintenance_legal_slugs', array( 'imprint', 'legal-notice', 'legal', 'privacy', 'privacy-policy', 'cookie-policy', 'cookies', 'terms', 'terms-and-conditions', 'terms-of-service', 'withdrawal', 'impressum', 'datenschutz', 'datenschutzerklaerung', 'agb', 'widerruf', 'widerrufsbelehrung', 'aviso-legal', 'politica-de-privacidad', 'privacidad', 'politica-de-cookies', 'terminos-y-condiciones', 'condiciones-generales', 'desistimiento' ) ); $found = get_posts( array( 'post_type' => 'page', 'post_status' => 'publish', 'post_name__in' => array_map( 'sanitize_title', $slugs ), 'fields' => 'ids', 'posts_per_page' => 20, 'no_found_rows' => true, ) ); foreach ( $found as $id ) { $ids[] = (int) $id; } return array_values( array_unique( $ids ) ); } /** * Footer links of the closed page: title => address of each legal page. * * @return array */ private static function legal_links(): array { $links = array(); foreach ( array_slice( self::legal_page_ids(), 0, 6 ) as $id ) { $url = get_permalink( $id ); if ( false !== $url ) { $links[ get_the_title( $id ) ] = $url; } } return $links; } /** * Address of an image from the media library, or an empty string. * * @param int $id Attachment id. * @param string $size Image size. */ private static function image_url( int $id, string $size ): string { if ( $id <= 0 ) { return ''; } $url = wp_get_attachment_image_url( $id, $size ); return is_string( $url ) ? $url : ''; } /** * Shows a page built in the block editor or a page builder instead of the built-in page, in the * theme's own template. * * @param int $page_id Page id. * @param bool $coming_soon Whether the coming-soon mode is on. * @return bool False when the page cannot be shown, so the built-in page is used. */ private function show_own_page( int $page_id, bool $coming_soon ): bool { $page = get_post( $page_id ); if ( ! $page instanceof \WP_Post || 'page' !== $page->post_type || 'trash' === $page->post_status || post_password_required( $page ) ) { return false; } $query = new \WP_Query( array( 'page_id' => $page_id, 'post_status' => array( 'publish', 'private', 'draft', 'pending', 'future' ), ) ); if ( ! $query->have_posts() ) { return false; } // The closed page becomes the main query, so the theme and page builders render it as usual. $GLOBALS['wp_query'] = $query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Replaces the main query with the closed page on purpose. $GLOBALS['wp_the_query'] = $query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Same. $GLOBALS['post'] = $page; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Same. setup_postdata( $page ); if ( $coming_soon ) { add_filter( 'wp_robots', 'wp_robots_no_robots' ); } status_header( $coming_soon ? 200 : 503 ); $template = get_page_template(); if ( '' === $template ) { $template = get_singular_template(); } if ( '' === $template ) { $template = get_index_template(); } $template = (string) apply_filters( 'template_include', $template ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook, so page builders can use their own templates. if ( '' === $template || ! is_readable( $template ) ) { return false; } include $template; return true; } /** * Reminds people who are signed in that visitors see the closed page. * * @param \WP_Admin_Bar $bar Admin bar. */ public function admin_bar( \WP_Admin_Bar $bar ): void { $values = self::settings()->all( MaintenanceConfig::SCOPE ); if ( ! current_user_can( Capabilities::MANAGE_SETTINGS ) || empty( $values['enabled'] ) ) { return; } $bar->add_node( array( 'id' => 'fxd-maintenance', 'title' => self::closed_now( $values ) ? __( 'Maintenance mode is on', 'forensix-digital' ) : __( 'Maintenance mode is scheduled', 'forensix-digital' ), 'href' => admin_url( 'admin.php?page=forensix-maintenance' ), ) ); } /** * Registers the REST routes. */ public function register_routes(): void { $can = static fn(): bool => current_user_can( Capabilities::MANAGE_SETTINGS ); register_rest_route( Api::NAMESPACE, '/maintenance', array( array( 'methods' => 'GET', 'callback' => array( $this, 'show' ), 'permission_callback' => $can, ), array( 'methods' => 'POST', 'callback' => array( $this, 'save' ), 'permission_callback' => $can, ), ) ); } /** * The saved settings and the address of the current user. */ public function show(): \WP_REST_Response { return new \WP_REST_Response( self::present( self::settings()->all( MaintenanceConfig::SCOPE ) ) ); } /** * Everything the screen shows next to the saved settings. * * @param array $values Saved settings. * @return array */ private static function present( array $values ): array { $pages = array(); foreach ( get_posts( array( 'post_type' => 'page', 'post_status' => array( 'publish', 'private', 'draft' ), 'posts_per_page' => 300, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- Admin screen only, page titles for the pickers. 'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true, ) ) as $page ) { $pages[] = array( 'id' => (int) $page->ID, 'title' => '' !== $page->post_title ? $page->post_title : __( '(no title)', 'forensix-digital' ), 'status' => $page->post_status, ); } $legal = array(); foreach ( self::legal_page_ids() as $id ) { $legal[] = get_the_title( $id ); } return array( 'values' => $values, 'closed' => self::closed_now( $values ), 'my_ip' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) ) : '', 'pages' => $pages, 'legal' => $legal, 'logo_url' => self::image_url( (int) $values['logo_id'], 'medium' ), 'image_url' => self::image_url( (int) $values['image_id'], 'medium' ), 'preview' => add_query_arg( 'forensix_closed_preview', '1', home_url( '/' ) ), ); } /** * Saves the settings. * * @param \WP_REST_Request $request Request. * @phpstan-param \WP_REST_Request> $request */ public function save( \WP_REST_Request $request ): \WP_REST_Response { $settings = self::settings(); $settings->update( MaintenanceConfig::SCOPE, (array) $request->get_json_params() ); $values = $settings->all( MaintenanceConfig::SCOPE ); if ( empty( $values['enabled'] ) ) { $message = __( 'Saved. The site is open to everyone.', 'forensix-digital' ); } elseif ( self::closed_now( $values ) ) { $message = __( 'Saved. Visitors now see the closed page. Everyone who is signed in still sees the site.', 'forensix-digital' ); } else { $message = __( 'Saved. The site stays open until the scheduled start.', 'forensix-digital' ); } return new \WP_REST_Response( array( 'ok' => true, 'message' => $message, ) + self::present( $values ) ); } /** * Adds the page to the Forensix menu. * * @param array $screens Screens. * @phpstan-param array $screens * @return array */ public function add_screen( array $screens ): array { $screens['forensix-maintenance'] = array( 'key' => 'maintenance', 'title' => __( 'Maintenance mode', 'forensix-digital' ), 'capability' => Capabilities::MANAGE_SETTINGS, ); return $screens; } /** * Nothing to set up. The mode stays off until the owner turns it on. */ public function activate(): void {} /** * Removes the hooks, which opens the site. */ public function deactivate(): void { remove_action( 'template_redirect', array( $this, 'close_site' ), 0 ); remove_action( 'admin_bar_menu', array( $this, 'admin_bar' ), 100 ); remove_action( 'rest_api_init', array( $this, 'register_routes' ) ); remove_filter( 'forensix_admin_screens', array( $this, 'add_screen' ) ); remove_action( 'admin_enqueue_scripts', array( $this, 'enqueue_media' ) ); } /** * Deletes the saved settings. */ public function delete_data(): void { delete_option( Settings::OPTION_PREFIX . MaintenanceConfig::SCOPE ); } }