Skip to content
Snippets Groups Projects
Commit 6412ced9 authored by Lucas Hedding's avatar Lucas Hedding Committed by Lucas Hedding
Browse files

Issue #3046855 by heddn, eiriksm, Gábor Hojtsy, ckrina, AaronMcHale,...

Issue #3046855 by heddn, eiriksm, Gábor Hojtsy, ckrina, AaronMcHale, worldlinemine, benjifisher: Notify site administrators when new PSA is posted
parent 312d2758
No related branches found
No related tags found
No related merge requests found
Showing
with 404 additions and 40 deletions
name: 'Automatic Updates'
type: module
description: 'Drupal Automatic Updates'
description: 'Display public service announcements and verify readiness for applying automatic updates to the site.'
core: 8.x
package: 'Security'
configure: automatic_updates.admin_form
configure: automatic_updates.settings
dependencies:
- drupal:update
......@@ -19,13 +19,13 @@ function automatic_updates_requirements() {
$psa = \Drupal::service('automatic_updates.psa');
$messages = $psa->getPublicServiceMessages();
$requirements['automatic_updates_psa'] = [
'title' => t('Drupal public service announcements'),
'title' => t('<a href="@link">Drupal public service announcements</a>', ['@link' => 'https://www.drupal.org/docs/8/update/automatic-updates#psas']),
'severity' => REQUIREMENT_OK,
'value' => t('No announcements requiring attention.'),
];
if (!empty($messages)) {
$requirements['automatic_updates_psa']['severity'] = REQUIREMENT_ERROR;
$requirements['automatic_updates_psa']['value'] = new PluralTranslatableMarkup(count($messages), '@count urgent announcement requiring your attention:', '@count urgent announcements requiring your attention:');
$requirements['automatic_updates_psa']['value'] = new PluralTranslatableMarkup(count($messages), '@count urgent announcement requires your attention:', '@count urgent announcements require your attention:');
$requirements['automatic_updates_psa']['description'] = [
'#theme' => 'item_list',
'#items' => $messages,
......
automatic_updates.admin_form:
title: 'Automatic Updates'
route_name: automatic_updates.admin_form
description: 'Configure automatic updates'
automatic_updates.settings:
title: 'Automatic updates'
route_name: automatic_updates.settings
description: 'Configure public service announcement notifications'
parent: system.admin_config_system
......@@ -31,8 +31,45 @@ function automatic_updates_page_top(array &$page_top) {
}
/** @var \Drupal\automatic_updates\Services\AutomaticUpdatesPsaInterface $psa */
$psa = \Drupal::service('automatic_updates.psa');
foreach ($psa->getPublicServiceMessages() as $psa) {
\Drupal::messenger()->addError($psa);
$messages = $psa->getPublicServiceMessages();
if ($messages) {
\Drupal::messenger()->addError(t('Drupal public service announcements:'));
foreach ($messages as $message) {
\Drupal::messenger()->addError($message);
}
}
}
}
/**
* Implements hook_theme().
*/
function automatic_updates_theme(array $existing, $type, $theme, $path) {
return [
'automatic_updates_psa_notify' => [
'variables' => [
'messages' => [],
],
],
];
}
/**
* Implements hook_cron().
*/
function automatic_updates_cron() {
/** @var \Drupal\automatic_updates\Services\NotifyInterface $notify */
$notify = \Drupal::service('automatic_updates.psa_notify');
$notify->send();
}
/**
* Implements hook_mail().
*/
function automatic_updates_mail($key, &$message, $params) {
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = \Drupal::service('renderer');
$message['subject'] = $params['subject'];
$message['body'][] = $renderer->render($params['body']);
}
automatic_updates.admin_form:
automatic_updates.settings:
path: '/admin/config/automatic_updates'
defaults:
_form: '\Drupal\automatic_updates\Form\AdminForm'
_form: '\Drupal\automatic_updates\Form\SettingsForm'
_title: 'Automatic Updates'
requirements:
_permission: 'administer software updates'
......
......@@ -13,3 +13,14 @@ services:
- '@extension.list.profile'
- '@extension.list.theme'
- '@logger.channel.automatic_updates'
automatic_updates.psa_notify:
class: Drupal\automatic_updates\Services\Notify
arguments:
- '@plugin.manager.mail'
- '@automatic_updates.psa'
- '@config.factory'
- '@language_manager'
- '@state'
- '@datetime.time'
- '@entity_type.manager'
- '@string_translation'
......@@ -3,3 +3,5 @@
# https://www.drupal.org/project/automatic_updates/issues/3045273
psa_endpoint: 'http://localhost/automatic_updates/test-json'
enable_psa: true
notify: true
check_frequency: 43200
......@@ -8,3 +8,9 @@ automatic_updates.settings:
enable_psa:
type: boolean
label: 'Enable PSA notices'
notify:
type: boolean
label: 'Notify when PSAs are available'
check_frequency:
type: integer
label: 'Frequency to check for PSAs, defaults to 12 hours'
......@@ -4,11 +4,12 @@ namespace Drupal\automatic_updates\Form;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
/**
* Administration form for automatic updates.
* Settings form for automatic updates.
*/
class AdminForm extends ConfigFormBase {
class SettingsForm extends ConfigFormBase {
/**
* {@inheritdoc}
......@@ -31,11 +32,20 @@ class AdminForm extends ConfigFormBase {
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$config = $this->config('automatic_updates.settings');
$form['description'] = [
'#markup' => '<p>' . $this->t('Public service announcements are compared against the entire code for the site, not just installed extensions.') . '</p>',
];
$form['enable_psa'] = [
'#type' => 'checkbox',
'#title' => $this->t('Enable messaging of public service alerts (PSAs)'),
'#title' => $this->t('Show public service announcements on administrative pages.'),
'#default_value' => $config->get('enable_psa'),
];
$form['notify'] = [
'#type' => 'checkbox',
'#title' => $this->t('Send email notifications for public service announcements.'),
'#default_value' => $config->get('notify'),
'#description' => $this->t('The email addresses listed in <a href="@update_manager">update manager settings</a> will be notified.', ['@update_manager' => Url::fromRoute('update.settings')->toString()]),
];
return parent::buildForm($form, $form_state);
}
......
......@@ -4,6 +4,7 @@ namespace Drupal\automatic_updates\Services;
use Composer\Semver\VersionParser;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Component\Version\Constraint;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
......@@ -22,7 +23,7 @@ class AutomaticUpdatesPsa implements AutomaticUpdatesPsaInterface {
use DependencySerializationTrait;
/**
* Module's configuration.
* This module's configuration.
*
* @var \Drupal\Core\Config\ImmutableConfig
*/
......@@ -122,13 +123,12 @@ class AutomaticUpdatesPsa implements AutomaticUpdatesPsaInterface {
$response = $cache->data;
}
else {
$psa_endpoint = $this->config->get('psa_endpoint');
try {
$psa_endpoint = $this->config->get('psa_endpoint');
$response = $this->httpClient->get($psa_endpoint)
->getBody()
->getContents();
// Set response in cache for 12 hours.
$this->cache->set('automatic_updates_psa', $response, $this->time->getCurrentTime() + 3600 * 12);
$this->cache->set('automatic_updates_psa', $response, $this->time->getCurrentTime() + $this->config->get('check_frequency'));
}
catch (TransferException $exception) {
$this->logger->error($exception->getMessage());
......@@ -138,14 +138,21 @@ class AutomaticUpdatesPsa implements AutomaticUpdatesPsaInterface {
try {
$json_payload = json_decode($response);
foreach ($json_payload as $json) {
if ($json->project === 'core') {
$this->coreParser($messages, $json);
}
else {
$this->contribParser($messages, $json);
if ($json_payload) {
foreach ($json_payload as $json) {
if ($json->project === 'core') {
$this->coreParser($messages, $json);
}
else {
$this->contribParser($messages, $json);
}
}
}
else {
$this->logger->error('Drupal PSA JSON is malformed: @response', ['@response' => $response]);
$messages[] = $this->t('Drupal PSA JSON is malformed.');
}
}
catch (\UnexpectedValueException $exception) {
$this->logger->error($exception->getMessage());
......@@ -172,7 +179,7 @@ class AutomaticUpdatesPsa implements AutomaticUpdatesPsaInterface {
$psa_constraint = $parser->parseConstraints($version_string);
$core_constraint = $parser->parseConstraints(\Drupal::VERSION);
if ($psa_constraint->matches($core_constraint)) {
$messages[] = $this->t('Drupal Core PSA: <a href=":url">:message</a>', [
$messages[] = new FormattableMarkup('<a href=":url">:message</a>', [
':message' => $json->title,
':url' => $json->link,
]);
......@@ -223,7 +230,7 @@ class AutomaticUpdatesPsa implements AutomaticUpdatesPsaInterface {
$version_string = implode('||', $json->secure_versions);
$constraint = new Constraint("<=$extension_version", \Drupal::CORE_COMPATIBILITY);
if (!$constraint->isCompatible($version_string)) {
$messages[] = $this->t('Drupal Contrib Project PSA: <a href=":url">:message</a>', [
$messages[] = new FormattableMarkup('<a href=":url">:message</a>', [
':message' => $json->title,
':url' => $json->link,
]);
......
<?php
namespace Drupal\automatic_updates\Services;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Mail\MailManagerInterface;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\PluralTranslatableMarkup;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
/**
* Class EmailNotify.
*/
class Notify implements NotifyInterface {
use StringTranslationTrait;
/**
* Mail manager.
*
* @var \Drupal\Core\Mail\MailManagerInterface
*/
protected $mailManager;
/**
* The automatic updates service.
*
* @var \Drupal\automatic_updates\Services\AutomaticUpdatesPsaInterface
*/
protected $automaticUpdatesPsa;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $state;
/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/**
* Entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Event dispatcher.
*
* @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* EmailNotify constructor.
*
* @param \Drupal\Core\Mail\MailManagerInterface $mail_manager
* The mail manager.
* @param \Drupal\automatic_updates\Services\AutomaticUpdatesPsaInterface $automatic_updates_psa
* The automatic updates service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
* @param \Drupal\Core\State\StateInterface $state
* The state service.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* Entity type manager.
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* The string translation service.
*/
public function __construct(MailManagerInterface $mail_manager, AutomaticUpdatesPsaInterface $automatic_updates_psa, ConfigFactoryInterface $config_factory, LanguageManagerInterface $language_manager, StateInterface $state, TimeInterface $time, EntityTypeManagerInterface $entity_type_manager, TranslationInterface $string_translation) {
$this->mailManager = $mail_manager;
$this->automaticUpdatesPsa = $automatic_updates_psa;
$this->configFactory = $config_factory;
$this->languageManager = $language_manager;
$this->state = $state;
$this->time = $time;
$this->entityTypeManager = $entity_type_manager;
$this->stringTranslation = $string_translation;
}
/**
* {@inheritdoc}
*/
public function send() {
$messages = $this->automaticUpdatesPsa->getPublicServiceMessages();
if (!$messages) {
return;
}
$notify_list = $this->configFactory->get('update.settings')->get('notification.emails');
if (!empty($notify_list)) {
$frequency = $this->configFactory->get('automatic_updates.settings')->get('check_frequency');
$last_check = $this->state->get('automatic_updates.last_check') ?: 0;
if (($this->time->getRequestTime() - $last_check) > $frequency) {
$this->state->set('automatic_updates.last_check', $this->time->getRequestTime());
$params['subject'] = new PluralTranslatableMarkup(
count($messages),
'@count urgent Drupal announcement requires your attention for @site_name',
'@count urgent Drupal announcements require your attention for @site_name',
['@site_name' => $this->configFactory->get('system.site')->get('name')]
);
$params['body'] = [
'#theme' => 'automatic_updates_psa_notify',
'#messages' => $messages,
];
$default_langcode = $this->languageManager->getDefaultLanguage()->getId();
$params['langcode'] = $default_langcode;
foreach ($notify_list as $to) {
$this->doSend($to, $params);
}
}
}
}
/**
* Composes and send the email message.
*
* @param string $to
* The email address where the message will be sent.
* @param array $params
* Parameters to build the email.
*/
protected function doSend($to, array $params) {
$users = $this->entityTypeManager->getStorage('user')
->loadByProperties(['mail' => $to]);
if ($users) {
$to_user = reset($users);
$params['langcode'] = $to_user->getPreferredLangcode();
}
$this->mailManager->mail('automatic_updates', 'notify', $to, $params['langcode'], $params);
}
}
<?php
namespace Drupal\automatic_updates\Services;
/**
* Interface NotifyInterface.
*/
interface NotifyInterface {
/**
* Send notification when PSAs are available.
*/
public function send();
}
{#
/**
* @file
* Template for the public service annoucements email notification.
*
* Available variables:
* - messages: The messages array
*
* @ingroup themeable
*/
#}
<p>
{% trans %}
A security update will be made available soon for your Drupal site. To ensure the security of the site, you should prepare the site to immediately install the update once it is released!
{% endtrans %}
</p>
<p>
{% set status_report = path('system.status') %}
{% trans %}
See the <a href="{{ status_report }}">site status report page</a> for more information.
{% endtrans %}
</p>
<p>{{ 'Drupal public service announcements:'|t }}</p>
<ul>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
<p>
{% set settings_link = path('automatic_updates.settings') %}
{% trans %}
Your site is currently configured to send these emails when a security update will be made available soon. To change how you are notified, you may <a href="{{ settings_link }}">configure email notifications</a>.
{% endtrans %}
</p>
......@@ -13,11 +13,9 @@ use Drupal\Tests\BrowserTestBase;
class AutomaticUpdatesTest extends BrowserTestBase {
/**
* Modules to enable.
*
* @var array
* {@inheritdoc}
*/
public static $modules = [
protected static $modules = [
'automatic_updates',
'test_automatic_updates',
'update',
......@@ -46,20 +44,21 @@ class AutomaticUpdatesTest extends BrowserTestBase {
* Tests that a PSA is displayed.
*/
public function testPsa() {
// Setup test PSA endpoint.
$end_point = $this->buildUrl(Url::fromRoute('test_automatic_updates.json_test_controller'));
$this->config('automatic_updates.settings')
->set('psa_endpoint', $end_point)
->save();
$this->drupalGet(Url::fromRoute('system.admin'));
$this->assertSession()->pageTextContains('Drupal Core PSA: Critical Release - PSA-2019-02-19');
$this->assertSession()->pageTextNotContains('Drupal Core PSA: Critical Release - PSA-Really Old');
$this->assertSession()->pageTextNotContains('Drupal Contrib Project PSA: Node - Moderately critical - Access bypass - SA-CONTRIB-2019');
$this->assertSession()->pageTextContains('Drupal Contrib Project PSA: Seven - Moderately critical - Access bypass - SA-CONTRIB-2019');
$this->assertSession()->pageTextContains('Drupal Contrib Project PSA: Standard - Moderately critical - Access bypass - SA-CONTRIB-2019');
$this->assertSession()->pageTextContains('Critical Release - PSA-2019-02-19');
$this->assertSession()->pageTextNotContains('Critical Release - PSA-Really Old');
$this->assertSession()->pageTextNotContains('Node - Moderately critical - Access bypass - SA-CONTRIB-2019');
$this->assertSession()->pageTextContains('Seven - Moderately critical - Access bypass - SA-CONTRIB-2019');
$this->assertSession()->pageTextContains('Standard - Moderately critical - Access bypass - SA-CONTRIB-2019');
// Test site status report.
$this->drupalGet(Url::fromRoute('system.status'));
$this->assertSession()->pageTextContains('3 urgent announcements requiring your attention:');
$this->assertSession()->pageTextContains('3 urgent announcements require your attention:');
// Test cache.
$end_point = 'http://localhost/automatic_updates/test-json-denied';
......@@ -67,7 +66,7 @@ class AutomaticUpdatesTest extends BrowserTestBase {
->set('psa_endpoint', $end_point)
->save();
$this->drupalGet(Url::fromRoute('system.admin'));
$this->assertSession()->pageTextContains('Drupal Core PSA: Critical Release - PSA-2019-02-19');
$this->assertSession()->pageTextContains('Critical Release - PSA-2019-02-19');
// Test transmit errors with JSON endpoint.
drupal_flush_all_caches();
......@@ -82,9 +81,9 @@ class AutomaticUpdatesTest extends BrowserTestBase {
->save();
drupal_flush_all_caches();
$this->drupalGet(Url::fromRoute('system.admin'));
$this->assertSession()->pageTextNotContains('Drupal Core PSA: Critical Release - PSA-2019-02-19');
$this->assertSession()->pageTextNotContains('Critical Release - PSA-2019-02-19');
$this->drupalGet(Url::fromRoute('system.status'));
$this->assertSession()->pageTextNotContains('4 announcements requiring your attention:');
$this->assertSession()->pageTextNotContains('urgent announcements require your attention');
}
}
<?php
namespace Drupal\Tests\automatic_updates\Functional;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Test\AssertMailTrait;
use Drupal\Core\Url;
use Drupal\Tests\BrowserTestBase;
/**
* Tests notification emails for PSAs.
*
* @group automatic_updates
*/
class NotifyTest extends BrowserTestBase {
use AssertMailTrait;
use StringTranslationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'automatic_updates',
'test_automatic_updates',
'update',
];
/**
* A user with permission to administer site configuration.
*
* @var \Drupal\user\UserInterface
*/
protected $user;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Setup test PSA endpoint.
$end_point = $this->buildUrl(Url::fromRoute('test_automatic_updates.json_test_controller'));
$this->config('automatic_updates.settings')
->set('psa_endpoint', $end_point)
->save();
// Setup a default destination email address.
$this->config('update.settings')
->set('notification.emails', ['admin@example.com'])
->save();
$this->user = $this->drupalCreateUser([
'administer site configuration',
'access administration pages',
]);
$this->drupalLogin($this->user);
}
/**
* Tests sending email notifications.
*/
public function testSendMail() {
// Test PSAs on admin pages.
$this->drupalGet(Url::fromRoute('system.admin'));
$this->assertSession()->pageTextContains('Critical Release - PSA-2019-02-19');
// Email should be sent.
$notify = $this->container->get('automatic_updates.psa_notify');
$notify->send();
$this->assertCount(1, $this->getMails());
$this->assertMailString('subject', '3 urgent Drupal announcements require your attention', 1);
$this->assertMailString('body', 'Critical Release - PSA-2019-02-19', 1);
// No email should be sent if PSA's are disabled.
$this->container->get('state')->set('system.test_mail_collector', []);
$this->container->get('state')->delete('automatic_updates.last_check');
$this->config('automatic_updates.settings')
->set('enable_psa', FALSE)
->save();
$notify->send();
$this->assertCount(0, $this->getMails());
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment