Initial Drupal 11 with DDEV setup

This commit is contained in:
gluebox
2025-10-08 11:39:17 -04:00
commit 89ef74b305
25344 changed files with 2599172 additions and 0 deletions

View File

@ -0,0 +1,5 @@
name: Announcements
type: module
description: Displays announcements from the Drupal community.
version: VERSION
package: Core

View File

@ -0,0 +1,17 @@
drupal.announcements_feed.dialog:
version: VERSION
css:
component:
css/announcements_feed.dialog.css: {}
drupal.announcements_feed.toolbar:
version: VERSION
css:
component:
css/announcements_feed.toolbar.css: {}
drupal.announcements_feed.page:
version: VERSION
css:
component:
css/announcements_feed.page.css: {}

View File

@ -0,0 +1,6 @@
announcements_feed.announcement:
title: Announcements
description: 'Displays announcements from the Drupal community.'
route_name: announcements_feed.announcement
weight: 10
parent: system.admin

View File

@ -0,0 +1,2 @@
access announcements:
title: 'View official announcements related to Drupal'

View File

@ -0,0 +1,7 @@
announcements_feed.announcement:
path: '/admin/announcements_feed'
defaults:
_controller: '\Drupal\announcements_feed\Controller\AnnounceController::getAnnouncements'
_title: 'Community announcements'
requirements:
_permission: 'access announcements'

View File

@ -0,0 +1,24 @@
parameters:
announcements_feed.feed_json_url: https://www.drupal.org/announcements.json
announcements_feed.feed_link: https://www.drupal.org/about/announcements
announcements_feed.skip_procedural_hook_scan: true
services:
_defaults:
autoconfigure: true
announcements_feed.fetcher:
class: Drupal\announcements_feed\AnnounceFetcher
arguments: ['@http_client', '@config.factory', '@keyvalue.expirable', '@logger.channel.announcements_feed', '%announcements_feed.feed_json_url%']
Drupal\announcements_feed\AnnounceFetcher: '@announcements_feed.fetcher'
logger.channel.announcements_feed:
parent: logger.channel_base
arguments: ['announcements_feed']
public: false
announcements_feed.lazy_builders:
class: Drupal\announcements_feed\LazyBuilders
arguments: [ '@plugin.manager.element_info']
Drupal\announcements_feed\LazyBuilders: '@announcements_feed.lazy_builders'
announcements_feed.renderer:
class: Drupal\announcements_feed\AnnounceRenderer
arguments: ['@announcements_feed.fetcher', '%announcements_feed.feed_link%']
Drupal\announcements_feed\AnnounceRenderer: '@announcements_feed.renderer'

View File

@ -0,0 +1,3 @@
max_age: 86400
cron_interval: 21600
limit: 10

View File

@ -0,0 +1,24 @@
announcements_feed.settings:
type: config_object
label: 'Announcements Settings'
constraints:
FullyValidatable: ~
mapping:
max_age:
type: integer
label: 'Cache announcements for max-age seconds.'
constraints:
Range:
min: 0
cron_interval:
type: integer
label: 'Cron interval for fetching announcements in seconds.'
constraints:
Range:
min: 0
limit:
type: integer
label: 'Number of announcements that will be displayed.'
constraints:
Range:
min: 0

View File

@ -0,0 +1,52 @@
/*
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/3084859
* @preserve
*/
/**
* @file
*
* Styles for the announcements feed within the off-canvas dialog.
*/
#drupal-off-canvas-wrapper .ui-dialog-titlebar.announce-titlebar::before {
mask-image: url("data:image/svg+xml,%3csvg width='20' height='19' viewBox='0 0 20 19' fill='none' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M5.73047 16.7648C6.00143 17.4831 6.6872 18 7.50009 18C8.31299 18 8.99876 17.4865 9.26972 16.7682C8.71107 16.8118 8.12231 16.8387 7.50009 16.8387C6.87788 16.8353 6.28912 16.8085 5.73047 16.7648Z' fill='white'/%3e%3cpath d='M14.331 13.4118H14.0801L12.4074 11.3979L11.5143 6.69897H11.5042C11.2333 5.05433 9.97881 3.74869 8.36976 3.39627C8.3731 3.38955 8.37979 3.38284 8.37979 3.37613L8.624 2.63772C8.74108 2.28529 8.53702 2 8.16905 2H6.83095C6.46298 2 6.25892 2.28529 6.37266 2.63772L6.61686 3.37613C6.62021 3.38284 6.62355 3.38955 6.6269 3.39627C5.01784 3.74869 3.76673 5.05433 3.49242 6.69897H3.48238L2.59255 11.3979L0.919938 13.4118H0.669046C0.30107 13.4118 0 13.7139 0 14.0831C0 14.4523 0.280999 14.8618 0.625558 14.996C0.625558 14.996 3.48573 16.0969 7.5 16.0969C11.5143 16.0969 14.3744 14.996 14.3744 14.996C14.719 14.8618 15 14.4523 15 14.0831C15 13.7139 14.6989 13.4118 14.331 13.4118ZM4.58296 6.95742L3.70317 11.8611L1.75624 14.0831H1.23439L3.21811 11.6933L4.15477 6.82652C4.28189 6.0579 4.68332 5.3799 5.24532 4.8798L5.49955 5.19866C5.03122 5.60478 4.68666 6.32305 4.58296 6.95742Z' fill='white'/%3e%3c/svg%3e");
}
#drupal-off-canvas-wrapper .announcements {
padding-block-start: var(--off-canvas-padding);
}
#drupal-off-canvas-wrapper .announcements ul {
margin: 0;
padding-inline-start: 0;
list-style: none;
}
#drupal-off-canvas-wrapper .announcement {
font-size: 0.875rem;
}
#drupal-off-canvas-wrapper .announcement--featured {
position: relative;
margin-inline: calc(-1 * var(--off-canvas-padding));
padding: 0 var(--off-canvas-padding) var(--off-canvas-padding);
}
#drupal-off-canvas-wrapper .announcement.announcement--featured + .announcement.announcement--standard {
border-block-start: 1px solid var(--off-canvas-border-color);
}
#drupal-off-canvas-wrapper .announcement--standard {
padding-block-start: var(--off-canvas-padding);
}
#drupal-off-canvas-wrapper .announcement__title {
font-size: 1rem;
}
#drupal-off-canvas-wrapper .announcements--view-all {
margin-block-start: 3rem;
}

View File

@ -0,0 +1,48 @@
/**
* @file
*
* Styles for the announcements feed within the off-canvas dialog.
*/
#drupal-off-canvas-wrapper {
& .ui-dialog-titlebar.announce-titlebar::before {
-webkit-mask-image: url("../images/announcement-bell.svg");
mask-image: url("../images/announcement-bell.svg");
}
& .announcements {
padding-block-start: var(--off-canvas-padding);
}
& .announcements ul {
margin: 0;
padding-inline-start: 0;
list-style: none;
}
& .announcement {
font-size: 0.875rem;
}
& .announcement--featured {
position: relative;
margin-inline: calc(-1 * var(--off-canvas-padding));
padding: 0 var(--off-canvas-padding) var(--off-canvas-padding);
}
& .announcement.announcement--featured + .announcement.announcement--standard {
border-block-start: 1px solid var(--off-canvas-border-color);
}
& .announcement--standard {
padding-block-start: var(--off-canvas-padding);
}
& .announcement__title {
font-size: 1rem;
}
& .announcements--view-all {
margin-block-start: 3rem;
}
}

View File

@ -0,0 +1,24 @@
/*
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/3084859
* @preserve
*/
.announcements ul {
margin-inline-start: 0;
list-style: none;
}
.announcement:not(.announcement:last-child) {
margin-block-end: 1rem;
}
.announcement.announcement--featured + .announcement.announcement--standard {
padding-block-start: 1rem;
border-top: 1px solid #aaa;
}
.announcements--view-all {
margin-block-start: 3rem;
}

View File

@ -0,0 +1,17 @@
.announcements ul {
margin-inline-start: 0;
list-style: none;
}
.announcement:not(.announcement:last-child) {
margin-block-end: 1rem;
}
.announcement.announcement--featured + .announcement.announcement--standard {
padding-block-start: 1rem;
border-top: 1px solid #aaa;
}
.announcements--view-all {
margin-block-start: 3rem;
}

View File

@ -0,0 +1,35 @@
/*
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/3084859
* @preserve
*/
/**
* @file
*
* Styles for the announcements toolbar item.
*/
.toolbar .toolbar-icon.announce-canvas-link::before {
background-image: url("data:image/svg+xml,%3csvg width='20' height='19' viewBox='0 0 20 19' fill='none' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M5.73047 16.7648C6.00143 17.4831 6.6872 18 7.50009 18C8.31299 18 8.99876 17.4865 9.26972 16.7682C8.71107 16.8118 8.12231 16.8387 7.50009 16.8387C6.87788 16.8353 6.28912 16.8085 5.73047 16.7648Z' fill='white'/%3e%3cpath d='M14.331 13.4118H14.0801L12.4074 11.3979L11.5143 6.69897H11.5042C11.2333 5.05433 9.97881 3.74869 8.36976 3.39627C8.3731 3.38955 8.37979 3.38284 8.37979 3.37613L8.624 2.63772C8.74108 2.28529 8.53702 2 8.16905 2H6.83095C6.46298 2 6.25892 2.28529 6.37266 2.63772L6.61686 3.37613C6.62021 3.38284 6.62355 3.38955 6.6269 3.39627C5.01784 3.74869 3.76673 5.05433 3.49242 6.69897H3.48238L2.59255 11.3979L0.919938 13.4118H0.669046C0.30107 13.4118 0 13.7139 0 14.0831C0 14.4523 0.280999 14.8618 0.625558 14.996C0.625558 14.996 3.48573 16.0969 7.5 16.0969C11.5143 16.0969 14.3744 14.996 14.3744 14.996C14.719 14.8618 15 14.4523 15 14.0831C15 13.7139 14.6989 13.4118 14.331 13.4118ZM4.58296 6.95742L3.70317 11.8611L1.75624 14.0831H1.23439L3.21811 11.6933L4.15477 6.82652C4.28189 6.0579 4.68332 5.3799 5.24532 4.8798L5.49955 5.19866C5.03122 5.60478 4.68666 6.32305 4.58296 6.95742Z' fill='white'/%3e%3c/svg%3e");
}
@media (forced-colors: active) {
.toolbar .toolbar-icon.announce-canvas-link::before {
background: linktext;
mask-image: url("data:image/svg+xml,%3csvg width='20' height='19' viewBox='0 0 20 19' fill='none' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M5.73047 16.7648C6.00143 17.4831 6.6872 18 7.50009 18C8.31299 18 8.99876 17.4865 9.26972 16.7682C8.71107 16.8118 8.12231 16.8387 7.50009 16.8387C6.87788 16.8353 6.28912 16.8085 5.73047 16.7648Z' fill='white'/%3e%3cpath d='M14.331 13.4118H14.0801L12.4074 11.3979L11.5143 6.69897H11.5042C11.2333 5.05433 9.97881 3.74869 8.36976 3.39627C8.3731 3.38955 8.37979 3.38284 8.37979 3.37613L8.624 2.63772C8.74108 2.28529 8.53702 2 8.16905 2H6.83095C6.46298 2 6.25892 2.28529 6.37266 2.63772L6.61686 3.37613C6.62021 3.38284 6.62355 3.38955 6.6269 3.39627C5.01784 3.74869 3.76673 5.05433 3.49242 6.69897H3.48238L2.59255 11.3979L0.919938 13.4118H0.669046C0.30107 13.4118 0 13.7139 0 14.0831C0 14.4523 0.280999 14.8618 0.625558 14.996C0.625558 14.996 3.48573 16.0969 7.5 16.0969C11.5143 16.0969 14.3744 14.996 14.3744 14.996C14.719 14.8618 15 14.4523 15 14.0831C15 13.7139 14.6989 13.4118 14.331 13.4118ZM4.58296 6.95742L3.70317 11.8611L1.75624 14.0831H1.23439L3.21811 11.6933L4.15477 6.82652C4.28189 6.0579 4.68332 5.3799 5.24532 4.8798L5.49955 5.19866C5.03122 5.60478 4.68666 6.32305 4.58296 6.95742Z' fill='white'/%3e%3c/svg%3e");
mask-repeat: no-repeat;
mask-position: center;
}
}
/* Pushes the tab to the opposite side of the page. */
.toolbar .toolbar-bar .announce-toolbar-tab.toolbar-tab {
float: right; /* LTR */
}
.toolbar .toolbar-bar .announce-toolbar-tab.toolbar-tab:dir(rtl) {
float: left;
}

View File

@ -0,0 +1,25 @@
/**
* @file
*
* Styles for the announcements toolbar item.
*/
.toolbar .toolbar-icon.announce-canvas-link::before {
background-image: url("../images/announcement-bell.svg");
@media (forced-colors: active) {
background: linktext;
mask-image: url("../images/announcement-bell.svg");
mask-repeat: no-repeat;
mask-position: center;
}
}
/* Pushes the tab to the opposite side of the page. */
.toolbar .toolbar-bar .announce-toolbar-tab.toolbar-tab {
float: right; /* LTR */
&:dir(rtl) {
float: left;
}
}

View File

@ -0,0 +1,24 @@
---
label: 'Viewing Drupal announcements'
top_level: true
---
{% set actions_link_text %}
{% trans %}Announcements{% endtrans %}
{% endset %}
{% set actions_link = render_var(help_route_link(actions_link_text, 'announcements_feed.announcement')) %}
{% set permissions_link_text %}
{% trans %}View official announcements related to Drupal{% endtrans %}
{% endset %}
{% set permissions_link = render_var(help_route_link(permissions_link_text, 'user.admin_permissions.module', {'modules': 'announcements_feed'})) %}
<h2>{% trans %}What are Drupal announcements?{% endtrans %}</h2>
<p>{% trans %}A feed of announcements about the Drupal project and Drupal Association programs.{% endtrans %}</p>
<p>{% trans %}The purpose of this feed is to provide a channel for outreach directly to Drupal site owners. This content must be highly relevant to site owners interests, serve the strategic goals of the project, and/or promote the sustainability of the project and the Drupal Association.{% endtrans %}</p>
<p>{% trans %}The module sources its content from a JSON feed generated from <a href="https://www.drupal.org/about/announcements">here</a>. The governance policy for the content is documented <a href="https://www.drupal.org/node/3274085">here</a>.{% endtrans %}</p>
<h2>{% trans %}How can I see the Announcements in my site?{% endtrans %}</h2>
<p>{% trans %}If you have the toolbar module enabled, you will see a direct link to them in the toolbar. If the toolbar module is not enabled, the content can always be accessed in the <em>{{ actions_link }}</em> page.{% endtrans %}</p>
<h2>{% trans %}Who can see the Announcements?{% endtrans %}</h2>
<p>{% trans %}Users with the <em>{{ permissions_link }}</em> permission can view Drupal announcements.{% endtrans %}</p>
<h2>{% trans %}Additional resources{% endtrans %}</h2>
<ul>
<li><a href="https://www.drupal.org/docs/core-modules-and-themes/core-modules/announcements-feed/announcements-feed-module-overview">{% trans %}Announcement module overview{% endtrans %}</a></li>
</ul>

View File

@ -0,0 +1,4 @@
<svg width="20" height="19" viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.73047 16.7648C6.00143 17.4831 6.6872 18 7.50009 18C8.31299 18 8.99876 17.4865 9.26972 16.7682C8.71107 16.8118 8.12231 16.8387 7.50009 16.8387C6.87788 16.8353 6.28912 16.8085 5.73047 16.7648Z" fill="white"/>
<path d="M14.331 13.4118H14.0801L12.4074 11.3979L11.5143 6.69897H11.5042C11.2333 5.05433 9.97881 3.74869 8.36976 3.39627C8.3731 3.38955 8.37979 3.38284 8.37979 3.37613L8.624 2.63772C8.74108 2.28529 8.53702 2 8.16905 2H6.83095C6.46298 2 6.25892 2.28529 6.37266 2.63772L6.61686 3.37613C6.62021 3.38284 6.62355 3.38955 6.6269 3.39627C5.01784 3.74869 3.76673 5.05433 3.49242 6.69897H3.48238L2.59255 11.3979L0.919938 13.4118H0.669046C0.30107 13.4118 0 13.7139 0 14.0831C0 14.4523 0.280999 14.8618 0.625558 14.996C0.625558 14.996 3.48573 16.0969 7.5 16.0969C11.5143 16.0969 14.3744 14.996 14.3744 14.996C14.719 14.8618 15 14.4523 15 14.0831C15 13.7139 14.6989 13.4118 14.331 13.4118ZM4.58296 6.95742L3.70317 11.8611L1.75624 14.0831H1.23439L3.21811 11.6933L4.15477 6.82652C4.28189 6.0579 4.68332 5.3799 5.24532 4.8798L5.49955 5.19866C5.03122 5.60478 4.68666 6.32305 4.58296 6.95742Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace Drupal\announcements_feed;
use Composer\Semver\Semver;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
use Drupal\Core\Utility\Error;
use GuzzleHttp\ClientInterface;
use Psr\Log\LoggerInterface;
/**
* Service to fetch announcements from the external feed.
*
* @internal
*/
final class AnnounceFetcher {
/**
* The configuration settings of this module.
*
* @var \Drupal\Core\Config\ImmutableConfig
*/
protected ImmutableConfig $config;
/**
* The tempstore service.
*
* @var \Drupal\Core\KeyValueStore\KeyValueExpirableFactory
*/
protected KeyValueStoreInterface $tempStore;
/**
* Construct an AnnounceFetcher service.
*
* @param \GuzzleHttp\ClientInterface $httpClient
* The http client.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config
* The config factory service.
* @param \Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface $temp_store
* The tempstore factory service.
* @param \Psr\Log\LoggerInterface $logger
* The logger service.
* @param string $feedUrl
* The feed url path.
*/
public function __construct(
protected ClientInterface $httpClient,
ConfigFactoryInterface $config,
KeyValueExpirableFactoryInterface $temp_store,
protected LoggerInterface $logger,
protected string $feedUrl,
) {
$this->config = $config->get('announcements_feed.settings');
$this->tempStore = $temp_store->get('announcements_feed');
}
/**
* Fetch ids of announcements.
*
* @return array
* An array with ids of all announcements in the feed.
*/
public function fetchIds(): array {
return array_column($this->fetch(), 'id');
}
/**
* Check whether the version given is relevant to the Drupal version used.
*
* @param string $version
* Version to check.
*
* @return bool
* Return True if the version matches Drupal version.
*/
protected static function isRelevantItem(string $version): bool {
return !empty($version) && Semver::satisfies(\Drupal::VERSION, $version);
}
/**
* Check whether a link is controlled by D.O.
*
* @param string $url
* URL to check.
*
* @return bool
* Return True if the URL is controlled by the D.O.
*/
public static function validateUrl(string $url): bool {
if (empty($url)) {
return FALSE;
}
$host = parse_url($url, PHP_URL_HOST);
// First character can only be a letter or a digit.
// @see https://www.rfc-editor.org/rfc/rfc1123#page-13
return $host && preg_match('/^([a-zA-Z0-9][a-zA-Z0-9\-_]*\.)?drupal\.org$/', $host);
}
/**
* Fetches the feed either from a local cache or fresh remotely.
*
* The feed follows the "JSON Feed" format:
* - https://www.jsonfeed.org/version/1.1/
*
* The structure of an announcement item in the feed is:
* - id: Id.
* - title: Title of the announcement.
* - content_html: Announcement teaser.
* - url: URL
* - date_modified: Last updated timestamp.
* - date_published: Created timestamp.
* - _drupalorg.featured: 1 if featured, 0 if not featured.
* - _drupalorg.version: Target version of Drupal, as a Composer version.
*
* @param bool $force
* (optional) Whether to always fetch new items or not. Defaults to FALSE.
*
* @return \Drupal\announcements_feed\Announcement[]
* An array of announcements from the feed relevant to the Drupal version.
* The array is empty if there were no matching announcements. If an error
* occurred while fetching/decoding the feed, it is thrown as an exception.
*
* @throws \Exception
*/
public function fetch(bool $force = FALSE): array {
$announcements = $this->tempStore->get('announcements');
if ($force || $announcements === NULL) {
try {
$feed_content = (string) $this->httpClient->get($this->feedUrl)->getBody();
}
catch (\Exception $e) {
$this->logger->error(Error::DEFAULT_ERROR_MESSAGE, Error::decodeException($e));
throw $e;
}
$announcements = Json::decode($feed_content);
if (!isset($announcements['items'])) {
$this->logger->error('The feed format is not valid.');
throw new \Exception('Invalid format');
}
$announcements = $announcements['items'] ?? [];
// Ensure that announcements reference drupal.org and are applicable to
// the current Drupal version.
$announcements = array_filter($announcements, function (array $announcement) {
return static::validateUrl($announcement['url'] ?? '') && static::isRelevantItem($announcement['_drupalorg']['version'] ?? '');
});
// Save the raw decoded and filtered array to temp store.
$this->tempStore->setWithExpire('announcements', $announcements,
$this->config->get('max_age'));
}
// The drupal.org endpoint is sorted by created date in descending order.
// We will limit the announcements based on the configuration limit.
$announcements = array_slice($announcements, 0, $this->config->get('limit') ?? 10);
// For the remaining announcements, put all the featured announcements
// before the rest.
uasort($announcements, function ($a, $b) {
$a_value = (int) $a['_drupalorg']['featured'];
$b_value = (int) $b['_drupalorg']['featured'];
if ($a_value == $b_value) {
return 0;
}
return ($a_value < $b_value) ? -1 : 1;
});
// Map the multidimensional array into an array of Announcement objects.
$announcements = array_map(function ($announcement) {
return new Announcement(
$announcement['id'],
$announcement['title'],
$announcement['url'],
$announcement['date_modified'],
$announcement['date_published'],
$announcement['content_html'],
$announcement['_drupalorg']['version'],
(bool) $announcement['_drupalorg']['featured'],
);
}, $announcements);
return $announcements;
}
}

View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Drupal\announcements_feed;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Service to render announcements from the external feed.
*
* @internal
*/
final class AnnounceRenderer {
use StringTranslationTrait;
/**
* Constructs an AnnouncementRenderer object.
*
* @param \Drupal\announcements_feed\AnnounceFetcher $announceFetcher
* The AnnounceFetcher service.
* @param string $feedLink
* The feed url path.
*/
public function __construct(
protected AnnounceFetcher $announceFetcher,
protected string $feedLink,
) {
}
/**
* Generates the announcements feed render array.
*
* @return array
* Render array containing the announcements feed.
*/
public function render(): array {
try {
$announcements = $this->announceFetcher->fetch();
}
catch (\Exception) {
return [
'#theme' => 'status_messages',
'#message_list' => [
'error' => [
$this->t('An error occurred while parsing the announcements feed, check the logs for more information.'),
],
],
'#status_headings' => [
'error' => $this->t('Error Message'),
],
];
}
$build = [];
foreach ($announcements as $announcement) {
$key = $announcement->featured ? '#featured' : '#standard';
$build[$key][] = $announcement;
}
$build += [
'#theme' => 'announcements_feed',
'#count' => count($announcements),
'#feed_link' => $this->feedLink,
'#cache' => [
'contexts' => [
'url.query_args:_wrapper_format',
],
'tags' => [
'announcements_feed:feed',
],
],
'#attached' => [
'library' => [
'announcements_feed/drupal.announcements_feed.dialog',
],
],
];
return $build;
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Drupal\announcements_feed;
use Drupal\Core\Datetime\DrupalDateTime;
/**
* Object containing a single announcement from the feed.
*
* @internal
*/
final class Announcement {
/**
* Construct an Announcement object.
*
* @param string $id
* Unique identifier of the announcement.
* @param string $title
* Title of the announcement.
* @param string $url
* URL where the announcement can be seen.
* @param string $date_modified
* When was the announcement last modified.
* @param string $date_published
* When was the announcement published.
* @param string $content_html
* HTML content of the announcement.
* @param string $version
* Target Drupal version of the announcement.
* @param bool $featured
* Whether this announcement is featured or not.
*/
public function __construct(
public readonly string $id,
public readonly string $title,
public readonly string $url,
public readonly string $date_modified,
public readonly string $date_published,
public readonly string $content_html,
public readonly string $version,
public readonly bool $featured,
) {
}
/**
* Returns the content of the announcement with no markup.
*
* @return string
* Content of the announcement without markup.
*/
public function getContent() {
return strip_tags($this->content_html);
}
/**
* Gets the published date in timestamp format.
*
* @return int
* Date published timestamp.
*/
public function getDatePublishedTimestamp() {
return DrupalDateTime::createFromFormat(DATE_ATOM, $this->date_published)->getTimestamp();
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Drupal\announcements_feed\Controller;
use Drupal\announcements_feed\AnnounceRenderer;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Controller for community announcements.
*
* @internal
*/
class AnnounceController extends ControllerBase implements ContainerInjectionInterface {
/**
* Constructs an AnnounceController object.
*
* @param \Drupal\announcements_feed\AnnounceRenderer $announceRenderer
* The AnnounceRenderer service.
*/
public function __construct(
protected AnnounceRenderer $announceRenderer,
) {
}
/**
* Returns the list of Announcements.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return array
* A build array with announcements.
*/
public function getAnnouncements(Request $request): array {
$build = $this->announceRenderer->render();
if ($request->query->get('_wrapper_format') != 'drupal_dialog.off_canvas') {
$build['#theme'] = 'announcements_feed_admin';
$build['#attached'] = [];
}
return $build;
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace Drupal\announcements_feed\Hook;
use Drupal\announcements_feed\RenderCallbacks;
use Drupal\Core\Link;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Hook implementations for announcements_feed.
*/
class AnnouncementsFeedHooks {
use StringTranslationTrait;
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help($route_name, RouteMatchInterface $route_match): ?string {
switch ($route_name) {
case 'help.page.announcements_feed':
$output = '';
$output .= '<h2>' . $this->t('About') . '</h2>';
$output .= '<p>' . $this->t('The Announcements module displays announcements from the Drupal community. For more information, see the <a href=":documentation">online documentation for the Announcements module</a>.', [
':documentation' => 'https://www.drupal.org/docs/core-modules-and-themes/core-modules/announcements-feed',
]) . '</p>';
$output .= '<h2>' . $this->t('Uses') . '</h2>';
$output .= '<dl><dt>' . $this->t('Accessing announcements') . '</dt>';
$output .= '<dd>' . $this->t('Users with the "View drupal.org announcements" permission may click on the "Announcements" item in the administration toolbar, or access @link, to see all announcements relevant to the Drupal version of your site.', [
'@link' => Link::createFromRoute($this->t('Announcements'), 'announcements_feed.announcement')->toString(),
]) . '</dd>';
$output .= '</dl>';
return $output;
}
return NULL;
}
/**
* Implements hook_toolbar().
*/
#[Hook('toolbar')]
public function toolbar(): array {
if (!\Drupal::currentUser()->hasPermission('access announcements')) {
return ['#cache' => ['contexts' => ['user.permissions']]];
}
$items['announcement'] = [
'#type' => 'toolbar_item',
'tab' => [
'#lazy_builder' => [
'announcements_feed.lazy_builders:renderAnnouncements',
[],
],
'#create_placeholder' => TRUE,
'#cache' => [
'tags' => [
'announcements_feed:feed',
],
],
],
'#wrapper_attributes' => [
'class' => [
'announce-toolbar-tab',
],
],
'#cache' => [
'contexts' => [
'user.permissions',
],
],
'#weight' => 3399,
];
// \Drupal\toolbar\Element\ToolbarItem::preRenderToolbarItem adds an
// #attributes property to each toolbar item's tab child automatically. Lazy
// builders don't support an #attributes property so we need to add another
// render callback to remove the #attributes property. We start by adding
// the defaults, and then we append our own pre render callback.
$items['announcement'] += \Drupal::service('plugin.manager.element_info')->getInfo('toolbar_item');
$items['announcement']['#pre_render'][] = [RenderCallbacks::class, 'removeTabAttributes'];
return $items;
}
/**
* Implements hook_toolbar_alter().
*/
#[Hook('toolbar_alter')]
public function toolbarAlter(&$items): void {
// As the "Announcements" link is shown already in the top toolbar bar, we
// don't need it again in the administration menu tray, so hide it.
if (!empty($items['administration']['tray'])) {
$callable = function (array $element) {
unset($element['administration_menu']['#items']['announcements_feed.announcement']);
return $element;
};
$items['administration']['tray']['toolbar_administration']['#pre_render'][] = $callable;
}
}
/**
* Implements hook_theme().
*/
#[Hook('theme')]
public function theme($existing, $type, $theme, $path) : array {
return [
'announcements_feed' => [
'variables' => [
'featured' => NULL,
'standard' => NULL,
'count' => 0,
'feed_link' => '',
],
],
'announcements_feed_admin' => [
'variables' => [
'featured' => NULL,
'standard' => NULL,
'count' => 0,
'feed_link' => '',
],
],
];
}
/**
* Implements hook_cron().
*/
#[Hook('cron')]
public function cron(): void {
$config = \Drupal::config('announcements_feed.settings');
$interval = $config->get('cron_interval');
$last_check = \Drupal::state()->get('announcements_feed.last_fetch', 0);
$time = \Drupal::time()->getRequestTime();
if ($time - $last_check > $interval) {
\Drupal::service('announcements_feed.fetcher')->fetch(TRUE);
\Drupal::state()->set('announcements_feed.last_fetch', $time);
}
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Drupal\announcements_feed;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Html;
use Drupal\Core\Render\ElementInfoManagerInterface;
use Drupal\Core\Security\TrustedCallbackInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
/**
* Defines a class for lazy building render arrays.
*
* @internal
*/
final class LazyBuilders implements TrustedCallbackInterface {
use StringTranslationTrait;
/**
* Constructs LazyBuilders object.
*
* @param \Drupal\Core\Render\ElementInfoManagerInterface $elementInfo
* Element info.
*/
public function __construct(
protected ElementInfoManagerInterface $elementInfo,
) {
}
/**
* Render announcements.
*
* @return array
* Render array.
*/
public function renderAnnouncements(): array {
$build = [
'#type' => 'link',
'#cache' => [
'context' => ['user.permissions'],
],
'#title' => $this->t('Announcements'),
'#url' => Url::fromRoute('announcements_feed.announcement'),
'#id' => Html::getId('toolbar-item-announcement'),
'#attributes' => [
'title' => $this->t('Announcements'),
'data-drupal-announce-trigger' => '',
'class' => [
'toolbar-icon',
'toolbar-item',
'toolbar-icon-announce',
'use-ajax',
'announce-canvas-link',
'announce-default',
],
'data-dialog-renderer' => 'off_canvas',
'data-dialog-type' => 'dialog',
'data-dialog-options' => Json::encode(
[
'announce' => TRUE,
'width' => '25%',
'classes' => [
'ui-dialog' => 'announce-dialog',
'ui-dialog-titlebar' => 'announce-titlebar',
'ui-dialog-title' => 'announce-title',
'ui-dialog-titlebar-close' => 'announce-close',
'ui-dialog-content' => 'announce-body',
],
]),
],
'#attached' => [
'library' => [
'announcements_feed/drupal.announcements_feed.toolbar',
],
],
];
// The renderer has already added element defaults by the time the lazy
// builder is run.
// @see https://www.drupal.org/project/drupal/issues/2609250
$build += $this->elementInfo->getInfo('link');
return $build;
}
/**
* {@inheritdoc}
*/
public static function trustedCallbacks(): array {
return ['renderAnnouncements'];
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Drupal\announcements_feed\Plugin\Block;
use Drupal\announcements_feed\AnnounceRenderer;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides an 'Announcements Feed' block.
*
* @internal
*/
#[Block(
id: 'announce_block',
admin_label: new TranslatableMarkup('Announcements Feed')),
]
class AnnounceBlock extends BlockBase implements ContainerFactoryPluginInterface {
/**
* Constructs a new AnnouncementsFeedBlock instance.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin ID for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\announcements_feed\AnnounceRenderer $announceRenderer
* The AnnounceRenderer service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, protected AnnounceRenderer $announceRenderer) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('announcements_feed.renderer')
);
}
/**
* {@inheritdoc}
*/
public function blockAccess(AccountInterface $account): AccessResultInterface {
return AccessResult::allowedIfHasPermission($account, 'access announcements');
}
/**
* {@inheritdoc}
*/
public function build(): array {
return $this->announceRenderer->render();
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Drupal\announcements_feed;
use Drupal\Core\Security\TrustedCallbackInterface;
/**
* Defines a class for render callbacks.
*
* @internal
*/
final class RenderCallbacks implements TrustedCallbackInterface {
/**
* Render callback.
*/
public static function removeTabAttributes(array $element): array {
unset($element['tab']['#attributes']);
return $element;
}
/**
* {@inheritdoc}
*/
public static function trustedCallbacks(): array {
return ['removeTabAttributes'];
}
}

View File

@ -0,0 +1,27 @@
{#
/**
* @file
* Template file for the theming of announcement_feed admin page.
*
* This template will get rendered when the user navigates to the announcements_feed.announcement route.
*
* Available variables:
* - count: Contains the total number of announcements.
* - featured: A list of featured announcement objects.
* - standard: A list of non-featured announcement objects.
*
* Announcement objects have the following variables:
* - id: Unique id of the announcement.
* - title: Title of the standard announcement.
* - content: Short description of the announcement.
* - datePublishedTimestamp: Timestamp of the announcement.
* - url: Learn more link of the standard announcement.
*
* @see announcements_feed_theme()
*
* @ingroup themeable
*/
#}
{{ attach_library('announcements_feed/drupal.announcements_feed.page') }}
{% include '@announcements_feed/announcements.html.twig' %}

View File

@ -0,0 +1,25 @@
{#
/**
* @file
* Template file for the theming of announcement_feed off-canvas dialog.
*
* This template will get rendered when the user clicks the announcement button in the toolbar.
*
* Available variables:
* - count: Contains the total number of announcements.
* - featured: A list of featured announcement objects.
* - standard: A list of non-featured announcement objects.
*
* Announcement objects have the following variables:
* - id: Unique id of the announcement.
* - title: Title of the standard announcement.
* - content: Short description of the announcement.
* - datePublishedTimestamp: Timestamp of the announcement.
* - url: Learn more link of the standard announcement.
*
* @see announcements_feed_theme()
*
* @ingroup themeable
*/
#}
{% include '@announcements_feed/announcements.html.twig' %}

View File

@ -0,0 +1,37 @@
{% if count %}
<nav class="announcements">
<ul>
{% if featured|length %}
{% for announcement in featured %}
<li class="announcement announcement--featured" data-drupal-featured>
<div class="announcement__title">
<h4>{{ announcement.title }}</h4>
</div>
<div class="announcement__teaser">
{{ announcement.content }}
</div>
<div class="announcement__link">
<a href="{{ announcement.url }}">{{ 'Learn More'|t }}</a>
</div>
</li>
{% endfor %}
{% endif %}
{% for announcement in standard %}
<li class="announcement announcement--standard">
<div class="announcement__title">
<a href="{{ announcement.url }}">{{ announcement.title }}</a>
<div class="announcement__date">{{ announcement.datePublishedTimestamp|format_date('short') }}</div>
</div>
</li>
{% endfor %}
</ul>
</nav>
{% if feed_link %}
<p class="announcements--view-all">
<a target="_blank" href="{{ feed_link }}">{{ 'View all announcements'|t }}</a>
</p>
{% endif %}
{% else %}
<div class="announcements announcements--empty"><p> {{ 'No announcements available'|t }}</p></div>
{% endif %}

View File

@ -0,0 +1,57 @@
{
"version": "https://jsonfeed.org/version/1.1",
"title": "Drupal Announcements Feed",
"home_page_url": "https://www.drupal.org",
"feed_url": "https://www.drupal.org/announcements.json",
"favicon": "https://www.drupal.org/favicon.ico",
"items": [
{
"id": "201",
"title": "new 9 - 10 Drupal 9.1.3 is available",
"content_html": "This release will have a community alert prototype to notify site admins about drupal updates and required information",
"url": "https://www.drupal.org/project/announce",
"date_modified": "2021-01-19T07:29:38+00:00",
"date_published": "2021-01-18T07:29:38+00:00",
"_drupalorg": {
"featured": false,
"version": "^9 | ^10"
}
},
{
"id": "2021",
"title": "updated 10 - DrupalCon is here",
"content_html": "This release will have a community alert prototype to notify site admins about drupal updates and required information",
"url": "https://www.drupal.org/project/announce",
"date_modified": "2021-01-19T07:29:38+00:00",
"date_published": "2021-01-18T07:29:38+00:00",
"_drupalorg": {
"featured": true,
"version": "^10"
}
},
{
"id": "2031",
"title": "new 9 only - Download latest drupal here",
"content_html": "This release will have a community alert prototype to notify site admins about drupal updates and required information",
"url": "https://www.drupal.org/project/announce",
"date_modified": "2021-01-19T07:29:38+00:00",
"date_published": "2021-01-18T07:29:38+00:00",
"_drupalorg": {
"featured": false,
"version": "^9"
}
},
{
"id": "2043",
"title": "Only 10 - Drupal 106 is available",
"content_html": "This release will have a community alert prototype to notify site admins about drupal updates and required information",
"url": "https://www.drupal.org/project/announce",
"date_modified": "2021-01-19T07:29:39+00:00",
"date_published": "2021-01-18T07:29:39+00:00",
"_drupalorg": {
"featured": false,
"version": "^10"
}
}
]
}

View File

@ -0,0 +1,8 @@
{
"version": "https://jsonfeed.org/version/1.1",
"title": "Drupal Announcements Feed",
"home_page_url": "https://www.drupal.org",
"feed_url": "https://www.drupal.org/announcements.json",
"favicon": "https://www.drupal.org/favicon.ico",
"items": []
}

View File

@ -0,0 +1,45 @@
{
"version": "https://jsonfeed.org/version/1.1",
"title": "Drupal Announcements Feed",
"home_page_url": "https://www.drupal.org",
"feed_url": "https://www.drupal.org/announcements.json",
"favicon": "https://www.drupal.org/favicon.ico",
"items": [
{
"id": "201",
"title": "new 9 - 10 Drupal 9.1.3 is available",
"content_html": "This release will have a community alert prototype to notify site admins about drupal updates and required information",
"url": "https://www.drupal.org/project/announce",
"date_modified": "2021-01-19T07:29:38+00:00",
"date_published": "2021-01-18T07:29:38+00:00",
"_drupalorg": {
"featured": true,
"version": "^9 | ^10"
}
},
{
"id": "2021",
"title": "updated 10 - DrupalCon is here",
"content_html": "This release will have a community alert prototype to notify site admins about drupal updates and required information",
"url": "https://www.drupal.org/project/announce",
"date_modified": "2021-01-19T07:29:38+00:00",
"date_published": "2021-01-18T07:29:38+00:00",
"_drupalorg": {
"featured": false,
"version": "^10"
}
},
{
"id": "2031",
"title": "new 9 only - Download latest drupal here",
"content_html": "This release will have a community alert prototype to notify site admins about drupal updates and required information",
"url": "https://www.drupal.org/project/announce",
"date_modified": "2021-01-19T07:29:38+00:00",
"date_published": "2021-01-18T07:29:38+00:00",
"_drupalorg": {
"featured": false,
"version": "^9"
}
}
]
}

View File

@ -0,0 +1,69 @@
{
"version": "https://jsonfeed.org/version/1.1",
"title": "Drupal Announcements Feed",
"home_page_url": "https://www.drupal.org",
"feed_url": "https://www.drupal.org/announcements.json",
"favicon": "https://www.drupal.org/favicon.ico",
"items": [
{
"id": "201",
"title": "new 9 - 10 Drupal 9.1.3 is available",
"content_html": "This release will have a community alert prototype to notify site admins about drupal updates and required information",
"url": "https://www.drupal.org/project/announce",
"date_modified": "2021-01-19T07:29:38+00:00",
"date_published": "2021-01-18T07:29:38+00:00",
"_drupalorg":{
"featured": true,
"version": "^9 | ^10"
}
},
{
"id": "2021",
"title": "updated 10 - DrupalCon is here",
"content_html": "This release will have a community alert prototype to notify site admins about drupal updates and required information",
"url": "https://www.drupal.org/project/announce",
"date_modified": "2021-01-19T07:29:38+00:00",
"date_published": "2021-01-18T07:29:38+00:00",
"_drupalorg": {
"featured": false,
"version": "^10"
}
},
{
"id": "2031",
"title": "new 9 only - Download latest drupal here",
"content_html": "This release will have a community alert prototype to notify site admins about drupal updates and required information",
"url": "https://www.drupal.org/project/announce",
"date_modified": "2021-01-19T07:29:38+00:00",
"date_published": "2021-01-18T07:29:38+00:00",
"_drupalorg": {
"featured": false,
"version": "^9"
}
},
{
"id": "2043",
"title": "announce title updated",
"content_html": "This release will have a community alert prototype to notify site admins about drupal updates and required information",
"url": "https://www.drupal.org/project/announce",
"date_modified": "2021-01-19T07:29:39+00:00",
"date_published": "2021-01-18T07:29:39+00:00",
"_drupalorg": {
"featured": false,
"version": "^10"
}
},
{
"id": "2044",
"title": "Only 10 - Drupal 106 is available and this feed is Updated",
"content_html": "This release will have a community alert prototype to notify site admins about drupal updates and required information",
"url": "https://www.drupal.org/project/announce-updated",
"date_modified": "2021-01-19T07:29:39+00:00",
"date_published": "2021-01-18T07:29:39+00:00",
"_drupalorg": {
"featured": false,
"version": "^10"
}
}
]
}

View File

@ -0,0 +1,4 @@
name: 'Announce feed test'
type: module
description: 'Support module for announce feed testing.'
package: Testing

View File

@ -0,0 +1,7 @@
announce_feed_test.json_test:
path: '/announce-feed-json/{json_name}'
defaults:
_title: 'Announce Feed test'
_controller: '\Drupal\announce_feed_test\Controller\AnnounceTestController::setFeedConfig'
requirements:
_access: 'TRUE'

View File

@ -0,0 +1,5 @@
services:
announce_feed_test.announce_client_middleware:
class: Drupal\announce_feed_test\AnnounceTestHttpClientMiddleware
tags:
- { name: http_client_middleware }

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Drupal\announce_feed_test;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Url;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\Uri;
use Psr\Http\Message\RequestInterface;
/**
* Overrides the requested endpoint when running tests.
*/
class AnnounceTestHttpClientMiddleware {
/**
* HTTP middleware that replaces request endpoint for a test one.
*/
public function __invoke(): \Closure {
return function ($handler) {
return function (RequestInterface $request, array $options) use ($handler): PromiseInterface {
$test_end_point = \Drupal::state()->get('announce_test_endpoint');
if ($test_end_point && str_contains((string) $request->getUri(), '://www.drupal.org/announcements.json')) {
// Only override $uri if it matches the advisories JSON feed to avoid
// changing any other uses of the 'http_client' service during tests
// with this module installed.
$request = $request->withUri(new Uri($test_end_point));
}
return $handler($request, $options);
};
};
}
/**
* Sets the test endpoint for the advisories JSON feed.
*
* @param string $test_endpoint
* The test endpoint.
*/
public static function setAnnounceTestEndpoint(string $test_endpoint): void {
// Convert the endpoint to an absolute URL.
$test_endpoint = Url::fromUri('base:/' . $test_endpoint)->setAbsolute()->toString();
\Drupal::state()->set('announce_test_endpoint', $test_endpoint);
\Drupal::service('keyvalue.expirable')->get('announcements_feed')->delete('announcements');
Cache::invalidateTags(['announcements_feed:feed']);
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Drupal\announce_feed_test\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Defines a controller to return JSON for security advisory tests.
*/
class AnnounceTestController {
/**
* Reads a JSON file and returns the contents as a Response.
*
* This method will replace the string '[CORE_VERSION]' with the current core
* version to allow testing core version matches.
*
* @param string $json_name
* The name of the JSON file without the file extension.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\Response
* If a fixture file with the name $json_name + '.json' is found a
* JsonResponse will be returned using the contents of the file, otherwise a
* Response will be returned with a 404 status code.
*/
public function setFeedConfig(string $json_name): JsonResponse|Response {
$file = __DIR__ . "/../../../../announce_feed/$json_name.json";
$headers = ['Content-Type' => 'application/json; charset=utf-8'];
if (!is_file($file)) {
// Return an empty response.
return new Response('', 404, $headers);
}
return new JsonResponse(file_get_contents($file), 200, $headers, TRUE);
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\announcements_feed\Functional;
use Drupal\Tests\BrowserTestBase;
use Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber;
/**
* Defines a class for testing pages are still cacheable with dynamic page cache.
*
* @group announcements_feed
*/
final class AnnouncementsCacheTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $modules = [
'announcements_feed',
'dynamic_page_cache',
'node',
'toolbar',
];
/**
* Tests dynamic page cache.
*/
public function testDynamicPageCache(): void {
$node_type = $this->drupalCreateContentType();
$node = $this->drupalCreateNode(['type' => $node_type->id()]);
$this->drupalLogin($this->drupalCreateUser([
'access toolbar',
'access announcements',
]));
$this->drupalGet($node->toUrl());
$this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'MISS');
// Reload the page, it should be cached now.
$this->drupalGet($node->toUrl());
$this->assertSession()->elementExists('css', '[data-drupal-announce-trigger]');
$this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\announcements_feed\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for announcements_feed.
*
* @group announcements_feed
*/
class GenericTest extends GenericModuleTestBase {}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\announcements_feed\FunctionalJavascript;
use Drupal\Tests\system\FunctionalJavascript\OffCanvasTestBase;
use Drupal\announce_feed_test\AnnounceTestHttpClientMiddleware;
/**
* Test the access announcement permissions to get access announcement icon.
*
* @group announcements_feed
*/
class AccessAnnouncementTest extends OffCanvasTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'user',
'toolbar',
'announcements_feed',
'announce_feed_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
public function setUp():void {
parent::setUp();
AnnounceTestHttpClientMiddleware::setAnnounceTestEndpoint('/announce-feed-json/community-feeds');
}
/**
* Test of viewing announcements by a user with appropriate permission.
*/
public function testAnnounceFirstLogin(): void {
$this->drupalLogin(
$this->drupalCreateUser(
[
'access toolbar',
'access announcements',
]
)
);
$this->drupalGet('<front>');
// Check that the user can see the toolbar.
$this->assertSession()->elementExists('css', '#toolbar-bar');
// And the announcements.
$this->assertSession()->elementExists('css', '.toolbar-icon-announce');
}
/**
* Testing announce icon without announce permission.
*/
public function testAnnounceWithoutPermission(): void {
// User without "access announcements" permission.
$account = $this->drupalCreateUser(
[
'access toolbar',
]
);
$this->drupalLogin($account);
$this->drupalGet('<front>');
// Check that the user can see the toolbar.
$this->assertSession()->elementExists('css', '#toolbar-bar');
// But not the announcements.
$this->assertSession()->elementNotExists('css', '.toolbar-icon-announce');
$this->drupalGet('admin/announcements_feed');
$this->assertSession()->responseContains('You are not authorized to access this page.');
}
}

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\announcements_feed\FunctionalJavascript;
use Drupal\Tests\system\FunctionalJavascript\OffCanvasTestBase;
use Drupal\announce_feed_test\AnnounceTestHttpClientMiddleware;
use Drupal\user\UserInterface;
/**
* Test the access announcement according to json feed changes.
*
* @group announcements_feed
*/
class AlertsJsonFeedTest extends OffCanvasTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'user',
'toolbar',
'announcements_feed',
'announce_feed_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A user with permission to access toolbar and access announcements.
*
* @var \Drupal\user\UserInterface
*/
protected UserInterface $user;
/**
* {@inheritdoc}
*/
public function setUp():void {
if ($this->name() === 'testAnnounceFeedUpdatedAndRemoved') {
$this->markTestSkipped('Skipped due to major version-specific logic. See https://www.drupal.org/project/drupal/issues/3359322');
}
parent::setUp();
$this->user = $this->drupalCreateUser(
[
'access toolbar',
'access announcements',
]
);
AnnounceTestHttpClientMiddleware::setAnnounceTestEndpoint('/announce-feed-json/community-feeds');
}
/**
* Check the status of the announcements when the feed is updated and removed.
*/
public function testAnnounceFeedUpdatedAndRemoved(): void {
$this->drupalLogin($this->user);
$this->drupalGet('<front>');
$this->clickLink('Announcements');
$this->waitForOffCanvasToOpen();
$page_html = $this->getSession()->getPage()->getHtml();
$this->assertStringNotContainsString('Only 10 - Drupal 106 is available and this feed is Updated', $page_html);
// Change the feed url and reset temp storage.
AnnounceTestHttpClientMiddleware::setAnnounceTestEndpoint('/announce-feed-json/updated');
$this->drupalGet('<front>');
$this->clickLink('Announcements');
$this->waitForOffCanvasToOpen();
$page_html = $this->getSession()->getPage()->getHtml();
$this->assertStringContainsString('Only 10 - Drupal 106 is available and this feed is Updated', $page_html);
$this->drupalLogout();
// Change the feed url and reset temp storage.
AnnounceTestHttpClientMiddleware::setAnnounceTestEndpoint('/announce-feed-json/removed');
$this->drupalLogin($this->user);
$this->drupalGet('<front>');
$this->clickLink('Announcements');
$this->waitForOffCanvasToOpen();
$page_html = $this->getSession()->getPage()->getHtml();
$this->assertStringNotContainsString('Only 10 - Drupal 106 is available and this feed is Updated', $page_html);
}
/**
* Check with an empty JSON feed.
*/
public function testAnnounceFeedEmpty(): void {
// Change the feed url and reset temp storage.
AnnounceTestHttpClientMiddleware::setAnnounceTestEndpoint('/announce-feed-json/empty');
$this->drupalLogin($this->user);
$this->drupalGet('<front>');
// Removed items should not display in the announcement model.
$this->clickLink('Announcements');
$this->waitForOffCanvasToOpen();
$this->assertStringContainsString('No announcements available', $this->getSession()->getPage()->getHtml());
}
}

View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\announcements_feed\FunctionalJavascript;
use Drupal\announce_feed_test\AnnounceTestHttpClientMiddleware;
use Drupal\block\BlockInterface;
use Drupal\Core\Access\AccessResultAllowed;
use Drupal\Core\Access\AccessResultNeutral;
use Drupal\Core\Session\AnonymousUserSession;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Test the announcement block test visibility.
*
* @group announcements_feed
*/
class AnnounceBlockTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'announcements_feed',
'block',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The announce block instance.
*
* @var \Drupal\block\BlockInterface
*/
protected BlockInterface $announceBlock;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
AnnounceTestHttpClientMiddleware::setAnnounceTestEndpoint('/announce-feed-json/community-feeds');
$this->announceBlock = $this->placeBlock('announce_block', [
'label' => 'Announcements Feed',
]);
}
/**
* Testing announce feed block visibility.
*/
public function testAnnounceWithoutPermission(): void {
// User with "access announcements" permission and anonymous session.
$account = $this->drupalCreateUser([
'access announcements',
]);
$anonymous_account = new AnonymousUserSession();
$this->drupalLogin($account);
$this->drupalGet('<front>');
$assert_session = $this->assertSession();
// Block should be visible for the user.
$assert_session->pageTextContains('Announcements Feed');
// Block is not accessible without permission.
$this->drupalLogout();
$assert_session->pageTextNotContains('Announcements Feed');
// Test access() method return type.
$this->assertTrue($this->announceBlock->getPlugin()->access($account));
$this->assertInstanceOf(AccessResultAllowed::class, $this->announceBlock->getPlugin()->access($account, TRUE));
$this->assertFalse($this->announceBlock->getPlugin()->access($anonymous_account));
$this->assertInstanceOf(AccessResultNeutral::class, $this->announceBlock->getPlugin()->access($anonymous_account, TRUE));
}
}

View File

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\announcements_feed\Kernel;
use GuzzleHttp\Psr7\Response;
/**
* @coversDefaultClass \Drupal\announcements_feed\AnnounceFetcher
*
* @group announcements_feed
*/
class AnnounceFetcherTest extends AnnounceTestBase {
/**
* {@inheritdoc}
*/
protected function setUp(): void {
$this->markTestSkipped('Skipped due to major version-specific logic. See https://www.drupal.org/project/drupal/issues/3359322');
parent::setUp();
$this->installConfig(['announcements_feed']);
}
/**
* Tests announcement that should be displayed.
*
* @param mixed[] $feed_item
* The feed item to test. 'title' and 'url' are omitted from this array
* because they do not need to vary between test cases.
*
* @dataProvider providerShowAnnouncements
*/
public function testShowAnnouncements(array $feed_item): void {
$this->setFeedItems([$feed_item]);
$feeds = $this->fetchFeedItems();
$this->assertCount(1, $feeds);
$this->assertSame('https://www.drupal.org/project/announce', $feeds[0]->url);
$this->assertSame('Drupal security update Test', $feeds[0]->title);
$this->assertSame('^10', $feeds[0]->version);
$this->assertCount(1, $this->history);
}
/**
* Tests feed fields.
*/
public function testFeedFields(): void {
$feed_item_1 = [
'id' => '1001',
'content_html' => 'Test teaser 1',
'url' => 'https://www.drupal.org/project/announce',
'_drupalorg' => [
'featured' => TRUE,
'version' => '^10',
],
'date_modified' => "2021-09-02T15:09:42+00:00",
'date_published' => "2021-09-01T15:09:42+00:00",
];
$this->setFeedItems([$feed_item_1]);
$feeds = $this->fetchFeedItems();
$this->assertCount(1, $feeds);
$this->assertSame($feed_item_1['id'], $feeds[0]->id);
$this->assertSame($feed_item_1['content_html'], $feeds[0]->content_html);
$this->assertSame($feed_item_1['_drupalorg']['featured'], $feeds[0]->featured);
$this->assertSame($feed_item_1['date_published'], $feeds[0]->date_published);
$this->assertSame($feed_item_1['_drupalorg']['version'], $feeds[0]->version);
}
/**
* Data provider for testShowAnnouncements().
*/
public static function providerShowAnnouncements(): array {
return [
'1' => [
'feed_item' => [
'id' => '1001',
'content_html' => 'Test teaser 1',
'_drupalorg' => [
'featured' => 1,
'version' => '^10',
],
'date_modified' => "2021-09-02T15:09:42+00:00",
'date_published' => "2021-09-01T15:09:42+00:00",
],
],
'2' => [
'feed_item' => [
'id' => '1002',
'content_html' => 'Test teaser 2',
'_drupalorg' => [
'featured' => 1,
'version' => '^10',
],
'date_modified' => "2021-09-02T15:09:42+00:00",
'date_published' => "2021-09-01T15:09:42+00:00",
],
],
'3' => [
'feed_item' => [
'id' => '1003',
'content_html' => 'Test teaser 3',
'_drupalorg' => [
'featured' => 1,
'version' => '^10',
],
'date_modified' => "2021-09-02T15:09:42+00:00",
'date_published' => "2021-09-01T15:09:42+00:00",
],
],
'4' => [
'feed_item' => [
'id' => '1004',
'content_html' => 'Test teaser 4',
'_drupalorg' => [
'featured' => 1,
'version' => '^10',
],
'date_modified' => "2021-09-02T15:09:42+00:00",
'date_published' => "2021-09-01T15:09:42+00:00",
],
],
];
}
/**
* Sets the feed items to be returned for the test.
*
* @param mixed[][] $feed_items
* The feeds items to test. Every time the http_client makes a request the
* next item in this array will be returned. For each feed item 'title' and
* 'url' are omitted because they do not need to vary between test cases.
*/
protected function setFeedItems(array $feed_items): void {
$responses = [];
foreach ($feed_items as $feed_item) {
$feed_item += [
'title' => 'Drupal security update Test',
'url' => 'https://www.drupal.org/project/announce',
];
$responses[] = new Response(200, [], json_encode(['items' => [$feed_item]]));
}
$this->setTestFeedResponses($responses);
}
/**
* Gets the announcements from the 'announce.fetcher' service.
*
* @return \Drupal\announcements_feed\Announcement[]
* The return value of AnnounceFetcher::fetch().
*/
protected function fetchFeedItems(): array {
return $this->container->get('announcements_feed.fetcher')->fetch();
}
}

View File

@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\announcements_feed\Kernel;
use Drupal\Tests\user\Traits\UserCreationTrait;
use GuzzleHttp\Psr7\Response;
/**
* @coversDefaultClass \Drupal\announcements_feed\AnnounceFetcher
*
* @group announcements_feed
*/
class AnnounceFetcherUserTest extends AnnounceTestBase {
use UserCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'toolbar',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installSchema('user', ['users_data']);
// Setting current user.
$permissions = [
'access toolbar',
'access announcements',
];
$this->setUpCurrentUser(['uid' => 1], $permissions);
}
/**
* Tests testAllAnnouncements should get all announcements.
*
* First time accessing the announcements.
*/
public function testAllAnnouncementsFirst(): void {
$this->markTestSkipped('Skipped due to major version-specific logic. See https://www.drupal.org/project/drupal/issues/3359322');
$feed_items = $this->providerShowAnnouncements();
// First time access.
$this->setFeedItems($feed_items);
$all_items = $this->container->get('announcements_feed.fetcher')->fetch();
$this->assertCount(4, $all_items);
$this->assertCount(1, $this->history);
// Second time access.
$this->setFeedItems($feed_items);
$all_items = $this->container->get('announcements_feed.fetcher')->fetch();
$this->assertCount(4, $all_items);
$this->assertCount(2, $this->history);
// Create another user and test again.
$permissions = [
'access toolbar',
'access announcements',
];
$this->setUpCurrentUser(['uid' => 2], $permissions);
$this->setFeedItems($feed_items);
// First time access.
$all_items = $this->container->get('announcements_feed.fetcher')->fetch();
$this->assertCount(4, $all_items);
$this->assertCount(3, $this->history);
// Check after adding new record.
$feed_items = $this->providerShowUpdatedAnnouncements();
$this->setFeedItems($feed_items);
$all_items = $this->container->get('announcements_feed.fetcher')->fetch();
$this->assertCount(5, $all_items);
$this->assertSame('1005', $all_items[0]->id);
$this->assertCount(4, $this->history);
}
/**
* Data provider for testAllAnnouncements().
*/
public function providerShowAnnouncements(): array {
return [
[
'id' => '1001',
'title' => 'Drupal security update Test',
'url' => 'https://www.drupal.org/project/announce',
'content_html' => 'Test teaser 1',
'_drupalorg' => [
'featured' => TRUE,
'version' => '^10',
],
'date_modified' => date('c', 1611041378),
'date_published' => date('c', 1610958578),
],
[
'id' => '1002',
'title' => 'Drupal security update Test',
'url' => 'https://www.drupal.org/project/announce',
'content_html' => 'Test teaser 2',
'_drupalorg' => [
'featured' => TRUE,
'version' => '^10',
],
'date_modified' => date('c', 1611041378),
'date_published' => date('c', 1610958578),
],
[
'id' => '1003',
'title' => 'Drupal security update Test',
'url' => 'https://www.drupal.org/project/announce',
'content_html' => 'Test teaser 3',
'_drupalorg' => [
'featured' => TRUE,
'version' => '^10',
],
'date_modified' => date('c', 1611041378),
'date_published' => date('c', 1610958578),
],
[
'id' => '1004',
'title' => 'Drupal security update Test',
'url' => 'https://www.drupal.org/project/announce',
'content_html' => 'Test teaser 4',
'_drupalorg' => [
'featured' => TRUE,
'version' => '^10',
],
'date_modified' => date('c', 1611041378),
'date_published' => date('c', 1610958578),
],
];
}
/**
* Data provider for testAllAnnouncements().
*/
public function providerShowUpdatedAnnouncements(): array {
return [
[
'id' => '1005',
'title' => 'Drupal security update Test new',
'url' => 'https://www.drupal.org/project/announce',
'content_html' => 'Test teaser 1',
'_drupalorg' => [
'featured' => TRUE,
'version' => '^10',
],
'date_modified' => date('c', 1611041378),
'date_published' => date('c', 1610958578),
],
[
'id' => '1001',
'title' => 'Drupal security update Test',
'url' => 'https://www.drupal.org/project/announce',
'content_html' => 'Test teaser 1',
'_drupalorg' => [
'featured' => TRUE,
'version' => '^10',
],
'date_modified' => date('c', 1611041378),
'date_published' => date('c', 1610958578),
],
[
'id' => '1002',
'title' => 'Drupal security update Test',
'url' => 'https://www.drupal.org/project/announce',
'content_html' => 'Test teaser 2',
'_drupalorg' => [
'featured' => TRUE,
'version' => '^10',
],
'date_modified' => date('c', 1611041378),
'date_published' => date('c', 1610958578),
],
[
'id' => '1003',
'title' => 'Drupal security update Test',
'url' => 'https://www.drupal.org/project/announce',
'content_html' => 'Test teaser 3',
'_drupalorg' => [
'featured' => TRUE,
'version' => '^10',
],
'date_modified' => date('c', 1611041378),
'date_published' => date('c', 1610958578),
],
[
'id' => '1004',
'title' => 'Drupal security update Test',
'url' => 'https://www.drupal.org/project/announce',
'content_html' => 'Test teaser 4',
'_drupalorg' => [
'featured' => TRUE,
'version' => '^10',
],
'date_modified' => date('c', 1611041378),
'date_published' => date('c', 1610958578),
],
];
}
/**
* Sets the feed items to be returned for the test.
*
* @param mixed[][] $feed_items
* The feeds items to test. Every time the http_client makes a request the
* next item in this array will be returned. For each feed item 'title' and
* 'url' are omitted because they do not need to vary between test cases.
*/
protected function setFeedItems(array $feed_items): void {
$responses[] = new Response(200, [], json_encode(['items' => $feed_items]));
$responses[] = new Response(200, [], json_encode(['items' => $feed_items]));
$responses[] = new Response(200, [], json_encode(['items' => $feed_items]));
$this->setTestFeedResponses($responses);
}
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\announcements_feed\Kernel;
use GuzzleHttp\Psr7\Response;
/**
* @coversDefaultClass \Drupal\announcements_feed\AnnounceRenderer
*
* @group announcements_feed
*/
class AnnounceRendererTest extends AnnounceTestBase {
/**
* Tests rendered valid when something goes wrong.
*/
public function testRendererException(): void {
$this->setTestFeedResponses([
new Response(403),
]);
$render = $this->container->get('announcements_feed.renderer')->render();
$this->assertEquals('status_messages', $render['#theme']);
$this->assertEquals('An error occurred while parsing the announcements feed, check the logs for more information.', $render['#message_list']['error'][0]);
}
/**
* Tests rendered valid content.
*/
public function testRendererContent(): void {
$feed_item_1 = [
'id' => '1001',
'content_html' => 'Test teaser 1',
'url' => 'https://www.drupal.org/project/announce',
'_drupalorg' => [
'featured' => TRUE,
'version' => '^10||^11',
],
'date_modified' => "2021-09-02T15:09:42+00:00",
'date_published' => "2021-09-01T15:09:42+00:00",
];
$feed_item_2 = [
'id' => '1002',
'content_html' => 'Test teaser 1',
'url' => 'https://www.drupal.org/project/announce',
'_drupalorg' => [
'featured' => FALSE,
'version' => '^10||^11',
],
'date_modified' => "2021-09-02T15:09:42+00:00",
'date_published' => "2021-09-01T15:09:42+00:00",
];
$this->setFeedItems([$feed_item_1, $feed_item_2]);
$render = $this->container->get('announcements_feed.renderer')->render();
$this->assertEquals('announcements_feed', $render['#theme']);
$this->assertEquals(1, $render['#count']);
$this->assertEquals(1001, $render['#featured'][0]->id);
$render = $this->container->get('announcements_feed.renderer')->render();
$this->assertEquals('announcements_feed', $render['#theme']);
$this->assertEquals(1, $render['#count']);
$this->assertEquals(1002, $render['#standard'][0]->id);
}
}

View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\announcements_feed\Kernel;
use Drupal\KernelTests\KernelTestBase;
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
/**
* Base class for Announce Kernel tests.
*/
abstract class AnnounceTestBase extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'user',
'system',
'announcements_feed',
];
/**
* History of requests/responses.
*
* @var array
*/
protected array $history = [];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installConfig('system');
$this->installConfig(['user']);
}
/**
* Sets the feed items to be returned for the test.
*
* @param mixed[][] $feed_items
* The feeds items to test. Every time the http_client makes a request the
* next item in this array will be returned. For each feed item 'title' and
* 'url' are omitted because they do not need to vary between test cases.
*/
protected function setFeedItems(array $feed_items): void {
$responses = [];
foreach ($feed_items as $feed_item) {
$feed_item += [
'title' => 'Drupal security update Test',
'url' => 'https://www.drupal.org/project/announce',
];
$responses[] = new Response(200, [], json_encode(['items' => [$feed_item]]));
}
$this->setTestFeedResponses($responses);
}
/**
* Sets test feed responses.
*
* @param \GuzzleHttp\Psr7\Response[] $responses
* The responses for the http_client service to return.
*/
protected function setTestFeedResponses(array $responses): void {
// Create a mock and queue responses.
$mock = new MockHandler($responses);
$handler_stack = HandlerStack::create($mock);
$history = Middleware::history($this->history);
$handler_stack->push($history);
// Rebuild the container because the 'system.sa_fetcher' service and other
// services may already have an instantiated instance of the 'http_client'
// service without these changes.
$this->container->get('kernel')->rebuildContainer();
$this->container = $this->container->get('kernel')->getContainer();
$this->container->set('http_client', new Client(['handler' => $handler_stack]));
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\announcements_feed\Unit;
use Drupal\Tests\UnitTestCase;
use Drupal\announcements_feed\AnnounceFetcher;
/**
* Simple test to ensure that asserts pass.
*
* @group announcements_feed
*/
class AnnounceFetcherUnitTest extends UnitTestCase {
/**
* The Fetcher service object.
*
* @var \Drupal\announcements_feed\AnnounceFetcher
*/
protected AnnounceFetcher $fetcher;
/**
* {@inheritdoc}
*/
public function setUp():void {
parent::setUp();
$httpClient = $this->createMock('GuzzleHttp\ClientInterface');
$config = $this->getConfigFactoryStub([
'announcements_feed.settings' => [
'max_age' => 86400,
'cron_interval' => 21600,
'limit' => 10,
],
]);
$tempStore = $this->createMock('Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface');
$tempStore->expects($this->once())
->method('get')
->willReturn($this->createMock('Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface'));
$logger = $this->createMock('Psr\Log\LoggerInterface');
$this->fetcher = new AnnounceFetcher($httpClient, $config, $tempStore, $logger, 'https://www.drupal.org/announcements.json');
}
/**
* Test the ValidateUrl() method.
*
* @covers \Drupal\announcements_feed\AnnounceFetcher::validateUrl
*
* @dataProvider urlProvider
*/
public function testValidateUrl($url, $isValid): void {
$this->assertEquals($isValid, $this->fetcher->validateUrl($url));
}
/**
* Data for the testValidateUrl.
*/
public static function urlProvider(): array {
return [
['https://www.drupal.org', TRUE],
['https://drupal.org', TRUE],
['https://api.drupal.org', TRUE],
['https://a.drupal.org', TRUE],
['https://123.drupal.org', TRUE],
['https://api-new.drupal.org', TRUE],
['https://api_new.drupal.org', TRUE],
['https://api-.drupal.org', TRUE],
['https://www.example.org', FALSE],
['https://example.org', FALSE],
['https://api.example.org/project/announce', FALSE],
['https://-api.drupal.org', FALSE],
['https://a.example.org/project/announce', FALSE],
['https://test.drupaal.com', FALSE],
['https://api.drupal.org.example.com', FALSE],
['https://example.org/drupal.org', FALSE],
];
}
}

View File

@ -0,0 +1,6 @@
name: 'Automated Cron'
type: module
description: 'Provides an automated way to run cron jobs, by executing them at the end of a server response.'
package: Core
version: VERSION
configure: system.cron_settings

View File

@ -0,0 +1,16 @@
<?php
/**
* @file
*/
use Drupal\Core\Form\FormStateInterface;
/**
* Form submission handler for system_cron_settings().
*/
function automated_cron_settings_submit(array $form, FormStateInterface $form_state): void {
\Drupal::configFactory()->getEditable('automated_cron.settings')
->set('interval', $form_state->getValue('interval'))
->save();
}

View File

@ -0,0 +1,9 @@
parameters:
automated_cron.skip_procedural_hook_scan: true
services:
_defaults:
autoconfigure: true
autowire: true
automated_cron.subscriber:
class: Drupal\automated_cron\EventSubscriber\AutomatedCron

View File

@ -0,0 +1 @@
interval: 10800

View File

@ -0,0 +1,14 @@
# Schema for the configuration files of the Automated cron module.
automated_cron.settings:
type: config_object
label: 'Automated cron settings'
constraints:
FullyValidatable: ~
mapping:
interval:
type: integer
label: 'Run cron every'
constraints:
Range:
min: 0

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Drupal\automated_cron\EventSubscriber;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\State\StateInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireServiceClosure;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* A subscriber running cron after a response is sent.
*/
class AutomatedCron implements EventSubscriberInterface {
public function __construct(
#[AutowireServiceClosure('cron')]
protected readonly \Closure $cron,
protected readonly ConfigFactoryInterface $configFactory,
protected StateInterface $state,
) {}
/**
* Run the automated cron if enabled.
*
* @param \Symfony\Component\HttpKernel\Event\TerminateEvent $event
* The Event to process.
*/
public function onTerminate(TerminateEvent $event): void {
$interval = $this->configFactory->get('automated_cron.settings')->get('interval');
if ($interval > 0) {
$cron_next = $this->state->get('system.cron_last', 0) + $interval;
if ((int) $event->getRequest()->server->get('REQUEST_TIME') > $cron_next) {
($this->cron)()->run();
}
}
}
/**
* Registers the methods in this class that should be listeners.
*
* @return array
* An array of event listener definitions.
*/
public static function getSubscribedEvents(): array {
return [KernelEvents::TERMINATE => [['onTerminate', 100]]];
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace Drupal\automated_cron\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for automated_cron.
*/
class AutomatedCronHooks {
use StringTranslationTrait;
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help($route_name, RouteMatchInterface $route_match): ?string {
switch ($route_name) {
case 'help.page.automated_cron':
$output = '';
$output .= '<h2>' . $this->t('About') . '</h2>';
$output .= '<p>' . $this->t('The Automated Cron module runs cron operations for your site using normal browser/page requests instead of having to set up a separate cron job. The Automated Cron module checks at the end of each server response when cron operation was last ran and, if it has been too long since last run, it executes the cron tasks after sending a server response. For more information, see the <a href=":automated_cron-documentation">online documentation for the Automated Cron module</a>.', [
':automated_cron-documentation' => 'https://www.drupal.org/documentation/modules/automated_cron',
]) . '</p>';
$output .= '<h2>' . $this->t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . $this->t('Configuring Automated Cron') . '</dt>';
$output .= '<dd>' . $this->t('On the <a href=":cron-settings">Cron page</a>, you can set the frequency (time interval) for running cron jobs.', [
':cron-settings' => Url::fromRoute('system.cron_settings')->toString(),
]) . '</dd>';
$output .= '<dt>' . $this->t('Disabling Automated Cron') . '</dt>';
$output .= '<dd>' . $this->t('To disable automated cron, the recommended method is to uninstall the module, to reduce site overhead. If you only want to disable it temporarily, you can set the frequency to Never on the Cron page, and then change the frequency back when you want to start it up again.') . '</dd>';
$output .= '</dl>';
return $output;
}
return NULL;
}
/**
* Implements hook_form_FORM_ID_alter() for the system_cron_settings() form.
*/
#[Hook('form_system_cron_settings_alter')]
public function formSystemCronSettingsAlter(&$form, &$form_state) : void {
$automated_cron_settings = \Drupal::config('automated_cron.settings');
$options = [3600, 10800, 21600, 43200, 86400, 604800];
$form['cron']['interval'] = [
'#type' => 'select',
'#title' => $this->t('Run cron every'),
'#description' => $this->t('More information about setting up scheduled tasks can be found by <a href=":url">reading the cron tutorial on drupal.org</a>.', [
':url' => 'https://www.drupal.org/docs/8/administering-a-drupal-8-site/cron-automated-tasks',
]),
'#default_value' => $automated_cron_settings->get('interval'),
'#options' => [
0 => $this->t('Never'),
] + array_map([
\Drupal::service('date.formatter'),
'formatInterval',
], array_combine($options, $options)),
];
// Add submit callback.
$form['#submit'][] = 'automated_cron_settings_submit';
// Theme this form as a config form.
$form['#theme'] = 'system_config_form';
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\automated_cron\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for automated_cron.
*
* @group automated_cron
*/
class GenericTest extends GenericModuleTestBase {}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\automated_cron\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Tests for automated_cron.
*
* @group automated_cron
*/
class AutomatedCronTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['automated_cron'];
/**
* Tests that automated cron runs cron on an HTTP request.
*
* @covers \Drupal\automated_cron\EventSubscriber\AutomatedCron::onTerminate
*/
public function testRunsCronOnHttpRequest(): void {
// Set automated_cron interval and times.
// Any interval > 0 should work.
$this->config('automated_cron.settings')->set('interval', 10800)->save();
$request = new Request();
// Cron uses `$_SERVER['REQUEST_TIME']` to set `system.cron_last`
// because there is no request stack, so we set the request time
// to the same.
$expected = $_SERVER['REQUEST_TIME'];
$request->server->set('REQUEST_TIME', $expected);
// Invoke `AutomatedCron::onTerminate` and check result.
$this->assertNull($this->container->get('state')->get('system.cron_last'));
$this->container->get('kernel')->terminate($request, new Response());
$this->assertEquals($expected, $this->container->get('state')->get('system.cron_last'));
}
}

View File

@ -0,0 +1,6 @@
name: Ban
type: module
description: 'Allows banning visits from specific IP addresses.'
package: Core
version: VERSION
configure: ban.admin_page

View File

@ -0,0 +1,35 @@
<?php
/**
* @file
* Install, update and uninstall functions for the Ban module.
*/
/**
* Implements hook_schema().
*/
function ban_schema(): array {
$schema['ban_ip'] = [
'description' => 'Stores banned IP addresses.',
'fields' => [
'iid' => [
'description' => 'Primary Key: unique ID for IP addresses.',
'type' => 'serial',
'unsigned' => TRUE,
'not null' => TRUE,
],
'ip' => [
'description' => 'IP address',
'type' => 'varchar_ascii',
'length' => 40,
'not null' => TRUE,
'default' => '',
],
],
'indexes' => [
'ip' => ['ip'],
],
'primary key' => ['iid'],
];
return $schema;
}

View File

@ -0,0 +1,6 @@
ban.admin_page:
title: 'IP address bans'
description: 'Ban visits from specific IP addresses.'
route_name: ban.admin_page
weight: 10
parent: user.admin_index

View File

@ -0,0 +1,2 @@
ban IP addresses:
title: 'Ban IP addresses'

View File

@ -0,0 +1,16 @@
ban.admin_page:
path: '/admin/config/people/ban/{default_ip}'
defaults:
_form: '\Drupal\ban\Form\BanAdmin'
_title: 'IP address bans'
default_ip: ''
requirements:
_permission: 'ban IP addresses'
ban.delete:
path: '/admin/config/people/ban/delete/{ban_id}'
defaults:
_form: '\Drupal\ban\Form\BanDelete'
_title: 'Delete IP address'
requirements:
_permission: 'ban IP addresses'

View File

@ -0,0 +1,19 @@
parameters:
ban.skip_procedural_hook_scan: true
services:
_defaults:
autoconfigure: true
ban.ip_manager:
class: Drupal\ban\BanIpManager
arguments: ['@database']
tags:
- { name: backend_overridable }
Drupal\ban\BanIpManagerInterface: '@ban.ip_manager'
ban.middleware:
class: Drupal\ban\BanMiddleware
arguments: ['@ban.ip_manager']
tags:
# Ensure to come before page caching, so you don't serve cached pages to
# banned users.
- { name: http_middleware, priority: 250 }

View File

@ -0,0 +1,15 @@
---
label: 'Banning IP addresses'
related:
- user.overview
---
{% set ban_link_text %}{% trans %}IP address bans{% endtrans %}{% endset %}
{% set ban_link = render_var(help_route_link(ban_link_text, 'ban.admin_page')) %}
<h2>{% trans %}Goal{% endtrans %}</h2>
<p>{% trans %}Ban visitors from one or more IP addresses from accessing and viewing your site.{% endtrans %}</p>
<h2>{% trans %}Steps{% endtrans %}</h2>
<ol>
<li>{% trans %}In the <em>Manage</em> administrative menu, navigate to <em>Configuration</em> &gt; <em>People</em> &gt; <em>{{ ban_link }}</em>{% endtrans %}</li>
<li>{% trans %}Enter an <em>IP address</em> and click <em>Add</em>.{% endtrans %}</li>
<li>{% trans %}You should see the IP address you entered listed under <em>Banned IP addresses</em>. Repeat the above steps to ban additional IP addresses.{% endtrans %}</li>
</ol>

View File

@ -0,0 +1,11 @@
id: d7_blocked_ips
label: Blocked IPs
migration_tags:
- Drupal 7
- Content
source:
plugin: d7_blocked_ips
process:
ip: ip
destination:
plugin: blocked_ip

View File

@ -0,0 +1,3 @@
finished:
7:
system: ban

View File

@ -0,0 +1,69 @@
<?php
namespace Drupal\ban;
use Drupal\Core\Database\Connection;
/**
* Ban IP manager.
*/
class BanIpManager implements BanIpManagerInterface {
/**
* The database connection used to check the IP against.
*
* @var \Drupal\Core\Database\Connection
*/
protected $connection;
/**
* Constructs a BanIpManager object.
*
* @param \Drupal\Core\Database\Connection $connection
* The database connection which will be used to check the IP against.
*/
public function __construct(Connection $connection) {
$this->connection = $connection;
}
/**
* {@inheritdoc}
*/
public function isBanned($ip) {
return (bool) $this->connection->query("SELECT * FROM {ban_ip} WHERE [ip] = :ip", [':ip' => $ip])->fetchField();
}
/**
* {@inheritdoc}
*/
public function findAll() {
return $this->connection->query('SELECT * FROM {ban_ip}');
}
/**
* {@inheritdoc}
*/
public function banIp($ip) {
$this->connection->merge('ban_ip')
->key('ip', $ip)
->fields(['ip' => $ip])
->execute();
}
/**
* {@inheritdoc}
*/
public function unbanIp($id) {
$this->connection->delete('ban_ip')
->condition('ip', $id)
->execute();
}
/**
* {@inheritdoc}
*/
public function findById($ban_id) {
return $this->connection->query("SELECT [ip] FROM {ban_ip} WHERE [iid] = :iid", [':iid' => $ban_id])->fetchField();
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace Drupal\ban;
/**
* Provides an interface defining a BanIp manager.
*/
interface BanIpManagerInterface {
/**
* Returns if this IP address is banned.
*
* @param string $ip
* The IP address to check.
*
* @return bool
* TRUE if the IP address is banned, FALSE otherwise.
*/
public function isBanned($ip);
/**
* Finds all banned IP addresses.
*
* @return \Drupal\Core\Database\StatementInterface
* The result of the database query.
*/
public function findAll();
/**
* Bans an IP address.
*
* @param string $ip
* The IP address to ban.
*/
public function banIp($ip);
/**
* Removes the ban of an IP address.
*
* @param string $id
* The IP address to unban.
*/
public function unbanIp($id);
/**
* Finds a banned IP address by its ID.
*
* @param int $ban_id
* The ID for a banned IP address.
*
* @return string|false
* Either the banned IP address or FALSE if none exist with that ID.
*/
public function findById($ban_id);
}

View File

@ -0,0 +1,53 @@
<?php
namespace Drupal\ban;
use Drupal\Component\Render\FormattableMarkup;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
* Provides a HTTP middleware to implement IP based banning.
*/
class BanMiddleware implements HttpKernelInterface {
/**
* The decorated kernel.
*
* @var \Symfony\Component\HttpKernel\HttpKernelInterface
*/
protected $httpKernel;
/**
* The ban IP manager.
*
* @var \Drupal\ban\BanIpManagerInterface
*/
protected $banIpManager;
/**
* Constructs a BanMiddleware object.
*
* @param \Symfony\Component\HttpKernel\HttpKernelInterface $http_kernel
* The decorated kernel.
* @param \Drupal\ban\BanIpManagerInterface $manager
* The ban IP manager.
*/
public function __construct(HttpKernelInterface $http_kernel, BanIpManagerInterface $manager) {
$this->httpKernel = $http_kernel;
$this->banIpManager = $manager;
}
/**
* {@inheritdoc}
*/
public function handle(Request $request, $type = self::MAIN_REQUEST, $catch = TRUE): Response {
$ip = $request->getClientIp();
if ($this->banIpManager->isBanned($ip)) {
return new Response(new FormattableMarkup('@ip has been banned', ['@ip' => $ip]), 403);
}
return $this->httpKernel->handle($request, $type, $catch);
}
}

View File

@ -0,0 +1,132 @@
<?php
namespace Drupal\ban\Form;
use Drupal\Core\Form\FormBase;
use Drupal\ban\BanIpManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Displays banned IP addresses.
*
* @internal
*/
class BanAdmin extends FormBase {
/**
* @var \Drupal\ban\BanIpManagerInterface
*/
protected $ipManager;
/**
* Constructs a new BanAdmin object.
*
* @param \Drupal\ban\BanIpManagerInterface $ip_manager
* The ban IP manager.
*/
public function __construct(BanIpManagerInterface $ip_manager) {
$this->ipManager = $ip_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('ban.ip_manager')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'ban_ip_form';
}
/**
* {@inheritdoc}
*
* @param array $form
* A nested array form elements comprising the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $default_ip
* (optional) IP address to be passed on to
* \Drupal::formBuilder()->getForm() for use as the default value of the IP
* address form field.
*/
public function buildForm(array $form, FormStateInterface $form_state, $default_ip = '') {
$rows = [];
$header = [$this->t('banned IP addresses'), $this->t('Operations')];
$result = $this->ipManager->findAll();
foreach ($result as $ip) {
$row = [];
$row[] = $ip->ip;
$links = [];
$links['delete'] = [
'title' => $this->t('Delete'),
'url' => Url::fromRoute('ban.delete', ['ban_id' => $ip->iid]),
];
$row[] = [
'data' => [
'#type' => 'operations',
'#links' => $links,
],
];
$rows[] = $row;
}
$form['ip'] = [
'#title' => $this->t('IP address'),
'#type' => 'textfield',
'#size' => 48,
'#maxlength' => 40,
'#default_value' => $default_ip,
'#description' => $this->t('Enter a valid IP address.'),
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Add'),
];
$form['ban_ip_banning_table'] = [
'#type' => 'table',
'#header' => $header,
'#rows' => $rows,
'#empty' => $this->t('No blocked IP addresses available.'),
'#weight' => 120,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
$ip = trim($form_state->getValue('ip'));
if ($this->ipManager->isBanned($ip)) {
$form_state->setErrorByName('ip', $this->t('This IP address is already banned.'));
}
elseif ($ip == $this->getRequest()->getClientIP()) {
$form_state->setErrorByName('ip', $this->t('You may not ban your own IP address.'));
}
elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE) == FALSE) {
$form_state->setErrorByName('ip', $this->t('Enter a valid IP address.'));
}
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$ip = trim($form_state->getValue('ip'));
$this->ipManager->banIp($ip);
$this->messenger()->addStatus($this->t('The IP address %ip has been banned.', ['%ip' => $ip]));
$form_state->setRedirect('ban.admin_page');
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace Drupal\ban\Form;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\ban\BanIpManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Provides a form to unban IP addresses.
*
* @internal
*/
class BanDelete extends ConfirmFormBase {
/**
* The banned IP address.
*
* @var string
*/
protected $banIp;
/**
* The IP manager.
*
* @var \Drupal\ban\BanIpManagerInterface
*/
protected $ipManager;
/**
* Constructs a new BanDelete object.
*
* @param \Drupal\ban\BanIpManagerInterface $ip_manager
* The IP manager.
*/
public function __construct(BanIpManagerInterface $ip_manager) {
$this->ipManager = $ip_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('ban.ip_manager')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'ban_ip_delete_form';
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to unblock %ip?', ['%ip' => $this->banIp]);
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Delete');
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return new Url('ban.admin_page');
}
/**
* {@inheritdoc}
*
* @param array $form
* A nested array form elements comprising the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $ban_id
* The IP address record ID to unban.
*/
public function buildForm(array $form, FormStateInterface $form_state, $ban_id = '') {
if (!$this->banIp = $this->ipManager->findById($ban_id)) {
throw new NotFoundHttpException();
}
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->ipManager->unbanIp($this->banIp);
$this->logger('user')->notice('Deleted %ip', ['%ip' => $this->banIp]);
$this->messenger()->addStatus($this->t('The IP address %ip was deleted.', ['%ip' => $this->banIp]));
$form_state->setRedirectUrl($this->getCancelUrl());
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace Drupal\ban\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for ban.
*/
class BanHooks {
use StringTranslationTrait;
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help($route_name, RouteMatchInterface $route_match): ?string {
switch ($route_name) {
case 'help.page.ban':
$output = '';
$output .= '<h2>' . $this->t('About') . '</h2>';
$output .= '<p>' . $this->t('The Ban module allows administrators to ban visits to their site from individual IP addresses. For more information, see the <a href=":url">online documentation for the Ban module</a>.', [':url' => 'https://www.drupal.org/documentation/modules/ban']) . '</p>';
$output .= '<h2>' . $this->t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . $this->t('Banning IP addresses') . '</dt>';
$output .= '<dd>' . $this->t('Administrators can enter IP addresses to ban on the <a href=":bans">IP address bans</a> page.', [':bans' => Url::fromRoute('ban.admin_page')->toString()]) . '</dd>';
$output .= '</dl>';
return $output;
case 'ban.admin_page':
return '<p>' . $this->t('IP addresses listed here are banned from your site. Banned addresses are completely forbidden from accessing the site and instead see a brief message explaining the situation.') . '</p>';
}
return NULL;
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace Drupal\ban\Plugin\migrate\destination;
use Drupal\ban\BanIpManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\Attribute\MigrateDestination;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Plugin\migrate\destination\DestinationBase;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Destination for blocked IP addresses.
*/
#[MigrateDestination('blocked_ip')]
class BlockedIp extends DestinationBase implements ContainerFactoryPluginInterface {
/**
* The IP ban manager.
*
* @var \Drupal\ban\BanIpManagerInterface
*/
protected $banManager;
/**
* Constructs a BlockedIp object.
*
* @param array $configuration
* Plugin configuration.
* @param string $plugin_id
* The plugin ID.
* @param mixed $plugin_definition
* The plugin definition.
* @param \Drupal\migrate\Plugin\MigrationInterface $migration
* The current migration.
* @param \Drupal\ban\BanIpManagerInterface $ban_manager
* The IP manager service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, BanIpManagerInterface $ban_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $migration);
$this->banManager = $ban_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, ?MigrationInterface $migration = NULL) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$migration,
$container->get('ban.ip_manager')
);
}
/**
* {@inheritdoc}
*/
public function getIds() {
return ['ip' => ['type' => 'string']];
}
/**
* {@inheritdoc}
*/
public function fields() {
return [
'ip' => $this->t('The blocked IP address.'),
];
}
/**
* {@inheritdoc}
*/
public function import(Row $row, array $old_destination_id_values = []) {
$this->banManager->banIp($row->getDestinationProperty('ip'));
return ['ip' => $row->getDestinationProperty('ip')];
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace Drupal\ban\Plugin\migrate\source\d7;
use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
/**
* Drupal 7 blocked IPs source from database.
*
* For available configuration keys, refer to the parent classes.
*
* @see \Drupal\migrate\Plugin\migrate\source\SqlBase
* @see \Drupal\migrate\Plugin\migrate\source\SourcePluginBase
*
* @MigrateSource(
* id = "d7_blocked_ips",
* source_module = "system"
* )
*/
class BlockedIps extends DrupalSqlBase {
/**
* {@inheritdoc}
*/
public function query() {
return $this->select('blocked_ips', 'bi')->fields('bi', ['ip']);
}
/**
* {@inheritdoc}
*/
public function fields() {
return [
'ip' => $this->t('The blocked IP address.'),
];
}
/**
* {@inheritdoc}
*/
public function getIds() {
return ['ip' => ['type' => 'string']];
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ban\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for ban.
*
* @group ban
*/
class GenericTest extends GenericModuleTestBase {}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ban\Kernel;
use Drupal\ban\BanIpManagerInterface;
use Drupal\Core\Database\Connection;
use Drupal\KernelTests\KernelTestBase;
/**
* @group ban
*/
class BanIpTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ban'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installSchema('ban', ['ban_ip']);
}
/**
* Test banning IPs.
*/
public function testBanIp(): void {
$banIp = $this->container->get(BanIpManagerInterface::class);
// Test valid IP addresses.
$ip = '1.2.3.3';
$this->assertCount(0, $this->getIpBans($ip));
$banIp->banIp($ip);
$this->assertCount(1, $this->getIpBans($ip));
// Test duplicate ip address are not present in the 'blocked_ips' table
// when they are entered programmatically.
$ip = '1.0.0.0';
$banIp->banIp($ip);
$banIp->banIp($ip);
$banIp->banIp($ip);
$this->assertCount(1, $this->getIpBans($ip));
$ip = '';
$banIp->banIp($ip);
$banIp->banIp($ip);
$this->assertCount(1, $this->getIpBans($ip));
}
/**
* Gets the IP bans.
*/
protected function getIpBans(string $ip): array {
$connection = $this->container->get(Connection::class);
$query = $connection->select('ban_ip', 'bip');
$query->fields('bip', ['iid']);
$query->condition('bip.ip', $ip);
return $query->execute()->fetchAll();
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ban\Kernel\Migrate\d7;
use Drupal\Tests\SchemaCheckTestTrait;
use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase;
/**
* Migrate blocked IPs.
*
* @group ban
*/
class MigrateBlockedIpsTest extends MigrateDrupal7TestBase {
use SchemaCheckTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['ban'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installSchema('ban', ['ban_ip']);
}
/**
* Tests migration of blocked IPs.
*/
public function testBlockedIps(): void {
$this->startCollectingMessages();
$this->executeMigration('d7_blocked_ips');
$this->assertEmpty($this->migrateMessages);
$this->assertTrue(\Drupal::service('ban.ip_manager')->isBanned('111.111.111.111'));
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ban\Kernel\Plugin\migrate\source\d7;
use Drupal\Tests\migrate\Kernel\MigrateSqlSourceTestBase;
/**
* Tests D7 blocked_ip source plugin.
*
* @covers \Drupal\ban\Plugin\migrate\source\d7\BlockedIps
* @group ban
*/
class BlockedIpsTest extends MigrateSqlSourceTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['ban', 'migrate_drupal'];
/**
* {@inheritdoc}
*/
public static function providerSource() {
$tests = [];
$tests[0]['source_data']['blocked_ips'] = [
[
'iid' => 1,
'ip' => '127.0.0.1',
],
];
$tests[0]['expected_data'] = [
[
'ip' => '127.0.0.1',
],
];
return $tests;
}
}

View File

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ban\Unit;
use Drupal\ban\BanIpManagerInterface;
use Drupal\ban\Form\BanAdmin;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Tests the BanAdmin form.
*
* @coversDefaultClass \Drupal\ban\Form\BanAdmin
* @group ban
*/
class BanAdminTest extends UnitTestCase {
/**
* Tests various user input to confirm correct validation.
*
* @covers ::validateForm
* @dataProvider providerIpValidation
*/
public function testIpValidation(string $ip, bool $isBanned, ?string $error): void {
$manager = $this->getIpManagerMock();
$manager->expects($this->once())
->method('isBanned')
->with($ip)
->willReturn($isBanned);
$formObject = new BanAdmin($manager);
$formObject->setStringTranslation($this->getStringTranslationStub());
$formObject->setRequestStack($this->getRequestStackMock());
$formState = $this->createMock(FormStateInterface::class);
$formState->expects($this->any())
->method('getValue')
->with('ip')
->willReturn($ip);
if ($error === NULL) {
$formState->expects($this->never())
->method('setErrorByName');
}
else {
$formState->expects($this->once())
->method('setErrorByName')
->with('ip', $error);
}
$form = [];
$formObject->validateForm($form, $formState);
}
/**
* Test form submission.
*/
public function testSubmit(): void {
$ip = '1.2.3.4';
$manager = $this->getIpManagerMock();
$manager->expects($this->once())
->method('banIp')
->with($ip);
$messenger = $this->createMock(MessengerInterface::class);
$messenger->expects($this->once())->method('addStatus');
$formObject = new BanAdmin($manager);
$formObject->setStringTranslation($this->getStringTranslationStub());
$formObject->setMessenger($messenger);
$formState = $this->createMock(FormStateInterface::class);
$formState->expects($this->any())
->method('getValue')
->with('ip')
->willReturn($ip);
$form = [];
$formObject->submitForm($form, $formState);
}
/**
* Test passing an IP address as a route parameter.
*
* @covers ::buildForm
*/
public function testRouteParameter(): void {
$ip = '1.2.3.4';
$formObject = new BanAdmin($this->getIpManagerMock());
$formObject->setStringTranslation($this->getStringTranslationStub());
$formState = $this->createMock(FormStateInterface::class);
$form = $formObject->buildForm([], $formState, $ip);
$this->assertSame($ip, $form['ip']['#default_value']);
}
/**
* Data provider for testIpValidation().
*/
public static function providerIpValidation(): array {
return [
'valid ip' => ['1.2.3.3', FALSE, NULL],
'already blocked' => ['1.2.3.3', TRUE, 'This IP address is already banned.'],
'reserved ip' => ['255.255.255.255', FALSE, 'Enter a valid IP address.'],
'fqdn' => ['test.example.com', FALSE, 'Enter a valid IP address.'],
'empty' => ['', FALSE, 'Enter a valid IP address.'],
'client ip' => ['127.0.0.1', FALSE, 'You may not ban your own IP address.'],
];
}
/**
* Get a request stack with a dummy IP.
*/
protected function getRequestStackMock(): RequestStack {
$request = $this->createMock(Request::class);
$request->expects($this->any())
->method('getClientIp')
->willReturn('127.0.0.1');
$requestStack = $this->createMock(RequestStack::class);
$requestStack->expects($this->any())
->method('getCurrentRequest')
->willReturn($request);
return $requestStack;
}
/**
* Get the mocked IP manager service.
*/
protected function getIpManagerMock(): BanIpManagerInterface {
$manager = $this->createMock(BanIpManagerInterface::class);
$manager->expects($this->any())
->method('findAll')
->willReturn([]);
return $manager;
}
}

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ban\Unit;
use Drupal\ban\BanMiddleware;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
* @coversDefaultClass \Drupal\ban\BanMiddleware
* @group ban
*/
class BanMiddlewareTest extends UnitTestCase {
/**
* The mocked wrapped kernel.
*
* @var \Symfony\Component\HttpKernel\HttpKernelInterface|\PHPUnit\Framework\MockObject\MockObject
*/
protected $kernel;
/**
* The mocked ban IP manager.
*
* @var \Drupal\ban\BanIpManagerInterface|\PHPUnit\Framework\MockObject\MockObject
*/
protected $banManager;
/**
* The tested ban middleware.
*
* @var \Drupal\ban\BanMiddleware
*/
protected $banMiddleware;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->kernel = $this->createMock('Symfony\Component\HttpKernel\HttpKernelInterface');
$this->banManager = $this->createMock('Drupal\ban\BanIpManagerInterface');
$this->banMiddleware = new BanMiddleware($this->kernel, $this->banManager);
}
/**
* Tests a banned IP.
*/
public function testBannedIp(): void {
$banned_ip = '17.0.0.2';
$this->banManager->expects($this->once())
->method('isBanned')
->with($banned_ip)
->willReturn(TRUE);
$this->kernel->expects($this->never())
->method('handle');
$request = Request::create('/test-path');
$request->server->set('REMOTE_ADDR', $banned_ip);
$response = $this->banMiddleware->handle($request);
$this->assertEquals(403, $response->getStatusCode());
}
/**
* Tests an unbanned IP.
*/
public function testUnbannedIp(): void {
$unbanned_ip = '18.0.0.2';
$this->banManager->expects($this->once())
->method('isBanned')
->with($unbanned_ip)
->willReturn(FALSE);
$request = Request::create('/test-path');
$request->server->set('REMOTE_ADDR', $unbanned_ip);
$expected_response = new Response(status: 200);
$this->kernel->expects($this->once())
->method('handle')
->with($request, HttpKernelInterface::MAIN_REQUEST, TRUE)
->willReturn($expected_response);
$response = $this->banMiddleware->handle($request);
$this->assertSame($expected_response, $response);
}
}

View File

@ -0,0 +1,7 @@
name: 'HTTP Basic Authentication'
type: module
description: 'Provides an HTTP Basic authentication provider.'
package: Web services
version: VERSION
dependencies:
- drupal:user

View File

@ -0,0 +1,16 @@
parameters:
basic_auth.skip_procedural_hook_scan: true
services:
_defaults:
autoconfigure: true
basic_auth.authentication.basic_auth:
class: Drupal\basic_auth\Authentication\Provider\BasicAuth
arguments: ['@config.factory', '@user.auth', '@flood', '@entity_type.manager']
tags:
- { name: authentication_provider, provider_id: 'basic_auth', priority: 100 }
basic_auth.page_cache_request_policy.disallow_basic_auth_requests:
class: Drupal\basic_auth\PageCache\DisallowBasicAuthRequests
public: false
tags:
- { name: page_cache_request_policy }

View File

@ -0,0 +1,183 @@
<?php
namespace Drupal\basic_auth\Authentication\Provider;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Authentication\AuthenticationProviderInterface;
use Drupal\Core\Authentication\AuthenticationProviderChallengeInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Flood\FloodInterface;
use Drupal\Core\Http\Exception\CacheableUnauthorizedHttpException;
use Drupal\user\UserAuthenticationInterface;
use Drupal\user\UserAuthInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* HTTP Basic authentication provider.
*/
class BasicAuth implements AuthenticationProviderInterface, AuthenticationProviderChallengeInterface {
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The user auth service.
*
* @var \Drupal\user\UserAuthInterface|\Drupal\user\UserAuthenticationInterface
*/
protected $userAuth;
/**
* The flood service.
*
* @var \Drupal\Core\Flood\FloodInterface
*/
protected $flood;
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a HTTP basic authentication provider object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\user\UserAuthInterface|\Drupal\user\UserAuthenticationInterface $user_auth
* The user authentication service.
* @param \Drupal\Core\Flood\FloodInterface $flood
* The flood service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
*/
public function __construct(ConfigFactoryInterface $config_factory, UserAuthInterface|UserAuthenticationInterface $user_auth, FloodInterface $flood, EntityTypeManagerInterface $entity_type_manager) {
$this->configFactory = $config_factory;
if (!$user_auth instanceof UserAuthenticationInterface) {
@trigger_error('The $user_auth parameter implementing UserAuthInterface is deprecated in drupal:10.3.0 and will be removed in drupal:12.0.0. Implement UserAuthenticationInterface instead. See https://www.drupal.org/node/3411040');
}
$this->userAuth = $user_auth;
$this->flood = $flood;
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public function applies(Request $request) {
$username = $request->headers->get('PHP_AUTH_USER');
$password = $request->headers->get('PHP_AUTH_PW');
return isset($username) && isset($password);
}
/**
* {@inheritdoc}
*/
public function authenticate(Request $request) {
$flood_config = $this->configFactory->get('user.flood');
$username = $request->headers->get('PHP_AUTH_USER');
$password = $request->headers->get('PHP_AUTH_PW');
// Flood protection: this is very similar to the user login form code.
// @see \Drupal\user\Form\UserLoginForm::validateAuthentication()
// Do not allow any login from the current user's IP if the limit has been
// reached. Default is 50 failed attempts allowed in one hour. This is
// independent of the per-user limit to catch attempts from one IP to log
// in to many different user accounts. We have a reasonably high limit
// since there may be only one apparent IP for all users at an institution.
if ($this->flood->isAllowed('basic_auth.failed_login_ip', $flood_config->get('ip_limit'), $flood_config->get('ip_window'))) {
$account = FALSE;
if ($this->userAuth instanceof UserAuthenticationInterface) {
$lookup = $this->userAuth->lookupAccount($username);
if ($lookup && !$lookup->isBlocked()) {
$account = $lookup;
}
}
else {
$accounts = $this->entityTypeManager->getStorage('user')->loadByProperties(['name' => $username, 'status' => 1]);
$account = reset($accounts);
}
if ($account) {
if ($flood_config->get('uid_only')) {
// Register flood events based on the uid only, so they apply for any
// IP address. This is the most secure option.
$identifier = $account->id();
}
else {
// The default identifier is a combination of uid and IP address. This
// is less secure but more resistant to denial-of-service attacks that
// could lock out all users with public user names.
$identifier = $account->id() . '-' . $request->getClientIP();
}
// Don't allow login if the limit for this user has been reached.
// Default is to allow 5 failed attempts every 6 hours.
if ($this->flood->isAllowed('basic_auth.failed_login_user', $flood_config->get('user_limit'), $flood_config->get('user_window'), $identifier)) {
$uid = FALSE;
if ($this->userAuth instanceof UserAuthenticationInterface) {
$uid = $this->userAuth->authenticateAccount($account, $password) ? $account->id() : FALSE;
}
else {
$uid = $this->userAuth->authenticate($username, $password);
}
if ($uid) {
$this->flood->clear('basic_auth.failed_login_user', $identifier);
return $account;
}
else {
// Register a per-user failed login event.
$this->flood->register('basic_auth.failed_login_user', $flood_config->get('user_window'), $identifier);
}
}
}
}
// Always register an IP-based failed login event.
$this->flood->register('basic_auth.failed_login_ip', $flood_config->get('ip_window'));
return NULL;
}
/**
* {@inheritdoc}
*/
public function challengeException(Request $request, \Exception $previous) {
$site_config = $this->configFactory->get('system.site');
$site_name = $site_config->get('name');
$challenge = new FormattableMarkup('Basic realm="@realm"', [
'@realm' => !empty($site_name) ? $site_name : 'Access restricted',
]);
// A 403 is converted to a 401 here, but it doesn't matter what the
// cacheability was of the 403 exception: what matters here is that
// authentication credentials are missing, i.e. this request was made
// as an anonymous user.
// Therefore, the following actions will be taken:
// 1. Verify whether the current user has the 'anonymous' role or not. This
// works fine because:
// - Thanks to \Drupal\basic_auth\PageCache\DisallowBasicAuthRequests,
// Page Cache never caches a response whose request has Basic Auth
// credentials.
// - Dynamic Page Cache will cache a different result for when the
// request is unauthenticated (this 401) versus authenticated (some
// other response)
// 2. Have the 'config:user.role.anonymous' cache tag, because the only
// reason this 401 would no longer be a 401 is if permissions for the
// 'anonymous' role change, causing the cache tag to be invalidated.
// @see \Drupal\Core\EventSubscriber\AuthenticationSubscriber::onExceptionSendChallenge()
// @see \Drupal\Core\EventSubscriber\ClientErrorResponseSubscriber()
// @see \Drupal\Core\EventSubscriber\FinishResponseSubscriber::onAllResponds()
$cacheability = CacheableMetadata::createFromObject($site_config)
->addCacheTags(['config:user.role.anonymous'])
->addCacheContexts(['user.roles:anonymous']);
return $request->isMethodCacheable()
? new CacheableUnauthorizedHttpException($cacheability, (string) $challenge, 'No authentication credentials provided.', $previous)
: new UnauthorizedHttpException((string) $challenge, 'No authentication credentials provided.', $previous);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace Drupal\basic_auth\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for basic_auth.
*/
class BasicAuthHooks {
use StringTranslationTrait;
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help($route_name, RouteMatchInterface $route_match): ?string {
switch ($route_name) {
case 'help.page.basic_auth':
$output = '';
$output .= '<h2>' . $this->t('About') . '</h2>';
$output .= '<p>' . $this->t('The HTTP Basic Authentication module supplies an <a href="http://en.wikipedia.org/wiki/Basic_access_authentication">HTTP Basic authentication</a> provider for web service requests. This authentication provider authenticates requests using the HTTP Basic Authentication username and password, as an alternative to using Drupal\'s standard cookie-based authentication system. It is only useful if your site provides web services configured to use this type of authentication (for instance, the <a href=":rest_help">RESTful Web Services module</a>). For more information, see the <a href=":hba_do">online documentation for the HTTP Basic Authentication module</a>.', [
':hba_do' => 'https://www.drupal.org/documentation/modules/basic_auth',
':rest_help' => \Drupal::moduleHandler()->moduleExists('rest') ? Url::fromRoute('help.page', [
'name' => 'rest',
])->toString() : '#',
]) . '</p>';
return $output;
}
return NULL;
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace Drupal\basic_auth\PageCache;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Cache policy for pages served from basic auth.
*
* This policy disallows caching of requests that use basic_auth for security
* reasons. Otherwise responses for authenticated requests can get into the
* page cache and could be delivered to unprivileged users.
*/
class DisallowBasicAuthRequests implements RequestPolicyInterface {
/**
* {@inheritdoc}
*/
public function check(Request $request) {
$username = $request->headers->get('PHP_AUTH_USER');
$password = $request->headers->get('PHP_AUTH_PW');
if (isset($username) && isset($password)) {
return self::DENY;
}
}
}

View File

@ -0,0 +1,5 @@
name: 'HTTP Basic Authentication test'
type: module
description: 'Support module for HTTP Basic Authentication testing.'
package: Testing
version: VERSION

View File

@ -0,0 +1,16 @@
basic_auth_test.state.modify:
path: '/basic_auth_test/state/modify'
defaults:
_controller: '\Drupal\basic_auth_test\BasicAuthTestController::modifyState'
options:
_auth:
- basic_auth
requirements:
_user_is_logged_in: 'TRUE'
basic_auth_test.state.read:
path: '/basic_auth_test/state/read'
defaults:
_controller: '\Drupal\basic_auth_test\BasicAuthTestController::readState'
requirements:
_access: 'TRUE'

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Drupal\basic_auth_test;
/**
* Provides routes for HTTP Basic Authentication testing.
*/
class BasicAuthTestController {
/**
* @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testControllerNotCalledBeforeAuth()
*/
public function modifyState() {
\Drupal::state()->set('basic_auth_test.state.controller_executed', TRUE);
return ['#markup' => 'Done'];
}
/**
* @see \Drupal\basic_auth\Tests\Authentication\BasicAuthTest::testControllerNotCalledBeforeAuth()
*/
public function readState() {
// Mark this page as being uncacheable.
\Drupal::service('page_cache_kill_switch')->trigger();
return [
'#markup' => \Drupal::state()->get('basic_auth_test.state.controller_executed') ? 'yep' : 'nope',
'#cache' => [
'max-age' => 0,
],
];
}
}

View File

@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\basic_auth\Functional;
use Drupal\Core\Url;
use Drupal\Tests\basic_auth\Traits\BasicAuthTestTrait;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\Entity\Role;
/**
* Tests for BasicAuth authentication provider.
*
* @group basic_auth
*/
class BasicAuthTest extends BrowserTestBase {
use BasicAuthTestTrait;
/**
* Modules installed for all tests.
*
* @var array
*/
protected static $modules = [
'basic_auth',
'router_test',
'locale',
'basic_auth_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests http basic authentication.
*/
public function testBasicAuth(): void {
// Enable page caching.
$config = $this->config('system.performance');
$config->set('cache.page.max_age', 300);
$config->save();
$account = $this->drupalCreateUser();
$url = Url::fromRoute('router_test.11');
// Ensure we can log in with valid authentication details.
$this->basicAuthGet($url, $account->getAccountName(), $account->pass_raw);
$this->assertSession()->pageTextContains($account->getAccountName());
$this->assertSession()->statusCodeEquals(200);
$this->mink->resetSessions();
$this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (request policy)');
// Check that Cache-Control is not set to public.
$this->assertSession()->responseHeaderNotContains('Cache-Control', 'public');
// Ensure that invalid authentication details give access denied.
$this->basicAuthGet($url, $account->getAccountName(), $this->randomMachineName());
$this->assertSession()->pageTextNotContains($account->getAccountName());
$this->assertSession()->statusCodeEquals(403);
$this->mink->resetSessions();
// Ensure that the user is prompted to authenticate if they are not yet
// authenticated and the route only allows basic auth.
$this->drupalGet($url);
$this->assertSession()->responseHeaderEquals('WWW-Authenticate', 'Basic realm="' . \Drupal::config('system.site')->get('name') . '"');
$this->assertSession()->statusCodeEquals(401);
// Ensure that a route without basic auth defined doesn't prompt for auth.
$this->drupalGet('admin');
$this->assertSession()->statusCodeEquals(403);
$account = $this->drupalCreateUser(['access administration pages']);
// Ensure that a route without basic auth defined doesn't allow login.
$this->basicAuthGet(Url::fromRoute('system.admin'), $account->getAccountName(), $account->pass_raw);
$this->assertSession()->linkNotExists('Log out', 'User is not logged in');
$this->assertSession()->statusCodeEquals(403);
$this->mink->resetSessions();
// Ensure that pages already in the page cache aren't returned from page
// cache if basic auth credentials are provided.
$url = Url::fromRoute('router_test.10');
$this->drupalGet($url);
$this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'MISS');
$this->basicAuthGet($url, $account->getAccountName(), $account->pass_raw);
$this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (request policy)');
// Check that Cache-Control is not set to public.
$this->assertSession()->responseHeaderNotContains('Cache-Control', 'public');
}
/**
* Tests the global login flood control.
*/
public function testGlobalLoginFloodControl(): void {
$this->config('user.flood')
->set('ip_limit', 2)
// Set a high per-user limit out so that it is not relevant in the test.
->set('user_limit', 4000)
->save();
$user = $this->drupalCreateUser([]);
$incorrect_user = clone $user;
$incorrect_user->pass_raw .= 'incorrect';
$url = Url::fromRoute('router_test.11');
// Try 2 failed logins.
for ($i = 0; $i < 2; $i++) {
$this->basicAuthGet($url, $incorrect_user->getAccountName(), $incorrect_user->pass_raw);
}
// IP limit has reached to its limit. Even valid user credentials will fail.
$this->basicAuthGet($url, $user->getAccountName(), $user->pass_raw);
$this->assertSession()->statusCodeEquals(403);
}
/**
* Tests the per-user login flood control.
*/
public function testPerUserLoginFloodControl(): void {
$this->config('user.flood')
// Set a high global limit out so that it is not relevant in the test.
->set('ip_limit', 4000)
->set('user_limit', 2)
->save();
$user = $this->drupalCreateUser([]);
$incorrect_user = clone $user;
$incorrect_user->pass_raw .= 'incorrect';
$user2 = $this->drupalCreateUser([]);
$url = Url::fromRoute('router_test.11');
// Try a failed login.
$this->basicAuthGet($url, $incorrect_user->getAccountName(), $incorrect_user->pass_raw);
// A successful login will reset the per-user flood control count.
$this->basicAuthGet($url, $user->getAccountName(), $user->pass_raw);
$this->assertSession()->statusCodeEquals(200);
// Try 2 failed logins for a user. They will trigger flood control.
for ($i = 0; $i < 2; $i++) {
$this->basicAuthGet($url, $incorrect_user->getAccountName(), $incorrect_user->pass_raw);
}
// Now the user account is blocked.
$this->basicAuthGet($url, $user->getAccountName(), $user->pass_raw);
$this->assertSession()->statusCodeEquals(403);
// Try one successful attempt for a different user, it should not trigger
// any flood control.
$this->basicAuthGet($url, $user2->getAccountName(), $user2->pass_raw);
$this->assertSession()->statusCodeEquals(200);
}
/**
* Tests compatibility with locale/UI translation.
*/
public function testLocale(): void {
ConfigurableLanguage::createFromLangcode('de')->save();
$this->config('system.site')->set('default_langcode', 'de')->save();
$account = $this->drupalCreateUser();
$url = Url::fromRoute('router_test.11');
$this->basicAuthGet($url, $account->getAccountName(), $account->pass_raw);
$this->assertSession()->pageTextContains($account->getAccountName());
$this->assertSession()->statusCodeEquals(200);
}
/**
* Tests if a comprehensive message is displayed when the route is denied.
*/
public function testUnauthorizedErrorMessage(): void {
$account = $this->drupalCreateUser();
$url = Url::fromRoute('router_test.11');
// Case when no credentials are passed, a user friendly access
// unauthorized message is displayed.
$this->drupalGet($url);
$this->assertSession()->statusCodeEquals(401);
$this->assertSession()->pageTextNotContains('Exception');
$this->assertSession()->pageTextContains('Log in to access this page.');
// Case when empty credentials are passed, a user friendly access denied
// message is displayed.
$this->basicAuthGet($url, NULL, NULL);
$this->assertSession()->statusCodeEquals(403);
$this->assertSession()->pageTextContains('Access denied');
// Case when wrong credentials are passed, a user friendly access denied
// message is displayed.
$this->basicAuthGet($url, $account->getAccountName(), $this->randomMachineName());
$this->assertSession()->statusCodeEquals(403);
$this->assertSession()->pageTextContains('Access denied');
// Case when correct credentials but hasn't access to the route, an user
// friendly access denied message is displayed.
$url = Url::fromRoute('router_test.15');
$this->basicAuthGet($url, $account->getAccountName(), $account->pass_raw);
$this->assertSession()->statusCodeEquals(403);
$this->assertSession()->pageTextContains('Access denied');
}
/**
* Tests the cacheability of the Basic Auth 401 response.
*
* @see \Drupal\basic_auth\Authentication\Provider\BasicAuth::challengeException()
*/
public function testCacheabilityOf401Response(): void {
$url = Url::fromRoute('router_test.11');
$assert_response_cacheability = function ($expected_page_cache_header_value, $expected_dynamic_page_cache_header_value) use ($url) {
$this->drupalGet($url);
$this->assertSession()->statusCodeEquals(401);
$this->assertSession()->responseHeaderEquals('X-Drupal-Cache', $expected_page_cache_header_value);
$this->assertSession()->responseHeaderEquals('X-Drupal-Dynamic-Cache', $expected_dynamic_page_cache_header_value);
};
// 1. First request: cold caches, both Page Cache and Dynamic Page Cache are
// now primed.
$assert_response_cacheability('MISS', 'MISS');
// 2. Second request: Page Cache HIT, we don't even hit Dynamic Page Cache.
// This is going to keep happening.
$assert_response_cacheability('HIT', 'MISS');
// 3. Third request: after clearing Page Cache, we now see that Dynamic Page
// Cache is a HIT too.
$this->container->get('cache.page')->deleteAll();
$assert_response_cacheability('MISS', 'HIT');
// 4. Fourth request: warm caches.
$assert_response_cacheability('HIT', 'HIT');
// If the permissions of the 'anonymous' role change, it may no longer be
// necessary to be authenticated to access this route. Therefore the cached
// 401 responses should be invalidated.
$this->grantPermissions(Role::load(Role::ANONYMOUS_ID), ['access content']);
$assert_response_cacheability('MISS', 'MISS');
$assert_response_cacheability('HIT', 'MISS');
// Idem for when the 'system.site' config changes.
$this->config('system.site')->save();
$assert_response_cacheability('MISS', 'MISS');
$assert_response_cacheability('HIT', 'MISS');
}
/**
* Tests if the controller is called before authentication.
*
* @see https://www.drupal.org/node/2817727
*/
public function testControllerNotCalledBeforeAuth(): void {
$this->drupalGet('/basic_auth_test/state/modify');
$this->assertSession()->statusCodeEquals(401);
$this->drupalGet('/basic_auth_test/state/read');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('nope');
$account = $this->drupalCreateUser();
$this->basicAuthGet('/basic_auth_test/state/modify', $account->getAccountName(), $account->pass_raw);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Done');
$this->mink->resetSessions();
$this->drupalGet('/basic_auth_test/state/read');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('yep');
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\basic_auth\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for basic_auth.
*
* @group basic_auth
*/
class GenericTest extends GenericModuleTestBase {}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\basic_auth\Traits;
/**
* Provides common functionality for Basic Authentication test classes.
*/
trait BasicAuthTestTrait {
/**
* Retrieves a Drupal path or an absolute path using basic authentication.
*
* @param \Drupal\Core\Url|string $path
* Drupal path or URL to load into the internal browser.
* @param string $username
* The username to use for basic authentication.
* @param string $password
* The password to use for basic authentication.
* @param array $options
* (optional) Options to be forwarded to the URL generator.
*
* @return string
* The retrieved HTML string, also available as $this->getRawContent().
*/
protected function basicAuthGet($path, $username, $password, array $options = []) {
return $this->drupalGet($path, $options, $this->getBasicAuthHeaders($username, $password));
}
/**
* Returns HTTP headers that can be used for basic authentication in Curl.
*
* @param string $username
* The username to use for basic authentication.
* @param string $password
* The password to use for basic authentication.
*
* @return array
* An array of raw request headers as used by curl_setopt().
*/
protected function getBasicAuthHeaders($username, $password): array {
// Set up Curl to use basic authentication with the test user's credentials.
return ['Authorization' => 'Basic ' . base64_encode("$username:$password")];
}
}

View File

@ -0,0 +1,5 @@
name: BigPipe
type: module
description: 'Sends pages using the BigPipe technique that allows browsers to show them much faster.'
package: Core
version: VERSION

View File

@ -0,0 +1,9 @@
big_pipe:
version: VERSION
js:
js/big_pipe.js: {}
drupalSettings:
bigPipePlaceholderIds: []
dependencies:
- core/drupal.ajax
- core/drupalSettings

View File

@ -0,0 +1,45 @@
<?php
/**
* @file
*/
/**
* Implements hook_theme_suggestions_HOOK().
*/
function big_pipe_theme_suggestions_big_pipe_interface_preview(array $variables): array {
$common_callbacks_simplified_suggestions = [
'Drupal_block_BlockViewBuilder__lazyBuilder' => 'block',
];
$suggestions = [];
$suggestion = 'big_pipe_interface_preview';
if ($variables['callback']) {
$callback = preg_replace('/[^a-zA-Z0-9]/', '_', $variables['callback']);
if (is_array($callback)) {
$callback = implode('__', $callback);
}
// Use simplified template suggestion, if any.
// For example, this simplifies
// phpcs:ignore Drupal.Files.LineLength
// big-pipe-interface-preview--Drupal-block-BlockViewBuilder--lazyBuilder--<BLOCK ID>.html.twig
// to
// big-pipe-interface-preview--block--<BLOCK ID>.html.twig
if (isset($common_callbacks_simplified_suggestions[$callback])) {
$callback = $common_callbacks_simplified_suggestions[$callback];
}
$suggestions[] = $suggestion .= '__' . $callback;
if (is_array($variables['arguments'])) {
$arguments = preg_replace('/[^a-zA-Z0-9]/', '_', $variables['arguments']);
foreach ($arguments as $argument) {
if (empty($argument)) {
continue;
}
$suggestions[] = $suggestion . '__' . $argument;
}
}
}
return $suggestions;
}

View File

@ -0,0 +1,15 @@
<?php
/**
* @file
* Post update functions for Big Pipe.
*/
/**
* Implements hook_removed_post_updates().
*/
function big_pipe_removed_post_updates(): array {
return [
'big_pipe_post_update_html5_placeholders' => '11.0.0',
];
}

View File

@ -0,0 +1,9 @@
big_pipe.nojs:
path: '/big_pipe/no-js'
defaults:
_controller: '\Drupal\big_pipe\Controller\BigPipeController:setNoJsCookie'
_title: 'BigPipe no-JS check'
options:
no_cache: TRUE
requirements:
_access: 'TRUE'

View File

@ -0,0 +1,28 @@
services:
_defaults:
autoconfigure: true
html_response.big_pipe_subscriber:
class: Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber
arguments: ['@big_pipe']
placeholder_strategy.big_pipe:
class: Drupal\big_pipe\Render\Placeholder\BigPipeStrategy
arguments: ['@session_configuration', '@request_stack', '@current_route_match']
tags:
- { name: placeholder_strategy, priority: 0 }
big_pipe:
class: Drupal\big_pipe\Render\BigPipe
arguments: ['@renderer', '@session', '@request_stack', '@http_kernel', '@event_dispatcher', '@config.factory', '@messenger', '@router.request_context', '@logger.channel.php', '%http.response.debug_cacheability_headers%']
Drupal\big_pipe\Render\BigPipe: '@big_pipe'
html_response.attachments_processor.big_pipe:
public: false
class: \Drupal\big_pipe\Render\BigPipeResponseAttachmentsProcessor
decorates: html_response.attachments_processor
arguments: ['@html_response.attachments_processor.big_pipe.inner', '@asset.resolver', '@config.factory', '@asset.css.collection_renderer', '@asset.js.collection_renderer', '@request_stack', '@renderer', '@module_handler', '@language_manager']
route_subscriber.no_big_pipe:
class: Drupal\big_pipe\EventSubscriber\NoBigPipeRouteAlterSubscriber
http_middleware.big_pipe:
class: \Drupal\big_pipe\StackMiddleware\ContentLength
tags:
# Must run after the content_length middleware.
- { name: http_middleware, priority: 150 }

View File

@ -0,0 +1,183 @@
/**
* @file
* Renders BigPipe placeholders using Drupal's Ajax system.
*/
((Drupal, drupalSettings) => {
/**
* CSS selector for script elements to process on page load.
*
* @type {string}
*/
const replacementsSelector = `script[data-big-pipe-replacement-for-placeholder-with-id]`;
/**
* Ajax object that will process all the BigPipe responses.
*
* Create a Drupal.Ajax object without associating an element, a progress
* indicator or a URL.
*
* @type {Drupal.Ajax}
*/
const ajaxObject = Drupal.ajax({
url: '',
base: false,
element: false,
progress: false,
});
/**
* Maps textContent of <script type="application/vnd.drupal-ajax"> to an AJAX
* response.
*
* @param {string} content
* The text content of a <script type="application/vnd.drupal-ajax"> DOM
* node.
* @return {Array|boolean}
* The parsed Ajax response containing an array of Ajax commands, or false
* in case the DOM node hasn't fully arrived yet.
*/
function mapTextContentToAjaxResponse(content) {
if (content === '') {
return false;
}
try {
return JSON.parse(content);
} catch (e) {
return false;
}
}
/**
* Executes Ajax commands in <script type="application/vnd.drupal-ajax"> tag.
*
* These Ajax commands replace placeholders with HTML and load missing CSS/JS.
*
* @param {HTMLScriptElement} replacement
* Script tag created by BigPipe.
*/
function processReplacement(replacement) {
const id = replacement.dataset.bigPipeReplacementForPlaceholderWithId;
// The content is not guaranteed to be complete at this point, but trimming
// it will not make a big change, since json will not be valid if it was
// not fully loaded anyway.
const content = replacement.textContent.trim();
// Ignore any placeholders that are not in the known placeholder list. Used
// to avoid someone trying to XSS the site via the placeholdering mechanism.
if (typeof drupalSettings.bigPipePlaceholderIds[id] === 'undefined') {
return;
}
const response = mapTextContentToAjaxResponse(content);
if (response === false) {
return;
}
// Immediately remove the replacement to prevent it being processed twice.
delete drupalSettings.bigPipePlaceholderIds[id];
// Then, simulate an AJAX response having arrived, and let the Ajax system
// handle it.
ajaxObject.success(response, 'success');
}
/**
* Checks if node is valid big pipe replacement.
*/
function checkMutation(node) {
return Boolean(
node.nodeType === Node.ELEMENT_NODE &&
node.nodeName === 'SCRIPT' &&
node.dataset?.bigPipeReplacementForPlaceholderWithId &&
typeof drupalSettings.bigPipePlaceholderIds[
node.dataset.bigPipeReplacementForPlaceholderWithId
] !== 'undefined',
);
}
/**
* Check that the element is valid to process and process it.
*
* @param {HTMLElement} node
* The node added to the body element.
*/
function checkMutationAndProcess(node) {
if (checkMutation(node)) {
processReplacement(node);
}
// Checks if parent node of target node has not been processed, which can
// occur if the script node was first observed with empty content and then
// the child text node was added in full later.
// @see `@ingroup large_chunk` for more information.
// If an element is added and then immediately (faster than the next
// setImmediate is triggered) removed to a watched element of a
// MutationObserver, the observer will notice and add a mutation for both
// the addedNode and the removedNode - but the referenced element will not
// have a parent node.
else if (node.parentNode !== null && checkMutation(node.parentNode)) {
processReplacement(node.parentNode);
}
}
/**
* Handles the mutation callback.
*
* @param {MutationRecord[]} mutations
* The list of mutations registered by the browser.
*/
function processMutations(mutations) {
mutations.forEach(({ addedNodes, type, target }) => {
addedNodes.forEach(checkMutationAndProcess);
// Checks if parent node of target node has not been processed.
// @see `@ingroup large_chunk` for more information.
if (
type === 'characterData' &&
checkMutation(target.parentNode) &&
drupalSettings.bigPipePlaceholderIds[
target.parentNode.dataset.bigPipeReplacementForPlaceholderWithId
] === true
) {
processReplacement(target.parentNode);
}
});
}
const observer = new MutationObserver(processMutations);
// Attach behaviors early, if possible.
Drupal.attachBehaviors(document);
// If loaded asynchronously there might already be replacement elements
// in the DOM before the mutation observer is started.
document.querySelectorAll(replacementsSelector).forEach(processReplacement);
// Start observing the body element for new children and for new changes in
// Text nodes of elements. We need to track Text nodes because content
// of the node can be too large, browser will receive not fully loaded chunk
// and render it as is. At this moment json inside script will be invalid and
// we need to track new changes to that json (Text node), once it will be
// fully loaded it will be processed.
// @ingroup large_chunk
observer.observe(document.body, {
childList: true,
// Without this options characterData will not be triggered inside child nodes.
subtree: true,
characterData: true,
});
// As soon as the document is loaded, no more replacements will be added.
// Immediately fetch and process all pending mutations and stop the observer.
window.addEventListener('DOMContentLoaded', () => {
const mutations = observer.takeRecords();
observer.disconnect();
if (mutations.length) {
processMutations(mutations);
}
// No more mutations will be processed, remove the leftover Ajax object.
Drupal.ajax.instances[ajaxObject.instanceIndex] = null;
});
})(Drupal, drupalSettings);

View File

@ -0,0 +1,60 @@
<?php
namespace Drupal\big_pipe\Controller;
use Drupal\big_pipe\Render\Placeholder\BigPipeStrategy;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Routing\LocalRedirectResponse;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Returns responses for BigPipe module routes.
*/
class BigPipeController {
/**
* Sets a BigPipe no-JS cookie, redirects back to the original location.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request.
*
* @return \Drupal\Core\Routing\LocalRedirectResponse
* A response that sets the no-JS cookie and redirects back to the original
* location.
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
* Thrown when the no-JS cookie is already set or when there is no session.
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
* Thrown when the original location is missing, i.e. when no 'destination'
* query argument is set.
*
* @see \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy
*/
public function setNoJsCookie(Request $request) {
// This controller may only be accessed when the browser does not support
// JavaScript. It is accessed automatically when that's the case thanks to
// big_pipe_page_attachments(). When this controller is executed, deny
// access when either:
// - the no-JS cookie is already set: this indicates a redirect loop, since
// the cookie was already set, yet the user is executing this controller;
// - there is no session, in which case BigPipe is not enabled anyway, so it
// is pointless to set this cookie.
if ($request->cookies->has(BigPipeStrategy::NOJS_COOKIE)) {
throw new AccessDeniedHttpException();
}
if (!$request->query->has('destination')) {
throw new HttpException(400, 'The original location is missing.');
}
$response = new LocalRedirectResponse($request->query->get('destination'));
// Set cookie without httpOnly, so that JavaScript can delete it.
$response->headers->setCookie(new Cookie(BigPipeStrategy::NOJS_COOKIE, TRUE, 0, '/', NULL, FALSE, FALSE, FALSE, NULL));
$response->addCacheableDependency((new CacheableMetadata())->addCacheContexts(['cookies:' . BigPipeStrategy::NOJS_COOKIE, 'session.exists']));
return $response;
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace Drupal\big_pipe\EventSubscriber;
use Drupal\Core\Render\HtmlResponse;
use Drupal\big_pipe\Render\BigPipe;
use Drupal\big_pipe\Render\BigPipeResponse;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Response subscriber to replace the HtmlResponse with a BigPipeResponse.
*
* @see \Drupal\big_pipe\Render\BigPipe
*
* @todo Refactor once https://www.drupal.org/node/2577631 lands.
*/
class HtmlResponseBigPipeSubscriber implements EventSubscriberInterface {
/**
* The BigPipe service.
*
* @var \Drupal\big_pipe\Render\BigPipe
*/
protected $bigPipe;
/**
* Constructs a HtmlResponseBigPipeSubscriber object.
*
* @param \Drupal\big_pipe\Render\BigPipe $big_pipe
* The BigPipe service.
*/
public function __construct(BigPipe $big_pipe) {
$this->bigPipe = $big_pipe;
}
/**
* Adds markers to the response necessary for the BigPipe render strategy.
*
* @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
* The event to process.
*/
public function onRespondEarly(ResponseEvent $event) {
$response = $event->getResponse();
if (!$response instanceof HtmlResponse) {
return;
}
// Wrap the scripts_bottom placeholder with a marker before and after,
// because \Drupal\big_pipe\Render\BigPipe needs to be able to extract that
// markup if there are no-JS BigPipe placeholders.
// @see \Drupal\big_pipe\Render\BigPipe::sendPreBody()
$attachments = $response->getAttachments();
if (isset($attachments['html_response_attachment_placeholders']['scripts_bottom'])) {
$scripts_bottom_placeholder = $attachments['html_response_attachment_placeholders']['scripts_bottom'];
$content = $response->getContent();
$content = str_replace($scripts_bottom_placeholder, '<drupal-big-pipe-scripts-bottom-marker>' . $scripts_bottom_placeholder . '<drupal-big-pipe-scripts-bottom-marker>', $content);
$response->setContent($content);
}
}
/**
* Transforms a HtmlResponse to a BigPipeResponse.
*
* @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
* The event to process.
*/
public function onRespond(ResponseEvent $event) {
$response = $event->getResponse();
if (!$response instanceof HtmlResponse) {
return;
}
$attachments = $response->getAttachments();
// If there are no no-JS BigPipe placeholders, unwrap the scripts_bottom
// markup.
// @see onRespondEarly()
// @see \Drupal\big_pipe\Render\BigPipe::sendPreBody()
if (empty($attachments['big_pipe_nojs_placeholders'])) {
$content = $response->getContent();
$content = str_replace('<drupal-big-pipe-scripts-bottom-marker>', '', $content);
$response->setContent($content);
}
// If there are neither BigPipe placeholders nor no-JS BigPipe placeholders,
// there isn't anything dynamic in this response, and we can return early:
// there is no point in sending this response using BigPipe.
if (empty($attachments['big_pipe_placeholders']) && empty($attachments['big_pipe_nojs_placeholders'])) {
return;
}
$big_pipe_response = new BigPipeResponse($response);
$big_pipe_response->setBigPipeService($this->getBigPipeService($event));
$event->setResponse($big_pipe_response);
}
/**
* Returns the BigPipe service to use to send the current response.
*
* @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
* A response event.
*
* @return \Drupal\big_pipe\Render\BigPipe
* The BigPipe service.
*/
protected function getBigPipeService(ResponseEvent $event) {
return $this->bigPipe;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
// Run after HtmlResponsePlaceholderStrategySubscriber (priority 5), i.e.
// after BigPipeStrategy has been applied, but before normal (priority 0)
// response subscribers have been applied, because by then it'll be too late
// to transform it into a BigPipeResponse.
$events[KernelEvents::RESPONSE][] = ['onRespondEarly', 3];
// Run as the last possible subscriber.
$events[KernelEvents::RESPONSE][] = ['onRespond', -10000];
return $events;
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace Drupal\big_pipe\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Drupal\Core\Routing\RoutingEvents;
use Drupal\Core\Routing\RouteBuildEvent;
/**
* Sets the '_no_big_pipe' option on select routes.
*/
class NoBigPipeRouteAlterSubscriber implements EventSubscriberInterface {
/**
* Alters select routes to have the '_no_big_pipe' option.
*
* @param \Drupal\Core\Routing\RouteBuildEvent $event
* The event to process.
*/
public function onRoutingRouteAlterSetNoBigPipe(RouteBuildEvent $event) {
$no_big_pipe_routes = [
// The batch system uses a <meta> refresh to work without JavaScript.
'system.batch_page.html',
// When a user would install the BigPipe module using a browser and with
// JavaScript disabled, the first response contains the status messages
// for installing a module, but then the BigPipe no-JS redirect occurs,
// which then causes the user to not see those status messages.
// @see https://www.drupal.org/node/2469431#comment-10901944
'system.modules_list',
];
$route_collection = $event->getRouteCollection();
foreach ($no_big_pipe_routes as $excluded_route) {
if ($route = $route_collection->get($excluded_route)) {
$route->setOption('_no_big_pipe', TRUE);
}
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
$events[RoutingEvents::ALTER][] = ['onRoutingRouteAlterSetNoBigPipe'];
return $events;
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace Drupal\big_pipe\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\big_pipe\Render\Placeholder\BigPipeStrategy;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for big_pipe.
*/
class BigPipeHooks {
use StringTranslationTrait;
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help($route_name, RouteMatchInterface $route_match): ?string {
switch ($route_name) {
case 'help.page.big_pipe':
$output = '<h2>' . $this->t('About') . '</h2>';
$output .= '<p>' . $this->t('The BigPipe module sends pages with dynamic content in a way that allows browsers to show them much faster. For more information, see the <a href=":big_pipe-documentation">online documentation for the BigPipe module</a>.', [
':big_pipe-documentation' => 'https://www.drupal.org/documentation/modules/big_pipe',
]) . '</p>';
$output .= '<h2>' . $this->t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . $this->t('Speeding up your site') . '</dt>';
$output .= '<dd>' . $this->t('The module requires no configuration. Every part of the page contains metadata that allows BigPipe to figure this out on its own.') . '</dd>';
$output .= '</dl>';
return $output;
}
return NULL;
}
/**
* Implements hook_page_attachments().
*
* @see \Drupal\big_pipe\Controller\BigPipeController::setNoJsCookie()
*/
#[Hook('page_attachments')]
public function pageAttachments(array &$page): void {
// Routes that don't use BigPipe also don't need no-JS detection.
if (\Drupal::routeMatch()->getRouteObject()->getOption('_no_big_pipe')) {
return;
}
$request = \Drupal::request();
// BigPipe is only used when there is an actual session, so only add the
// no-JS detection when there actually is a session. @see
// \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy.
$session_exists = \Drupal::service('session_configuration')->hasSession($request);
$page['#cache']['contexts'][] = 'session.exists';
// Only do the no-JS detection while we don't know if there's no JS support:
// avoid endless redirect loops.
$has_big_pipe_nojs_cookie = $request->cookies->has(BigPipeStrategy::NOJS_COOKIE);
$page['#cache']['contexts'][] = 'cookies:' . BigPipeStrategy::NOJS_COOKIE;
if ($session_exists) {
if (!$has_big_pipe_nojs_cookie) {
// Let server set the BigPipe no-JS cookie.
$page['#attached']['html_head'][] = [
[
// Redirect through a 'Refresh' meta tag if JavaScript is disabled.
'#tag' => 'meta',
'#noscript' => TRUE,
'#attributes' => [
'http-equiv' => 'Refresh',
'content' => '0; URL=' . Url::fromRoute('big_pipe.nojs', [], [
'query' => \Drupal::service('redirect.destination')->getAsArray(),
])->toString(),
],
],
'big_pipe_detect_nojs',
];
}
else {
// Let client delete the BigPipe no-JS cookie.
$page['#attached']['html_head'][] = [
[
'#tag' => 'script',
'#value' => 'document.cookie = "' . BigPipeStrategy::NOJS_COOKIE . '=1; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"',
],
'big_pipe_detect_js',
];
}
}
}
/**
* Implements hook_theme().
*/
#[Hook('theme')]
public function theme() : array {
return [
'big_pipe_interface_preview' => [
'variables' => [
'callback' => NULL,
'arguments' => NULL,
'preview' => NULL,
],
],
];
}
}

Some files were not shown because too many files have changed in this diff Show More