first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
name: Actions UI
type: module
description: 'Allows configuration of tasks to be executed in response to events.'
package: Core
# version: VERSION
lifecycle: deprecated
lifecycle_link: https://www.drupal.org/node/3223395#s-action
configure: entity.action.collection
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,5 @@
action.admin:
title: Actions
description: 'Create tasks that the system can execute.'
route_name: entity.action.collection
parent: system.admin_config_system

View File

@@ -0,0 +1,4 @@
action.admin:
route_name: entity.action.collection
title: 'Manage actions'
base_route: entity.action.collection

View File

@@ -0,0 +1,72 @@
<?php
/**
* @file
* This is the Actions UI module for executing stored actions.
*/
use Drupal\Core\Url;
use Drupal\action\Form\ActionAddForm;
use Drupal\action\Form\ActionEditForm;
use Drupal\system\Plugin\migrate\source\Action;
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Implements hook_help().
*/
function action_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.action':
$output = '';
$output .= '<h2>' . t('About') . '</h2>';
$output .= '<p>' . t('The Actions UI module provides tasks that can be executed by the site such as unpublishing content, sending email messages, or blocking a user. Other modules can trigger these actions when specific system events happen; for example, when new content is posted or when a user logs in. Modules can also provide additional actions. For more information, see the <a href=":documentation">online documentation for the Actions UI module</a>.', [':documentation' => 'https://www.drupal.org/documentation/modules/action']) . '</p>';
$output .= '<h2>' . t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . t('Using simple actions') . '</dt>';
$output .= '<dd>' . t('<em>Simple actions</em> do not require configuration and are listed automatically as available on the <a href=":actions">Actions administration page</a>.', [':actions' => Url::fromRoute('entity.action.collection')->toString()]) . '</dd>';
$output .= '<dt>' . t('Creating and configuring advanced actions') . '</dt>';
$output .= '<dd>' . t('<em>Advanced actions</em> are user-created and have to be configured individually. Create an advanced action on the <a href=":actions">Actions administration page</a> by selecting an action type from the drop-down list. Then configure your action, for example by specifying the recipient of an automated email message.', [':actions' => Url::fromRoute('entity.action.collection')->toString()]) . '</dd>';
$output .= '</dl>';
return $output;
case 'entity.action.collection':
$output = '<p>' . t('There are two types of actions: simple and advanced. Simple actions do not require any additional configuration and are listed here automatically. Advanced actions need to be created and configured before they can be used because they have options that need to be specified; for example, sending an email to a specified address or unpublishing content containing certain words. To create an advanced action, select the action from the drop-down list in the advanced action section below and click the <em>Create</em> button.') . '</p>';
return $output;
case 'action.admin_add':
case 'entity.action.edit_form':
return '<p>' . t('An advanced action offers additional configuration options which may be filled out below. Changing the <em>Label</em> field is recommended in order to better identify the precise action taking place.') . '</p>';
}
}
/**
* Implements hook_entity_type_build().
*/
function action_entity_type_build(array &$entity_types) {
/** @var \Drupal\Core\Entity\EntityTypeInterface[] $entity_types */
$entity_types['action']
->setFormClass('add', ActionAddForm::class)
->setFormClass('edit', ActionEditForm::class)
->setFormClass('delete', 'Drupal\action\Form\ActionDeleteForm')
->setListBuilderClass('Drupal\action\ActionListBuilder')
->setLinkTemplate('delete-form', '/admin/config/system/actions/configure/{action}/delete')
->setLinkTemplate('edit-form', '/admin/config/system/actions/configure/{action}')
->setLinkTemplate('collection', '/admin/config/system/actions');
}
/**
* Implements hook_migration_plugins_alter().
*/
function action_migration_plugins_alter(array &$migrations) {
foreach ($migrations as $migration_id => $migration) {
// Add Actions plugins in actions module.
/** @var \Drupal\migrate\Plugin\migrate\source\SqlBase $source_plugin */
$source_plugin = \Drupal::service('plugin.manager.migration')
->createStubMigration($migration)
->getSourcePlugin();
if (is_a($source_plugin, Action::class) && isset($migration['process']['plugin'])) {
$migrations[$migration_id]['process']['plugin'][0]['map']['comment_unpublish_by_keyword_action'] = 'comment_unpublish_by_keyword_action';
$migrations[$migration_id]['process']['plugin'][0]['map']['node_unpublish_by_keyword_action'] = 'node_unpublish_by_keyword_action';
}
}
}

View File

@@ -0,0 +1,16 @@
<?php
/**
* @file
* Post update functions for Actions UI module.
*/
/**
* Implements hook_removed_post_updates().
*/
function action_removed_post_updates() {
return [
'action_post_update_move_plugins' => '10.0.0',
'action_post_update_remove_settings' => '10.0.0',
];
}

View File

@@ -0,0 +1,31 @@
entity.action.collection:
path: '/admin/config/system/actions'
defaults:
_title: 'Actions'
_entity_list: 'action'
requirements:
_permission: 'administer actions'
action.admin_add:
path: '/admin/config/system/actions/add/{action_id}'
defaults:
_entity_form: 'action.add'
_title: 'Add action'
requirements:
_permission: 'administer actions'
entity.action.edit_form:
path: '/admin/config/system/actions/configure/{action}'
defaults:
_entity_form: 'action.edit'
_title: 'Edit action'
requirements:
_permission: 'administer actions'
entity.action.delete_form:
path: '/admin/config/system/actions/configure/{action}/delete'
defaults:
_entity_form: 'action.delete'
_title: 'Delete action'
requirements:
_permission: 'administer actions'

View File

@@ -0,0 +1,28 @@
---
label: 'Creating an advanced action'
related:
- action.overview
- views_ui.bulk_operations
---
{% set actions_link_text %}
{% trans %}Actions{% endtrans %}
{% endset %}
{% set actions = render_var(help_route_link(actions_link_text, 'entity.action.collection')) %}
{% set action_permissions_link_text %}
{% trans %}Administer actions{% endtrans %}
{% endset %}
{% set action_permissions = render_var(help_route_link(action_permissions_link_text, 'user.admin_permissions.module', {'modules': 'action'})) %}
{% set action_overview = render_var(help_topic_link('action.overview')) %}
<h2>{% trans %}Goal{% endtrans %}</h2>
<p>{% trans %}Create an advanced action. You can, for example, create an action to change the author of multiple content items. See {{ action_overview }} for more about actions.{% endtrans %}</p>
<h2>{% trans %}Who can create actions?{% endtrans %}</h2>
<p>{% trans %}Users with the <em>{{ action_permissions }}</em> permission (typically administrators) can create actions.{% endtrans %}</p>
<h2>{% trans %}Steps{% endtrans %}</h2>
<ol>
<li>{% trans %}In the <em>Manage</em> administrative menu, navigate to <em>Configuration</em> &gt; <em>System</em> &gt; <em>{{ actions }}</em>. A list of all actions is shown.{% endtrans %}</li>
<li>{% trans %}Choose an advanced action from the dropdown and click <em>Create</em>.{% endtrans %}</li>
<li>{% trans %}Enter a name for the action in the <em>Label</em> field. This label will be visible for the user.{% endtrans %}</li>
<li>{% trans %}Configure any of the other available options. These will depend on the kind of action that you have chosen.{% endtrans %}</li>
<li>{% trans %}Click <em>Save</em>. You will be returned to the list of actions, with your new action added to the list.{% endtrans %}</li>
<li>{% trans %}To edit an action you have previously created, click <em>Configure</em> in the <em>Operations</em> drop-down list. To delete an action you have previously created, click <em>Delete</em> in the <em>Operations</em> drop-down list.{% endtrans %}</li>
</ol>

View File

@@ -0,0 +1,25 @@
---
label: 'Configuring actions'
top_level: true
related:
- views.overview
- views_ui.bulk_operations
---
{% set actions_link_text %}
{% trans %}Actions administration page{% endtrans %}
{% endset %}
{% set actions_page = render_var(help_route_link(actions_link_text, 'entity.action.collection')) %}
<h2>{% trans %}What are actions?{% endtrans %}</h2>
<p>{% trans %}Actions are module-defined tasks that can be executed on the site; for example, unpublishing content, sending an email message, or blocking a user.{% endtrans %}</p>
<h2>{% trans %}What are simple actions?{% endtrans %}</h2>
<p>{% trans %}Simple actions do not require configuration. They are automatically available to be executed, and are always listed as available on the {{ actions_page }}.{% endtrans %}</p>
<h2>{% trans %}What are advanced actions?{% endtrans %}</h2>
<p>{% trans %}Advanced actions require configuration. Before they are available for listing and execution, they need to be created and configured. For example, for an action that sends email, you would need to configure the email address.{% endtrans %}</p>
<h2>{% trans %}How are actions executed?{% endtrans %}</h2>
<p>{% trans %}In the core software, actions can be executed through a <em>bulk operations form</em> added to a view; if you have the core Views module installed, see the related topic "Managing content listings (views)" for more information about views and bulk operations.{% endtrans %}</p>
<h2>{% trans %}Configuring actions overview{% endtrans %}</h2>
<p>{% trans %}The Actions UI module provides a user interface for listing and configuring actions. The core Views UI module provides a user interface for creating views, which may include bulk operations forms for executing actions. See the related topics listed below for specific tasks.{% endtrans %}</p>
<h2>{% trans %}Additional resources{% endtrans %}</h2>
<ul>
<li><a href="https://www.drupal.org/documentation/modules/action">{% trans %}Online documentation for the Actions UI module{% endtrans %}</a></li>
</ul>

View File

@@ -0,0 +1,134 @@
<?php
namespace Drupal\action;
use Drupal\action\Form\ActionAdminManageForm;
use Drupal\Core\Action\ActionManager;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Config\Entity\ConfigEntityListBuilder;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a class to build a listing of action entities.
*
* @see \Drupal\system\Entity\Action
* @see action_entity_type_build()
*/
class ActionListBuilder extends ConfigEntityListBuilder {
/**
* @var bool
*/
protected $hasConfigurableActions = FALSE;
/**
* The action plugin manager.
*
* @var \Drupal\Core\Action\ActionManager
*/
protected $actionManager;
/**
* Constructs a new ActionListBuilder object.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
* @param \Drupal\Core\Entity\EntityStorageInterface $storage
* The action storage.
* @param \Drupal\Core\Action\ActionManager $action_manager
* The action plugin manager.
* @param \Drupal\Core\Form\FormBuilderInterface $formBuilder
* The form builder.
*/
public function __construct(
EntityTypeInterface $entity_type,
EntityStorageInterface $storage,
ActionManager $action_manager,
protected ?FormBuilderInterface $formBuilder = NULL,
) {
parent::__construct($entity_type, $storage);
$this->actionManager = $action_manager;
if (!$formBuilder) {
@trigger_error('Calling ' . __METHOD__ . ' without the $formBuilder argument is deprecated in drupal:10.3.0 and it will be required in drupal:11.0.0. See https://www.drupal.org/node/3159776', E_USER_DEPRECATED);
$this->formBuilder = \Drupal::service('form_builder');
}
}
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static(
$entity_type,
$container->get('entity_type.manager')->getStorage($entity_type->id()),
$container->get('plugin.manager.action'),
$container->get('form_builder')
);
}
/**
* {@inheritdoc}
*/
public function load() {
$entities = parent::load();
foreach ($entities as $entity) {
if ($entity->isConfigurable()) {
$this->hasConfigurableActions = TRUE;
break;
}
}
return $entities;
}
/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity) {
$row['type'] = $entity->getType();
$row['label'] = $entity->label();
if ($this->hasConfigurableActions) {
$row += parent::buildRow($entity);
}
return $row;
}
/**
* {@inheritdoc}
*/
public function buildHeader() {
$header = [
'type' => t('Action type'),
'label' => t('Label'),
] + parent::buildHeader();
return $header;
}
/**
* {@inheritdoc}
*/
public function getDefaultOperations(EntityInterface $entity) {
$operations = parent::getDefaultOperations($entity);
if (isset($operations['edit'])) {
$operations['edit']['title'] = t('Configure');
}
return $operations;
}
/**
* {@inheritdoc}
*/
public function render() {
$build['action_admin_manage_form'] = $this->formBuilder->getForm(ActionAdminManageForm::class);
$build['action_header']['#markup'] = '<h3>' . $this->t('Available actions:') . '</h3>';
$build['action_table'] = parent::render();
if (!$this->hasConfigurableActions) {
unset($build['action_table']['table']['#header']['operations']);
}
return $build;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Drupal\action\Form;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides a form for action add forms.
*
* @internal
*/
class ActionAddForm extends ActionFormBase {
/**
* {@inheritdoc}
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param string $action_id
* The action ID.
*/
public function buildForm(array $form, FormStateInterface $form_state, $action_id = NULL) {
$this->entity->setPlugin($action_id);
// Derive the label and type from the action definition.
$definition = $this->entity->getPluginDefinition();
$this->entity->set('label', $definition['label']);
$this->entity->set('type', $definition['type']);
return parent::buildForm($form, $form_state);
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace Drupal\action\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Action\ActionManager;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a configuration form for configurable actions.
*
* @internal
*/
class ActionAdminManageForm extends FormBase {
/**
* The action plugin manager.
*
* @var \Drupal\Core\Action\ActionManager
*/
protected $manager;
/**
* Constructs a new ActionAdminManageForm.
*
* @param \Drupal\Core\Action\ActionManager $manager
* The action plugin manager.
*/
public function __construct(ActionManager $manager) {
$this->manager = $manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin.manager.action')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'action_admin_manage';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$actions = [];
foreach ($this->manager->getDefinitions() as $id => $definition) {
$actions[$id] = $definition['label'];
}
asort($actions);
$form['parent'] = [
'#type' => 'details',
'#title' => $this->t('Create an advanced action'),
'#attributes' => ['class' => ['container-inline']],
'#open' => TRUE,
];
$form['parent']['action'] = [
'#type' => 'select',
'#title' => $this->t('Action'),
'#title_display' => 'invisible',
'#options' => $actions,
'#empty_option' => $this->t('- Select -'),
];
$form['parent']['actions'] = [
'#type' => 'actions',
];
$form['parent']['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Create'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
if ($form_state->getValue('action')) {
$form_state->setRedirect(
'action.admin_add',
['action_id' => $form_state->getValue('action')]
);
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Drupal\action\Form;
use Drupal\Core\Entity\EntityDeleteForm;
use Drupal\Core\Url;
/**
* Builds a form to delete an action.
*
* @internal
*/
class ActionDeleteForm extends EntityDeleteForm {
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return new Url('entity.action.collection');
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Drupal\action\Form;
/**
* Provides a form for action edit forms.
*
* @internal
*/
class ActionEditForm extends ActionFormBase {
}

View File

@@ -0,0 +1,153 @@
<?php
namespace Drupal\action\Form;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a base form for action forms.
*/
abstract class ActionFormBase extends EntityForm {
/**
* The action storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $storage;
/**
* The action entity.
*
* @var \Drupal\system\ActionConfigEntityInterface
*/
protected $entity;
/**
* Constructs a new action form.
*
* @param \Drupal\Core\Entity\EntityStorageInterface $storage
* The action storage.
*/
public function __construct(EntityStorageInterface $storage) {
$this->storage = $storage;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager')->getStorage('action')
);
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Label'),
'#default_value' => $this->entity->label(),
'#maxlength' => '255',
'#description' => $this->t('A unique label for this advanced action. This label will be displayed in the interface of modules that integrate with actions.'),
];
$form['id'] = [
'#type' => 'machine_name',
'#default_value' => $this->entity->id(),
'#disabled' => !$this->entity->isNew(),
'#maxlength' => 64,
'#description' => $this->t('A unique name for this action. It must only contain lowercase letters, numbers and underscores.'),
'#machine_name' => [
'exists' => [$this, 'exists'],
],
];
$form['plugin'] = [
'#type' => 'value',
'#value' => $this->entity->get('plugin'),
];
$form['type'] = [
'#type' => 'value',
'#value' => $this->entity->getType(),
];
if ($plugin = $this->getPlugin()) {
$form += $plugin->buildConfigurationForm($form, $form_state);
}
return parent::form($form, $form_state);
}
/**
* Determines if the action already exists.
*
* @param string $id
* The action ID.
*
* @return bool
* TRUE if the action exists, FALSE otherwise.
*/
public function exists($id) {
$action = $this->storage->load($id);
return !empty($action);
}
/**
* {@inheritdoc}
*/
protected function actions(array $form, FormStateInterface $form_state) {
$actions = parent::actions($form, $form_state);
unset($actions['delete']);
return $actions;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
parent::validateForm($form, $form_state);
if ($plugin = $this->getPlugin()) {
$plugin->validateConfigurationForm($form, $form_state);
}
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
parent::submitForm($form, $form_state);
if ($plugin = $this->getPlugin()) {
$plugin->submitConfigurationForm($form, $form_state);
}
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
$this->entity->save();
$this->messenger()->addStatus($this->t('The action has been successfully saved.'));
$form_state->setRedirect('entity.action.collection');
}
/**
* Gets the action plugin while ensuring it implements configuration form.
*
* @return \Drupal\Core\Action\ActionInterface|\Drupal\Core\Plugin\PluginFormInterface|null
* The action plugin, or NULL if it does not implement configuration forms.
*/
protected function getPlugin() {
if ($this->entity->getPlugin() instanceof PluginFormInterface) {
return $this->entity->getPlugin();
}
return NULL;
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Drupal\action\Plugin\Action;
use Drupal\Core\Action\ConfigurableActionBase;
use Drupal\Core\Action\Attribute\Action;
use Drupal\Core\Database\Connection;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\user\Entity\User;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Assigns ownership of a node to a user.
*/
#[Action(
id: 'node_assign_owner_action',
label: new TranslatableMarkup('Change the author of content'),
type: 'node'
)]
class AssignOwnerNode extends ConfigurableActionBase implements ContainerFactoryPluginInterface {
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $connection;
/**
* Constructs a new AssignOwnerNode action.
*
* @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\Core\Database\Connection $connection
* The database connection.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, Connection $connection) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->connection = $connection;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($configuration, $plugin_id, $plugin_definition,
$container->get('database')
);
}
/**
* {@inheritdoc}
*/
public function execute($entity = NULL) {
/** @var \Drupal\node\NodeInterface $entity */
$entity->setOwnerId($this->configuration['owner_uid'])->save();
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'owner_uid' => '',
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$description = $this->t('The username of the user to which you would like to assign ownership.');
$count = $this->connection->query("SELECT COUNT(*) FROM {users}")->fetchField();
// Use dropdown for fewer than 200 users; textbox for more than that.
if (intval($count) < 200) {
$options = [];
$result = $this->connection->query("SELECT [uid], [name] FROM {users_field_data} WHERE [uid] > 0 AND [default_langcode] = 1 ORDER BY [name]");
foreach ($result as $data) {
$options[$data->uid] = $data->name;
}
$form['owner_uid'] = [
'#type' => 'select',
'#title' => $this->t('Username'),
'#default_value' => $this->configuration['owner_uid'],
'#options' => $options,
'#description' => $description,
];
}
else {
$form['owner_uid'] = [
'#type' => 'entity_autocomplete',
'#title' => $this->t('Username'),
'#target_type' => 'user',
'#selection_settings' => [
'include_anonymous' => FALSE,
],
'#default_value' => User::load($this->configuration['owner_uid']),
// Validation is done in static::validateConfigurationForm().
'#validate_reference' => FALSE,
'#size' => '6',
'#maxlength' => '60',
'#description' => $description,
];
}
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
$exists = (bool) $this->connection->queryRange('SELECT 1 FROM {users_field_data} WHERE [uid] = :uid AND [default_langcode] = 1', 0, 1, [':uid' => $form_state->getValue('owner_uid')])->fetchField();
if (!$exists) {
$form_state->setErrorByName('owner_uid', $this->t('Enter a valid username.'));
}
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['owner_uid'] = $form_state->getValue('owner_uid');
}
/**
* {@inheritdoc}
*/
public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
/** @var \Drupal\node\NodeInterface $object */
$result = $object->access('update', $account, TRUE)
->andIf($object->getOwner()->access('edit', $account, TRUE));
return $return_as_object ? $result : $result->isAllowed();
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Drupal\action\Plugin\Action;
use Drupal\Component\Utility\Tags;
use Drupal\Core\Action\ConfigurableActionBase;
use Drupal\Core\Action\Attribute\Action;
use Drupal\Core\Entity\EntityViewBuilderInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Unpublishes a comment containing certain keywords.
*/
#[Action(
id: 'comment_unpublish_by_keyword_action',
label: new TranslatableMarkup('Unpublish comment containing keyword(s)'),
type: 'comment'
)]
class UnpublishByKeywordComment extends ConfigurableActionBase implements ContainerFactoryPluginInterface {
/**
* The comment entity builder handler.
*
* @var \Drupal\Core\Entity\EntityViewBuilderInterface
*/
protected $viewBuilder;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Constructs an UnpublishByKeywordComment object.
*
* @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\Core\Entity\EntityViewBuilderInterface $comment_view_builder
* The comment entity builder handler.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityViewBuilderInterface $comment_view_builder, RendererInterface $renderer) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->viewBuilder = $comment_view_builder;
$this->renderer = $renderer;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager')->getViewBuilder('comment'),
$container->get('renderer')
);
}
/**
* {@inheritdoc}
*/
public function execute($comment = NULL) {
$build = $this->viewBuilder->view($comment);
$text = (string) $this->renderer->renderInIsolation($build);
foreach ($this->configuration['keywords'] as $keyword) {
if (str_contains($text, $keyword)) {
$comment->setUnpublished();
$comment->save();
break;
}
}
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'keywords' => [],
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['keywords'] = [
'#title' => $this->t('Keywords'),
'#type' => 'textarea',
'#description' => $this->t('The comment will be unpublished if it contains any of the phrases above. Use a case-sensitive, comma-separated list of phrases. Example: funny, bungee jumping, "Company, Inc."'),
'#default_value' => Tags::implode($this->configuration['keywords']),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['keywords'] = Tags::explode($form_state->getValue('keywords'));
}
/**
* {@inheritdoc}
*/
public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
/** @var \Drupal\comment\CommentInterface $object */
$result = $object->access('update', $account, TRUE)
->andIf($object->status->access('edit', $account, TRUE));
return $return_as_object ? $result : $result->isAllowed();
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Drupal\action\Plugin\Action;
use Drupal\Component\Utility\Tags;
use Drupal\Core\Action\ConfigurableActionBase;
use Drupal\Core\Action\Attribute\Action;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Unpublishes a node containing certain keywords.
*/
#[Action(
id: 'node_unpublish_by_keyword_action',
label: new TranslatableMarkup('Unpublish content containing keyword(s)'),
type: 'node'
)]
class UnpublishByKeywordNode extends ConfigurableActionBase {
/**
* {@inheritdoc}
*/
public function execute($node = NULL) {
$elements = \Drupal::entityTypeManager()
->getViewBuilder('node')
->view(clone $node);
$render = (string) \Drupal::service('renderer')->renderInIsolation($elements);
foreach ($this->configuration['keywords'] as $keyword) {
if (str_contains($render, $keyword) || str_contains($node->label(), $keyword)) {
$node->setUnpublished();
$node->save();
break;
}
}
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'keywords' => [],
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['keywords'] = [
'#title' => $this->t('Keywords'),
'#type' => 'textarea',
'#description' => $this->t('The content will be unpublished if it contains any of the phrases above. Use a case-sensitive, comma-separated list of phrases. Example: funny, bungee jumping, "Company, Inc."'),
'#default_value' => Tags::implode($this->configuration['keywords']),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['keywords'] = Tags::explode($form_state->getValue('keywords'));
}
/**
* {@inheritdoc}
*/
public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
/** @var \Drupal\node\NodeInterface $object */
$access = $object->access('update', $account, TRUE)
->andIf($object->status->access('edit', $account, TRUE));
return $return_as_object ? $access : $access->isAllowed();
}
}

View File

@@ -0,0 +1,11 @@
name: action_form_ajax_test
type: module
description: 'module used for testing ajax in action config entity forms.'
package: Core
# version: VERSION
hidden: true
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,90 @@
<?php
namespace Drupal\action_form_ajax_test\Plugin\Action;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Action\ConfigurableActionBase;
use Drupal\Core\Action\Attribute\Action;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Plugin used for testing AJAX in action config entity forms.
*/
#[Action(
id: 'action_form_ajax_test',
label: new TranslatableMarkup('action_form_ajax_test'),
type: 'system'
)]
class ActionAjaxTest extends ConfigurableActionBase {
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'party_time' => '',
];
}
/**
* {@inheritdoc}
*/
public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
$result = AccessResult::allowed();
return $return_as_object ? $result : $result->isAllowed();
}
/**
* {@inheritdoc}
*/
public function execute() {
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$having_a_party = $form_state->getValue('having_a_party', !empty($this->configuration['party_time']));
$form['having_a_party'] = [
'#type' => 'checkbox',
'#title' => $this->t('Are we having a party?'),
'#ajax' => [
'wrapper' => 'party-container',
'callback' => [$this, 'partyCallback'],
],
'#default_value' => $having_a_party,
];
$form['container'] = [
'#type' => 'container',
'#prefix' => '<div id="party-container">',
'#suffix' => '</div>',
];
if ($having_a_party) {
$form['container']['party_time'] = [
'#type' => 'textfield',
'#title' => $this->t('Party time'),
'#default_value' => $this->configuration['party_time'],
];
}
return $form;
}
/**
* Callback for party checkbox.
*/
public function partyCallback(array $form, FormStateInterface $form_state) {
return $form['container'];
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['party_time'] = $form_state->getValue('party_time');
}
}

View File

@@ -0,0 +1 @@
recursion_limit: 35

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\action\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Test behaviors when visiting the action listing page.
*
* @group action
* @group legacy
*/
class ActionListTest extends BrowserTestBase {
/**
* Modules to install.
*
* @var array
*/
protected static $modules = ['action', 'user'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests the behavior when there are no actions to list in the admin page.
*/
public function testEmptyActionList(): void {
// Create a user with permission to view the actions administration pages.
$this->drupalLogin($this->drupalCreateUser(['administer actions']));
// Ensure the empty text appears on the action list page.
/** @var \Drupal\Core\Entity\EntityStorageInterface $storage */
$storage = $this->container->get('entity_type.manager')->getStorage('action');
$actions = $storage->loadMultiple();
$storage->delete($actions);
$this->drupalGet('/admin/config/system/actions');
$this->assertSession()->pageTextContains('There are no actions yet.');
}
/**
* Tests that non-configurable actions can be created by the UI.
*/
public function testNonConfigurableActionsCanBeCreated(): void {
$this->drupalLogin($this->drupalCreateUser(['administer actions']));
$this->drupalGet('/admin/config/system/actions');
$this->assertSession()->elementExists('css', 'select > option[value="user_block_user_action"]');
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\action\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Tests that uninstalling Actions UI does not remove other modules' actions.
*
* @group action
* @group legacy
* @see \Drupal\views\Plugin\views\field\BulkForm
* @see \Drupal\user\Plugin\Action\BlockUser
*/
class ActionUninstallTest extends BrowserTestBase {
/**
* Modules to install.
*
* @var array
*/
protected static $modules = ['views', 'action'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests Actions UI uninstall.
*/
public function testActionUninstall(): void {
\Drupal::service('module_installer')->uninstall(['action']);
$storage = $this->container->get('entity_type.manager')->getStorage('action');
$storage->resetCache(['user_block_user_action']);
$this->assertNotEmpty($storage->load('user_block_user_action'), 'Configuration entity \'user_block_user_action\' still exists after uninstalling action module.');
$admin_user = $this->drupalCreateUser(['administer users']);
$this->drupalLogin($admin_user);
$this->drupalGet('admin/people');
// Ensure we have the user_block_user_action listed.
$this->assertSession()->responseContains('<option value="user_block_user_action">Block the selected user(s)</option>');
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\action\Functional;
use Drupal\system\Entity\Action;
use Drupal\Tests\BrowserTestBase;
/**
* Tests complex actions configuration.
*
* @group action
* @group legacy
*/
class ConfigurationTest extends BrowserTestBase {
/**
* Modules to install.
*
* @var array
*/
protected static $modules = ['action'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests configuration of advanced actions through administration interface.
*/
public function testActionConfiguration(): void {
// Create a user with permission to view the actions administration pages.
$user = $this->drupalCreateUser(['administer actions']);
$this->drupalLogin($user);
// Make a POST request to admin/config/system/actions.
$edit = [];
$edit['action'] = 'action_goto_action';
$this->drupalGet('admin/config/system/actions');
$this->submitForm($edit, 'Create');
$this->assertSession()->statusCodeEquals(200);
// Make a POST request to the individual action configuration page.
$edit = [];
$action_label = $this->randomMachineName();
$edit['label'] = $action_label;
$edit['id'] = strtolower($action_label);
$edit['url'] = 'admin';
$this->drupalGet('admin/config/system/actions/add/action_goto_action');
$this->submitForm($edit, 'Save');
$this->assertSession()->statusCodeEquals(200);
$action_id = $edit['id'];
// Make sure that the new complex action was saved properly.
$this->assertSession()->pageTextContains('The action has been successfully saved.');
// The action label appears on the configuration page.
$this->assertSession()->pageTextContains($action_label);
// Make another POST request to the action edit page.
$this->clickLink('Configure');
$edit = [];
$new_action_label = $this->randomMachineName();
$edit['label'] = $new_action_label;
$edit['url'] = 'admin';
$this->submitForm($edit, 'Save');
$this->assertSession()->statusCodeEquals(200);
// Make sure that the action updated properly.
$this->assertSession()->pageTextContains('The action has been successfully saved.');
// The old action label does NOT appear on the configuration page.
$this->assertSession()->pageTextNotContains($action_label);
// The action label appears on the configuration page after we've updated
// the complex action.
$this->assertSession()->pageTextContains($new_action_label);
// Make sure the URL appears when re-editing the action.
$this->clickLink('Configure');
$this->assertSession()->fieldValueEquals('url', 'admin');
// Make sure that deletions work properly.
$this->drupalGet('admin/config/system/actions');
$this->clickLink('Delete');
$this->assertSession()->statusCodeEquals(200);
$edit = [];
$this->submitForm($edit, 'Delete');
$this->assertSession()->statusCodeEquals(200);
// Make sure that the action was actually deleted.
$this->assertSession()->pageTextContains("The action $new_action_label has been deleted.");
$this->drupalGet('admin/config/system/actions');
$this->assertSession()->statusCodeEquals(200);
// The action label does not appear on the overview page.
$this->assertSession()->pageTextNotContains($new_action_label);
$action = Action::load($action_id);
$this->assertNull($action, 'Make sure the action is gone after being deleted.');
}
}

View File

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

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\action\Functional\Node;
use Drupal\Component\Serialization\Json;
use Drupal\Tests\BrowserTestBase;
use Drupal\system\Entity\Action;
use Drupal\user\Entity\User;
/**
* Tests configuration of actions provided by the Node module.
*
* @group action
* @group legacy
*/
class NodeActionsConfigurationTest extends BrowserTestBase {
/**
* Modules to install.
*
* @var array
*/
protected static $modules = ['action', 'node'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests configuration of the node_assign_owner_action action.
*/
public function testAssignOwnerNodeActionConfiguration(): void {
// Create a user with permission to view the actions administration pages.
$user = $this->drupalCreateUser(['administer actions']);
$this->drupalLogin($user);
// Make a POST request to admin/config/system/actions.
$edit = [];
$edit['action'] = 'node_assign_owner_action';
$this->drupalGet('admin/config/system/actions');
$this->submitForm($edit, 'Create');
$this->assertSession()->statusCodeEquals(200);
// Make a POST request to the individual action configuration page.
$edit = [];
$action_label = $this->randomMachineName();
$edit['label'] = $action_label;
$edit['id'] = strtolower($action_label);
$edit['owner_uid'] = $user->id();
$this->drupalGet('admin/config/system/actions/add/node_assign_owner_action');
$this->submitForm($edit, 'Save');
$this->assertSession()->statusCodeEquals(200);
$action_id = $edit['id'];
$action_configure = sprintf('/admin/config/system/actions/configure/%s', $action_id);
$action_delete = sprintf('/admin/config/system/actions/configure/%s/delete', $action_id);
// Make sure that the new action was saved properly.
$this->assertSession()->pageTextContains('The action has been successfully saved.');
// Check that the label of the node_assign_owner_action action appears on
// the actions administration page after saving.
$this->assertSession()->pageTextContains($action_label);
// Make another POST request to the action edit page.
$this->assertSession()->linkByHrefExists($action_configure);
$this->drupalGet($action_configure);
$edit = [];
$new_action_label = $this->randomMachineName();
$edit['label'] = $new_action_label;
$edit['owner_uid'] = $user->id();
$this->submitForm($edit, 'Save');
$this->assertSession()->statusCodeEquals(200);
// Make sure that the action updated properly.
$this->assertSession()->pageTextContains('The action has been successfully saved.');
// Check that the old label for the node_assign_owner_action action does not
// appear on the actions administration page after updating.
$this->assertSession()->pageTextNotContains($action_label);
// Check that the new label for the node_assign_owner_action action appears
// on the actions administration page after updating.
$this->assertSession()->pageTextContains($new_action_label);
// Make sure that deletions work properly.
$this->drupalGet('admin/config/system/actions');
$this->assertSession()->linkByHrefExists($action_delete);
$this->drupalGet($action_delete);
$this->assertSession()->statusCodeEquals(200);
$edit = [];
$this->submitForm($edit, 'Delete');
$this->assertSession()->statusCodeEquals(200);
// Make sure that the action was actually deleted.
$this->assertSession()->pageTextContains("The action {$new_action_label} has been deleted.");
$this->drupalGet('admin/config/system/actions');
$this->assertSession()->statusCodeEquals(200);
// Check that the label for the node_assign_owner_action action does not
// appear on the actions administration page after deleting.
$this->assertSession()->pageTextNotContains($new_action_label);
$action = Action::load($action_id);
$this->assertNull($action, 'The node_assign_owner_action action is not available after being deleted.');
}
/**
* Tests the autocomplete field when configuring the AssignOwnerNode action.
*/
public function testAssignOwnerNodeActionAutocomplete(): void {
// Create 200 users to force the action's configuration page to use an
// autocomplete field instead of a select field. See
// \Drupal\node\Plugin\Action\AssignOwnerNode::buildConfigurationForm().
for ($i = 0; $i < 200; $i++) {
$this->drupalCreateUser();
}
// Create a user with permission to view the actions administration pages
// and additionally permission to administer users. Otherwise the user would
// not be able to reference the anonymous user.
$this->drupalLogin($this->drupalCreateUser(['administer actions', 'administer users']));
// Create AssignOwnerNode action.
$this->drupalGet('admin/config/system/actions');
$this->submitForm(['action' => 'node_assign_owner_action'], 'Create');
// Get the autocomplete URL of the owner_uid textfield.
$autocomplete_field = $this->getSession()->getPage()->findField('owner_uid');
$autocomplete_url = $this->getAbsoluteUrl($autocomplete_field->getAttribute('data-autocomplete-path'));
// Make sure that autocomplete works.
$user = $this->drupalCreateUser();
$data = Json::decode($this->drupalGet($autocomplete_url, ['query' => ['q' => $user->getDisplayName(), '_format' => 'json']]));
$this->assertNotEmpty($data);
$anonymous = User::getAnonymousUser();
// Ensure that the anonymous user exists.
$this->assertNotNull($anonymous);
// Make sure the autocomplete does not show the anonymous user.
$data = Json::decode($this->drupalGet($autocomplete_url, ['query' => ['q' => $anonymous->getDisplayName(), '_format' => 'json']]));
$this->assertEmpty($data);
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\action\FunctionalJavascript;
use Drupal\Core\Url;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\system\Entity\Action;
/**
* Tests action plugins using JavaScript.
*
* @group action
* @group legacy
*/
class ActionFormAjaxTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['action', 'action_form_ajax_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$user = $this->drupalCreateUser(['administer actions']);
$this->drupalLogin($user);
}
/**
* Tests action plugins with AJAX save their configuration.
*/
public function testActionConfigurationWithAjax(): void {
$url = Url::fromRoute('action.admin_add', ['action_id' => 'action_form_ajax_test']);
$this->drupalGet($url);
$page = $this->getSession()->getPage();
$id = 'test_plugin';
$this->assertSession()->waitForElementVisible('named', ['button', 'Edit'])->press();
$this->assertSession()->waitForElementVisible('css', '[name="id"]')->setValue($id);
$page->find('css', '[name="having_a_party"]')
->check();
$this->assertSession()->waitForElementVisible('css', '[name="party_time"]');
$party_time = 'Evening';
$page->find('css', '[name="party_time"]')
->setValue($party_time);
$page->find('css', '[value="Save"]')
->click();
$url = Url::fromRoute('entity.action.collection');
$this->assertSession()->pageTextContains('The action has been successfully saved.');
$this->assertSession()->addressEquals($url);
// Check storage.
$instance = Action::load($id);
$configuration = $instance->getPlugin()->getConfiguration();
$this->assertEquals(['party_time' => $party_time], $configuration);
// Configuration should be shown in edit form.
$this->drupalGet($instance->toUrl('edit-form'));
$this->assertSession()->checkboxChecked('having_a_party');
$this->assertSession()->fieldValueEquals('party_time', $party_time);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\action\Kernel\Migrate\d6;
use Drupal\system\Entity\Action;
use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase;
/**
* Tests migration of action items.
*
* @group migrate_drupal_6
*/
class MigrateActionsTest extends MigrateDrupal6TestBase {
protected static $modules = ['action', 'comment', 'node'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->executeMigration('d6_action');
}
/**
* Tests Drupal 6 action migration to Drupal 8.
*/
public function testActions(): void {
// Test advanced actions.
$this->assertEntity('unpublish_comment_containing_keyword_s_', 'Unpublish comment containing keyword(s)', 'comment', ["keywords" => [0 => "drupal"]]);
$this->assertEntity('unpublish_post_containing_keyword_s_', 'Unpublish post containing keyword(s)', 'node', ["keywords" => [0 => "drupal"]]);
}
/**
* Asserts various aspects of an Action entity.
*
* @param string $id
* The expected Action ID.
* @param string $label
* The expected Action label.
* @param string $type
* The expected Action type.
* @param array $configuration
* The expected Action configuration.
*
* @internal
*/
protected function assertEntity(string $id, string $label, string $type, array $configuration): void {
$action = Action::load($id);
$this->assertInstanceOf(Action::class, $action);
/** @var \Drupal\system\Entity\Action $action */
$this->assertSame($id, $action->id());
$this->assertSame($label, $action->label());
$this->assertSame($type, $action->getType());
$this->assertSame($configuration, $action->get('configuration'));
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\action\Kernel\Migrate\d7;
use Drupal\system\Entity\Action;
use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase;
/**
* Tests migration of action items.
*
* @group action
*/
class MigrateActionsTest extends MigrateDrupal7TestBase {
protected static $modules = ['action', 'comment', 'node'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->executeMigration('d7_action');
}
/**
* Tests Drupal 7 action migration to Drupal 8.
*/
public function testActions(): void {
// Test advanced actions.
$this->assertEntity('unpublish_comment_containing_keyword_s_', 'Unpublish comment containing keyword(s)', 'comment', ["keywords" => [0 => "drupal"]]);
$this->assertEntity('unpublish_content_containing_keyword_s_', 'Unpublish content containing keyword(s)', 'node', ["keywords" => [0 => "drupal"]]);
}
/**
* Asserts various aspects of an Action entity.
*
* @param string $id
* The expected Action ID.
* @param string $label
* The expected Action label.
* @param string $type
* The expected Action type.
* @param array $configuration
* The expected Action configuration.
*
* @internal
*/
protected function assertEntity(string $id, string $label, string $type, array $configuration): void {
$action = Action::load($id);
$this->assertInstanceOf(Action::class, $action);
/** @var \Drupal\system\Entity\Action $action */
$this->assertSame($id, $action->id());
$this->assertSame($label, $action->label());
$this->assertSame($type, $action->getType());
$this->assertSame($configuration, $action->get('configuration'));
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\action\Kernel;
use Drupal\Core\Render\RenderContext;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\system\Entity\Action;
/**
* @group action
*/
class UnpublishByKeywordActionTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['action', 'node', 'system', 'user', 'field'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installSchema('node', ['node_access']);
// Install system's configuration as default date formats are needed.
$this->installConfig(['system']);
// Create a node type for testing.
$type = NodeType::create(['type' => 'page', 'name' => 'page', 'display_submitted' => FALSE]);
$type->save();
}
/**
* Tests creating an action using the node_unpublish_by_keyword_action plugin.
*/
public function testUnpublishByKeywordAction(): void {
/** @var \Drupal\node\Plugin\Action\UnpublishByKeywordNode $action */
$action = Action::create([
'id' => 'foo',
'label' => 'Foo',
'plugin' => 'node_unpublish_by_keyword_action',
'configuration' => [
'keywords' => ['test'],
],
]);
$action->save();
$node1 = Node::create([
'type' => 'page',
'title' => 'test',
'uid' => 1,
]);
$node1->setPublished();
$node1->save();
$node2 = Node::create([
'type' => 'page',
'title' => 'Another node',
'uid' => 1,
]);
$node2->setPublished();
$node2->save();
$this->container->get('renderer')->executeInRenderContext(new RenderContext(), function () use (&$node1, &$node2, $action) {
$action->execute([$node1, $node2]);
});
$this->assertFalse($node1->isPublished());
$this->assertTrue($node2->isPublished());
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\action\Kernel;
use Drupal\comment\Entity\Comment;
use Drupal\comment\Entity\CommentType;
use Drupal\comment\Tests\CommentTestTrait;
use Drupal\Core\Datetime\Entity\DateFormat;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\filter\Entity\FilterFormat;
use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
use Drupal\system\Entity\Action;
/**
* {@inheritdoc}
*
* @group action
*/
class UnpublishByKeywordCommentTest extends EntityKernelTestBase {
use CommentTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['action', 'comment', 'entity_test'];
/**
* Keywords used for testing.
*
* @var string[]
*/
protected $keywords;
/**
* The comment entity.
*
* @var \Drupal\comment\CommentInterface
*/
protected $comment;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installConfig(['user', 'comment']);
$this->installSchema('comment', ['comment_entity_statistics']);
// Create a comment type.
CommentType::create([
'id' => 'comment',
'label' => 'Default comments',
'description' => 'Default comment field',
'target_entity_type_id' => 'entity_test',
])->save();
$this->addDefaultCommentField('entity_test', 'entity_test', 'comment');
// Setup date format to render comment date.
DateFormat::create([
'id' => 'fallback',
'pattern' => 'D, m/d/Y - H:i',
'label' => 'Fallback',
])->save();
// Create format without filters to prevent filtering.
FilterFormat::create([
'format' => 'no_filters',
'name' => 'No filters',
'filters' => [],
])->save();
// Set current user to allow filters display comment body.
$this->drupalSetCurrentUser($this->drupalCreateUser());
$this->keywords = [$this->randomMachineName(), $this->randomMachineName()];
// Create a comment against a test entity.
$host = EntityTest::create();
$host->save();
$this->comment = Comment::create([
'entity_type' => 'entity_test',
'name' => $this->randomString(),
'hostname' => 'magic.example.com',
'mail' => 'tonythemagicalpony@example.com',
'subject' => $this->keywords[0],
'comment_body' => $this->keywords[1],
'entity_id' => $host->id(),
'comment_type' => 'comment',
'field_name' => 'comment',
]);
$this->comment->get('comment_body')->format = 'no_filters';
$this->comment->setPublished();
}
/**
* Tests comment module's default config actions.
*
* @see \Drupal\Core\Entity\Form\DeleteMultipleForm::submitForm()
* @see \Drupal\Core\Action\Plugin\Action\DeleteAction
* @see \Drupal\Core\Action\Plugin\Action\Derivative\EntityDeleteActionDeriver
* @see \Drupal\Core\Action\Plugin\Action\PublishAction
* @see \Drupal\Core\Action\Plugin\Action\SaveAction
*/
public function testCommentDefaultConfigActions(): void {
$this->assertTrue($this->comment->isNew());
$action = Action::load('comment_save_action');
$action->execute([$this->comment]);
$this->assertFalse($this->comment->isNew());
$this->assertTrue($this->comment->isPublished());
// Tests comment unpublish.
$action = Action::load('comment_unpublish_action');
$action->execute([$this->comment]);
$this->assertFalse($this->comment->isPublished(), 'Comment was unpublished');
$this->assertSame(['module' => ['comment']], $action->getDependencies());
// Tests comment publish.
$action = Action::load('comment_publish_action');
$action->execute([$this->comment]);
$this->assertTrue($this->comment->isPublished(), 'Comment was published');
$action = Action::load('comment_delete_action');
$action->execute([$this->comment]);
/** @var \Drupal\Core\TempStore\PrivateTempStoreFactory $temp_store */
$temp_store = $this->container->get('tempstore.private');
$account_id = $this->container->get('current_user')->id();
$store_entries = $temp_store->get('entity_delete_multiple_confirm')->get($account_id . ':comment');
$this->assertSame([$account_id => ['en' => 'en']], $store_entries);
}
/**
* Tests the unpublish comment by keyword action.
*
* @see \Drupal\comment\Plugin\Action\UnpublishByKeywordComment
*/
public function testCommentUnpublishByKeyword(): void {
$this->comment->save();
$action = Action::create([
'id' => 'comment_unpublish_by_keyword_action',
'label' => $this->randomMachineName(),
'type' => 'comment',
'plugin' => 'comment_unpublish_by_keyword_action',
]);
// Tests no keywords.
$action->execute([$this->comment]);
$this->assertTrue($this->comment->isPublished(), 'The comment status was set to published.');
// Tests keyword in subject.
$action->set('configuration', ['keywords' => [$this->keywords[0]]]);
$action->execute([$this->comment]);
$this->assertFalse($this->comment->isPublished(), 'The comment status was set to not published.');
// Tests keyword in comment body.
$this->comment->setPublished();
$action->set('configuration', ['keywords' => [$this->keywords[1]]]);
$action->execute([$this->comment]);
$this->assertFalse($this->comment->isPublished(), 'The comment status was set to not published.');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\action\Unit\Menu;
use Drupal\Tests\Core\Menu\LocalTaskIntegrationTestBase;
/**
* Tests action local tasks.
*
* @group action
* @group legacy
*/
class ActionLocalTasksTest extends LocalTaskIntegrationTestBase {
/**
* {@inheritdoc}
*/
protected function setUp(): void {
$this->directoryList = ['action' => 'core/modules/action'];
parent::setUp();
}
/**
* Tests local task existence.
*/
public function testActionLocalTasks(): void {
$this->assertLocalTasks('entity.action.collection', [['action.admin']]);
}
}

View File

@@ -0,0 +1,10 @@
name: Announcements
type: module
description: Displays announcements from the Drupal community.
# version: VERSION
package: Core
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

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,123 @@
<?php
/**
* @file
* Fetch community announcements from www.drupal.org feed.
*/
use Drupal\announcements_feed\RenderCallbacks;
use Drupal\Core\Link;
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Implements hook_help().
*/
function announcements_feed_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.announcements_feed':
$output = '';
$output .= '<h2>' . t('About') . '</h2>';
$output .= '<p>' . 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>' . t('Uses') . '</h2>';
$output .= '<dl><dt>' . t('Accessing announcements') . '</dt>';
$output .= '<dd>' . 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(t('Announcements'), 'announcements_feed.announcement')->toString(),
]) . '</dd>';
$output .= '</dl>';
return $output;
}
}
/**
* Implements hook_toolbar().
*/
function announcements_feed_toolbar() {
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().
*/
function announcements_feed_toolbar_alter(&$items) {
// 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().
*/
function announcements_feed_theme($existing, $type, $theme, $path) {
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().
*/
function announcements_feed_cron() {
$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,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,21 @@
parameters:
announcements_feed.feed_json_url: https://www.drupal.org/announcements.json
announcements_feed.feed_link: https://www.drupal.org/about/announcements
services:
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,53 @@
/*
* 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 {
-webkit-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-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,38 @@
/*
* 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;
-webkit-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-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");
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
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"] .toolbar .toolbar-bar .announce-toolbar-tab.toolbar-tab {
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 $e) {
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,93 @@
<?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\Url;
/**
* Defines a class for lazy building render arrays.
*
* @internal
*/
final class LazyBuilders implements TrustedCallbackInterface {
/**
* 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' => t('Announcements'),
'#url' => Url::fromRoute('announcements_feed.announcement'),
'#id' => Html::getId('toolbar-item-announcement'),
'#attributes' => [
'title' => 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,69 @@
<?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\BlockBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides an 'Announcements Feed' block.
*
* @Block(
* id = "announce_block",
* admin_label = @Translation("Announcements Feed"),
* )
*
* @internal
*/
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,9 @@
name: 'Announce feed test'
type: module
description: 'Support module for announce feed testing.'
package: Testing
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

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,48 @@
<?php
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($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,37 @@
<?php
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,48 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\announcements_feed\Functional;
use Drupal\Core\Url;
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',
'toolbar',
];
/**
* Tests dynamic page cache.
*/
public function testDynamicPageCache(): void {
$this->drupalLogin($this->drupalCreateUser([
'access toolbar',
'access announcements',
]));
// Front-page is visited right after login.
$this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'MISS');
// Reload the page, it should be cached now.
$this->drupalGet(Url::fromRoute('<front>'));
$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,104 @@
<?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 {
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->markTestSkipped('Skipped due to major version-specific logic. See https://www.drupal.org/project/drupal/issues/3359322');
$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,156 @@
<?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 {
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->markTestSkipped('Skipped due to major version-specific logic. See https://www.drupal.org/project/drupal/issues/3359322');
$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 {
$this->markTestSkipped('Skipped due to major version-specific logic. See https://www.drupal.org/project/drupal/issues/3359322');
$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.
*/
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,11 @@
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
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,61 @@
<?php
/**
* @file
* Provides an automated cron by executing it at the end of a response.
*/
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* Implements hook_help().
*/
function automated_cron_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.automated_cron':
$output = '';
$output .= '<h2>' . t('About') . '</h2>';
$output .= '<p>' . 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>' . t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . t('Configuring Automated Cron') . '</dt>';
$output .= '<dd>' . 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>' . t('Disabling Automated Cron') . '</dt>';
$output .= '<dd>' . 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;
}
}
/**
* Implements hook_form_FORM_ID_alter() for the system_cron_settings() form.
*/
function automated_cron_form_system_cron_settings_alter(&$form, &$form_state) {
$automated_cron_settings = \Drupal::config('automated_cron.settings');
$options = [3600, 10800, 21600, 43200, 86400, 604800];
$form['cron']['interval'] = [
'#type' => 'select',
'#title' => t('Run cron every'),
'#description' => 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 => 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';
}
/**
* Form submission handler for system_cron_settings().
*/
function automated_cron_settings_submit(array $form, FormStateInterface $form_state) {
\Drupal::configFactory()->getEditable('automated_cron.settings')
->set('interval', $form_state->getValue('interval'))
->save();
}

View File

@@ -0,0 +1,6 @@
services:
_defaults:
autoconfigure: true
automated_cron.subscriber:
class: Drupal\automated_cron\EventSubscriber\AutomatedCron
arguments: ['@cron', '@config.factory', '@state']

View File

@@ -0,0 +1,80 @@
<?php
namespace Drupal\automated_cron\EventSubscriber;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\CronInterface;
use Drupal\Core\State\StateInterface;
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 {
/**
* The cron service.
*
* @var \Drupal\Core\CronInterface
*/
protected $cron;
/**
* The cron configuration.
*
* @var \Drupal\Core\Config\Config
*/
protected $config;
/**
* The state key value store.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $state;
/**
* Constructs a new automated cron runner.
*
* @param \Drupal\Core\CronInterface $cron
* The cron service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\State\StateInterface $state
* The state key-value store service.
*/
public function __construct(CronInterface $cron, ConfigFactoryInterface $config_factory, StateInterface $state) {
$this->cron = $cron;
$this->config = $config_factory->get('automated_cron.settings');
$this->state = $state;
}
/**
* Run the automated cron if enabled.
*
* @param \Symfony\Component\HttpKernel\Event\TerminateEvent $event
* The Event to process.
*/
public function onTerminate(TerminateEvent $event) {
$interval = $this->config->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,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 {}

11
core/modules/ban/ban.info.yml Executable file
View File

@@ -0,0 +1,11 @@
name: Ban
type: module
description: 'Allows banning visits from specific IP addresses.'
package: Core
# version: VERSION
configure: ban.admin_page
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

35
core/modules/ban/ban.install Executable file
View File

@@ -0,0 +1,35 @@
<?php
/**
* @file
* Install, update and uninstall functions for the Ban module.
*/
/**
* Implements hook_schema().
*/
function ban_schema() {
$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

30
core/modules/ban/ban.module Executable file
View File

@@ -0,0 +1,30 @@
<?php
/**
* @file
* Allows to ban individual IP addresses.
*/
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Implements hook_help().
*/
function ban_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.ban':
$output = '';
$output .= '<h2>' . t('About') . '</h2>';
$output .= '<p>' . 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>' . t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . t('Banning IP addresses') . '</dt>';
$output .= '<dd>' . 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>' . 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>';
}
}

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,14 @@
services:
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,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,116 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\ban\Functional;
use Drupal\Tests\BrowserTestBase;
use Drupal\Core\Database\Database;
use Drupal\ban\BanIpManager;
/**
* Tests IP address banning.
*
* @group ban
*/
class IpAddressBlockingTest extends BrowserTestBase {
/**
* Modules to install.
*
* @var array
*/
protected static $modules = ['ban'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests various user input to confirm correct validation and saving of data.
*/
public function testIPAddressValidation(): void {
// Create user.
$admin_user = $this->drupalCreateUser(['ban IP addresses']);
$this->drupalLogin($admin_user);
$this->drupalGet('admin/config/people/ban');
$connection = Database::getConnection();
// Ban a valid IP address.
$edit = [];
$edit['ip'] = '1.2.3.3';
$this->drupalGet('admin/config/people/ban');
$this->submitForm($edit, 'Add');
$ip = $connection->select('ban_ip', 'bi')->fields('bi', ['iid'])->condition('ip', $edit['ip'])->execute()->fetchField();
$this->assertNotEmpty($ip, 'IP address found in database.');
$this->assertSession()->pageTextContains('The IP address 1.2.3.3 has been banned.');
// Try to block an IP address that's already blocked.
$edit = [];
$edit['ip'] = '1.2.3.3';
$this->drupalGet('admin/config/people/ban');
$this->submitForm($edit, 'Add');
$this->assertSession()->pageTextContains('This IP address is already banned.');
// Try to block a reserved IP address.
$edit = [];
$edit['ip'] = '255.255.255.255';
$this->drupalGet('admin/config/people/ban');
$this->submitForm($edit, 'Add');
$this->assertSession()->pageTextContains('Enter a valid IP address.');
// Try to block a reserved IP address.
$edit = [];
$edit['ip'] = 'test.example.com';
$this->drupalGet('admin/config/people/ban');
$this->submitForm($edit, 'Add');
$this->assertSession()->pageTextContains('Enter a valid IP address.');
// Submit an empty form.
$edit = [];
$edit['ip'] = '';
$this->drupalGet('admin/config/people/ban');
$this->submitForm($edit, 'Add');
$this->assertSession()->pageTextContains('Enter a valid IP address.');
// Pass an IP address as a URL parameter and submit it.
$submit_ip = '1.2.3.4';
$this->drupalGet('admin/config/people/ban/' . $submit_ip);
$this->submitForm([], 'Add');
$ip = $connection->select('ban_ip', 'bi')->fields('bi', ['iid'])->condition('ip', $submit_ip)->execute()->fetchField();
$this->assertNotEmpty($ip, 'IP address found in database');
$this->assertSession()->pageTextContains("The IP address $submit_ip has been banned.");
// Submit your own IP address. This fails, although it works when testing
// manually.
// @todo On some systems this test fails due to a bug/inconsistency in cURL.
// $edit = array();
// $edit['ip'] = \Drupal::request()->getClientIP();
// $this->drupalGet('admin/config/people/ban');
// $this->submitForm($edit, 'Save');
// $this->assertSession()->pageTextContains('You may not ban your own IP address.');
// Test duplicate ip address are not present in the 'blocked_ips' table.
// when they are entered programmatically.
$banIp = new BanIpManager($connection);
$ip = '1.0.0.0';
$banIp->banIp($ip);
$banIp->banIp($ip);
$banIp->banIp($ip);
$query = $connection->select('ban_ip', 'bip');
$query->fields('bip', ['iid']);
$query->condition('bip.ip', $ip);
$ip_count = $query->execute()->fetchAll();
$this->assertCount(1, $ip_count);
$ip = '';
$banIp->banIp($ip);
$banIp->banIp($ip);
$query = $connection->select('ban_ip', 'bip');
$query->fields('bip', ['iid']);
$query->condition('bip.ip', $ip);
$ip_count = $query->execute()->fetchAll();
$this->assertCount(1, $ip_count);
}
}

View File

@@ -0,0 +1,44 @@
<?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;
/**
* Modules to enable.
*
* @var array
*/
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'));
}
}

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