maintenance-1.0.0-beta1/src/EventSubscriber/MaintenanceSubscriber.php
src/EventSubscriber/MaintenanceSubscriber.php
<?php
namespace Drupal\maintenance\EventSubscriber;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Component\Utility\Html;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Path\CurrentPathStack;
use Drupal\Core\Render\BareHtmlPageRendererInterface;
use Drupal\Core\Routing\RouteMatch;
use Drupal\Core\Routing\TrustedRedirectResponse;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Site\MaintenanceModeEvents;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\maintenance\Maintenance;
use Drupal\maintenance\MaintenanceManagerInterface;
use Drupal\node\NodeInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Subscribes to maintenance mode events and handles custom logic.
*
* @package Drupal\maintenance\EventSubscriber
*/
class MaintenanceSubscriber implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* The maintenance mode.
*
* @var \Drupal\maintenance\Maintenance
*/
protected Maintenance $maintenanceMode;
/**
* The custom maintenance manager service.
*
* @var \Drupal\maintenance\MaintenanceManagerInterface
*/
protected MaintenanceManagerInterface $maintenance;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected ConfigFactoryInterface $config;
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected StateInterface $state;
/**
* The current user service.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected AccountInterface $account;
/**
* The current path service.
*
* @var \Drupal\Core\Path\CurrentPathStack
*/
protected CurrentPathStack $currentPath;
/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected TimeInterface $time;
/**
* The bare HTML page renderer.
*
* @var \Drupal\Core\Render\BareHtmlPageRendererInterface
*/
protected BareHtmlPageRendererInterface $bareHtmlPageRenderer;
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected EntityTypeManagerInterface $entityTypeManager;
/**
* The messenger.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected MessengerInterface $messenger;
/**
* Constructs a new MaintenanceSubscriber.
*
* @param \Drupal\maintenance\Maintenance $maintenance_mode
* The maintenance mode service.
* @param \Drupal\maintenance\MaintenanceManagerInterface $maintenance
* The custom maintenance manager service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The configuration factory.
* @param \Drupal\Core\State\StateInterface $state
* The state service.
* @param \Drupal\Core\Session\AccountInterface $account
* The current user account.
* @param \Drupal\Core\Path\CurrentPathStack $current_path
* The current path stack.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
* @param \Drupal\Core\Render\BareHtmlPageRendererInterface $bare_html_page_renderer
* The bare HTML page renderer.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger service.
*/
public function __construct(
Maintenance $maintenance_mode,
MaintenanceManagerInterface $maintenance,
ConfigFactoryInterface $config_factory,
StateInterface $state,
AccountInterface $account,
CurrentPathStack $current_path,
TimeInterface $time,
BareHtmlPageRendererInterface $bare_html_page_renderer,
EntityTypeManagerInterface $entity_type_manager,
MessengerInterface $messenger,
) {
$this->maintenanceMode = $maintenance_mode;
$this->maintenance = $maintenance;
$this->config = $config_factory;
$this->state = $state;
$this->account = $account;
$this->currentPath = $current_path;
$this->time = $time;
$this->bareHtmlPageRenderer = $bare_html_page_renderer;
$this->entityTypeManager = $entity_type_manager;
$this->messenger = $messenger;
}
/**
* Adds a response header indicating maintenance mode is active.
*
* This header can be used by proxies or monitoring systems to detect
* maintenance state and act accordingly.
*
* @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
* The response event object from the HTTP kernel.
*/
public function onRespond(ResponseEvent $event): void {
// Only handle the main request, ignore sub-requests (e.g., assets).
if (!$event->isMainRequest()) {
return;
}
// Check global maintenance mode state.
if ($this->state->get('system.maintenance_mode')) {
// Add a custom header to signal that the site is under maintenance.
$event->getResponse()->headers->set('maintenance-mode', 'on');
}
}
/**
* Overrides the HTTP status code if configured and applicable.
*
* If the override is enabled and user is not exempt, replace the
* default status code with the one from configuration (e.g., 503).
*
* @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
* The response event dispatched by the kernel.
*/
public function onKernelResponse(ResponseEvent $event): void {
// Only proceed if custom status override is enabled in config.
$config = $this->config->get('maintenance.settings');
if (!$config->get('status.enabled')) {
return;
}
// Fetch the status code to be applied (e.g. 503).
$status_code = (int) $config->get('status.code');
// Create a route match object for current request.
$route_match = RouteMatch::createFromRequest($event->getRequest());
// Get the response object from the event.
$response = $event->getResponse();
// Only override if the current route is subject to maintenance
// and the user does not have access to bypass it.
if (
$this->maintenanceMode->applies($route_match) &&
!$this->maintenanceMode->exempt($this->account)
) {
$response->setStatusCode($status_code);
}
}
/**
* Displays a maintenance page for non-exempt users.
*
* This method renders a customized maintenance page when maintenance
* mode is active and the current user does not have access.
* It supports both config-based and node-based content, optional
* redirect logic, and auto-reload options for enhanced UX.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The request event triggered by the kernel.
*/
public function onMaintenanceModeRequest(RequestEvent $event): void {
// Load full configuration for maintenance mode.
$config = $this->maintenance->getConfig();
// Determine the source of the maintenance message content,
// Supports node content, custom HTML, or fallback to default message.
switch ($config->get('content.base')) {
case 'node':
// Get node ID from settings.
$node_id = $config->get('content.node');
// If node not set, abort rendering.
if (empty($node_id)) {
return;
}
// Attempt to load node object.
/** @var \Drupal\node\NodeInterface|null $node */
$node = $this->entityTypeManager
->getStorage('node')
->load($node_id);
// If node not found, abort rendering.
if (!$node instanceof NodeInterface) {
return;
}
// Use node title and body as page content.
$title = $node->label();
$message = new FormattableMarkup($node->get('body')->value, [
'@site' => $this->config->get('system.site')->get('name'),
]);
break;
case 'html':
// Load title and body from config.
$title = $config->get('content.title');
$message = new FormattableMarkup($config->get('content.message'), [
'@site' => $this->config->get('system.site')->get('name'),
]);
break;
default:
// Load title and body from config.
$title = $this->t('Site under maintenance');
$message = $this->maintenanceMode->getSiteMaintenanceMessage();
break;
}
// Get the current request object (used for content type detection, etc.).
$request = $event->getRequest();
// If request is non-HTML (e.g., JSON, XML), return plain text response.
if ($request->getRequestFormat() !== 'html') {
$response = new Response($message, 503, ['Content-Type' => 'text/plain']);
// Calling RequestEvent::setResponse() also stops propagation of event.
$event->setResponse($response);
return;
}
// Build the render array for the maintenance page.
$build = [
'#title' => $title,
'#markup' => $message,
];
// Load access permission and settings for redirection and reload.
$has_access = $this->maintenanceMode->exempt($this->account);
// Extract redirect settings.
$redirect_enabled = $config->get('redirect.enabled');
$redirect_url = trim((string) $config->get('redirect.url'));
$redirect_delay = (int) $config->get('redirect.delay');
// If redirection is enabled and delay > 0, attach JS redirection.
if (
$redirect_enabled &&
$redirect_delay > 0 &&
!$has_access &&
!empty($redirect_url)
) {
$build['#attached']['html_head'][] = [
[
'#tag' => 'script',
'#value' => sprintf(
'setTimeout(function(){ window.location.href = "%s"; }, %d);',
htmlspecialchars($redirect_url, ENT_QUOTES, 'UTF-8'),
$redirect_delay * 1000
),
],
'maintenance_redirect_script',
];
}
// Extract reload settings.
$reload_enabled = $config->get('refresh.enabled');
$reload_mode = (int) $config->get('refresh.reload');
// Append manual reload button or auto-reload logic.
if ($reload_enabled && !$has_access) {
if ($reload_mode === 0) {
// Show reload button.
$reload_button =
'<div style="text-align:center;margin-top:30px;">
<button type="button" data-maintenance-reload class="button button--primary">
Reload Page
</button>
</div>';
$build['#markup'] .= $reload_button;
$build['#allowed_tags'] = ['div', 'button'];
$build['#attached']['library'][] = 'maintenance/maintenance.reload';
}
elseif ($reload_mode === 2) {
// Auto-reload the page after 15 seconds.
$build['#attached']['html_head'][] = [
[
'#tag' => 'script',
'#value' => 'setTimeout(function(){ location.reload(); }, 15000);',
],
'maintenance_auto_reload_script',
];
}
}
// Load the maintenance theme for bare page rendering.
drupal_maintenance_theme();
// Render the maintenance page using the bare page renderer.
$response = $this->bareHtmlPageRenderer->renderBarePage(
$build,
$title,
'maintenance_page'
);
// Set the HTTP status code to 503 (Service Unavailable).
$response->setStatusCode(503);
// Calling RequestEvent::setResponse() also stops propagation of the event.
$event->setResponse($response);
}
/**
* Performs immediate redirect during maintenance mode if required.
*
* This handles the case where redirection should happen immediately via
* HTTP 302 (without delay). JavaScript-based delayed redirection is handled
* separately inside the maintenance page rendering.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The current kernel request event.
*/
public function checkForRedirection(RequestEvent $event): void {
// Ensure we are processing the main request only.
if (!$event->isMainRequest()) {
return;
}
// Skip redirect for users with maintenance access permission.
if ($this->account->hasPermission('access site in maintenance mode')) {
return;
}
// Check if the site is currently in maintenance mode.
$maintenance_mode = $this->state->get('system.maintenance_mode');
// Retrieve maintenance module configuration.
$config = $this->config->get('maintenance.settings');
// Extract redirect settings.
$redirect_enabled = (bool) $config->get('redirect.enabled');
$redirect_url = trim((string) $config->get('redirect.url'));
$redirect_delay = (int) $config->get('redirect.delay');
// Skip redirection if:
// - Maintenance mode is not active,
// - Redirect feature is disabled,
// - No target URL is provided,
// - Redirect delay is greater than zero (handled by JS).
if (
!$maintenance_mode ||
!$redirect_enabled ||
empty($redirect_url) ||
$redirect_delay > 0
) {
return;
}
// Define system paths that should be excluded from redirection.
$excluded_paths = [
'/user',
'/user/login',
'/user/password',
];
// Get the current request path.
$current_path = $this->currentPath->getPath();
// Only redirect if the current path is not in the exclusion list.
if (!in_array($current_path, $excluded_paths, TRUE)) {
// Perform HTTP 302 temporary redirect to target URL.
$event->setResponse(new TrustedRedirectResponse($redirect_url, 302));
}
}
/**
* Handles scheduled start and end of maintenance mode.
*
* This method checks if a scheduled maintenance time is configured
* and either activates maintenance mode automatically or displays
* a warning message ahead of time.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The request event object.
*/
public function checkForScheduled(RequestEvent $event): void {
// Load configuration from manager service.
$config = $this->maintenance->getConfig();
// Skip processing if schedule feature is disabled.
if (!$config->get('schedule.enabled')) {
return;
}
// Get current Unix timestamp.
$now = $this->time->getRequestTime();
// Load scheduled start datetime string.
$start_time_str = $config->get('schedule.start');
$start_time = $start_time_str ? strtotime($start_time_str) : NULL;
// Check if Auto-disable is enabled (defaults to TRUE if not set).
$auto_disable = (bool) $config->get('schedule.automatic');
if ($start_time && $now >= $start_time) {
if (!$this->state->get('system.maintenance_mode')) {
// Turn ON maintenance mode.
$this->state->set('system.maintenance_mode', TRUE);
}
// Clear start time after activation.
$config->clear('schedule.start');
if (!$auto_disable) {
// This is a one-time schedule with no auto-disable.
// Disable the scheduling system to prevent future activation.
$config->set('schedule.enabled', FALSE);
}
// Save the updated configuration.
$config->save();
}
// Load scheduled end datetime string.
$end_time_str = $config->get('schedule.end');
$end_time = $end_time_str ? strtotime($end_time_str) : NULL;
if (
$auto_disable &&
$end_time &&
$this->state->get('system.maintenance_mode') &&
$now >= $end_time
) {
// Turn OFF maintenance mode.
$this->state->set('system.maintenance_mode', FALSE);
// Clear end time and auto-disable flag.
$config
->set('schedule.enabled', FALSE)
->set('schedule.automatic', FALSE)
->clear('schedule.end')
->save();
}
// Display message before start time using offset.
$warning_text = $config->get('schedule.warning');
// Proceed only if warning message is set and start time is valid.
if (!empty($warning_text) && $start_time) {
$offset = $config->get('schedule.offset') ?? [];
$offset_value = $offset['value'] ?? 0;
$offset_unit = $offset['unit'] ?? '';
// Validate unit and value.
if (
is_numeric($offset_value) && $offset_value > 0 &&
in_array($offset_unit, array_keys($this->maintenance->getAllowedUnits()), TRUE)
) {
// Compute timestamp for warning.
$warning_time = strtotime("-{$offset_value} {$offset_unit}", $start_time);
// Show warning only if current time reached warning window.
if ($now >= $warning_time && $now < $start_time) {
$this->messenger->addWarning(
$this->t('@message', ['@message' => Html::escape($warning_text)])
);
}
}
}
}
/**
* {@inheritdoc}
*
* Subscribes to relevant maintenance-related kernel and system events.
* Prioritization ensures this subscriber executes before Drupal Core's
* default maintenance mode handling.
*
* @return array
* An array of event listener definitions.
*/
public static function getSubscribedEvents(): array {
$events = [];
// Handle maintenance mode requests early.
// This ensures our logic overrides the default behavior.
$events[MaintenanceModeEvents::MAINTENANCE_MODE_REQUEST][] = [
'onMaintenanceModeRequest', -999,
];
// Add custom HTTP headers (e.g., `maintenance-mode: on`) to all responses.
$events[KernelEvents::RESPONSE][] = [
'onRespond', -99,
];
// Customize HTTP response code for maintenance pages.
$events[KernelEvents::RESPONSE][] = [
'onKernelResponse', 36,
];
// Check for redirect instructions before page builds.
$events[KernelEvents::REQUEST][] = [
'checkForRedirection', 45,
];
// Check if maintenance should start based on a scheduled time.
$events[KernelEvents::REQUEST][] = [
'checkForScheduled', 54,
];
return $events;
}
}
