Unverified Commit b7b2a8be authored by alexpott's avatar alexpott
Browse files

Issue #3049048 by danflanagan8, ndobromirov, mglaman, bbrala, alexpott, Wim...

Issue #3049048 by danflanagan8, ndobromirov, mglaman, bbrala, alexpott, Wim Leers, gabesullice: Invalid JSON:API responses when maintenance mode is on
parent 4f4f390b
......@@ -1229,10 +1229,10 @@ services:
- { name: access_check, needs_incoming_request: TRUE }
maintenance_mode:
class: Drupal\Core\Site\MaintenanceMode
arguments: ['@state']
arguments: ['@state', '@config.factory']
maintenance_mode_subscriber:
class: Drupal\Core\EventSubscriber\MaintenanceModeSubscriber
arguments: ['@maintenance_mode', '@config.factory', '@string_translation', '@url_generator', '@current_user', '@bare_html_page_renderer', '@messenger']
arguments: ['@maintenance_mode', '@config.factory', '@string_translation', '@url_generator', '@current_user', '@bare_html_page_renderer', '@messenger', '@event_dispatcher']
tags:
- { name: event_subscriber }
route_access_response_subscriber:
......
......@@ -2,13 +2,13 @@
namespace Drupal\Core\EventSubscriber;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Render\BareHtmlPageRendererInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Render\BareHtmlPageRendererInterface;
use Drupal\Core\Routing\RouteMatch;
use Drupal\Core\Routing\UrlGeneratorInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Site\MaintenanceModeEvents;
use Drupal\Core\Site\MaintenanceModeInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
......@@ -16,6 +16,7 @@
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Maintenance mode subscriber for controller requests.
......@@ -66,6 +67,13 @@ class MaintenanceModeSubscriber implements EventSubscriberInterface {
*/
protected $messenger;
/**
* An event dispatcher instance to use for configuration events.
*
* @var \Symfony\Contracts\EventDispatcher\EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* Constructs a new MaintenanceModeSubscriber.
*
......@@ -83,8 +91,10 @@ class MaintenanceModeSubscriber implements EventSubscriberInterface {
* The bare HTML page renderer.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger.
* @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher
* The event dispatcher.
*/
public function __construct(MaintenanceModeInterface $maintenance_mode, ConfigFactoryInterface $config_factory, TranslationInterface $translation, UrlGeneratorInterface $url_generator, AccountInterface $account, BareHtmlPageRendererInterface $bare_html_page_renderer, MessengerInterface $messenger) {
public function __construct(MaintenanceModeInterface $maintenance_mode, ConfigFactoryInterface $config_factory, TranslationInterface $translation, UrlGeneratorInterface $url_generator, AccountInterface $account, BareHtmlPageRendererInterface $bare_html_page_renderer, MessengerInterface $messenger, EventDispatcherInterface $event_dispatcher = NULL) {
$this->maintenanceMode = $maintenance_mode;
$this->config = $config_factory;
$this->stringTranslation = $translation;
......@@ -92,6 +102,11 @@ public function __construct(MaintenanceModeInterface $maintenance_mode, ConfigFa
$this->account = $account;
$this->bareHtmlPageRenderer = $bare_html_page_renderer;
$this->messenger = $messenger;
if (!$event_dispatcher) {
@trigger_error('Calling MaintenanceModeSubscriber::__construct() without the $event_dispatcher argument is deprecated in drupal:9.4.0 and the $event_dispatcher argument will be required in drupal:10.0.0. See https://www.drupal.org/node/3255799', E_USER_DEPRECATED);
$event_dispatcher = \Drupal::service('event_dispatcher');
}
$this->eventDispatcher = $event_dispatcher;
}
/**
......@@ -108,20 +123,8 @@ public function onKernelRequestMaintenance(RequestEvent $event) {
\Drupal::service('page_cache_kill_switch')->trigger();
if (!$this->maintenanceMode->exempt($this->account)) {
// Deliver the 503 page if the site is in maintenance mode and the
// logged in user is not allowed to bypass it.
// If the request format is not 'html' then show default maintenance
// mode page else show a text/plain page with maintenance message.
if ($request->getRequestFormat() !== 'html') {
$response = new Response($this->getSiteMaintenanceMessage(), 503, ['Content-Type' => 'text/plain']);
$event->setResponse($response);
return;
}
drupal_maintenance_theme();
$response = $this->bareHtmlPageRenderer->renderBarePage(['#markup' => $this->getSiteMaintenanceMessage()], $this->t('Site under maintenance'), 'maintenance_page');
$response->setStatusCode(503);
$event->setResponse($response);
// When the account is not exempt, other subscribers handle request.
$this->eventDispatcher->dispatch($event, MaintenanceModeEvents::MAINTENANCE_MODE_REQUEST);
}
else {
// Display a message if the logged in user has access to the site in
......@@ -140,15 +143,24 @@ public function onKernelRequestMaintenance(RequestEvent $event) {
}
/**
* Gets the site maintenance message.
* Returns response when site is in maintenance mode and user is not exempt.
*
* @return \Drupal\Component\Render\MarkupInterface
* The formatted site maintenance message.
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The event to process.
*/
protected function getSiteMaintenanceMessage() {
return new FormattableMarkup($this->config->get('system.maintenance')->get('message'), [
'@site' => $this->config->get('system.site')->get('name'),
]);
public function onMaintenanceModeRequest(RequestEvent $event) {
$request = $event->getRequest();
if ($request->getRequestFormat() !== 'html') {
$response = new Response($this->maintenanceMode->getSiteMaintenanceMessage(), 503, ['Content-Type' => 'text/plain']);
// Calling RequestEvent::setResponse() also stops propagation of event.
$event->setResponse($response);
return;
}
drupal_maintenance_theme();
$response = $this->bareHtmlPageRenderer->renderBarePage(['#markup' => $this->maintenanceMode->getSiteMaintenanceMessage()], $this->t('Site under maintenance'), 'maintenance_page');
$response->setStatusCode(503);
// Calling RequestEvent::setResponse() also stops propagation of the event.
$event->setResponse($response);
}
/**
......@@ -157,6 +169,10 @@ protected function getSiteMaintenanceMessage() {
public static function getSubscribedEvents() {
$events[KernelEvents::REQUEST][] = ['onKernelRequestMaintenance', 30];
$events[KernelEvents::EXCEPTION][] = ['onKernelRequestMaintenance'];
$events[MaintenanceModeEvents::MAINTENANCE_MODE_REQUEST][] = [
'onMaintenanceModeRequest',
-1000,
];
return $events;
}
......
......@@ -2,6 +2,8 @@
namespace Drupal\Core\Site;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\State\StateInterface;
......@@ -18,14 +20,28 @@ class MaintenanceMode implements MaintenanceModeInterface {
*/
protected $state;
/**
* The configuration factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $config;
/**
* Constructs a new maintenance mode service.
*
* @param \Drupal\Core\State\StateInterface $state
* The state.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
*/
public function __construct(StateInterface $state) {
public function __construct(StateInterface $state, ConfigFactoryInterface $config_factory = NULL) {
$this->state = $state;
if (!$config_factory) {
@trigger_error('Calling MaintenanceMode::__construct() without the $config_factory argument is deprecated in drupal:9.4.0 and the $config_factory argument will be required in drupal:10.0.0. See https://www.drupal.org/node/3255815', E_USER_DEPRECATED);
$config_factory = \Drupal::service('config.factory');
}
$this->config = $config_factory;
}
/**
......@@ -52,4 +68,13 @@ public function exempt(AccountInterface $account) {
return $account->hasPermission('access site in maintenance mode');
}
/**
* {@inheritdoc}
*/
public function getSiteMaintenanceMessage() {
return new FormattableMarkup($this->config->get('system.maintenance')->get('message'), [
'@site' => $this->config->get('system.site')->get('name'),
]);
}
}
<?php
namespace Drupal\Core\Site;
/**
* Defines events for maintenance mode.
*/
final class MaintenanceModeEvents {
/**
* The name of the event fired when request is made in maintenance more.
*/
const MAINTENANCE_MODE_REQUEST = 'site.maintenance_mode_request';
}
......@@ -32,4 +32,12 @@ public function applies(RouteMatchInterface $route_match);
*/
public function exempt(AccountInterface $account);
/**
* Gets the site maintenance message.
*
* @return \Drupal\Component\Render\MarkupInterface
* The formatted site maintenance message.
*/
public function getSiteMaintenanceMessage();
}
langcode: en
read_only: true
maintenance_header_retry_seconds:
min: 5
max: 10
......@@ -5,3 +5,13 @@ jsonapi.settings:
read_only:
type: boolean
label: 'Restrict JSON:API to only read operations'
maintenance_header_retry_seconds:
type: mapping
label: 'Maintenance mode Retry-After header settings'
mapping:
min:
type: integer
label: 'Minimum value for Retry-After header in seconds'
max:
type: integer
label: 'Maximum value for Retry-After header in seconds'
......@@ -82,3 +82,17 @@ function jsonapi_requirements($phase) {
function jsonapi_update_last_removed() {
return 8701;
}
/**
* Set values for maintenance_header_retry_seconds min and max.
*
* @see https://www.drupal.org/node/3247453
*/
function jsonapi_update_9401() {
$config = \Drupal::configFactory()->getEditable('jsonapi.settings');
$config->set('maintenance_header_retry_seconds', [
'min' => 5,
'max' => 10,
]);
$config->save(TRUE);
}
......@@ -211,6 +211,11 @@ services:
- [setValidator, []]
tags:
- { name: event_subscriber, priority: 1000 }
jsonapi.maintenance_mode_subscriber:
class: Drupal\jsonapi\EventSubscriber\JsonapiMaintenanceModeSubscriber
arguments: ['@maintenance_mode', '@config.factory']
tags:
- { name: event_subscriber }
# Revision management.
jsonapi.version_negotiator:
......
<?php
namespace Drupal\jsonapi\EventSubscriber;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Site\MaintenanceModeEvents;
use Drupal\Core\Site\MaintenanceModeInterface;
use Drupal\jsonapi\JsonApiResource\ErrorCollection;
use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
use Drupal\jsonapi\JsonApiResource\LinkCollection;
use Drupal\jsonapi\JsonApiResource\NullIncludedData;
use Drupal\jsonapi\ResourceResponse;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Maintenance mode subscriber for JSON:API requests.
*
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
* may change at any time and could break any dependencies on it.
*/
class JsonapiMaintenanceModeSubscriber implements EventSubscriberInterface {
/**
* The maintenance mode.
*
* @var \Drupal\Core\Site\MaintenanceMode
*/
protected $maintenanceMode;
/**
* The configuration factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $config;
/**
* Constructs a new JsonapiMaintenanceModeSubscriber.
*
* @param \Drupal\Core\Site\MaintenanceModeInterface $maintenance_mode
* The maintenance mode.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
*/
public function __construct(MaintenanceModeInterface $maintenance_mode, ConfigFactoryInterface $config_factory) {
$this->maintenanceMode = $maintenance_mode;
$this->config = $config_factory;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events = [];
$events[MaintenanceModeEvents::MAINTENANCE_MODE_REQUEST][] = [
'onMaintenanceModeRequest',
-800,
];
return $events;
}
/**
* Returns response when site is in maintenance mode and user is not exempt.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The event to process.
*/
public function onMaintenanceModeRequest(RequestEvent $event) {
$request = $event->getRequest();
if ($request->getRequestFormat() !== 'api_json') {
return;
}
// Retry-After will be random within a range defined in jsonapi settings.
// The goals are to keep it short and to reduce the thundering herd problem.
$header_settings = $this->config->get('jsonapi.settings')->get('maintenance_header_retry_seconds');
$retry_after_time = rand($header_settings['min'], $header_settings['max']);
$http_exception = new HttpException(503, $this->maintenanceMode->getSiteMaintenanceMessage());
$document = new JsonApiDocumentTopLevel(new ErrorCollection([$http_exception]), new NullIncludedData(), new LinkCollection([]));
$response = new ResourceResponse($document, $http_exception->getStatusCode(), [
'Content-Type' => 'application/vnd.api+json',
'Retry-After' => $retry_after_time,
]);
// Calling RequestEvent::setResponse() also stops propagation of event.
$event->setResponse($response);
}
}
<?php
/**
* @file
* Test fixture.
*/
use Drupal\Core\Database\Database;
use Drupal\Core\Serialization\Yaml;
$connection = Database::getConnection();
$connection->insert('key_value')
->fields([
'collection',
'name',
'value',
])
->values([
'collection' => 'system.schema',
'name' => 'jsonapi',
'value' => serialize(9000),
])
->execute();
// Update core.extension.
$extensions = $connection->select('config')
->fields('config', ['data'])
->condition('collection', '')
->condition('name', 'core.extension')
->execute()
->fetchField();
$extensions = unserialize($extensions);
$extensions['module']['jsonapi'] = 0;
$extensions['module']['serialization'] = 0;
$connection->update('config')
->fields(['data' => serialize($extensions)])
->condition('collection', '')
->condition('name', 'core.extension')
->execute();
$jsonapi_settings = Yaml::decode(file_get_contents(__DIR__ . '/jsonapi.settings.yml'));
$connection->insert('config')
->fields([
'collection',
'name',
'data',
])
->values([
'collection' => '',
'name' => 'jsonapi.settings',
'data' => serialize($jsonapi_settings),
])
->execute();
......@@ -513,6 +513,39 @@ public function testRead() {
]));
$this->assertSession()->statusCodeEquals(200);
$this->assertCount(0, $collection_output['data']);
// Request in maintenance mode returns valid JSON.
$this->container->get('state')->set('system.maintenance_mode', TRUE);
$response = $this->drupalGet('/jsonapi/taxonomy_term/tags');
$this->assertSession()->statusCodeEquals(503);
$this->assertSession()->responseHeaderContains('Content-Type', 'application/vnd.api+json');
$retry_after_time = $this->getSession()->getResponseHeader('Retry-After');
$this->assertTrue($retry_after_time >= 5 && $retry_after_time <= 10);
$expected_message = 'Drupal is currently under maintenance. We should be back shortly. Thank you for your patience.';
$this->assertSame($expected_message, Json::decode($response)['errors'][0]['detail']);
// Test that logged in user does not get logged out in maintenance mode
// when hitting jsonapi route.
$this->container->get('state')->set('system.maintenance_mode', FALSE);
$this->drupalLogin($this->userCanViewProfiles);
$this->container->get('state')->set('system.maintenance_mode', TRUE);
$this->drupalGet('/jsonapi/taxonomy_term/tags');
$this->assertSession()->statusCodeEquals(503);
$this->assertTrue($this->drupalUserIsLoggedIn($this->userCanViewProfiles));
// Test that user gets logged out when hitting non-jsonapi route.
$this->drupalGet('/some/normal/route');
$this->assertFalse($this->drupalUserIsLoggedIn($this->userCanViewProfiles));
$this->container->get('state')->set('system.maintenance_mode', FALSE);
// Test that admin user can bypass maintenance mode.
$admin_user = $this->drupalCreateUser([], NULL, TRUE);
$this->drupalLogin($admin_user);
$this->container->get('state')->set('system.maintenance_mode', TRUE);
$this->drupalGet('/jsonapi/taxonomy_term/tags');
$this->assertSession()->statusCodeEquals(200);
$this->assertTrue($this->drupalUserIsLoggedIn($admin_user));
$this->container->get('state')->set('system.maintenance_mode', FALSE);
$this->drupalLogout();
}
/**
......
<?php
namespace Drupal\Tests\jsonapi\Functional\Update;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
/**
* Tests adding retry-after header settings.
*
* @group legacy
* @group jsonapi
*/
class JsonApiUpdatePathTest extends UpdatePathTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setDatabaseDumpFiles() {
$this->databaseDumpFiles = [
__DIR__ . '/../../../../../system/tests/fixtures/update/drupal-9.0.0.bare.standard.php.gz',
__DIR__ . '/../../../../tests/fixtures/update/jsonapi.php',
];
}
/**
* Tests adding retry-after header settings.
*
* @see jsonapi_update_9401()
*/
public function testUpdate9401() {
$config = $this->config('jsonapi.settings');
$this->assertTrue($config->get('read_only'));
$this->assertNull($config->get('maintenance_header_retry_seconds'));
// Run updates.
$this->runUpdates();
$config = $this->config('jsonapi.settings');
$this->assertTrue($config->get('read_only'));
$header_settings = $config->get('maintenance_header_retry_seconds');
$this->assertSame(5, $header_settings['min']);
$this->assertSame(10, $header_settings['max']);
}
}
......@@ -4,12 +4,12 @@
use Drupal\Core\Routing\RouteMatch;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Site\MaintenanceModeEvents;
use Drupal\Core\Site\MaintenanceModeInterface;
use Drupal\Core\Url;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Maintenance mode subscriber to log out users.
......@@ -48,8 +48,14 @@ public function __construct(MaintenanceModeInterface $maintenance_mode, AccountI
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The event to process.
*
* @deprecated in drupal:9.4.0 and is removed from drupal:10.0.0. Use
* \Drupal\user\EventSubscriber::onMaintenanceModeRequest() instead.
*
* @see https://www.drupal.org/node/3255799
*/
public function onKernelRequestMaintenance(RequestEvent $event) {
@trigger_error('\Drupal\user\EventSubscriber::onKernelRequestMaintenance() is deprecated in drupal:9.4.0 and is removed from drupal:10.0.0. Use \Drupal\user\EventSubscriber::onMaintenanceModeRequest() instead. See https://www.drupal.org/node/3255799', E_USER_DEPRECATED);
$request = $event->getRequest();
$route_match = RouteMatch::createFromRequest($request);
if ($this->maintenanceMode->applies($route_match)) {
......@@ -64,11 +70,31 @@ public function onKernelRequestMaintenance(RequestEvent $event) {
}
}
/**
* Logout users if site is in maintenance mode and user is not exempt.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The event to process.
*/
public function onMaintenanceModeRequest(RequestEvent $event) {
// If the site is offline, log out unprivileged users.
if ($this->account->isAuthenticated()) {
user_logout();
// Redirect to homepage.
$event->setResponse(
new RedirectResponse(Url::fromRoute('<front>')->toString())
);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events[KernelEvents::REQUEST][] = ['onKernelRequestMaintenance', 31];
$events[MaintenanceModeEvents::MAINTENANCE_MODE_REQUEST][] = [
'onMaintenanceModeRequest',
-900,
];
return $events;
}
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment