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,40 @@
<?php
namespace Drupal\Core\Access;
use Drupal\Component\Utility\ArgumentsResolver;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Resolves the arguments to pass to an access check callable.
*/
class AccessArgumentsResolverFactory implements AccessArgumentsResolverFactoryInterface {
/**
* {@inheritdoc}
*/
public function getArgumentsResolver(RouteMatchInterface $route_match, AccountInterface $account, ?Request $request = NULL) {
$route = $route_match->getRouteObject();
// Defaults for the parameters defined on the route object need to be added
// to the raw arguments.
$raw_route_arguments = $route_match->getRawParameters()->all() + $route->getDefaults();
$upcasted_route_arguments = $route_match->getParameters()->all();
// Parameters which are not defined on the route object, but still are
// essential for access checking are passed as wildcards to the argument
// resolver. An access-check method with a parameter of type Route,
// RouteMatchInterface, AccountInterface or Request will receive those
// arguments regardless of the parameter name.
$wildcard_arguments = [$route, $route_match, $account];
if (isset($request)) {
$wildcard_arguments[] = $request;
}
return new ArgumentsResolver($raw_route_arguments, $upcasted_route_arguments, $wildcard_arguments);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Drupal\Core\Access;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Constructs the arguments resolver instance to use when running access checks.
*/
interface AccessArgumentsResolverFactoryInterface {
/**
* Returns the arguments resolver to use when running access checks.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match object to be checked.
* @param \Drupal\Core\Session\AccountInterface $account
* The account being checked.
* @param \Symfony\Component\HttpFoundation\Request $request
* Optional, the request object.
*
* @return \Drupal\Component\Utility\ArgumentsResolverInterface
* The parametrized arguments resolver instance.
*/
public function getArgumentsResolver(RouteMatchInterface $route_match, AccountInterface $account, ?Request $request = NULL);
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Drupal\Core\Access;
use Symfony\Component\Routing\Route;
use Drupal\Core\Routing\Access\AccessInterface as RoutingAccessInterface;
/**
* An access check service determines access rules for particular routes.
*/
interface AccessCheckInterface extends RoutingAccessInterface {
/**
* Declares whether the access check applies to a specific route or not.
*
* @param \Symfony\Component\Routing\Route $route
* The route to consider attaching to.
*
* @return bool
* TRUE if this access checker applies to this route.
*/
public function applies(Route $route);
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Drupal\Core\Access;
/**
* An exception thrown for access errors.
*
* Examples could be invalid access callback return values, or invalid access
* objects being used.
*/
class AccessException extends \RuntimeException {
}

View File

@@ -0,0 +1,169 @@
<?php
namespace Drupal\Core\Access;
use Drupal\Core\ParamConverter\ParamConverterManagerInterface;
use Drupal\Core\ParamConverter\ParamNotConvertedException;
use Drupal\Core\Routing\RouteMatch;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Routing\RouteProviderInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Component\Utility\ArgumentsResolverInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Exception\RouteNotFoundException;
use Drupal\Core\Routing\RouteObjectInterface;
/**
* Attaches access check services to routes and runs them on request.
*
* @see \Drupal\Tests\Core\Access\AccessManagerTest
*/
class AccessManager implements AccessManagerInterface {
/**
* The route provider.
*
* @var \Drupal\Core\Routing\RouteProviderInterface
*/
protected $routeProvider;
/**
* The paramconverter manager.
*
* @var \Drupal\Core\ParamConverter\ParamConverterManagerInterface
*/
protected $paramConverterManager;
/**
* The access arguments resolver.
*
* @var \Drupal\Core\Access\AccessArgumentsResolverFactoryInterface
*/
protected $argumentsResolverFactory;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* The check provider.
*
* @var \Drupal\Core\Access\CheckProviderInterface
*/
protected $checkProvider;
/**
* Constructs an AccessManager instance.
*
* @param \Drupal\Core\Routing\RouteProviderInterface $route_provider
* The route provider.
* @param \Drupal\Core\ParamConverter\ParamConverterManagerInterface $paramconverter_manager
* The param converter manager.
* @param \Drupal\Core\Access\AccessArgumentsResolverFactoryInterface $arguments_resolver_factory
* The access arguments resolver.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user.
* @param CheckProviderInterface $check_provider
* The check access provider.
*/
public function __construct(RouteProviderInterface $route_provider, ParamConverterManagerInterface $paramconverter_manager, AccessArgumentsResolverFactoryInterface $arguments_resolver_factory, AccountInterface $current_user, CheckProviderInterface $check_provider) {
$this->routeProvider = $route_provider;
$this->paramConverterManager = $paramconverter_manager;
$this->argumentsResolverFactory = $arguments_resolver_factory;
$this->currentUser = $current_user;
$this->checkProvider = $check_provider;
}
/**
* {@inheritdoc}
*/
public function checkNamedRoute($route_name, array $parameters = [], ?AccountInterface $account = NULL, $return_as_object = FALSE) {
try {
$route = $this->routeProvider->getRouteByName($route_name);
// ParamConverterManager relies on the route name and object being
// available from the parameters array.
$parameters[RouteObjectInterface::ROUTE_NAME] = $route_name;
$parameters[RouteObjectInterface::ROUTE_OBJECT] = $route;
$upcasted_parameters = $this->paramConverterManager->convert($parameters + $route->getDefaults());
$route_match = new RouteMatch($route_name, $route, $upcasted_parameters, $parameters);
return $this->check($route_match, $account, NULL, $return_as_object);
}
catch (RouteNotFoundException $e) {
// Cacheable until extensions change.
$result = AccessResult::forbidden()->addCacheTags(['config:core.extension']);
return $return_as_object ? $result : $result->isAllowed();
}
catch (ParamNotConvertedException $e) {
// Uncacheable because conversion of the parameter may not have been
// possible due to dynamic circumstances.
$result = AccessResult::forbidden()->setCacheMaxAge(0);
return $return_as_object ? $result : $result->isAllowed();
}
}
/**
* {@inheritdoc}
*/
public function checkRequest(Request $request, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
$route_match = RouteMatch::createFromRequest($request);
return $this->check($route_match, $account, $request, $return_as_object);
}
/**
* {@inheritdoc}
*/
public function check(RouteMatchInterface $route_match, ?AccountInterface $account = NULL, ?Request $request = NULL, $return_as_object = FALSE) {
if (!isset($account)) {
$account = $this->currentUser;
}
$route = $route_match->getRouteObject();
$checks = $route->getOption('_access_checks') ?: [];
// Filter out checks which require the incoming request.
if (!isset($request)) {
$checks = array_diff($checks, $this->checkProvider->getChecksNeedRequest());
}
$result = AccessResult::neutral();
if (!empty($checks)) {
$arguments_resolver = $this->argumentsResolverFactory->getArgumentsResolver($route_match, $account, $request);
$result = AccessResult::allowed();
foreach ($checks as $service_id) {
$result = $result->andIf($this->performCheck($service_id, $arguments_resolver));
}
}
return $return_as_object ? $result : $result->isAllowed();
}
/**
* Performs the specified access check.
*
* @param string $service_id
* The access check service ID to use.
* @param \Drupal\Component\Utility\ArgumentsResolverInterface $arguments_resolver
* The parametrized arguments resolver instance.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*
* @throws \Drupal\Core\Access\AccessException
* Thrown when the access check returns an invalid value.
*/
protected function performCheck($service_id, ArgumentsResolverInterface $arguments_resolver) {
$callable = $this->checkProvider->loadCheck($service_id);
$arguments = $arguments_resolver->getArguments($callable);
/** @var \Drupal\Core\Access\AccessResultInterface $service_access **/
$service_access = call_user_func_array($callable, $arguments);
if (!$service_access instanceof AccessResultInterface) {
throw new AccessException("Access error in $service_id. Access services must return an object that implements AccessResultInterface.");
}
return $service_access;
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Drupal\Core\Access;
use Symfony\Component\HttpFoundation\Request;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Provides an interface for attaching and running access check services.
*/
interface AccessManagerInterface {
/**
* Checks a named route with parameters against applicable access check services.
*
* Determines whether the route is accessible or not.
*
* @param string $route_name
* The route to check access to.
* @param array $parameters
* Optional array of values to substitute into the route path pattern.
* @param \Drupal\Core\Session\AccountInterface $account
* (optional) Run access checks for this account. Defaults to the current
* user.
* @param bool $return_as_object
* (optional) Defaults to FALSE.
*
* @return bool|\Drupal\Core\Access\AccessResultInterface
* The access result. Returns a boolean if $return_as_object is FALSE (this
* is the default) and otherwise an AccessResultInterface object.
* When a boolean is returned, the result of AccessInterface::isAllowed() is
* returned, i.e. TRUE means access is explicitly allowed, FALSE means
* access is either explicitly forbidden or "no opinion".
*/
public function checkNamedRoute($route_name, array $parameters = [], ?AccountInterface $account = NULL, $return_as_object = FALSE);
/**
* Execute access checks against the incoming request.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The incoming request.
* @param \Drupal\Core\Session\AccountInterface $account
* (optional) Run access checks for this account. Defaults to the current
* user.
* @param bool $return_as_object
* (optional) Defaults to FALSE.
*
* @return bool|\Drupal\Core\Access\AccessResultInterface
* The access result. Returns a boolean if $return_as_object is FALSE (this
* is the default) and otherwise an AccessResultInterface object.
* When a boolean is returned, the result of AccessInterface::isAllowed() is
* returned, i.e. TRUE means access is explicitly allowed, FALSE means
* access is either explicitly forbidden or "no opinion".
*/
public function checkRequest(Request $request, ?AccountInterface $account = NULL, $return_as_object = FALSE);
/**
* Checks a route against applicable access check services.
*
* Determines whether the route is accessible or not.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
* @param \Drupal\Core\Session\AccountInterface $account
* (optional) Run access checks for this account. Defaults to the current
* user.
* @param \Symfony\Component\HttpFoundation\Request $request
* Optional, a request. Only supply this parameter when checking the
* incoming request, do not specify when checking routes on output.
* @param bool $return_as_object
* (optional) Defaults to FALSE.
*
* @return bool|\Drupal\Core\Access\AccessResultInterface
* The access result. Returns a boolean if $return_as_object is FALSE (this
* is the default) and otherwise an AccessResultInterface object.
* When a boolean is returned, the result of AccessInterface::isAllowed() is
* returned, i.e. TRUE means access is explicitly allowed, FALSE means
* access is either explicitly forbidden or "no opinion".
*/
public function check(RouteMatchInterface $route_match, ?AccountInterface $account = NULL, ?Request $request = NULL, $return_as_object = FALSE);
}

View File

@@ -0,0 +1,421 @@
<?php
namespace Drupal\Core\Access;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
use Drupal\Core\Cache\RefinableCacheableDependencyTrait;
use Drupal\Core\Session\AccountInterface;
/**
* Value object for passing an access result with cacheability metadata.
*
* The access result itself excluding the cacheability metadata  is
* immutable. There are subclasses for each of the three possible access results
* themselves:
*
* @see \Drupal\Core\Access\AccessResultAllowed
* @see \Drupal\Core\Access\AccessResultForbidden
* @see \Drupal\Core\Access\AccessResultNeutral
*
* When using ::orIf() and ::andIf(), cacheability metadata will be merged
* accordingly as well.
*/
abstract class AccessResult implements AccessResultInterface, RefinableCacheableDependencyInterface {
use RefinableCacheableDependencyTrait;
/**
* Creates an AccessResultInterface object with isNeutral() === TRUE.
*
* @param string|null $reason
* (optional) The reason why access is neutral. Intended for developers,
* hence not translatable.
*
* @return \Drupal\Core\Access\AccessResultNeutral
* isNeutral() will be TRUE.
*/
public static function neutral($reason = NULL) {
assert(is_string($reason) || is_null($reason));
return new AccessResultNeutral($reason);
}
/**
* Creates an AccessResultInterface object with isAllowed() === TRUE.
*
* @return \Drupal\Core\Access\AccessResultAllowed
* isAllowed() will be TRUE.
*/
public static function allowed() {
return new AccessResultAllowed();
}
/**
* Creates an AccessResultInterface object with isForbidden() === TRUE.
*
* @param string|null $reason
* (optional) The reason why access is forbidden. Intended for developers,
* hence not translatable.
*
* @return \Drupal\Core\Access\AccessResultForbidden
* isForbidden() will be TRUE.
*/
public static function forbidden($reason = NULL) {
assert(is_string($reason) || is_null($reason));
return new AccessResultForbidden($reason);
}
/**
* Creates an allowed or neutral access result.
*
* @param bool $condition
* The condition to evaluate.
*
* @return \Drupal\Core\Access\AccessResult
* If $condition is TRUE, isAllowed() will be TRUE, otherwise isNeutral()
* will be TRUE.
*/
public static function allowedIf($condition) {
return $condition ? static::allowed() : static::neutral();
}
/**
* Creates a forbidden or neutral access result.
*
* @param bool $condition
* The condition to evaluate.
* @param string|null $reason
* (optional) The reason why access is forbidden. Intended for developers,
* hence not translatable
*
* @return \Drupal\Core\Access\AccessResult
* If $condition is TRUE, isForbidden() will be TRUE, otherwise isNeutral()
* will be TRUE.
*/
public static function forbiddenIf($condition, $reason = NULL) {
return $condition ? static::forbidden($reason) : static::neutral();
}
/**
* Creates an allowed access result if the permission is present, neutral otherwise.
*
* Checks the permission and adds a 'user.permissions' cache context.
*
* @param \Drupal\Core\Session\AccountInterface $account
* The account for which to check a permission.
* @param string $permission
* The permission to check for.
*
* @return \Drupal\Core\Access\AccessResult
* If the account has the permission, isAllowed() will be TRUE, otherwise
* isNeutral() will be TRUE.
*/
public static function allowedIfHasPermission(AccountInterface $account, $permission) {
$access_result = static::allowedIf($account->hasPermission($permission))->addCacheContexts(['user.permissions']);
if ($access_result instanceof AccessResultReasonInterface) {
$access_result->setReason("The '$permission' permission is required.");
}
return $access_result;
}
/**
* Creates an allowed access result if the permissions are present, neutral otherwise.
*
* Checks the permission and adds a 'user.permissions' cache contexts.
*
* @param \Drupal\Core\Session\AccountInterface $account
* The account for which to check permissions.
* @param array $permissions
* The permissions to check.
* @param string $conjunction
* (optional) 'AND' if all permissions are required, 'OR' in case just one.
* Defaults to 'AND'
*
* @return \Drupal\Core\Access\AccessResult
* If the account has the permissions, isAllowed() will be TRUE, otherwise
* isNeutral() will be TRUE.
*/
public static function allowedIfHasPermissions(AccountInterface $account, array $permissions, $conjunction = 'AND') {
$access = FALSE;
if ($conjunction == 'AND' && !empty($permissions)) {
$access = TRUE;
foreach ($permissions as $permission) {
if (!$account->hasPermission($permission)) {
$access = FALSE;
break;
}
}
}
else {
foreach ($permissions as $permission) {
if ($account->hasPermission($permission)) {
$access = TRUE;
break;
}
}
}
$access_result = static::allowedIf($access)->addCacheContexts(empty($permissions) ? [] : ['user.permissions']);
if ($access_result instanceof AccessResultReasonInterface) {
if (count($permissions) === 1) {
$access_result->setReason("The '$permission' permission is required.");
}
elseif (count($permissions) > 1) {
$quote = function ($s) {
return "'$s'";
};
$access_result->setReason(sprintf("The following permissions are required: %s.", implode(" $conjunction ", array_map($quote, $permissions))));
}
}
return $access_result;
}
/**
* {@inheritdoc}
*
* @see \Drupal\Core\Access\AccessResultAllowed
*/
public function isAllowed() {
return FALSE;
}
/**
* {@inheritdoc}
*
* @see \Drupal\Core\Access\AccessResultForbidden
*/
public function isForbidden() {
return FALSE;
}
/**
* {@inheritdoc}
*
* @see \Drupal\Core\Access\AccessResultNeutral
*/
public function isNeutral() {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function getCacheContexts() {
return $this->cacheContexts;
}
/**
* {@inheritdoc}
*/
public function getCacheTags() {
return $this->cacheTags;
}
/**
* {@inheritdoc}
*/
public function getCacheMaxAge() {
return $this->cacheMaxAge;
}
/**
* Resets cache contexts (to the empty array).
*
* @return $this
*/
public function resetCacheContexts() {
$this->cacheContexts = [];
return $this;
}
/**
* Resets cache tags (to the empty array).
*
* @return $this
*/
public function resetCacheTags() {
$this->cacheTags = [];
return $this;
}
/**
* Sets the maximum age for which this access result may be cached.
*
* @param int $max_age
* The maximum time in seconds that this access result may be cached.
*
* @return $this
*/
public function setCacheMaxAge($max_age) {
$this->cacheMaxAge = $max_age;
return $this;
}
/**
* Convenience method, adds the "user.permissions" cache context.
*
* @return $this
*/
public function cachePerPermissions() {
$this->addCacheContexts(['user.permissions']);
return $this;
}
/**
* Convenience method, adds the "user" cache context.
*
* @return $this
*/
public function cachePerUser() {
$this->addCacheContexts(['user']);
return $this;
}
/**
* {@inheritdoc}
*/
public function orIf(AccessResultInterface $other) {
$merge_other = FALSE;
// $other's cacheability metadata is merged if $merge_other gets set to TRUE
// and this happens in three cases:
// 1. $other's access result is the one that determines the combined access
// result.
// 2. This access result is not cacheable and $other's access result is the
// same. i.e. attempt to return a cacheable access result.
// 3. Neither access result is 'forbidden' and both are cacheable: inherit
// the other's cacheability metadata because it may turn into a
// 'forbidden' for another value of the cache contexts in the
// cacheability metadata. In other words: this is necessary to respect
// the contagious nature of the 'forbidden' access result.
// e.g. we have two access results A and B. Neither is forbidden. A is
// globally cacheable (no cache contexts). B is cacheable per role. If we
// don't have merging case 3, then A->orIf(B) will be globally cacheable,
// which means that even if a user of a different role logs in, the
// cached access result will be used, even though for that other role, B
// is forbidden!
if ($this->isForbidden() || $other->isForbidden()) {
$result = static::forbidden();
if (!$this->isForbidden() || ($this->getCacheMaxAge() === 0 && $other->isForbidden())) {
$merge_other = TRUE;
}
if ($this->isForbidden() && $this instanceof AccessResultReasonInterface && $this->getReason() !== '') {
$result->setReason($this->getReason());
}
elseif ($other->isForbidden() && $other instanceof AccessResultReasonInterface && $other->getReason() !== '') {
$result->setReason($other->getReason());
}
}
elseif ($this->isAllowed() || $other->isAllowed()) {
$result = static::allowed();
if (!$this->isAllowed() || ($this->getCacheMaxAge() === 0 && $other->isAllowed()) || ($this->getCacheMaxAge() !== 0 && $other instanceof CacheableDependencyInterface && $other->getCacheMaxAge() !== 0)) {
$merge_other = TRUE;
}
}
else {
$result = static::neutral();
if (!$this->isNeutral() || ($this->getCacheMaxAge() === 0 && $other->isNeutral()) || ($this->getCacheMaxAge() !== 0 && $other instanceof CacheableDependencyInterface && $other->getCacheMaxAge() !== 0)) {
$merge_other = TRUE;
}
if ($this instanceof AccessResultReasonInterface && $this->getReason() !== '') {
$result->setReason($this->getReason());
}
elseif ($other instanceof AccessResultReasonInterface && $other->getReason() !== '') {
$result->setReason($other->getReason());
}
}
$result->inheritCacheability($this);
if ($merge_other) {
$result->inheritCacheability($other);
}
return $result;
}
/**
* {@inheritdoc}
*/
public function andIf(AccessResultInterface $other) {
// The other access result's cacheability metadata is merged if $merge_other
// gets set to TRUE. It gets set to TRUE in one case: if the other access
// result is used.
$merge_other = FALSE;
if ($this->isForbidden() || $other->isForbidden()) {
$result = static::forbidden();
if (!$this->isForbidden()) {
if ($other instanceof AccessResultReasonInterface) {
$result->setReason($other->getReason());
}
$merge_other = TRUE;
}
else {
if ($this instanceof AccessResultReasonInterface) {
$result->setReason($this->getReason());
}
}
}
elseif ($this->isAllowed() && $other->isAllowed()) {
$result = static::allowed();
$merge_other = TRUE;
}
else {
$result = static::neutral();
if (!$this->isNeutral()) {
$merge_other = TRUE;
if ($other instanceof AccessResultReasonInterface) {
$result->setReason($other->getReason());
}
}
else {
if ($this instanceof AccessResultReasonInterface) {
$result->setReason($this->getReason());
}
}
}
$result->inheritCacheability($this);
if ($merge_other) {
$result->inheritCacheability($other);
// If this access result is not cacheable, then an AND with another access
// result must also not be cacheable, except if the other access result
// has isForbidden() === TRUE. isForbidden() access results are contagious
// in that they propagate regardless of the other value.
if ($this->getCacheMaxAge() === 0 && !$result->isForbidden()) {
$result->setCacheMaxAge(0);
}
}
return $result;
}
/**
* Inherits the cacheability of the other access result, if any.
*
* This method differs from addCacheableDependency() in how it handles
* max-age, because it is designed to inherit the cacheability of the second
* operand in the andIf() and orIf() operations. There, the situation
* "allowed, max-age=0 OR allowed, max-age=1000" needs to yield max-age 1000
* as the end result.
*
* @param \Drupal\Core\Access\AccessResultInterface $other
* The other access result, whose cacheability (if any) to inherit.
*
* @return $this
*/
public function inheritCacheability(AccessResultInterface $other) {
$this->addCacheableDependency($other);
if ($other instanceof CacheableDependencyInterface) {
if ($this->getCacheMaxAge() !== 0 && $other->getCacheMaxAge() !== 0) {
$this->setCacheMaxAge(Cache::mergeMaxAges($this->getCacheMaxAge(), $other->getCacheMaxAge()));
}
else {
$this->setCacheMaxAge($other->getCacheMaxAge());
}
}
return $this;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Drupal\Core\Access;
/**
* Value object indicating an allowed access result, with cacheability metadata.
*/
class AccessResultAllowed extends AccessResult {
/**
* {@inheritdoc}
*/
public function isAllowed() {
return TRUE;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Drupal\Core\Access;
/**
* Value object indicating a forbidden access result, with cacheability metadata.
*/
class AccessResultForbidden extends AccessResult implements AccessResultReasonInterface {
/**
* The reason why access is forbidden. For use in error messages.
*
* @var string
*/
protected $reason;
/**
* Constructs a new AccessResultForbidden instance.
*
* @param null|string $reason
* (optional) A message to provide details about this access result.
*/
public function __construct($reason = NULL) {
$this->reason = $reason;
}
/**
* {@inheritdoc}
*/
public function isForbidden() {
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getReason() {
return (string) $this->reason;
}
/**
* {@inheritdoc}
*/
public function setReason($reason) {
$this->reason = $reason;
return $this;
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Drupal\Core\Access;
/**
* Interface for access result value objects.
*
* IMPORTANT NOTE: You have to call isAllowed() when you want to know whether
* someone has access. Just using
* @code
* if ($access_result) {
* // The user has access!
* }
* else {
* // The user doesn't have access!
* }
* @endcode
* would never enter the else-statement and hence introduce a critical security
* issue.
*/
interface AccessResultInterface {
/**
* Checks whether this access result indicates access is explicitly allowed.
*
* Call this method to check whether someone has access, to convert an access
* result object to boolean.
*
* @return bool
* When TRUE then isForbidden() and isNeutral() are FALSE.
*/
public function isAllowed();
/**
* Checks whether this access result indicates access is explicitly forbidden.
*
* Call this when optimizing an access checker (for hook_entity_access() or a
* route requirement): if this is TRUE, the final result will be forbidden and
* no further checking is necessary.
*
* Do not use this method to decide whether someone has access, to convert an
* access result to boolean: just because this returns FALSE, the end result
* might be neutral which is not allowed. Always use isAllowed() for this.
*
* @return bool
* When TRUE then isAllowed() and isNeutral() are FALSE.
*/
public function isForbidden();
/**
* Checks whether this access result indicates access is not yet determined.
*
* @return bool
* When TRUE then isAllowed() and isForbidden() are FALSE.
*
* @internal
*/
public function isNeutral();
/**
* Combine this access result with another using OR.
*
* When ORing two access results, the result is:
* - isForbidden() in either isForbidden()
* - otherwise if isAllowed() in either isAllowed()
* - otherwise both must be isNeutral() isNeutral()
*
* Truth table:
* @code
* |A N F
* --+-----
* A |A A F
* N |A N F
* F |F F F
* @endcode
*
* @param \Drupal\Core\Access\AccessResultInterface $other
* The other access result to OR this one with.
*
* @return static
*/
public function orIf(AccessResultInterface $other);
/**
* Combine this access result with another using AND.
*
* When AND is performed on two access results, the result is:
* - isForbidden() in either isForbidden()
* - otherwise, if isAllowed() in both isAllowed()
* - otherwise, one of them is isNeutral() isNeutral()
*
* Truth table:
* @code
* |A N F
* --+-----
* A |A N F
* N |N N F
* F |F F F
* @endcode
*
* @param \Drupal\Core\Access\AccessResultInterface $other
* The other access result to AND this one with.
*
* @return static
*/
public function andIf(AccessResultInterface $other);
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Drupal\Core\Access;
/**
* Value object indicating a neutral access result, with cacheability metadata.
*/
class AccessResultNeutral extends AccessResult implements AccessResultReasonInterface {
/**
* The reason why access is neutral. For use in messages.
*
* @var string
*/
protected $reason;
/**
* Constructs a new AccessResultNeutral instance.
*
* @param null|string $reason
* (optional) A message to provide details about this access result
*/
public function __construct($reason = NULL) {
$this->reason = $reason;
}
/**
* {@inheritdoc}
*/
public function isNeutral() {
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getReason() {
return (string) $this->reason;
}
/**
* {@inheritdoc}
*/
public function setReason($reason) {
$this->reason = $reason;
return $this;
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Drupal\Core\Access;
/**
* Interface for access result value objects with stored reason for developers.
*
* For example, a developer can specify the reason for forbidden access:
* @code
* new AccessResultForbidden('You are not authorized to hack core');
* @endcode
*
* @see \Drupal\Core\Access\AccessResultInterface
*/
interface AccessResultReasonInterface extends AccessResultInterface {
/**
* Gets the reason for this access result.
*
* @return string
* The reason of this access result or an empty string if no reason is
* provided.
*/
public function getReason();
/**
* Sets the reason for this access result.
*
* @param string|null $reason
* The reason of this access result or NULL if no reason is provided.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result instance.
*/
public function setReason($reason);
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Drupal\Core\Access;
use Drupal\Core\Session\AccountInterface;
/**
* Interface for checking access.
*
* @ingroup entity_api
*/
interface AccessibleInterface {
/**
* Checks data value access.
*
* @param string $operation
* The operation to be performed.
* @param \Drupal\Core\Session\AccountInterface $account
* (optional) The user for which to check access, or NULL to check access
* for the current user. Defaults to NULL.
* @param bool $return_as_object
* (optional) Defaults to FALSE.
*
* @return bool|\Drupal\Core\Access\AccessResultInterface
* The access result. Returns a boolean if $return_as_object is FALSE (this
* is the default) and otherwise an AccessResultInterface object.
* When a boolean is returned, the result of AccessInterface::isAllowed() is
* returned, i.e. TRUE means access is explicitly allowed, FALSE means
* access is either explicitly forbidden or "no opinion".
*/
public function access($operation, ?AccountInterface $account = NULL, $return_as_object = FALSE);
}

View File

@@ -0,0 +1,176 @@
<?php
namespace Drupal\Core\Access;
use Drupal\Core\Routing\Access\AccessInterface;
use Psr\Container\ContainerInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Loads access checkers from the container.
*/
class CheckProvider implements CheckProviderInterface {
/**
* Array of registered access check service ids.
*
* @var array
*/
protected $checkIds = [];
/**
* Array of access check objects keyed by service id.
*
* @var \Drupal\Core\Routing\Access\AccessInterface[]
*/
protected $checks;
/**
* Array of access check method names keyed by service ID.
*
* @var array
*/
protected $checkMethods = [];
/**
* Array of access checks which only will be run on the incoming request.
*/
protected $checksNeedsRequest = [];
/**
* An array to map static requirement keys to service IDs.
*
* @var array
*/
protected $staticRequirementMap;
/**
* An array to map dynamic requirement keys to service IDs.
*
* @var array
*/
protected $dynamicRequirementMap;
/**
* Constructs a CheckProvider object.
*
* @param array|null $dynamic_requirements_map
* An array to map dynamic requirement keys to service IDs.
* @param \Psr\Container\ContainerInterface|null $container
* The check provider service locator.
*/
public function __construct(
?array $dynamic_requirements_map = NULL,
protected ?ContainerInterface $container = NULL,
) {
$this->dynamicRequirementMap = $dynamic_requirements_map;
if (is_null($this->dynamicRequirementMap)) {
@trigger_error('Calling ' . __METHOD__ . ' without the $dynamic_requirements_map argument is deprecated in drupal:10.3.0 and it will be required in drupal:11.0.0. See https://www.drupal.org/node/3416353', E_USER_DEPRECATED);
$this->dynamicRequirementMap = \Drupal::getContainer()->getParameter('dynamic_access_check_services');
}
if (!$this->container) {
@trigger_error('Calling ' . __METHOD__ . ' without the $container argument is deprecated in drupal:10.3.0 and it will be required in drupal:11.0.0. See https://www.drupal.org/node/3416353', E_USER_DEPRECATED);
$this->container = \Drupal::getContainer();
}
}
/**
* {@inheritdoc}
*/
public function addCheckService($service_id, $service_method, array $applies_checks = [], $needs_incoming_request = FALSE) {
$this->checkIds[] = $service_id;
$this->checkMethods[$service_id] = $service_method;
if ($needs_incoming_request) {
$this->checksNeedsRequest[$service_id] = $service_id;
}
foreach ($applies_checks as $applies_check) {
$this->staticRequirementMap[$applies_check][] = $service_id;
}
}
/**
* {@inheritdoc}
*/
public function getChecksNeedRequest() {
return $this->checksNeedsRequest;
}
/**
* {@inheritdoc}
*/
public function setChecks(RouteCollection $routes) {
foreach ($routes as $route) {
if ($checks = $this->applies($route)) {
$route->setOption('_access_checks', $checks);
}
}
}
/**
* {@inheritdoc}
*/
public function loadCheck($service_id) {
if (empty($this->checks[$service_id])) {
if (!in_array($service_id, $this->checkIds)) {
throw new \InvalidArgumentException(sprintf('No check has been registered for %s', $service_id));
}
$check = $this->container->get($service_id);
if (!($check instanceof AccessInterface)) {
throw new AccessException('All access checks must implement AccessInterface.');
}
if (!is_callable([$check, $this->checkMethods[$service_id]])) {
throw new AccessException(sprintf('Access check method %s in service %s must be callable.', $this->checkMethods[$service_id], $service_id));
}
$this->checks[$service_id] = $check;
}
return [$this->checks[$service_id], $this->checkMethods[$service_id]];
}
/**
* Determine which registered access checks apply to a route.
*
* @param \Symfony\Component\Routing\Route $route
* The route to get list of access checks for.
*
* @return array
* An array of service ids for the access checks that apply to passed
* route.
*/
protected function applies(Route $route) {
$checks = [];
// Iterate through map requirements from appliesTo() on access checkers.
// Only iterate through all checkIds if this is not used.
foreach ($route->getRequirements() as $key => $value) {
if (isset($this->staticRequirementMap[$key])) {
foreach ($this->staticRequirementMap[$key] as $service_id) {
$this->loadCheck($service_id);
$checks[] = $service_id;
}
}
}
// Finally, see if any dynamic access checkers apply.
foreach ($this->dynamicRequirementMap as $service_id) {
$this->loadCheck($service_id);
if ($this->checks[$service_id]->applies($route)) {
$checks[] = $service_id;
}
}
return $checks;
}
/**
* Compiles a mapping of requirement keys to access checker service IDs.
*/
protected function loadDynamicRequirementMap() {
if (!isset($this->dynamicRequirementMap)) {
$this->dynamicRequirementMap = $this->container->getParameter('dynamic_access_check_services');
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Drupal\Core\Access;
use Symfony\Component\Routing\RouteCollection;
/**
* Provides the available access checkers by service IDs.
*
* Access checker services are added by ::addCheckService calls and are loaded
* by ::loadCheck.
*
* The checker provider service and the actual checking is separated in order
* to not require the full access manager on route build time.
*/
interface CheckProviderInterface {
/**
* For each route, saves a list of applicable access checks to the route.
*
* @param \Symfony\Component\Routing\RouteCollection $routes
* A collection of routes to apply checks to.
*/
public function setChecks(RouteCollection $routes);
/**
* Registers a new AccessCheck by service ID.
*
* @param string $service_id
* The ID of the service in the Container that provides a check.
* @param string $service_method
* The method to invoke on the service object for performing the check.
* @param array $applies_checks
* (optional) An array of route requirement keys the checker service applies
* to.
* @param bool $needs_incoming_request
* (optional) True if access-check method only acts on an incoming request.
*/
public function addCheckService($service_id, $service_method, array $applies_checks = [], $needs_incoming_request = FALSE);
/**
* Lazy-loads access check services.
*
* @param string $service_id
* The service id of the access check service to load.
*
* @return callable
* A callable access check.
*
* @throws \InvalidArgumentException
* Thrown when the service hasn't been registered in addCheckService().
* @throws \Drupal\Core\Access\AccessException
* Thrown when the service doesn't implement the required interface.
*/
public function loadCheck($service_id);
/**
* A list of checks that needs the request.
*
* @return array
*/
public function getChecksNeedRequest();
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Drupal\Core\Access;
use Drupal\Core\Routing\Access\AccessInterface as RoutingAccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\HttpFoundation\Request;
/**
* Access protection against CSRF attacks.
*
* The CsrfAccessCheck is added to any route with the '_csrf_token' route
* requirement. If a link/url to a protected route is generated using the
* url_generator service, a valid token will be added automatically. Otherwise,
* a valid token can be generated by the csrf_token service using the route's
* path (without leading slash) as the argument when generating the token. This
* token can then be added as the 'token' query parameter when accessing the
* protected route.
*
* @see \Drupal\Core\Access\RouteProcessorCsrf
* @see \Drupal\Core\Access\CsrfTokenGenerator
* @see https://www.drupal.org/docs/8/api/routing-system/access-checking-on-routes/csrf-access-checking
*/
class CsrfAccessCheck implements RoutingAccessInterface {
/**
* The CSRF token generator.
*
* @var \Drupal\Core\Access\CsrfTokenGenerator
*/
protected $csrfToken;
/**
* Constructs a CsrfAccessCheck object.
*
* @param \Drupal\Core\Access\CsrfTokenGenerator $csrf_token
* The CSRF token generator.
*/
public function __construct(CsrfTokenGenerator $csrf_token) {
$this->csrfToken = $csrf_token;
}
/**
* Checks access based on a CSRF token for the request.
*
* @param \Symfony\Component\Routing\Route $route
* The route to check against.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match object.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(Route $route, Request $request, RouteMatchInterface $route_match) {
$parameters = $route_match->getRawParameters();
$path = ltrim($route->getPath(), '/');
// Replace the path parameters with values from the parameters array.
foreach ($parameters as $param => $value) {
$path = str_replace("{{$param}}", $value, $path);
}
if ($this->csrfToken->validate($request->query->get('token', ''), $path)) {
$result = AccessResult::allowed();
}
else {
$result = AccessResult::forbidden($request->query->has('token') ? "'csrf_token' URL query argument is invalid." : "'csrf_token' URL query argument is missing.");
}
// Not cacheable because the CSRF token is highly dynamic.
return $result->setCacheMaxAge(0);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace Drupal\Core\Access;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Session\SessionConfigurationInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\HttpFoundation\Request;
/**
* Access protection against CSRF attacks.
*/
class CsrfRequestHeaderAccessCheck implements AccessCheckInterface {
/**
* A string key that will used to designate the token used by this class.
*/
const TOKEN_KEY = 'X-CSRF-Token request header';
/**
* The session configuration.
*
* @var \Drupal\Core\Session\SessionConfigurationInterface
*/
protected $sessionConfiguration;
/**
* The token generator.
*
* @var \Drupal\Core\Access\CsrfTokenGenerator
*/
protected $csrfToken;
/**
* Constructs a new rest CSRF access check.
*
* @param \Drupal\Core\Session\SessionConfigurationInterface $session_configuration
* The session configuration.
* @param \Drupal\Core\Access\CsrfTokenGenerator $csrf_token
* The token generator.
*/
public function __construct(SessionConfigurationInterface $session_configuration, CsrfTokenGenerator $csrf_token) {
$this->sessionConfiguration = $session_configuration;
$this->csrfToken = $csrf_token;
}
/**
* {@inheritdoc}
*/
public function applies(Route $route) {
$requirements = $route->getRequirements();
if (array_key_exists('_csrf_request_header_token', $requirements)) {
if (isset($requirements['_method'])) {
// There could be more than one method requirement separated with '|'.
$methods = explode('|', $requirements['_method']);
// CSRF protection only applies to write operations, so we can filter
// out any routes that require reading methods only.
$write_methods = array_diff($methods, ['GET', 'HEAD', 'OPTIONS', 'TRACE']);
if (empty($write_methods)) {
return FALSE;
}
}
// No method requirement given, so we run this access check to be on the
// safe side.
return TRUE;
}
}
/**
* Checks access.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
* @param \Drupal\Core\Session\AccountInterface $account
* The currently logged in account.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(Request $request, AccountInterface $account) {
$method = $request->getMethod();
// Read-only operations are always allowed.
if (in_array($method, ['GET', 'HEAD', 'OPTIONS', 'TRACE'], TRUE)) {
return AccessResult::allowed();
}
// This check only applies if
// 1. the user was successfully authenticated and
// 2. the request comes with a session cookie.
if ($account->isAuthenticated()
&& $this->sessionConfiguration->hasSession($request)
) {
if (!$request->headers->has('X-CSRF-Token')) {
return AccessResult::forbidden()->setReason('X-CSRF-Token request header is missing')->setCacheMaxAge(0);
}
$csrf_token = $request->headers->get('X-CSRF-Token');
// @todo Remove validate call using 'rest' in 8.3.
// Kept here for sessions active during update.
if (!$this->csrfToken->validate($csrf_token, self::TOKEN_KEY)
&& !$this->csrfToken->validate($csrf_token, 'rest')) {
return AccessResult::forbidden()->setReason('X-CSRF-Token request header is invalid')->setCacheMaxAge(0);
}
}
// Let other access checkers decide if the request is legit.
return AccessResult::allowed()->setCacheMaxAge(0);
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace Drupal\Core\Access;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\PrivateKey;
use Drupal\Core\Session\MetadataBag;
use Drupal\Core\Site\Settings;
/**
* Generates and validates CSRF tokens.
*
* @see \Drupal\Tests\Core\Access\CsrfTokenGeneratorTest
*/
class CsrfTokenGenerator {
/**
* The private key service.
*
* @var \Drupal\Core\PrivateKey
*/
protected $privateKey;
/**
* The session metadata bag.
*
* @var \Drupal\Core\Session\MetadataBag
*/
protected $sessionMetadata;
/**
* Constructs the token generator.
*
* @param \Drupal\Core\PrivateKey $private_key
* The private key service.
* @param \Drupal\Core\Session\MetadataBag $session_metadata
* The session metadata bag.
*/
public function __construct(PrivateKey $private_key, MetadataBag $session_metadata) {
$this->privateKey = $private_key;
$this->sessionMetadata = $session_metadata;
}
/**
* Generates a token based on $value, the user session, and the private key.
*
* The generated token is based on the session of the current user. Normally,
* anonymous users do not have a session, so the generated token will be
* different on every page request. To generate a token for users without a
* session, manually start a session prior to calling this function.
*
* @param string $value
* (optional) An additional value to base the token on.
*
* @return string
* A 43-character URL-safe token for validation, based on the token seed,
* the hash salt provided by Settings::getHashSalt(), and the
* 'drupal_private_key' configuration variable.
*
* @see \Drupal\Core\Site\Settings::getHashSalt()
* @see \Symfony\Component\HttpFoundation\Session\SessionInterface::start()
*/
public function get($value = '') {
$seed = $this->sessionMetadata->getCsrfTokenSeed();
if (empty($seed)) {
$seed = Crypt::randomBytesBase64();
$this->sessionMetadata->setCsrfTokenSeed($seed);
}
return $this->computeToken($seed, $value);
}
/**
* Validates a token based on $value, the user session, and the private key.
*
* @param string $token
* The token to be validated.
* @param string $value
* (optional) An additional value to base the token on.
*
* @return bool
* TRUE for a valid token, FALSE for an invalid token.
*/
public function validate($token, $value = '') {
$seed = $this->sessionMetadata->getCsrfTokenSeed();
if (empty($seed)) {
return FALSE;
}
$value = $this->computeToken($seed, $value);
// PHP 8.0 strictly type hints for hash_equals. Maintain BC until we can
// enforce scalar type hints on this method.
if (!is_string($token)) {
return FALSE;
}
return hash_equals($value, $token);
}
/**
* Generates a token based on $value, the token seed, and the private key.
*
* @param string $seed
* The per-session token seed.
* @param string $value
* (optional) An additional value to base the token on.
*
* @return string
* A 43-character URL-safe token for validation, based on the token seed,
* the hash salt provided by Settings::getHashSalt(), and the site private
* key.
*
* @see \Drupal\Core\Site\Settings::getHashSalt()
*/
protected function computeToken($seed, $value = '') {
return Crypt::hmacBase64($value, $seed . $this->privateKey->get() . Settings::getHashSalt());
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Drupal\Core\Access;
use Drupal\Core\Controller\ControllerResolverInterface;
use Drupal\Core\Routing\Access\AccessInterface as RoutingAccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Utility\CallableResolver;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Route;
/**
* Defines an access checker that allows specifying a custom method for access.
*
* You should only use it when you are sure that the access callback will not be
* reused. Good examples in core are Edit or Toolbar module.
*
* The method is called on another instance of the controller class, so you
* cannot reuse any stored property of your actual controller instance used
* to generate the output.
*/
class CustomAccessCheck implements RoutingAccessInterface {
/**
* The callable resolver.
*
* @var \Drupal\Core\Utility\CallableResolver
*/
protected CallableResolver $callableResolver;
/**
* The arguments resolver.
*
* @var \Drupal\Core\Access\AccessArgumentsResolverFactoryInterface
*/
protected $argumentsResolverFactory;
/**
* Constructs a CustomAccessCheck instance.
*
* @param \Drupal\Core\Utility\CallableResolver|\Drupal\Core\Controller\ControllerResolverInterface $callable_resolver
* The callable resolver.
* @param \Drupal\Core\Access\AccessArgumentsResolverFactoryInterface $arguments_resolver_factory
* The arguments resolver factory.
*/
public function __construct(ControllerResolverInterface|CallableResolver $callable_resolver, AccessArgumentsResolverFactoryInterface $arguments_resolver_factory) {
if ($callable_resolver instanceof ControllerResolverInterface) {
@trigger_error('Calling ' . __METHOD__ . '() with an argument of ControllerResolverInterface is deprecated in drupal:10.3.0 and is removed in drupal:11.0.0. Use \Drupal\Core\Utility\CallableResolver instead. See https://www.drupal.org/node/3397706', E_USER_DEPRECATED);
$callable_resolver = \Drupal::service('callable_resolver');
}
$this->callableResolver = $callable_resolver;
$this->argumentsResolverFactory = $arguments_resolver_factory;
}
/**
* Checks access for the account and route using the custom access checker.
*
* @param \Symfony\Component\Routing\Route $route
* The route.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match object to be checked.
* @param \Drupal\Core\Session\AccountInterface $account
* The account being checked.
* @param \Symfony\Component\HttpFoundation\Request $request
* Optional, a request. Only supply this parameter when checking the
* incoming request.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(Route $route, RouteMatchInterface $route_match, AccountInterface $account, ?Request $request = NULL) {
try {
$callable = $this->callableResolver->getCallableFromDefinition($route->getRequirement('_custom_access'));
}
catch (\InvalidArgumentException $e) {
// The custom access controller method was not found.
throw new \BadMethodCallException(sprintf('The "%s" method is not callable as a _custom_access callback in route "%s"', $route->getRequirement('_custom_access'), $route->getPath()));
}
$arguments_resolver = $this->argumentsResolverFactory->getArgumentsResolver($route_match, $account, $request);
$arguments = $arguments_resolver->getArguments($callable);
return call_user_func_array($callable, $arguments);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Drupal\Core\Access;
use Drupal\Core\Routing\Access\AccessInterface as RoutingAccessInterface;
use Symfony\Component\Routing\Route;
/**
* Allows access to routes to be controlled by an '_access' boolean parameter.
*/
class DefaultAccessCheck implements RoutingAccessInterface {
/**
* Checks access to the route based on the _access parameter.
*
* @param \Symfony\Component\Routing\Route $route
* The route to check against.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(Route $route) {
if ($route->getRequirement('_access') === 'TRUE') {
return AccessResult::allowed();
}
elseif ($route->getRequirement('_access') === 'FALSE') {
return AccessResult::forbidden();
}
else {
return AccessResult::neutral();
}
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Drupal\Core\Access;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Security\TrustedCallbackInterface;
use Drupal\Core\RouteProcessor\OutboundRouteProcessorInterface;
use Symfony\Component\Routing\Route;
/**
* Processes the outbound route to handle the CSRF token.
*/
class RouteProcessorCsrf implements OutboundRouteProcessorInterface, TrustedCallbackInterface {
/**
* The CSRF token generator.
*
* @var \Drupal\Core\Access\CsrfTokenGenerator
*/
protected $csrfToken;
/**
* Constructs a RouteProcessorCsrf object.
*
* @param \Drupal\Core\Access\CsrfTokenGenerator $csrf_token
* The CSRF token generator.
*/
public function __construct(CsrfTokenGenerator $csrf_token) {
$this->csrfToken = $csrf_token;
}
/**
* {@inheritdoc}
*/
public function processOutbound($route_name, Route $route, array &$parameters, ?BubbleableMetadata $bubbleable_metadata = NULL) {
if ($route->hasRequirement('_csrf_token')) {
$path = ltrim($route->getPath(), '/');
// Replace the path parameters with values from the parameters array.
foreach ($parameters as $param => $value) {
$path = str_replace("{{$param}}", $value, $path);
}
// Adding this to the parameters means it will get merged into the query
// string when the route is compiled.
if (!$bubbleable_metadata) {
$parameters['token'] = $this->csrfToken->get($path);
}
else {
// Generate a placeholder and a render array to replace it.
$placeholder = Crypt::hashBase64($path);
$placeholder_render_array = [
'#lazy_builder' => ['route_processor_csrf:renderPlaceholderCsrfToken', [$path]],
];
// Instead of setting an actual CSRF token as the query string, we set
// the placeholder, which will be replaced at the very last moment. This
// ensures links with CSRF tokens don't break cacheability.
$parameters['token'] = $placeholder;
$bubbleable_metadata->addAttachments(['placeholders' => [$placeholder => $placeholder_render_array]]);
}
}
}
/**
* #lazy_builder callback; gets a CSRF token for the given path.
*
* @param string $path
* The path to get a CSRF token for.
*
* @return array
* A renderable array representing the CSRF token.
*/
public function renderPlaceholderCsrfToken($path) {
return [
'#markup' => $this->csrfToken->get($path),
// Tokens are per session.
'#cache' => [
'contexts' => [
'session',
],
],
];
}
/**
* {@inheritdoc}
*/
public static function trustedCallbacks() {
return ['renderPlaceholderCsrfToken'];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Drupal\Core\Action;
use Drupal\Core\Plugin\PluginBase;
/**
* Provides a base implementation for an Action plugin.
*
* @see \Drupal\Core\Annotation\Action
* @see \Drupal\Core\Action\ActionManager
* @see \Drupal\Core\Action\ActionInterface
* @see plugin_api
*/
abstract class ActionBase extends PluginBase implements ActionInterface {
/**
* {@inheritdoc}
*/
public function executeMultiple(array $entities) {
foreach ($entities as $entity) {
$this->execute($entity);
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Drupal\Core\Action;
use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Executable\ExecutableInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Provides an interface for an Action plugin.
*
* @todo WARNING: The action API is going to receive some additions before
* release. The following additions are likely to happen:
* - The way configuration is handled and configuration forms are built is
* likely to change in order for the plugin to be of use for Rules.
* - Actions are going to become context-aware in
* https://www.drupal.org/node/2011038, what will deprecated the 'type'
* annotation.
* - Instead of action implementations saving entities, support for marking
* required context as to be saved by the execution manager will be added as
* part of https://www.drupal.org/node/2347017.
* - Actions will receive a data processing API that allows for token
* replacements to happen outside of the action plugin implementations,
* see https://www.drupal.org/node/2347023.
*
* @see \Drupal\Core\Annotation\Action
* @see \Drupal\Core\Action\ActionManager
* @see \Drupal\Core\Action\ActionBase
* @see plugin_api
*/
interface ActionInterface extends ExecutableInterface, PluginInspectionInterface {
/**
* Executes the plugin for an array of objects.
*
* @param array $objects
* An array of entities.
*/
public function executeMultiple(array $objects);
/**
* Checks object access.
*
* @param mixed $object
* The object to execute the action on.
* @param \Drupal\Core\Session\AccountInterface $account
* (optional) The user for which to check access, or NULL to check access
* for the current user. Defaults to NULL.
* @param bool $return_as_object
* (optional) Defaults to FALSE.
*
* @return bool|\Drupal\Core\Access\AccessResultInterface
* The access result. Returns a boolean if $return_as_object is FALSE (this
* is the default) and otherwise an AccessResultInterface object.
* When a boolean is returned, the result of AccessInterface::isAllowed() is
* returned, i.e. TRUE means access is explicitly allowed, FALSE means
* access is either explicitly forbidden or "no opinion".
*/
public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE);
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Drupal\Core\Action;
use Drupal\Component\Plugin\CategorizingPluginManagerInterface;
use Drupal\Core\Action\Attribute\Action;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\CategorizingPluginManagerTrait;
use Drupal\Core\Plugin\DefaultPluginManager;
/**
* Provides an Action plugin manager.
*
* @see \Drupal\Core\Annotation\Action
* @see \Drupal\Core\Action\ActionInterface
* @see \Drupal\Core\Action\ActionBase
* @see plugin_api
*/
class ActionManager extends DefaultPluginManager implements CategorizingPluginManagerInterface {
use CategorizingPluginManagerTrait;
/**
* Constructs a new class instance.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/Action', $namespaces, $module_handler, 'Drupal\Core\Action\ActionInterface', Action::class, 'Drupal\Core\Annotation\Action');
$this->alterInfo('action_info');
$this->setCacheBackend($cache_backend, 'action_info');
}
/**
* Gets the plugin definitions for this entity type.
*
* @param string $type
* The entity type name.
*
* @return array
* An array of plugin definitions for this entity type.
*/
public function getDefinitionsByType($type) {
return array_filter($this->getDefinitions(), function ($definition) use ($type) {
return $definition['type'] === $type;
});
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Drupal\Core\Action;
use Drupal\Core\Plugin\DefaultSingleLazyPluginCollection;
/**
* Provides a container for lazily loading Action plugins.
*/
class ActionPluginCollection extends DefaultSingleLazyPluginCollection {
/**
* {@inheritdoc}
*
* @return \Drupal\Core\Action\ActionInterface
*/
public function &get($instance_id) {
return parent::get($instance_id);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Drupal\Core\Action\Attribute;
use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines an Action attribute object.
*
* Plugin Namespace: Plugin\Action
*
* @see \Drupal\Core\Action\ActionInterface
* @see \Drupal\Core\Action\ActionManager
* @see \Drupal\Core\Action\ActionBase
* @see \Drupal\Core\Action\Plugin\Action\UnpublishAction
* @see plugin_api
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class Action extends Plugin {
/**
* Constructs an Action attribute.
*
* @param string $id
* The plugin ID.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $label
* The label of the action.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $action_label
* (optional) A label that can be used by the action deriver.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $category
* (optional) The category under which the action should be listed in the
* UI.
* @param class-string|null $deriver
* (optional) The deriver class.
* @param string|null $confirm_form_route_name
* (optional) The route name for a confirmation form for this action.
* @param string|null $type
* (optional) The entity type the action can apply to.
*/
public function __construct(
public readonly string $id,
public readonly ?TranslatableMarkup $label = NULL,
public readonly ?TranslatableMarkup $action_label = NULL,
public readonly ?TranslatableMarkup $category = NULL,
public readonly ?string $deriver = NULL,
public readonly ?string $confirm_form_route_name = NULL,
public readonly ?string $type = NULL,
) {}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Drupal\Core\Action;
use Drupal\Component\Plugin\ConfigurableInterface;
use Drupal\Component\Plugin\DependentPluginInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginFormInterface;
/**
* Provides a base implementation for a configurable Action plugin.
*/
abstract class ConfigurableActionBase extends ActionBase implements ConfigurableInterface, DependentPluginInterface, PluginFormInterface {
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->setConfiguration($configuration);
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [];
}
/**
* {@inheritdoc}
*/
public function getConfiguration() {
return $this->configuration;
}
/**
* {@inheritdoc}
*/
public function setConfiguration(array $configuration) {
$this->configuration = $configuration + $this->defaultConfiguration();
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
return [];
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Drupal\Core\Action\Plugin\Action;
use Drupal\Core\Action\Plugin\Action\Derivative\EntityDeleteActionDeriver;
use Drupal\Core\Action\Attribute\Action;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Redirects to an entity deletion form.
*/
#[Action(
id: 'entity:delete_action',
action_label: new TranslatableMarkup('Delete'),
deriver: EntityDeleteActionDeriver::class
)]
class DeleteAction extends EntityActionBase {
/**
* The tempstore object.
*
* @var \Drupal\Core\TempStore\PrivateTempStoreFactory
*/
protected $tempStore;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* Constructs a new DeleteAction 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\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\TempStore\PrivateTempStoreFactory $temp_store_factory
* The tempstore factory.
* @param \Drupal\Core\Session\AccountInterface $current_user
* Current user.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, PrivateTempStoreFactory $temp_store_factory, AccountInterface $current_user) {
$this->currentUser = $current_user;
$this->tempStore = $temp_store_factory->get('entity_delete_multiple_confirm');
parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager);
}
/**
* {@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'),
$container->get('tempstore.private'),
$container->get('current_user')
);
}
/**
* {@inheritdoc}
*/
public function executeMultiple(array $entities) {
/** @var \Drupal\Core\Entity\EntityInterface[] $entities */
$selection = [];
foreach ($entities as $entity) {
$langcode = $entity->language()->getId();
$selection[$entity->id()][$langcode] = $langcode;
}
$this->tempStore->set($this->currentUser->id() . ':' . $this->getPluginDefinition()['type'], $selection);
}
/**
* {@inheritdoc}
*/
public function execute($object = NULL) {
$this->executeMultiple([$object]);
}
/**
* {@inheritdoc}
*/
public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
return $object->access('delete', $account, $return_as_object);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Drupal\Core\Action\Plugin\Action\Derivative;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a base action for each entity type with specific interfaces.
*/
abstract class EntityActionDeriverBase extends DeriverBase implements ContainerDeriverInterface {
use StringTranslationTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a new EntityActionDeriverBase object.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* The string translation service.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, TranslationInterface $string_translation) {
$this->entityTypeManager = $entity_type_manager;
$this->stringTranslation = $string_translation;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id) {
return new static(
$container->get('entity_type.manager'),
$container->get('string_translation')
);
}
/**
* Indicates whether the deriver can be used for the provided entity type.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type.
*
* @return bool
* TRUE if the entity type can be used, FALSE otherwise.
*/
abstract protected function isApplicable(EntityTypeInterface $entity_type);
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
if (empty($this->derivatives)) {
$definitions = [];
foreach ($this->getApplicableEntityTypes() as $entity_type_id => $entity_type) {
$definition = $base_plugin_definition;
$definition['type'] = $entity_type_id;
$definition['label'] = sprintf('%s %s', $base_plugin_definition['action_label'], $entity_type->getSingularLabel());
$definitions[$entity_type_id] = $definition;
}
$this->derivatives = $definitions;
}
return parent::getDerivativeDefinitions($base_plugin_definition);
}
/**
* Gets a list of applicable entity types.
*
* The list consists of all entity types which match the conditions for the
* given deriver.
* For example, if the action applies to entities that are publishable,
* this method will find all entity types that are publishable.
*
* @return \Drupal\Core\Entity\EntityTypeInterface[]
* The applicable entity types, keyed by entity type ID.
*/
protected function getApplicableEntityTypes() {
$entity_types = $this->entityTypeManager->getDefinitions();
$entity_types = array_filter($entity_types, function (EntityTypeInterface $entity_type) {
return $this->isApplicable($entity_type);
});
return $entity_types;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Drupal\Core\Action\Plugin\Action\Derivative;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\Core\Entity\EntityTypeInterface;
/**
* Provides an action deriver that finds entity types of EntityChangedInterface.
*
* @see \Drupal\Core\Action\Plugin\Action\SaveAction
*/
class EntityChangedActionDeriver extends EntityActionDeriverBase {
/**
* {@inheritdoc}
*/
protected function isApplicable(EntityTypeInterface $entity_type) {
return $entity_type->entityClassImplements(EntityChangedInterface::class);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Drupal\Core\Action\Plugin\Action\Derivative;
use Drupal\Core\Entity\EntityTypeInterface;
/**
* Provides an action deriver that finds entity types with delete form.
*
* @see \Drupal\Core\Action\Plugin\Action\DeleteAction
*/
class EntityDeleteActionDeriver extends EntityActionDeriverBase {
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
if (empty($this->derivatives)) {
$definitions = [];
foreach ($this->getApplicableEntityTypes() as $entity_type_id => $entity_type) {
$definition = $base_plugin_definition;
$definition['type'] = $entity_type_id;
$definition['label'] = $this->t('Delete @entity_type', ['@entity_type' => $entity_type->getSingularLabel()]);
$definition['confirm_form_route_name'] = 'entity.' . $entity_type->id() . '.delete_multiple_form';
$definitions[$entity_type_id] = $definition;
}
$this->derivatives = $definitions;
}
return $this->derivatives;
}
/**
* {@inheritdoc}
*/
protected function isApplicable(EntityTypeInterface $entity_type) {
return $entity_type->hasLinkTemplate('delete-multiple-form');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Drupal\Core\Action\Plugin\Action\Derivative;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\EntityTypeInterface;
/**
* Provides an action deriver that finds publishable entity types.
*
* @see \Drupal\Core\Action\Plugin\Action\PublishAction
* @see \Drupal\Core\Action\Plugin\Action\UnpublishAction
*/
class EntityPublishedActionDeriver extends EntityActionDeriverBase {
/**
* {@inheritdoc}
*/
protected function isApplicable(EntityTypeInterface $entity_type) {
return $entity_type->entityClassImplements(EntityPublishedInterface::class);
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace Drupal\Core\Action\Plugin\Action;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Component\Utility\EmailValidatorInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Action\ConfigurableActionBase;
use Drupal\Core\Action\Attribute\Action;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Mail\MailManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Utility\Token;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Sends an email message.
*/
#[Action(
id: 'action_send_email_action',
label: new TranslatableMarkup('Send email'),
type: 'system'
)]
class EmailAction extends ConfigurableActionBase implements ContainerFactoryPluginInterface {
/**
* The token service.
*
* @var \Drupal\Core\Utility\Token
*/
protected $token;
/**
* The user storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $storage;
/**
* A logger instance.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* The mail manager.
*
* @var \Drupal\Core\Mail\MailManagerInterface
*/
protected $mailManager;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* The email validator.
*
* @var \Drupal\Component\Utility\EmailValidatorInterface
*/
protected $emailValidator;
/**
* Constructs an EmailAction 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\Utility\Token $token
* The token service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Psr\Log\LoggerInterface $logger
* A logger instance.
* @param \Drupal\Core\Mail\MailManagerInterface $mail_manager
* The mail manager.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
* @param \Drupal\Component\Utility\EmailValidatorInterface $email_validator
* The email validator.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, Token $token, EntityTypeManagerInterface $entity_type_manager, LoggerInterface $logger, MailManagerInterface $mail_manager, LanguageManagerInterface $language_manager, EmailValidatorInterface $email_validator) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->token = $token;
$this->storage = $entity_type_manager->getStorage('user');
$this->logger = $logger;
$this->mailManager = $mail_manager;
$this->languageManager = $language_manager;
$this->emailValidator = $email_validator;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($configuration, $plugin_id, $plugin_definition,
$container->get('token'),
$container->get('entity_type.manager'),
$container->get('logger.factory')->get('action'),
$container->get('plugin.manager.mail'),
$container->get('language_manager'),
$container->get('email.validator')
);
}
/**
* {@inheritdoc}
*/
public function execute($entity = NULL) {
if (empty($this->configuration['node'])) {
$this->configuration['node'] = $entity;
}
$recipient = PlainTextOutput::renderFromHtml($this->token->replace($this->configuration['recipient'], $this->configuration));
// If the recipient is a registered user with a language preference, use
// the recipient's preferred language. Otherwise, use the system default
// language.
$recipient_accounts = $this->storage->loadByProperties(['mail' => $recipient]);
$recipient_account = reset($recipient_accounts);
if ($recipient_account) {
$langcode = $recipient_account->getPreferredLangcode();
}
else {
$langcode = $this->languageManager->getDefaultLanguage()->getId();
}
$params = ['context' => $this->configuration];
$message = $this->mailManager->mail('system', 'action_send_email', $recipient, $langcode, $params);
// Error logging is handled by \Drupal\Core\Mail\MailManager::mail().
if ($message['result']) {
$this->logger->info('Sent email to %recipient', ['%recipient' => $recipient]);
}
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'recipient' => '',
'subject' => '',
'message' => '',
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['recipient'] = [
'#type' => 'textfield',
'#title' => $this->t('Recipient email address'),
'#default_value' => $this->configuration['recipient'],
'#maxlength' => '254',
'#description' => $this->t('You may also use tokens: [node:author:mail], [comment:author:mail], etc. Separate recipients with a comma.'),
];
$form['subject'] = [
'#type' => 'textfield',
'#title' => $this->t('Subject'),
'#default_value' => $this->configuration['subject'],
'#maxlength' => '254',
'#description' => $this->t('The subject of the message.'),
];
$form['message'] = [
'#type' => 'textarea',
'#title' => $this->t('Message'),
'#default_value' => $this->configuration['message'],
'#cols' => '80',
'#rows' => '20',
'#description' => $this->t('The message that should be sent. You may include placeholders like [node:title], [user:account-name], [user:display-name] and [comment:body] to represent data that will be different each time message is sent. Not all placeholders will be available in all contexts.'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
if (!$this->emailValidator->isValid($form_state->getValue('recipient')) && !str_contains($form_state->getValue('recipient'), ':mail')) {
// We want the literal %author placeholder to be emphasized in the error message.
$form_state->setErrorByName('recipient', $this->t('Enter a valid email address or use a token email address such as %author.', ['%author' => '[node:author:mail]']));
}
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['recipient'] = $form_state->getValue('recipient');
$this->configuration['subject'] = $form_state->getValue('subject');
$this->configuration['message'] = $form_state->getValue('message');
}
/**
* {@inheritdoc}
*/
public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
$result = AccessResult::allowed();
return $return_as_object ? $result : $result->isAllowed();
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Drupal\Core\Action\Plugin\Action;
use Drupal\Component\Plugin\DependentPluginInterface;
use Drupal\Core\Action\ActionBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Base class for entity-based actions.
*/
abstract class EntityActionBase extends ActionBase implements DependentPluginInterface, ContainerFactoryPluginInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs an EntityActionBase object.
*
* @param mixed[] $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\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@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')
);
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
$module_name = $this->entityTypeManager
->getDefinition($this->getPluginDefinition()['type'])
->getProvider();
return ['module' => [$module_name]];
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace Drupal\Core\Action\Plugin\Action;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Action\ConfigurableActionBase;
use Drupal\Core\Action\Attribute\Action;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Utility\UnroutedUrlAssemblerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Redirects to a different URL.
*/
#[Action(
id: 'action_goto_action',
label: new TranslatableMarkup('Redirect to URL'),
type: 'system'
)]
class GotoAction extends ConfigurableActionBase implements ContainerFactoryPluginInterface {
/**
* The event dispatcher service.
*
* @var \Symfony\Contracts\EventDispatcher\EventDispatcherInterface
*/
protected $dispatcher;
/**
* The unrouted URL assembler service.
*
* @var \Drupal\Core\Utility\UnroutedUrlAssemblerInterface
*/
protected $unroutedUrlAssembler;
/**
* Constructs a GotoAction 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 \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $dispatcher
* The tempstore factory.
* @param \Drupal\Core\Utility\UnroutedUrlAssemblerInterface $url_assembler
* The unrouted URL assembler service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EventDispatcherInterface $dispatcher, UnroutedUrlAssemblerInterface $url_assembler) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->dispatcher = $dispatcher;
$this->unroutedUrlAssembler = $url_assembler;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($configuration, $plugin_id, $plugin_definition, $container->get('event_dispatcher'), $container->get('unrouted_url_assembler'));
}
/**
* {@inheritdoc}
*/
public function execute($object = NULL) {
$url = $this->configuration['url'];
// Leave external URLs unchanged, and assemble others as absolute URLs
// relative to the site's base URL.
if (!UrlHelper::isExternal($url)) {
$parts = UrlHelper::parse($url);
// @todo '<front>' is valid input for BC reasons, may be removed by
// https://www.drupal.org/node/2421941
if ($parts['path'] === '<front>') {
$parts['path'] = '';
}
$uri = 'base:' . $parts['path'];
$options = [
'query' => $parts['query'],
'fragment' => $parts['fragment'],
'absolute' => TRUE,
];
// Treat this as if it's user input of a path relative to the site's
// base URL.
$url = $this->unroutedUrlAssembler->assemble($uri, $options);
}
$response = new RedirectResponse($url);
$listener = function ($event) use ($response) {
$event->setResponse($response);
};
// Add the listener to the event dispatcher.
$this->dispatcher->addListener(KernelEvents::RESPONSE, $listener);
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'url' => '',
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['url'] = [
'#type' => 'textfield',
'#title' => $this->t('URL'),
'#description' => $this->t('The URL to which the user should be redirected. This can be an internal URL like /node/1234 or an external URL like @url.', ['@url' => 'http://example.com']),
'#default_value' => $this->configuration['url'],
'#required' => TRUE,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['url'] = $form_state->getValue('url');
}
/**
* {@inheritdoc}
*/
public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
$access = AccessResult::allowed();
return $return_as_object ? $access : $access->isAllowed();
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace Drupal\Core\Action\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\Messenger\MessengerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Utility\Token;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Sends a message to the current user's screen.
*/
#[Action(
id: 'action_message_action',
label: new TranslatableMarkup('Display a message to the user'),
type: 'system'
)]
class MessageAction extends ConfigurableActionBase implements ContainerFactoryPluginInterface {
/**
* The token service.
*
* @var \Drupal\Core\Utility\Token
*/
protected $token;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* The messenger.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* Constructs a MessageAction 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\Utility\Token $token
* The token service.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, Token $token, RendererInterface $renderer, MessengerInterface $messenger) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->token = $token;
$this->renderer = $renderer;
$this->messenger = $messenger;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($configuration, $plugin_id, $plugin_definition, $container->get('token'), $container->get('renderer'), $container->get('messenger'));
}
/**
* {@inheritdoc}
*/
public function execute($entity = NULL) {
if (empty($this->configuration['node'])) {
$this->configuration['node'] = $entity;
}
$message = $this->token->replace($this->configuration['message'], $this->configuration);
$build = [
'#markup' => $message,
];
// @todo Fix in https://www.drupal.org/node/2577827
$this->messenger->addStatus($this->renderer->renderInIsolation($build));
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'message' => '',
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['message'] = [
'#type' => 'textarea',
'#title' => $this->t('Message'),
'#default_value' => $this->configuration['message'],
'#required' => TRUE,
'#rows' => '8',
'#description' => $this->t('The message to be displayed to the current user. You may include placeholders like [node:title], [user:account-name], [user:display-name] and [comment:body] to represent data that will be different each time message is sent. Not all placeholders will be available in all contexts.'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$this->configuration['message'] = $form_state->getValue('message');
unset($this->configuration['node']);
}
/**
* {@inheritdoc}
*/
public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
$result = AccessResult::allowed();
return $return_as_object ? $result : $result->isAllowed();
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Drupal\Core\Action\Plugin\Action;
use Drupal\Core\Action\Plugin\Action\Derivative\EntityPublishedActionDeriver;
use Drupal\Core\Action\Attribute\Action;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Publishes an entity.
*/
#[Action(
id: 'entity:publish_action',
action_label: new TranslatableMarkup('Publish'),
deriver: EntityPublishedActionDeriver::class
)]
class PublishAction extends EntityActionBase {
/**
* {@inheritdoc}
*/
public function execute($entity = NULL) {
$entity->setPublished()->save();
}
/**
* {@inheritdoc}
*/
public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
$key = $object->getEntityType()->getKey('published');
/** @var \Drupal\Core\Entity\EntityInterface $object */
$result = $object->access('update', $account, TRUE)
->andIf($object->$key->access('edit', $account, TRUE));
return $return_as_object ? $result : $result->isAllowed();
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace Drupal\Core\Action\Plugin\Action;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Action\Plugin\Action\Derivative\EntityChangedActionDeriver;
use Drupal\Core\Action\Attribute\Action;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides an action that can save any entity.
*/
#[Action(
id: 'entity:save_action',
action_label: new TranslatableMarkup('Save'),
deriver: EntityChangedActionDeriver::class
)]
class SaveAction extends EntityActionBase {
/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/**
* Constructs a SaveAction object.
*
* @param mixed[] $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\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, TimeInterface $time) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager);
$this->time = $time;
}
/**
* {@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'),
$container->get('datetime.time')
);
}
/**
* {@inheritdoc}
*/
public function execute($entity = NULL) {
$entity->setChangedTime($this->time->getRequestTime())->save();
}
/**
* {@inheritdoc}
*/
public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
// It's not necessary to check the changed field access here, because
// Drupal\Core\Field\ChangedFieldItemList would anyway return 'not allowed'.
// Also changing the changed field value is only a workaround to trigger an
// entity resave. Without a field change, this would not be possible.
/** @var \Drupal\Core\Entity\EntityInterface $object */
return $object->access('update', $account, $return_as_object);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Drupal\Core\Action\Plugin\Action;
use Drupal\Core\Action\Plugin\Action\Derivative\EntityPublishedActionDeriver;
use Drupal\Core\Action\Attribute\Action;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Unpublishes an entity.
*/
#[Action(
id: 'entity:unpublish_action',
action_label: new TranslatableMarkup('Unpublish'),
deriver: EntityPublishedActionDeriver::class
)]
class UnpublishAction extends EntityActionBase {
/**
* {@inheritdoc}
*/
public function execute($entity = NULL) {
$entity->setUnpublished()->save();
}
/**
* {@inheritdoc}
*/
public function access($object, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
$key = $object->getEntityType()->getKey('published');
/** @var \Drupal\Core\Entity\EntityInterface $object */
$result = $object->access('update', $account, TRUE)
->andIf($object->$key->access('edit', $account, TRUE));
return $return_as_object ? $result : $result->isAllowed();
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Drupal\Core\Ajax;
/**
* An AJAX command for adding css to the page via ajax.
*
* This command is implemented by Drupal.AjaxCommands.prototype.add_css()
* defined in misc/ajax.js.
*
* @see misc/ajax.js
*
* @ingroup ajax
*/
class AddCssCommand implements CommandInterface {
/**
* Arrays containing attributes of the stylesheets to be added to the page.
*
* @var string[][]|string
*/
protected $styles;
/**
* Constructs an AddCssCommand.
*
* @param string[][]|string $styles
* Arrays containing attributes of the stylesheets to be added to the page.
* i.e. `['href' => 'someURL']` becomes `<link href="someURL">`.
*/
public function __construct($styles) {
if (is_string($styles)) {
@trigger_error('The ' . __NAMESPACE__ . '\AddCssCommand with a string argument is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. See http://www.drupal.org/node/3154948', E_USER_DEPRECATED);
}
$this->styles = $styles;
}
/**
* {@inheritdoc}
*/
public function render() {
return [
'command' => 'add_css',
'data' => $this->styles,
];
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Drupal\Core\Ajax;
/**
* An AJAX command for adding JS to the page via AJAX.
*
* This command will make sure all the files are loaded before continuing
* executing the next AJAX command. This command is implemented by
* Drupal.AjaxCommands.prototype.add_js() defined in misc/ajax.js.
*
* @see misc/ajax.js
*
* @ingroup ajax
*/
class AddJsCommand implements CommandInterface {
/**
* An array containing attributes of the scripts to be added to the page.
*
* @var string[]
*/
protected $scripts;
/**
* A CSS selector string.
*
* If the command is a response to a request from an #ajax form element then
* this value will default to 'body'.
*
* @var string
*/
protected $selector;
/**
* Constructs an AddJsCommand.
*
* @param array $scripts
* An array containing the attributes of the 'script' tags to be added to
* the page. i.e. `['src' => 'someURL', 'defer' => TRUE]` becomes
* `<script src="someURL" defer>`.
* @param string $selector
* A CSS selector of the element where the script tags will be appended.
*/
public function __construct(array $scripts, string $selector = 'body') {
$this->scripts = $scripts;
$this->selector = $selector;
}
/**
* {@inheritdoc}
*/
public function render() {
return [
'command' => 'add_js',
'selector' => $this->selector,
'data' => $this->scripts,
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Drupal\Core\Ajax;
/**
* An AJAX command for calling the jQuery after() method.
*
* The 'insert/after' command instructs the client to use jQuery's after()
* method to insert the given render array or HTML content after each element
* matched by the given selector.
*
* This command is implemented by Drupal.AjaxCommands.prototype.insert()
* defined in misc/ajax.js.
*
* @see http://docs.jquery.com/Manipulation/after#content
*
* @ingroup ajax
*/
class AfterCommand extends InsertCommand {
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'insert',
'method' => 'after',
'selector' => $this->selector,
'data' => $this->getRenderedContent(),
'settings' => $this->settings,
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Drupal\Core\Ajax;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides a helper to for submitting an AJAX form.
*
* @internal
*/
trait AjaxFormHelperTrait {
use AjaxHelperTrait;
/**
* Submit form dialog #ajax callback.
*
* @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.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* An AJAX response that display validation error messages or represents a
* successful submission.
*/
public function ajaxSubmit(array &$form, FormStateInterface $form_state) {
if ($form_state->hasAnyErrors()) {
$form['status_messages'] = [
'#type' => 'status_messages',
'#weight' => -1000,
];
$form['#sorted'] = FALSE;
$response = new AjaxResponse();
$response->addCommand(new ReplaceCommand('[data-drupal-selector="' . $form['#attributes']['data-drupal-selector'] . '"]', $form));
}
else {
$response = $this->successfulAjaxSubmit($form, $form_state);
}
return $response;
}
/**
* Allows the form to respond to a successful AJAX submission.
*
* @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.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* An AJAX response.
*/
abstract protected function successfulAjaxSubmit(array $form, FormStateInterface $form_state);
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Drupal\Core\Ajax;
use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
/**
* Provides a helper to determine if the current request is via AJAX.
*
* @internal
*/
trait AjaxHelperTrait {
/**
* Determines if the current request is via AJAX.
*
* @return bool
* TRUE if the current request is via AJAX, FALSE otherwise.
*/
protected function isAjax() {
$wrapper_format = $this->getRequestWrapperFormat() ?? '';
return str_contains($wrapper_format, 'drupal_ajax') ||
str_contains($wrapper_format, 'drupal_modal') ||
str_contains($wrapper_format, 'drupal_dialog');
}
/**
* Gets the wrapper format of the current request.
*
* @return string|null
* The wrapper format. NULL if the wrapper format is not set.
*/
protected function getRequestWrapperFormat() {
return \Drupal::request()->get(MainContentViewSubscriber::WRAPPER_FORMAT);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Drupal\Core\Ajax;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Render\AttachmentsInterface;
use Drupal\Core\Render\AttachmentsTrait;
use Symfony\Component\HttpFoundation\JsonResponse;
/**
* JSON response object for AJAX requests.
*
* @ingroup ajax
*/
class AjaxResponse extends JsonResponse implements AttachmentsInterface {
use AttachmentsTrait;
/**
* The array of ajax commands.
*
* @var array
*/
protected $commands = [];
/**
* Add an AJAX command to the response.
*
* @param \Drupal\Core\Ajax\CommandInterface $command
* An AJAX command object implementing CommandInterface.
* @param bool $prepend
* A boolean which determines whether the new command should be executed
* before previously added commands. Defaults to FALSE.
*
* @return $this
* The current AjaxResponse.
*/
public function addCommand(CommandInterface $command, $prepend = FALSE) {
if ($prepend) {
array_unshift($this->commands, $command->render());
}
else {
$this->commands[] = $command->render();
}
if ($command instanceof CommandWithAttachedAssetsInterface) {
$assets = $command->getAttachedAssets();
$attachments = [
'library' => $assets->getLibraries(),
'drupalSettings' => $assets->getSettings(),
];
$attachments = BubbleableMetadata::mergeAttachments($this->getAttachments(), $attachments);
$this->setAttachments($attachments);
}
return $this;
}
/**
* Gets all AJAX commands.
*
* @return array
* Returns render arrays for all previously added commands.
*/
public function &getCommands() {
return $this->commands;
}
}

View File

@@ -0,0 +1,210 @@
<?php
namespace Drupal\Core\Ajax;
use Drupal\Core\Asset\AssetCollectionRendererInterface;
use Drupal\Core\Asset\AssetResolverInterface;
use Drupal\Core\Asset\AttachedAssets;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Render\AttachmentsInterface;
use Drupal\Core\Render\AttachmentsResponseProcessorInterface;
use Drupal\Core\Render\RendererInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Processes attachments of AJAX responses.
*
* @see \Drupal\Core\Ajax\AjaxResponse
* @see \Drupal\Core\Render\MainContent\AjaxRenderer
*/
class AjaxResponseAttachmentsProcessor implements AttachmentsResponseProcessorInterface {
/**
* The asset resolver service.
*
* @var \Drupal\Core\Asset\AssetResolverInterface
*/
protected $assetResolver;
/**
* A config object for the system performance configuration.
*
* @var \Drupal\Core\Config\Config
*/
protected $config;
/**
* The CSS asset collection renderer service.
*
* @var \Drupal\Core\Asset\AssetCollectionRendererInterface
*/
protected $cssCollectionRenderer;
/**
* The JS asset collection renderer service.
*
* @var \Drupal\Core\Asset\AssetCollectionRendererInterface
*/
protected $jsCollectionRenderer;
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* Constructs an AjaxResponseAttachmentsProcessor object.
*
* @param \Drupal\Core\Asset\AssetResolverInterface $asset_resolver
* An asset resolver.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* A config factory for retrieving required config objects.
* @param \Drupal\Core\Asset\AssetCollectionRendererInterface $css_collection_renderer
* The CSS asset collection renderer.
* @param \Drupal\Core\Asset\AssetCollectionRendererInterface $js_collection_renderer
* The JS asset collection renderer.
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
* @param \Drupal\Core\Language\LanguageManagerInterface|null $languageManager
* The language manager.
*/
public function __construct(AssetResolverInterface $asset_resolver, ConfigFactoryInterface $config_factory, AssetCollectionRendererInterface $css_collection_renderer, AssetCollectionRendererInterface $js_collection_renderer, RequestStack $request_stack, RendererInterface $renderer, ModuleHandlerInterface $module_handler, protected ?LanguageManagerInterface $languageManager = NULL) {
$this->assetResolver = $asset_resolver;
$this->config = $config_factory->get('system.performance');
$this->cssCollectionRenderer = $css_collection_renderer;
$this->jsCollectionRenderer = $js_collection_renderer;
$this->requestStack = $request_stack;
$this->renderer = $renderer;
$this->moduleHandler = $module_handler;
if (!isset($languageManager)) {
@trigger_error('Calling ' . __METHOD__ . '() without the $language_manager argument is deprecated in drupal:10.1.0 and will be required in drupal:11.0.0. See https://www.drupal.org/node/3347754', E_USER_DEPRECATED);
$this->languageManager = \Drupal::languageManager();
}
}
/**
* {@inheritdoc}
*/
public function processAttachments(AttachmentsInterface $response) {
assert($response instanceof AjaxResponse, '\Drupal\Core\Ajax\AjaxResponse instance expected.');
$request = $this->requestStack->getCurrentRequest();
if ($response->getContent() == '{}') {
$response->setData($this->buildAttachmentsCommands($response, $request));
}
return $response;
}
/**
* Prepares the AJAX commands to attach assets.
*
* @param \Drupal\Core\Ajax\AjaxResponse $response
* The AJAX response to update.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object that the AJAX is responding to.
*
* @return array
* An array of commands ready to be returned as JSON.
*/
protected function buildAttachmentsCommands(AjaxResponse $response, Request $request) {
$ajax_page_state = $request->get('ajax_page_state');
$maintenance_mode = defined('MAINTENANCE_MODE') || \Drupal::state()->get('system.maintenance_mode');
// Aggregate CSS/JS if necessary, but only during normal site operation.
$optimize_css = !$maintenance_mode && $this->config->get('css.preprocess');
$optimize_js = $maintenance_mode && $this->config->get('js.preprocess');
$attachments = $response->getAttachments();
// Resolve the attached libraries into asset collections.
$assets = new AttachedAssets();
$assets->setLibraries($attachments['library'] ?? [])
->setAlreadyLoadedLibraries(isset($ajax_page_state['libraries']) ? explode(',', $ajax_page_state['libraries']) : [])
->setSettings($attachments['drupalSettings'] ?? []);
$css_assets = $this->assetResolver->getCssAssets($assets, $optimize_css, $this->languageManager->getCurrentLanguage());
[$js_assets_header, $js_assets_footer] = $this->assetResolver->getJsAssets($assets, $optimize_js, $this->languageManager->getCurrentLanguage());
// First, AttachedAssets::setLibraries() ensures duplicate libraries are
// removed: it converts it to a set of libraries if necessary. Second,
// AssetResolver::getJsSettings() ensures $assets contains the final set of
// JavaScript settings. AttachmentsResponseProcessorInterface also mandates
// that the response it processes contains the final attachment values, so
// update both the 'library' and 'drupalSettings' attachments accordingly.
$attachments['library'] = $assets->getLibraries();
$attachments['drupalSettings'] = $assets->getSettings();
$response->setAttachments($attachments);
// Render the HTML to load these files, and add AJAX commands to insert this
// HTML in the page. Settings are handled separately, afterwards.
$settings = [];
if (isset($js_assets_header['drupalSettings'])) {
$settings = $js_assets_header['drupalSettings']['data'];
unset($js_assets_header['drupalSettings']);
}
if (isset($js_assets_footer['drupalSettings'])) {
$settings = $js_assets_footer['drupalSettings']['data'];
unset($js_assets_footer['drupalSettings']);
}
// Prepend commands to add the assets, preserving their relative order.
$resource_commands = [];
if ($css_assets) {
$css_render_array = $this->cssCollectionRenderer->render($css_assets);
$resource_commands[] = new AddCssCommand(array_column($css_render_array, '#attributes'));
}
if ($js_assets_header) {
$js_header_render_array = $this->jsCollectionRenderer->render($js_assets_header);
$resource_commands[] = new AddJsCommand(array_column($js_header_render_array, '#attributes'), 'head');
}
if ($js_assets_footer) {
$js_footer_render_array = $this->jsCollectionRenderer->render($js_assets_footer);
$resource_commands[] = new AddJsCommand(array_column($js_footer_render_array, '#attributes'));
}
foreach (array_reverse($resource_commands) as $resource_command) {
$response->addCommand($resource_command, TRUE);
}
// Prepend a command to merge changes and additions to drupalSettings.
if (!empty($settings)) {
// During Ajax requests basic path-specific settings are excluded from
// new drupalSettings values. The original page where this request comes
// from already has the right values. An Ajax request would update them
// with values for the Ajax request and incorrectly override the page's
// values.
// @see system_js_settings_alter()
unset($settings['path']);
$response->addCommand(new SettingsCommand($settings, TRUE), TRUE);
}
$commands = $response->getCommands();
$this->moduleHandler->alter('ajax_render', $commands);
return $commands;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Drupal\Core\Ajax;
/**
* AJAX command for a javascript alert box.
*
* @ingroup ajax
*/
class AlertCommand implements CommandInterface {
/**
* The text to be displayed in the alert box.
*
* @var string
*/
protected $text;
/**
* Constructs an AlertCommand object.
*
* @param string $text
* The text to be displayed in the alert box.
*/
public function __construct($text) {
$this->text = $text;
}
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'alert',
'text' => $this->text,
];
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Drupal\Core\Ajax;
use Drupal\Core\Asset\AttachedAssets;
/**
* AJAX command for a JavaScript Drupal.announce() call.
*
* Developers should be extra careful if this command and
* \Drupal\Core\Ajax\MessageCommand are included in the same response. By
* default, MessageCommand will also call Drupal.announce() and announce the
* message to the screen reader (unless the option to suppress announcements is
* passed to the constructor). Manual testing with a screen reader is strongly
* recommended.
*
* @see \Drupal\Core\Ajax\MessageCommand
*
* @ingroup ajax
*/
class AnnounceCommand implements CommandInterface, CommandWithAttachedAssetsInterface {
/**
* The assertive priority attribute value.
*
* @var string
*/
const PRIORITY_ASSERTIVE = 'assertive';
/**
* The polite priority attribute value.
*
* @var string
*/
const PRIORITY_POLITE = 'polite';
/**
* The text to be announced.
*
* @var string
*/
protected $text;
/**
* The priority that will be used for the announcement.
*
* @var string
*/
protected $priority;
/**
* Constructs an AnnounceCommand object.
*
* @param string $text
* The text to be announced.
* @param string|null $priority
* (optional) The priority that will be used for the announcement. Defaults
* to NULL which will not set a 'priority' in the response sent to the
* client and therefore the JavaScript Drupal.announce() default of 'polite'
* will be used for the message.
*/
public function __construct($text, $priority = NULL) {
$this->text = $text;
$this->priority = $priority;
}
/**
* {@inheritdoc}
*/
public function render() {
$render = [
'command' => 'announce',
'text' => $this->text,
];
if ($this->priority !== NULL) {
$render['priority'] = $this->priority;
}
return $render;
}
/**
* {@inheritdoc}
*/
public function getAttachedAssets() {
$assets = new AttachedAssets();
$assets->setLibraries(['core/drupal.announce']);
return $assets;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Drupal\Core\Ajax;
/**
* An AJAX command for calling the jQuery append() method.
*
* The 'insert/append' command instructs the client to use jQuery's append()
* method to append the given render array or HTML content to the inside of each
* element matched by the given selector.
*
* This command is implemented by Drupal.AjaxCommands.prototype.insert()
* defined in misc/ajax.js.
*
* @see http://docs.jquery.com/Manipulation/append#content
*
* @ingroup ajax
*/
class AppendCommand extends InsertCommand {
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'insert',
'method' => 'append',
'selector' => $this->selector,
'data' => $this->getRenderedContent(),
'settings' => $this->settings,
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Drupal\Core\Ajax;
/**
* Base command that only exists to simplify AJAX commands.
*/
class BaseCommand implements CommandInterface {
/**
* The name of the command.
*
* @var string
*/
protected $command;
/**
* The data to pass on to the client side.
*
* @var string
*/
protected $data;
/**
* Constructs a BaseCommand object.
*
* @param string $command
* The name of the command.
* @param string $data
* The data to pass on to the client side.
*/
public function __construct($command, $data) {
$this->command = $command;
$this->data = $data;
}
/**
* {@inheritdoc}
*/
public function render() {
return [
'command' => $this->command,
'data' => $this->data,
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Drupal\Core\Ajax;
/**
* An AJAX command for calling the jQuery before() method.
*
* The 'insert/before' command instructs the client to use jQuery's before()
* method to insert the given render array or HTML content before each of
* elements matched by the given selector.
*
* This command is implemented by Drupal.AjaxCommands.prototype.insert()
* defined in misc/ajax.js.
*
* @see http://docs.jquery.com/Manipulation/before#content
*
* @ingroup ajax
*/
class BeforeCommand extends InsertCommand {
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'insert',
'method' => 'before',
'selector' => $this->selector,
'data' => $this->getRenderedContent(),
'settings' => $this->settings,
];
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Drupal\Core\Ajax;
/**
* An AJAX command for marking HTML elements as changed.
*
* This command instructs the client to mark each of the elements matched by the
* given selector as 'ajax-changed'.
*
* This command is implemented by Drupal.AjaxCommands.prototype.changed()
* defined in misc/ajax.js.
*
* @ingroup ajax
*/
class ChangedCommand implements CommandInterface {
/**
* A CSS selector string.
*
* If the command is a response to a request from an #ajax form element then
* this value can be NULL.
*
* @var string
*/
protected $selector;
/**
* An optional CSS selector for elements to which asterisks will be appended.
*
* @var string
*/
protected $asterisk;
/**
* Constructs a ChangedCommand object.
*
* @param string $selector
* CSS selector for elements to be marked as changed.
* @param string $asterisk
* CSS selector for elements to which an asterisk will be appended.
*/
public function __construct($selector, $asterisk = '') {
$this->selector = $selector;
$this->asterisk = $asterisk;
}
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'changed',
'selector' => $this->selector,
'asterisk' => $this->asterisk,
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Drupal\Core\Ajax;
/**
* Defines an AJAX command that closes the current active dialog.
*
* @ingroup ajax
*/
class CloseDialogCommand implements CommandInterface {
/**
* A CSS selector string of the dialog to close.
*
* @var string
*/
protected $selector;
/**
* Whether to persist the dialog in the DOM or not.
*
* @var bool
*/
protected $persist;
/**
* Constructs a CloseDialogCommand object.
*
* @param string $selector
* A CSS selector string of the dialog to close.
* @param bool $persist
* (optional) Whether to persist the dialog in the DOM or not.
*/
public function __construct($selector = NULL, $persist = FALSE) {
$this->selector = $selector ? $selector : '#drupal-modal';
$this->persist = $persist;
}
/**
* {@inheritdoc}
*/
public function render() {
return [
'command' => 'closeDialog',
'selector' => $this->selector,
'persist' => $this->persist,
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Drupal\Core\Ajax;
/**
* Defines an AJAX command that closes the currently visible modal dialog.
*
* @ingroup ajax
*/
class CloseModalDialogCommand extends CloseDialogCommand {
/**
* Constructs a CloseModalDialogCommand object.
*
* @param bool $persist
* (optional) Whether to persist the dialog in the DOM or not.
*/
public function __construct($persist = FALSE) {
$this->selector = '#drupal-modal';
$this->persist = $persist;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Drupal\Core\Ajax;
/**
* AJAX command interface.
*
* All AJAX commands passed to AjaxResponse objects should implement these
* methods.
*
* @ingroup ajax
*/
interface CommandInterface {
/**
* Return an array to be run through json_encode and sent to the client.
*/
public function render();
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Drupal\Core\Ajax;
/**
* Interface for Ajax commands that render content and attach assets.
*
* All Ajax commands that render HTML should implement these methods
* to be able to return attached assets to the calling AjaxResponse object.
*
* @ingroup ajax
*/
interface CommandWithAttachedAssetsInterface {
/**
* Gets the attached assets.
*
* @return \Drupal\Core\Asset\AttachedAssets|null
* The attached assets for this command.
*/
public function getAttachedAssets();
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Drupal\Core\Ajax;
use Drupal\Core\Asset\AttachedAssets;
/**
* Trait for Ajax commands that render content and attach assets.
*
* @ingroup ajax
*/
trait CommandWithAttachedAssetsTrait {
/**
* The attached assets for this Ajax command.
*
* @var \Drupal\Core\Asset\AttachedAssets
*/
protected $attachedAssets;
/**
* Processes the content for output.
*
* If content is a render array, it may contain attached assets to be
* processed.
*
* @return string|\Drupal\Component\Render\MarkupInterface
* HTML rendered content.
*/
protected function getRenderedContent() {
$this->attachedAssets = new AttachedAssets();
if (is_array($this->content)) {
if (!$this->content) {
return '';
}
$html = \Drupal::service('renderer')->renderRoot($this->content);
$this->attachedAssets = AttachedAssets::createFromRenderArray($this->content);
return $html;
}
else {
return $this->content;
}
}
/**
* Gets the attached assets.
*
* @return \Drupal\Core\Asset\AttachedAssets|null
* The attached assets for this command.
*/
public function getAttachedAssets() {
return $this->attachedAssets;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Drupal\Core\Ajax;
/**
* An AJAX command for calling the jQuery css() method.
*
* The 'css' command will instruct the client to use the jQuery css() method to
* apply the CSS arguments to elements matched by the given selector.
*
* This command is implemented by Drupal.AjaxCommands.prototype.css() defined
* in misc/ajax.js.
*
* @see http://docs.jquery.com/CSS/css#properties
*
* @ingroup ajax
*/
class CssCommand implements CommandInterface {
/**
* A CSS selector string.
*
* If the command is a response to a request from an #ajax form element then
* this value can be NULL.
*
* @var string
*/
protected $selector;
/**
* An array of property/value pairs to set in the CSS for the selector.
*
* @var array
*/
protected $css = [];
/**
* Constructs a CssCommand object.
*
* @param string $selector
* A CSS selector for elements to which the CSS will be applied.
* @param array $css
* An array of CSS property/value pairs to set.
*/
public function __construct($selector, array $css = []) {
$this->selector = $selector;
$this->css = $css;
}
/**
* Adds a property/value pair to the CSS to be added to this element.
*
* @param $property
* The CSS property to be changed.
* @param $value
* The new value of the CSS property.
*
* @return $this
*/
public function setProperty($property, $value) {
$this->css[$property] = $value;
return $this;
}
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'css',
'selector' => $this->selector,
'argument' => $this->css,
];
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Drupal\Core\Ajax;
/**
* An AJAX command for implementing jQuery's data() method.
*
* This instructs the client to attach the name=value pair of data to the
* selector via jQuery's data cache.
*
* This command is implemented by Drupal.AjaxCommands.prototype.data() defined
* in misc/ajax.js.
*
* @ingroup ajax
*/
class DataCommand implements CommandInterface {
/**
* A CSS selector string for elements to which data will be attached.
*
* If the command is a response to a request from an #ajax form element then
* this value can be NULL.
*
* @var string
*/
protected $selector;
/**
* The key of the data attached to elements matched by the selector.
*
* @var string
*/
protected $name;
/**
* The value of the data to be attached to elements matched by the selector.
*
* The data is not limited to strings; it can be any format.
*
* @var mixed
*/
protected $value;
/**
* Constructs a DataCommand object.
*
* @param string $selector
* A CSS selector for the elements to which the data will be attached.
* @param string $name
* The key of the data to be attached to elements matched by the selector.
* @param mixed $value
* The value of the data to be attached to elements matched by the selector.
*/
public function __construct($selector, $name, $value) {
$this->selector = $selector;
$this->name = $name;
$this->value = $value;
}
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'data',
'selector' => $this->selector,
'name' => $this->name,
'value' => $this->value,
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Drupal\Core\Ajax;
/**
* AJAX command for focusing an element.
*
* This command is provided a selector then does the following:
* - The first element matching the provided selector will become the container
* where the search for tabbable elements is conducted.
* - If one or more tabbable elements are found within the container, the first
* of those will receive focus.
* - If no tabbable elements are found within the container, but the container
* itself is focusable, then the container will receive focus.
* - If the container is not focusable and contains no tabbable elements, the
* triggering element will remain focused.
*
* @see Drupal.AjaxCommands.focusFirst
*
* @ingroup ajax
*/
class FocusFirstCommand implements CommandInterface {
/**
* The selector of the container with tabbable elements.
*
* @var string
*/
protected $selector;
/**
* Constructs an FocusFirstCommand object.
*
* @param string $selector
* The selector of the container with tabbable elements.
*/
public function __construct($selector) {
$this->selector = $selector;
}
/**
* {@inheritdoc}
*/
public function render() {
return [
'command' => 'focusFirst',
'selector' => $this->selector,
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Drupal\Core\Ajax;
/**
* AJAX command for calling the jQuery html() method.
*
* The 'insert/html' command instructs the client to use jQuery's html() method
* to set the HTML content of each element matched by the given selector while
* leaving the outer tags intact using a given render array or HTML markup.
*
* This command is implemented by Drupal.AjaxCommands.prototype.insert()
* defined in misc/ajax.js.
*
* @see http://docs.jquery.com/Attributes/html#val
*
* @ingroup ajax
*/
class HtmlCommand extends InsertCommand {
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'insert',
'method' => 'html',
'selector' => $this->selector,
'data' => $this->getRenderedContent(),
'settings' => $this->settings,
];
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Drupal\Core\Ajax;
/**
* Generic AJAX command for inserting content.
*
* This command instructs the client to insert the given render array or HTML
* using whichever jQuery DOM manipulation method has been specified in the
* #ajax['method'] variable of the element that triggered the request.
*
* This command is implemented by Drupal.AjaxCommands.prototype.insert()
* defined in misc/ajax.js.
*
* @ingroup ajax
*/
class InsertCommand implements CommandInterface, CommandWithAttachedAssetsInterface {
use CommandWithAttachedAssetsTrait;
/**
* A CSS selector string.
*
* If the command is a response to a request from an #ajax form element then
* this value can be NULL.
*
* @var string
*/
protected $selector;
/**
* The content for the matched element(s).
*
* Either a render array or an HTML string.
*
* @var string|array
*/
protected $content;
/**
* A settings array to be passed to any attached JavaScript behavior.
*
* @var array
*/
protected $settings;
/**
* Constructs an InsertCommand object.
*
* @param string $selector
* A CSS selector.
* @param string|array $content
* The content that will be inserted in the matched element(s), either a
* render array or an HTML string.
* @param array $settings
* An array of JavaScript settings to be passed to any attached behaviors.
*/
public function __construct($selector, $content, ?array $settings = NULL) {
$this->selector = $selector;
$this->content = $content;
$this->settings = $settings;
}
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'insert',
'method' => NULL,
'selector' => $this->selector,
'data' => $this->getRenderedContent(),
'settings' => $this->settings,
];
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Drupal\Core\Ajax;
/**
* AJAX command for invoking an arbitrary jQuery method.
*
* The 'invoke' command will instruct the client to invoke the given jQuery
* method with the supplied arguments on the elements matched by the given
* selector. Intended for simple jQuery commands, such as attr(), addClass(),
* removeClass(), toggleClass(), etc.
*
* This command is implemented by Drupal.AjaxCommands.prototype.invoke()
* defined in misc/ajax.js.
*
* @ingroup ajax
*/
class InvokeCommand implements CommandInterface {
/**
* A CSS selector string.
*
* If the command is a response to a request from an #ajax form element then
* this value can be NULL.
*
* @var string
*/
protected $selector;
/**
* A jQuery method to invoke.
*
* @var string
*/
protected $method;
/**
* An optional list of arguments to pass to the method.
*
* @var array
*/
protected $arguments;
/**
* Constructs an InvokeCommand object.
*
* @param string $selector
* A jQuery selector.
* @param string $method
* The name of a jQuery method to invoke.
* @param array $arguments
* An optional array of arguments to pass to the method.
*/
public function __construct($selector, $method, array $arguments = []) {
$this->selector = $selector;
$this->method = $method;
$this->arguments = $arguments;
}
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'invoke',
'selector' => $this->selector,
'method' => $this->method,
'args' => $this->arguments,
];
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Drupal\Core\Ajax;
use Drupal\Core\Asset\AttachedAssets;
/**
* AJAX command for a JavaScript Drupal.message() call.
*
* Developers should be extra careful if this command and
* \Drupal\Core\Ajax\AnnounceCommand are included in the same response. Unless
* the `announce` option is set to an empty string (''), this command will
* result in the message being announced to screen readers. When combined with
* AnnounceCommand, this may result in unexpected behavior. Manual testing with
* a screen reader is strongly recommended.
*
* Here are examples of how to suppress announcements:
* @code
* $command = new MessageCommand("I won't be announced", NULL, [
* 'announce' => '',
* ]);
* @endcode
*
* @see \Drupal\Core\Ajax\AnnounceCommand
*
* @ingroup ajax
*/
class MessageCommand implements CommandInterface, CommandWithAttachedAssetsInterface {
/**
* The message text.
*
* @var string
*/
protected $message;
/**
* Whether to clear previous messages.
*
* @var bool
*/
protected $clearPrevious;
/**
* The query selector for the element the message will appear in.
*
* @var string
*/
protected $wrapperQuerySelector;
/**
* The options passed to Drupal.message().add().
*
* @var array
*/
protected $options;
/**
* Constructs a MessageCommand object.
*
* @param string $message
* The text of the message.
* @param string|null $wrapper_query_selector
* The query selector of the element to display messages in when they
* should be displayed somewhere other than the default.
* @see Drupal.Message.defaultWrapper()
* @param array $options
* The options passed to Drupal.message().add().
* @param bool $clear_previous
* If TRUE, previous messages will be cleared first.
*/
public function __construct($message, $wrapper_query_selector = NULL, array $options = [], $clear_previous = TRUE) {
$this->message = $message;
$this->wrapperQuerySelector = $wrapper_query_selector;
$this->options = $options;
$this->clearPrevious = $clear_previous;
}
/**
* {@inheritdoc}
*/
public function render() {
return [
'command' => 'message',
'message' => $this->message,
'messageWrapperQuerySelector' => $this->wrapperQuerySelector,
'messageOptions' => $this->options,
'clearPrevious' => $this->clearPrevious,
];
}
/**
* {@inheritdoc}
*/
public function getAttachedAssets() {
$assets = new AttachedAssets();
$assets->setLibraries(['core/drupal.message']);
return $assets;
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace Drupal\Core\Ajax;
use Drupal\Component\Render\PlainTextOutput;
/**
* Defines an AJAX command to open certain content in a dialog.
*
* @ingroup ajax
*/
class OpenDialogCommand implements CommandInterface, CommandWithAttachedAssetsInterface {
use CommandWithAttachedAssetsTrait;
/**
* The selector of the dialog.
*
* @var string
*/
protected $selector;
/**
* The title of the dialog.
*
* @var string
*/
protected $title;
/**
* The content for the dialog.
*
* Either a render array or an HTML string.
*
* @var string|array
*/
protected $content;
/**
* Stores dialog-specific options passed directly to jQuery UI dialogs.
*
* Any jQuery UI option can be used.
*
* @see http://api.jqueryui.com/dialog.
*
* @var array
*/
protected $dialogOptions;
/**
* Custom settings passed to Drupal behaviors on the content of the dialog.
*
* @var array
*/
protected $settings;
/**
* Constructs an OpenDialogCommand object.
*
* @param string $selector
* The selector of the dialog.
* @param string|\Stringable|null $title
* The title of the dialog.
* @param string|array $content
* The content that will be placed in the dialog, either a render array
* or an HTML string.
* @param array $dialog_options
* (optional) Options to be passed to the dialog implementation. Any
* jQuery UI option can be used. See http://api.jqueryui.com/dialog.
* @param array|null $settings
* (optional) Custom settings that will be passed to the Drupal behaviors
* on the content of the dialog. If left empty, the settings will be
* populated automatically from the current request.
*/
public function __construct($selector, string|\Stringable|null $title, $content, array $dialog_options = [], $settings = NULL) {
$title = PlainTextOutput::renderFromHtml($title);
$dialog_options += ['title' => $title];
if (isset($dialog_options['dialogClass'])) {
@trigger_error('Passing $dialog_options[\'dialogClass\'] to OpenDialogCommand::__construct() is deprecated in drupal:10.3.0 and will be removed in drupal:12.0.0. Use $dialog_options[\'classes\'] instead. See https://www.drupal.org/node/3440844', E_USER_DEPRECATED);
if (isset($dialog_options['classes']['ui-dialog'])) {
$dialog_options['classes']['ui-dialog'] = $dialog_options['classes']['ui-dialog'] . ' ' . $dialog_options['dialogClass'];
}
else {
$dialog_options['classes']['ui-dialog'] = $dialog_options['dialogClass'];
}
}
$this->selector = $selector;
$this->content = $content;
$this->dialogOptions = $dialog_options;
$this->settings = $settings;
}
/**
* Returns the dialog options.
*
* @return array
*/
public function getDialogOptions() {
return $this->dialogOptions;
}
/**
* Sets the dialog options array.
*
* @param array $dialog_options
* Options to be passed to the dialog implementation. Any jQuery UI option
* can be used. See http://api.jqueryui.com/dialog.
*/
public function setDialogOptions($dialog_options) {
$this->dialogOptions = $dialog_options;
}
/**
* Sets a single dialog option value.
*
* @param string $key
* Key of the dialog option. Any jQuery UI option can be used.
* See http://api.jqueryui.com/dialog.
* @param mixed $value
* Option to be passed to the dialog implementation.
*/
public function setDialogOption($key, $value) {
$this->dialogOptions[$key] = $value;
}
/**
* Sets the dialog title (an alias of setDialogOptions).
*
* @param string $title
* The new title of the dialog.
*/
public function setDialogTitle($title) {
$this->setDialogOption('title', $title);
}
/**
* Implements \Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
// For consistency ensure the modal option is set to TRUE or FALSE.
$this->dialogOptions['modal'] = isset($this->dialogOptions['modal']) && $this->dialogOptions['modal'];
return [
'command' => 'openDialog',
'selector' => $this->selector,
'settings' => $this->settings,
'data' => $this->getRenderedContent(),
'dialogOptions' => $this->dialogOptions,
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Drupal\Core\Ajax;
/**
* Defines an AJAX command to open certain content in a dialog in a modal dialog.
*
* @ingroup ajax
*/
class OpenModalDialogCommand extends OpenDialogCommand {
/**
* Constructs an OpenModalDialog object.
*
* The modal dialog differs from the normal modal provided by
* OpenDialogCommand in that a modal prevents other interactions on the page
* until the modal has been completed. Drupal provides a built-in modal for
* this purpose, so no selector needs to be provided.
*
* @param string|\Stringable|null $title
* The title of the dialog.
* @param string|array $content
* The content that will be placed in the dialog, either a render array
* or an HTML string.
* @param array $dialog_options
* (optional) Settings to be passed to the dialog implementation. Any
* jQuery UI option can be used. See http://api.jqueryui.com/dialog.
* @param array|null $settings
* (optional) Custom settings that will be passed to the Drupal behaviors
* on the content of the dialog. If left empty, the settings will be
* populated automatically from the current request.
*/
public function __construct(string|\Stringable|null $title, $content, array $dialog_options = [], $settings = NULL) {
$dialog_options['modal'] = TRUE;
parent::__construct('#drupal-modal', $title, $content, $dialog_options, $settings);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Drupal\Core\Ajax;
use Drupal\Component\Utility\UrlHelper;
/**
* Provides an AJAX command for opening a modal with URL.
*
* OpenDialogCommand is a similar class which opens modals but works
* differently as it needs all data to be passed through dialogOptions while
* OpenModalDialogWithUrl fetches the data from routing info of the URL.
*
* @see \Drupal\Core\Ajax\OpenDialogCommand
*/
class OpenModalDialogWithUrl implements CommandInterface {
/**
* Constructs a OpenModalDialogWithUrl object.
*
* @param string $url
* Only Internal URLs or URLs with the same domain and base path are
* allowed.
* @param array $settings
* The dialog settings.
*/
public function __construct(
protected string $url,
protected array $settings,
) {}
/**
* {@inheritdoc}
*/
public function render() {
// @see \Drupal\Core\Routing\LocalAwareRedirectResponseTrait::isLocal()
if (!UrlHelper::isExternal($this->url) || UrlHelper::externalIsLocal($this->url, $this->getBaseURL())) {
return [
'command' => 'openModalDialogWithUrl',
'url' => $this->url,
'dialogOptions' => $this->settings,
];
}
throw new \LogicException('External URLs are not allowed.');
}
/**
* Gets the complete base URL.
*/
private function getBaseUrl() {
$requestContext = \Drupal::service('router.request_context');
return $requestContext->getCompleteBaseUrl();
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Drupal\Core\Ajax;
/**
* Defines an AJAX command to open content in a dialog in an off-canvas tray.
*
* @ingroup ajax
*/
class OpenOffCanvasDialogCommand extends OpenDialogCommand {
/**
* The dialog width to use if none is provided.
*/
const DEFAULT_DIALOG_WIDTH = 300;
/**
* Constructs an OpenOffCanvasDialogCommand object.
*
* The off-canvas dialog differs from the normal modal provided by
* OpenDialogCommand in that an off-canvas has built in positioning and
* behaviors. Drupal provides a built-in off-canvas dialog for this purpose,
* so the selector is hard-coded in the call to the parent constructor.
*
* @param string|\Stringable|null $title
* The title of the dialog.
* @param string|array $content
* The content that will be placed in the dialog, either a render array
* or an HTML string.
* @param array $dialog_options
* (optional) Settings to be passed to the dialog implementation. Any
* jQuery UI option can be used. See http://api.jqueryui.com/dialog.
* @param array|null $settings
* (optional) Custom settings that will be passed to the Drupal behaviors
* on the content of the dialog. If left empty, the settings will be
* populated automatically from the current request.
* @param string $position
* (optional) The position to render the off-canvas dialog.
*/
public function __construct(string|\Stringable|null $title, $content, array $dialog_options = [], $settings = NULL, $position = 'side') {
$dialog_class = '';
if (isset($dialog_options['dialogClass'])) {
@trigger_error('Passing $dialog_options[\'dialogClass\'] to OpenOffCanvasDialogCommand::__construct() is deprecated in drupal:10.3.0 and will be removed in drupal:12.0.0. Use $dialog_options[\'classes\'] instead. See https://www.drupal.org/node/3440844', E_USER_DEPRECATED);
$dialog_class = $dialog_options['dialogClass'];
}
if (isset($dialog_options['classes']['ui-dialog'])) {
$dialog_class = $dialog_options['classes']['ui-dialog'];
}
$dialog_options['classes']['ui-dialog'] = trim("$dialog_class ui-dialog-off-canvas ui-dialog-position-$position");
parent::__construct('#drupal-off-canvas', $title, $content, $dialog_options, $settings);
$this->dialogOptions['modal'] = FALSE;
$this->dialogOptions['autoResize'] = FALSE;
$this->dialogOptions['resizable'] = 'w';
$this->dialogOptions['draggable'] = FALSE;
$this->dialogOptions['drupalAutoButtons'] = FALSE;
$this->dialogOptions['drupalOffCanvasPosition'] = $position;
// Add CSS class to #drupal-off-canvas element. This enables developers to
// select previous versions of off-canvas styles by using custom selector:
// #drupal-off-canvas:not(.drupal-off-canvas-reset).
$this->dialogOptions['classes']['ui-dialog-content'] = 'drupal-off-canvas-reset';
// If no width option is provided then use the default width to avoid the
// dialog staying at the width of the previous instance when opened
// more than once, with different widths, on a single page.
if (!isset($this->dialogOptions['width'])) {
$this->dialogOptions['width'] = static::DEFAULT_DIALOG_WIDTH;
}
}
/**
* {@inheritdoc}
*/
public function render() {
$build = parent::render();
$build['effect'] = 'fade';
$build['speed'] = 1000;
return $build;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Drupal\Core\Ajax;
/**
* AJAX command for calling the jQuery insert() method.
*
* The 'insert/prepend' command instructs the client to use jQuery's prepend()
* method to prepend the given render array or HTML content to the inside each
* element matched by the given selector.
*
* This command is implemented by Drupal.AjaxCommands.prototype.insert()
* defined in misc/ajax.js.
*
* @see http://docs.jquery.com/Manipulation/prepend#content
*
* @ingroup ajax
*/
class PrependCommand extends InsertCommand {
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'insert',
'method' => 'prepend',
'selector' => $this->selector,
'data' => $this->getRenderedContent(),
'settings' => $this->settings,
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Drupal\Core\Ajax;
/**
* Defines an AJAX command to set the window.location, loading that URL.
*
* @ingroup ajax
*/
class RedirectCommand implements CommandInterface {
/**
* The URL that will be loaded into window.location.
*
* @var string
*/
protected $url;
/**
* Constructs an RedirectCommand object.
*
* @param string $url
* The URL that will be loaded into window.location. This should be a full
* URL.
*/
public function __construct($url) {
$this->url = $url;
}
/**
* Implements \Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'redirect',
'url' => $this->url,
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Drupal\Core\Ajax;
/**
* AJAX command for calling the jQuery remove() method.
*
* The 'remove' command instructs the client to use jQuery's remove() method
* to remove each of elements matched by the given selector, and everything
* within them.
*
* This command is implemented by Drupal.AjaxCommands.prototype.remove()
* defined in misc/ajax.js.
*
* @see http://docs.jquery.com/Manipulation/remove#expr
*
* @ingroup ajax
*/
class RemoveCommand implements CommandInterface {
/**
* The CSS selector for the element(s) to be removed.
*
* @var string
*/
protected $selector;
/**
* Constructs a RemoveCommand object.
*
* @param string $selector
* The selector.
*/
public function __construct($selector) {
$this->selector = $selector;
}
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'remove',
'selector' => $this->selector,
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Drupal\Core\Ajax;
/**
* AJAX command for calling the jQuery replace() method.
*
* The 'insert/replaceWith' command instructs the client to use jQuery's
* replaceWith() method to replace each element matched by the given selector
* with the given render array or HTML.
*
* This command is implemented by Drupal.AjaxCommands.prototype.insert()
* defined in misc/ajax.js.
*
* See
* @link http://docs.jquery.com/Manipulation/replaceWith#content jQuery replaceWith command @endlink
*
* @ingroup ajax
*/
class ReplaceCommand extends InsertCommand {
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'insert',
'method' => 'replaceWith',
'selector' => $this->selector,
'data' => $this->getRenderedContent(),
'settings' => $this->settings,
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Drupal\Core\Ajax;
/**
* AJAX command for resetting the striping on a table.
*
* The 'restripe' command instructs the client to restripe a table. This is
* usually used after a table has been modified by a replace or append command.
*
* This command is implemented by Drupal.AjaxCommands.prototype.restripe()
* defined in misc/ajax.js.
*
* @ingroup ajax
*/
class RestripeCommand implements CommandInterface {
/**
* A CSS selector string.
*
* If the command is a response to a request from an #ajax form element then
* this value can be NULL.
*
* @var string
*/
protected $selector;
/**
* Constructs a RestripeCommand object.
*
* @param string $selector
* A CSS selector for the table to be restriped.
*/
public function __construct($selector) {
$this->selector = $selector;
}
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'restripe',
'selector' => $this->selector,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Drupal\Core\Ajax;
/**
* Provides an AJAX command for scrolling to the top of an element.
*
* This command is implemented in Drupal.AjaxCommands.prototype.scrollTop.
*/
class ScrollTopCommand implements CommandInterface {
/**
* A CSS selector string.
*
* @var string
*/
protected $selector;
/**
* Constructs a \Drupal\Core\Ajax\ScrollTopCommand object.
*
* @param string $selector
* A CSS selector.
*/
public function __construct($selector) {
$this->selector = $selector;
}
/**
* {@inheritdoc}
*/
public function render(): array {
return [
'command' => 'scrollTop',
'selector' => $this->selector,
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Drupal\Core\Ajax;
/**
* Defines an AJAX command that sets jQuery UI dialog properties.
*
* @ingroup ajax
*/
class SetDialogOptionCommand implements CommandInterface {
/**
* A CSS selector string.
*
* @var string
*/
protected $selector;
/**
* A jQuery UI dialog option name.
*
* @var string
*/
protected $optionName;
/**
* A jQuery UI dialog option value.
*
* @var mixed
*/
protected $optionValue;
/**
* Constructs a SetDialogOptionCommand object.
*
* @param string $selector
* The selector of the dialog whose title will be set. If set to an empty
* value, the default modal dialog will be selected.
* @param string $option_name
* The name of the option to set. May be any jQuery UI dialog option.
* See http://api.jqueryui.com/dialog.
* @param mixed $option_value
* The value of the option to be passed to the dialog.
*/
public function __construct($selector, $option_name, $option_value) {
$this->selector = $selector ? $selector : '#drupal-modal';
$this->optionName = $option_name;
$this->optionValue = $option_value;
}
/**
* {@inheritdoc}
*/
public function render() {
return [
'command' => 'setDialogOption',
'selector' => $this->selector,
'optionName' => $this->optionName,
'optionValue' => $this->optionValue,
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Drupal\Core\Ajax;
/**
* Defines an AJAX command that sets jQuery UI dialog properties.
*
* @ingroup ajax
*/
class SetDialogTitleCommand extends SetDialogOptionCommand {
/**
* Constructs a SetDialogTitleCommand object.
*
* @param string $selector
* The selector of the dialog whose title will be set. If set to an empty
* value, the default modal dialog will be selected.
* @param string $title
* The title that will be set on the dialog.
*/
public function __construct($selector, $title) {
$this->selector = $selector ? $selector : '#drupal-modal';
$this->optionName = 'title';
$this->optionValue = $title;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Drupal\Core\Ajax;
use Drupal\Component\Utility\UrlHelper;
/**
* AJAX command for adjusting Drupal's JavaScript settings.
*
* The 'settings' command instructs the client either to use the given array as
* the settings for ajax-loaded content or to extend drupalSettings with the
* given array, depending on the value of the $merge parameter.
*
* This command is implemented by Drupal.AjaxCommands.prototype.settings()
* defined in misc/ajax.js.
*
* @ingroup ajax
*/
class SettingsCommand implements CommandInterface {
/**
* An array of key/value pairs of JavaScript settings.
*
* This will be used for all commands after this if they do not include their
* own settings array.
*
* @var array
*/
protected $settings;
/**
* Whether the settings should be merged into the global drupalSettings.
*
* By default (FALSE), the settings that are passed to Drupal.attachBehaviors
* will not include the global drupalSettings.
*
* @var bool
*/
protected $merge;
/**
* Constructs a SettingsCommand object.
*
* @param array $settings
* An array of key/value pairs of JavaScript settings.
* @param bool $merge
* Whether the settings should be merged into the global drupalSettings.
*/
public function __construct(array $settings, $merge = FALSE) {
$this->settings = $settings;
$this->merge = $merge;
}
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
if (isset($this->settings['ajax_page_state']['libraries'])) {
$this->settings['ajax_page_state']['libraries'] = UrlHelper::compressQueryParameter($this->settings['ajax_page_state']['libraries']);
}
return [
'command' => 'settings',
'settings' => $this->settings,
'merge' => $this->merge,
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Drupal\Core\Ajax;
use Drupal\Core\Asset\AttachedAssets;
/**
* AJAX command for conveying changed tabledrag rows.
*
* This command is provided an id of a table row then does the following:
* - Marks the row as changed.
* - If a message generated by the tableDragChangedWarning is not present above
* the table the row belongs to, that message is added there.
*
* @see Drupal.AjaxCommands.prototype.tabledragChanged
*
* @ingroup ajax
*/
class TabledragWarningCommand implements CommandInterface, CommandWithAttachedAssetsInterface {
/**
* Constructs a TableDragWarningCommand object.
*
* @param string $id
* The id of the changed row.
* @param string $tabledrag_instance
* The identifier of the tabledrag instance.
*/
public function __construct(
protected string $id,
protected string $tabledrag_instance,
) {}
/**
* {@inheritdoc}
*/
public function render() {
return [
'command' => 'tabledragChanged',
'id' => $this->id,
'tabledrag_instance' => $this->tabledrag_instance,
];
}
/**
* {@inheritdoc}
*/
public function getAttachedAssets() {
$assets = new AttachedAssets();
$assets->setLibraries(['core/drupal.tabledrag.ajax']);
return $assets;
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Drupal\Core\Ajax;
/**
* Ajax command for updating the form build ID.
*
* Used for updating the value of a hidden form_build_id input element on a
* form. It requires the form passed in to have keys for both the old build ID
* in #build_id_old and the new build ID in #build_id.
*
* The primary use case for this Ajax command is to serve a new build ID to a
* form served from the cache to an anonymous user, preventing one anonymous
* user from accessing the form state of another anonymous user on Ajax enabled
* forms.
*
* This command is implemented by
* Drupal.AjaxCommands.prototype.update_build_id() defined in misc/ajax.js.
*
* @ingroup ajax
*/
class UpdateBuildIdCommand implements CommandInterface {
/**
* Old build id.
*
* @var string
*/
protected $old;
/**
* New build id.
*
* @var string
*/
protected $new;
/**
* Constructs an UpdateBuildIdCommand object.
*
* @param string $old
* The old build_id.
* @param string $new
* The new build_id.
*/
public function __construct($old, $new) {
$this->old = $old;
$this->new = $new;
}
/**
* {@inheritdoc}
*/
public function render() {
return [
'command' => 'update_build_id',
'old' => $this->old,
'new' => $this->new,
];
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Drupal\Core\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines an Action annotation object.
*
* Plugin Namespace: Plugin\Action
*
* @see \Drupal\Core\Action\ActionInterface
* @see \Drupal\Core\Action\ActionManager
* @see \Drupal\Core\Action\ActionBase
* @see \Drupal\Core\Action\Plugin\Action\UnpublishAction
* @see plugin_api
*
* @Annotation
*/
class Action extends Plugin {
/**
* The plugin ID.
*
* @var string
*/
public $id;
/**
* The human-readable name of the action plugin.
*
* @ingroup plugin_translatable
*
* @var \Drupal\Core\Annotation\Translation
*/
public $label;
/**
* The route name for a confirmation form for this action.
*
* This property is optional and it does not need to be declared.
*
* @todo Provide a more generic way to allow an action to be confirmed first.
*
* @var string
*/
public $confirm_form_route_name = '';
/**
* The entity type the action can apply to.
*
* @todo Replace with \Drupal\Core\Plugin\Context\Context.
*
* @var string
*/
public $type = '';
/**
* The category under which the action should be listed in the UI.
*
* @var \Drupal\Core\Annotation\Translation
*
* @ingroup plugin_translatable
*/
public $category;
}

View File

@@ -0,0 +1,163 @@
<?php
namespace Drupal\Core\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* @defgroup plugin_context Annotation for context definition
* @{
* Describes how to use ContextDefinition annotation.
*
* When providing plugin annotations, contexts can be defined to support UI
* interactions through providing limits, and mapping contexts to appropriate
* plugins. Context definitions can be provided as such:
* @code
* context_definitions = {
* "node" = @ContextDefinition("entity:node")
* }
* @endcode
*
* To add a label to a context definition use the "label" key:
* @code
* context_definitions = {
* "node" = @ContextDefinition("entity:node", label = @Translation("Node"))
* }
* @endcode
*
* Contexts are required unless otherwise specified. To make an optional
* context use the "required" key:
* @code
* context_definitions = {
* "node" = @ContextDefinition("entity:node", required = FALSE, label = @Translation("Node"))
* }
* @endcode
*
* To define multiple contexts, simply provide different key names in the
* context array:
* @code
* context_definitions = {
* "artist" = @ContextDefinition("entity:node", label = @Translation("Artist")),
* "album" = @ContextDefinition("entity:node", label = @Translation("Album"))
* }
* @endcode
*
* Specifying a default value for the context definition:
* @code
* context_definitions = {
* "message" = @ContextDefinition("string",
* label = @Translation("Message"),
* default_value = @Translation("Checkout complete! Thank you for your purchase.")
* )
* }
* @endcode
*
* @see annotation
*
* @}
*/
/**
* Defines a context definition annotation object.
*
* Some plugins require various data contexts in order to function. This class
* supports that need by allowing the contexts to be easily defined within an
* annotation and return a ContextDefinitionInterface implementing class.
*
* @Annotation
*
* @ingroup plugin_context
*/
class ContextDefinition extends Plugin {
/**
* The ContextDefinitionInterface object.
*
* @var \Drupal\Core\Plugin\Context\ContextDefinitionInterface
*/
protected $definition;
/**
* Constructs a new context definition object.
*
* @param array $values
* An associative array with the following keys:
* - value: The required data type.
* - label: (optional) The UI label of this context definition.
* - required: (optional) Whether the context definition is required.
* - multiple: (optional) Whether the context definition is multivalue.
* - description: (optional) The UI description of this context definition.
* - default_value: (optional) The default value in case the underlying
* value is not set.
* - class: (optional) A custom ContextDefinitionInterface class.
*
* @throws \Exception
* Thrown when the class key is specified with a non
* ContextDefinitionInterface implementing class.
*/
public function __construct(array $values) {
$values += [
'required' => TRUE,
'multiple' => FALSE,
'default_value' => NULL,
];
// Annotation classes extract data from passed annotation classes directly
// used in the classes they pass to.
foreach (['label', 'description'] as $key) {
// @todo Remove this workaround in https://www.drupal.org/node/2362727.
if (isset($values[$key]) && $values[$key] instanceof Translation) {
$values[$key] = (string) $values[$key]->get();
}
else {
$values[$key] = NULL;
}
}
if (isset($values['class']) && !in_array('Drupal\Core\Plugin\Context\ContextDefinitionInterface', class_implements($values['class']))) {
throw new \Exception('ContextDefinition class must implement \Drupal\Core\Plugin\Context\ContextDefinitionInterface.');
}
$class = $this->getDefinitionClass($values);
$this->definition = new $class($values['value'], $values['label'], $values['required'], $values['multiple'], $values['description'], $values['default_value']);
if (isset($values['constraints'])) {
foreach ($values['constraints'] as $constraint_name => $options) {
$this->definition->addConstraint($constraint_name, $options);
}
}
}
/**
* Determines the context definition class to use.
*
* If the annotation specifies a specific context definition class, we use
* that. Otherwise, we use \Drupal\Core\Plugin\Context\EntityContextDefinition
* if the data type starts with 'entity:', since it contains specialized logic
* specific to entities. Otherwise, we fall back to the generic
* \Drupal\Core\Plugin\Context\ContextDefinition class.
*
* @param array $values
* The annotation values.
*
* @return string
* The fully-qualified name of the context definition class.
*/
protected function getDefinitionClass(array $values) {
if (isset($values['class'])) {
return $values['class'];
}
if (str_starts_with($values['value'], 'entity:')) {
return 'Drupal\Core\Plugin\Context\EntityContextDefinition';
}
return 'Drupal\Core\Plugin\Context\ContextDefinition';
}
/**
* Returns the value of an annotation.
*
* @return \Drupal\Core\Plugin\Context\ContextDefinitionInterface
*/
public function get() {
return $this->definition;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Drupal\Core\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines a Mail annotation object.
*
* Plugin Namespace: Plugin\Mail
*
* For a working example, see \Drupal\Core\Mail\Plugin\Mail\PhpMail
*
* @see \Drupal\Core\Mail\MailInterface
* @see \Drupal\Core\Mail\MailManager
* @see plugin_api
*
* @Annotation
*/
class Mail extends Plugin {
/**
* The plugin ID.
*
* @var string
*/
public $id;
/**
* The human-readable name of the mail plugin.
*
* @var \Drupal\Core\Annotation\Translation
*
* @ingroup plugin_translatable
*/
public $label;
/**
* A short description of the mail plugin.
*
* @var \Drupal\Core\Annotation\Translation
*
* @ingroup plugin_translatable
*/
public $description;
}

View File

@@ -0,0 +1,105 @@
<?php
namespace Drupal\Core\Annotation;
use Drupal\Component\Annotation\AnnotationBase;
/**
* Defines an annotation object for strings that require plural forms.
*
* Note that the return values for both 'singular' and 'plural' keys needs to be
* passed to
* \Drupal\Core\StringTranslation\TranslationInterface::formatPlural().
*
* For example, the annotation can look like this:
* @code
* label_count = @ PluralTranslation(
* singular = "@count item",
* plural = "@count items",
* context = "cart_items",
* ),
* @endcode
* Remove spaces after @ in your actual plugin - these are put into this sample
* code so that it is not recognized as annotation.
*
* Code samples that make use of this annotation class and the definition sample
* above:
* @code
* // Returns: 1 item
* $entity_type->getCountLabel(1);
*
* // Returns: 5 items
* $entity_type->getCountLabel(5);
* @endcode
*
* @see \Drupal\Core\Entity\EntityType::getSingularLabel()
* @see \Drupal\Core\Entity\EntityType::getPluralLabel()
* @see \Drupal\Core\Entity\EntityType::getCountLabel()
*
* @ingroup plugin_translatable
*
* @Annotation
*/
class PluralTranslation extends AnnotationBase {
/**
* The string for the singular case.
*
* @var string
*/
protected $singular;
/**
* The string for the plural case.
*
* @var string
*/
protected $plural;
/**
* The context the source strings belong to.
*
* @var string
*/
protected $context;
/**
* Constructs a new class instance.
*
* @param array $values
* An associative array with the following keys:
* - singular: The string for the singular case.
* - plural: The string for the plural case.
* - context: The context the source strings belong to.
*
* @throws \InvalidArgumentException
* Thrown when the keys 'singular' or 'plural' are missing from the $values
* array.
*/
public function __construct(array $values) {
if (!isset($values['singular'])) {
throw new \InvalidArgumentException('Missing "singular" value in the PluralTranslation annotation');
}
if (!isset($values['plural'])) {
throw new \InvalidArgumentException('Missing "plural" value in the PluralTranslation annotation');
}
$this->singular = $values['singular'];
$this->plural = $values['plural'];
if (isset($values['context'])) {
$this->context = $values['context'];
}
}
/**
* {@inheritdoc}
*/
public function get() {
return [
'singular' => $this->singular,
'plural' => $this->plural,
'context' => $this->context,
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Drupal\Core\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Declare a worker class for processing a queue item.
*
* Worker plugins are used by some queues for processing the individual items
* in the queue. In that case, the ID of the worker plugin needs to match the
* machine name of a queue, so that you can retrieve the queue back end by
* calling \Drupal\Core\Queue\QueueFactory::get($plugin_id).
*
* \Drupal\Core\Cron::processQueues() processes queues that use workers; they
* can also be processed outside of the cron process.
*
* Some queues do not use worker plugins: you can create queues, add items to
* them, claim them, etc. without using a QueueWorker plugin. However, you will
* need to take care of processing the items in the queue in that case. You can
* look at \Drupal\Core\Cron::processQueues() for an example of how to process
* a queue that uses workers, and adapt it to your queue.
*
* Plugin Namespace: Plugin\QueueWorker
*
* For a working example, see
* \Drupal\locale\Plugin\QueueWorker\LocaleTranslation.
*
* @see \Drupal\Core\Queue\QueueWorkerInterface
* @see \Drupal\Core\Queue\QueueWorkerBase
* @see \Drupal\Core\Queue\QueueWorkerManager
* @see plugin_api
*
* @ingroup queue
*
* @Annotation
*/
class QueueWorker extends Plugin {
/**
* The plugin ID.
*
* @var string
*/
public $id;
/**
* The human-readable title of the plugin.
*
* @var \Drupal\Core\Annotation\Translation
*
* @ingroup plugin_translatable
*/
public $title;
/**
* An optional associative array of settings for cron.
*
* @var array
* The array has one key, time, which is set to the time Drupal cron should
* spend on calling this worker in seconds. The default is set in
* \Drupal\Core\Queue\QueueWorkerManager::processDefinition().
*
* @see \Drupal\Core\Queue\QueueWorkerManager::processDefinition()
*/
public $cron;
}

View File

@@ -0,0 +1,94 @@
<?php
namespace Drupal\Core\Annotation;
use Drupal\Component\Annotation\AnnotationBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* @defgroup plugin_translatable Annotation for translatable text
* @{
* Describes how to put translatable UI text into annotations.
*
* When providing plugin annotation, properties whose values are displayed in
* the user interface should be made translatable. Much the same as how user
* interface text elsewhere is wrapped in t() to make it translatable, in plugin
* annotation, wrap translatable strings in the @ Translation() annotation.
* For example:
* @code
* title = @ Translation("Title of the plugin"),
* @endcode
* Remove spaces after @ in your actual plugin - these are put into this sample
* code so that it is not recognized as annotation.
*
* To provide replacement values for placeholders, use the "arguments" array:
* @code
* title = @ Translation("Bundle @title", arguments = {"@title" = "Foo"}),
* @endcode
*
* It is also possible to provide a context with the text, similar to t():
* @code
* title = @ Translation("Bundle", context = "Validation"),
* @endcode
* Other t() arguments like language code are not valid to pass in. Only
* context is supported.
*
* @see i18n
* @see annotation
* @}
*/
/**
* Defines a translatable annotation object.
*
* Some metadata within an annotation needs to be translatable. This class
* supports that need by allowing both the translatable string and, if
* specified, a context for that string. The string (with optional context)
* is passed into t().
*
* @ingroup plugin_translatable
*
* @Annotation
*/
class Translation extends AnnotationBase {
/**
* The string translation object.
*
* @var \Drupal\Core\StringTranslation\TranslatableMarkup
*/
protected $translation;
/**
* Constructs a new class instance.
*
* Parses values passed into this class through the t() function in Drupal and
* handles an optional context for the string.
*
* @param array $values
* Possible array keys:
* - value (required): the string that is to be translated.
* - arguments (optional): an array with placeholder replacements, keyed by
* placeholder.
* - context (optional): a string that describes the context of "value";
*/
public function __construct(array $values) {
$string = $values['value'];
$arguments = $values['arguments'] ?? [];
$options = [];
if (!empty($values['context'])) {
$options = [
'context' => $values['context'],
];
}
$this->translation = new TranslatableMarkup($string, $arguments, $options);
}
/**
* {@inheritdoc}
*/
public function get() {
return $this->translation;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Drupal\Core\Archiver\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines an archiver annotation object.
*
* Plugin Namespace: Plugin\Archiver
*
* For a working example, see \Drupal\system\Plugin\Archiver\Zip
*
* @see \Drupal\Core\Archiver\ArchiverManager
* @see \Drupal\Core\Archiver\ArchiverInterface
* @see plugin_api
* @see hook_archiver_info_alter()
*
* @Annotation
*/
class Archiver extends Plugin {
/**
* The archiver plugin ID.
*
* @var string
*/
public $id;
/**
* The human-readable name of the archiver plugin.
*
* @ingroup plugin_translatable
*
* @var \Drupal\Core\Annotation\Translation
*/
public $title;
/**
* The description of the archiver plugin.
*
* @ingroup plugin_translatable
*
* @var \Drupal\Core\Annotation\Translation
*/
public $description;
/**
* An array of valid extensions for this archiver.
*
* @var array
*/
public $extensions;
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Drupal\Core\Archiver;
/**
* Extends Pear's Archive_Tar to use exceptions.
*/
class ArchiveTar extends \Archive_Tar {
/**
* {@inheritdoc}
*/
public function _error($p_message) {
throw new \Exception($p_message);
}
/**
* {@inheritdoc}
*/
public function _warning($p_message) {
throw new \Exception($p_message);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace Drupal\Core\Archiver;
/**
* Defines an exception class for Drupal\Core\Archiver\ArchiverInterface.
*/
class ArchiverException extends \Exception {}

View File

@@ -0,0 +1,60 @@
<?php
namespace Drupal\Core\Archiver;
/**
* Defines the common interface for all Archiver classes.
*
* @see \Drupal\Core\Archiver\ArchiverManager
* @see \Drupal\Core\Archiver\Attribute\Archiver
* @see plugin_api
*/
interface ArchiverInterface {
/**
* Adds the specified file or directory to the archive.
*
* @param string $file_path
* The full system path of the file or directory to add. Only local files
* and directories are supported.
*
* @return $this
* The called object.
*/
public function add($file_path);
/**
* Removes the specified file from the archive.
*
* @param string $path
* The file name relative to the root of the archive to remove.
*
* @return $this
* The called object.
*/
public function remove($path);
/**
* Extracts multiple files in the archive to the specified path.
*
* @param string $path
* A full system path of the directory to which to extract files.
* @param array $files
* Optionally specify a list of files to be extracted. Files are
* relative to the root of the archive. If not specified, all files
* in the archive will be extracted.
*
* @return $this
* The called object.
*/
public function extract($path, array $files = []);
/**
* Lists all files in the archive.
*
* @return array
* An array of file names relative to the root of the archive.
*/
public function listContents();
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Drupal\Core\Archiver;
use Drupal\Component\Plugin\Factory\DefaultFactory;
use Drupal\Core\Archiver\Attribute\Archiver;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
/**
* Provides an Archiver plugin manager.
*
* @see \Drupal\Core\Archiver\Attribute\Archiver
* @see \Drupal\Core\Archiver\ArchiverInterface
* @see plugin_api
*/
class ArchiverManager extends DefaultPluginManager {
/**
* The file system service.
*
* @var \Drupal\Core\File\FileSystemInterface
*/
protected $fileSystem;
/**
* Constructs an ArchiverManager object.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
* @param \Drupal\Core\File\FileSystemInterface $file_system
* The file handler.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler, FileSystemInterface $file_system) {
parent::__construct('Plugin/Archiver', $namespaces, $module_handler, 'Drupal\Core\Archiver\ArchiverInterface', Archiver::class, 'Drupal\Core\Archiver\Annotation\Archiver');
$this->alterInfo('archiver_info');
$this->setCacheBackend($cache_backend, 'archiver_info_plugins');
$this->fileSystem = $file_system;
}
/**
* {@inheritdoc}
*/
public function createInstance($plugin_id, array $configuration = []) {
$plugin_definition = $this->getDefinition($plugin_id);
$plugin_class = DefaultFactory::getPluginClass($plugin_id, $plugin_definition, 'Drupal\Core\Archiver\ArchiverInterface');
return new $plugin_class($this->fileSystem->realpath($configuration['filepath']), $configuration);
}
/**
* {@inheritdoc}
*/
public function getInstance(array $options) {
$filepath = $options['filepath'];
foreach ($this->getDefinitions() as $plugin_id => $definition) {
foreach ($definition['extensions'] as $extension) {
// Because extensions may be multi-part, such as .tar.gz,
// we cannot use simpler approaches like substr() or pathinfo().
// This method isn't quite as clean but gets the job done.
// Also note that the file may not yet exist, so we cannot rely
// on fileinfo() or other disk-level utilities.
if (strrpos($filepath, '.' . $extension) === strlen($filepath) - strlen('.' . $extension)) {
return $this->createInstance($plugin_id, $options);
}
}
}
}
/**
* Returns a string of supported archive extensions.
*
* @return string
* A space-separated string of extensions suitable for use by the file
* validation system.
*/
public function getExtensions() {
$valid_extensions = [];
foreach ($this->getDefinitions() as $archive) {
foreach ($archive['extensions'] as $extension) {
foreach (explode('.', $extension) as $part) {
if (!in_array($part, $valid_extensions)) {
$valid_extensions[] = $part;
}
}
}
}
return implode(' ', $valid_extensions);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Drupal\Core\Archiver\Attribute;
use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines an archiver attribute object.
*
* Plugin Namespace: Plugin\Archiver
*
* For a working example, see \Drupal\system\Plugin\Archiver\Zip
*
* @see \Drupal\Core\Archiver\ArchiverManager
* @see \Drupal\Core\Archiver\ArchiverInterface
* @see plugin_api
* @see hook_archiver_info_alter()
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class Archiver extends Plugin {
/**
* Constructs an archiver plugin attribute object.
*
* @param string $id
* The archiver plugin ID.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $title
* The human-readable name of the archiver plugin.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $description
* The description of the archiver plugin.
* @param array $extensions
* An array of valid extensions for this archiver.
* @param class-string|null $deriver
* (optional) The deriver class.
*/
public function __construct(
public readonly string $id,
public readonly ?TranslatableMarkup $title = NULL,
public readonly ?TranslatableMarkup $description = NULL,
public readonly array $extensions = [],
public readonly ?string $deriver = NULL,
) {}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace Drupal\Core\Archiver;
/**
* Defines an archiver implementation for .tar files.
*/
class Tar implements ArchiverInterface {
/**
* The underlying ArchiveTar instance that does the heavy lifting.
*
* @var \Drupal\Core\Archiver\ArchiveTar
*/
protected $tar;
/**
* Constructs a Tar object.
*
* @param string $file_path
* The full system path of the archive to manipulate. Only local files
* are supported. If the file does not yet exist, it will be created if
* appropriate.
* @param array $configuration
* (Optional) settings to open the archive with the following keys:
* - 'compress': Indicates if the 'gzip', 'bz2', or 'lzma2' compression is
* required.
* - 'buffer_length': Length of the read buffer in bytes.
*
* @throws \Drupal\Core\Archiver\ArchiverException
*/
public function __construct($file_path, array $configuration = []) {
$compress = $configuration['compress'] ?? NULL;
$buffer = $configuration['buffer_length'] ?? 512;
$this->tar = new ArchiveTar($file_path, $compress, $buffer);
}
/**
* {@inheritdoc}
*/
public function add($file_path) {
$this->tar->add($file_path);
return $this;
}
/**
* {@inheritdoc}
*/
public function remove($file_path) {
// @todo Archive_Tar doesn't have a remove operation
// so we'll have to simulate it somehow, probably by
// creating a new archive with everything but the removed
// file.
return $this;
}
/**
* {@inheritdoc}
*/
public function extract($path, array $files = []) {
if ($files) {
$this->tar->extractList($files, $path, '', FALSE, FALSE);
}
else {
$this->tar->extract($path, FALSE, FALSE);
}
return $this;
}
/**
* {@inheritdoc}
*/
public function listContents() {
$files = [];
foreach ($this->tar->listContent() as $file_data) {
$files[] = $file_data['filename'];
}
return $files;
}
/**
* Retrieves the tar engine itself.
*
* In some cases it may be necessary to directly access the underlying
* ArchiveTar object for implementation-specific logic. This is for advanced
* use only as it is not shared by other implementations of ArchiveInterface.
*
* @return ArchiveTar
* The ArchiveTar object used by this object.
*/
public function getArchive() {
return $this->tar;
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace Drupal\Core\Archiver;
/**
* Defines an archiver implementation for .zip files.
*
* @link http://php.net/zip
*/
class Zip implements ArchiverInterface {
/**
* The underlying ZipArchive instance that does the heavy lifting.
*
* @var \ZipArchive
*/
protected $zip;
/**
* Constructs a Zip object.
*
* @param string $file_path
* The full system path of the archive to manipulate. Only local files
* are supported. If the file does not yet exist, it will be created if
* appropriate.
* @param array $configuration
* (Optional) settings to open the archive with the following keys:
* - 'flags': The mode to open the archive with \ZipArchive::open().
*
* @throws \Drupal\Core\Archiver\ArchiverException
*/
public function __construct($file_path, array $configuration = []) {
$this->zip = new \ZipArchive();
if ($this->zip->open($file_path, $configuration['flags'] ?? 0) !== TRUE) {
throw new ArchiverException("Cannot open '$file_path'");
}
}
/**
* {@inheritdoc}
*/
public function add($file_path) {
$this->zip->addFile($file_path);
return $this;
}
/**
* {@inheritdoc}
*/
public function remove($file_path) {
$this->zip->deleteName($file_path);
return $this;
}
/**
* {@inheritdoc}
*/
public function extract($path, array $files = []) {
if ($files) {
$this->zip->extractTo($path, $files);
}
else {
$this->zip->extractTo($path);
}
return $this;
}
/**
* {@inheritdoc}
*/
public function listContents() {
$files = [];
for ($i = 0; $i < $this->zip->numFiles; $i++) {
$files[] = $this->zip->getNameIndex($i);
}
return $files;
}
/**
* Retrieves the zip engine itself.
*
* In some cases it may be necessary to directly access the underlying
* ZipArchive object for implementation-specific logic. This is for advanced
* use only as it is not shared by other implementations of ArchiveInterface.
*
* @return \ZipArchive
* The ZipArchive object used by this object.
*/
public function getArchive() {
return $this->zip;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Drupal\Core\Asset;
/**
* Interface defining a service that optimizes a collection of assets.
*
* Contains an additional method to allow for optimizing an asset group.
*/
interface AssetCollectionGroupOptimizerInterface extends AssetCollectionOptimizerInterface {
/**
* Optimizes a specific group of assets.
*
* @param array $group
* An asset group.
*
* @return string
* The optimized string for the group.
*/
public function optimizeGroup(array $group): string;
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Drupal\Core\Asset;
/**
* Interface defining a service that logically groups a collection of assets.
*/
interface AssetCollectionGrouperInterface {
/**
* Groups a collection of assets into logical groups of asset collections.
*
* @param array $assets
* An asset collection.
*
* @return array
* A sorted array of asset groups.
*/
public function group(array $assets);
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Drupal\Core\Asset;
/**
* Interface defining a service that optimizes a collection of assets.
*/
interface AssetCollectionOptimizerInterface {
/**
* Optimizes a collection of assets.
*
* @param array $assets
* An asset collection.
* @param array $libraries
* An array of library names.
*
* @return array
* An optimized asset collection.
*/
public function optimize(array $assets, array $libraries);
/**
* Returns all optimized asset collections assets.
*
* @return string[]
* URIs for all optimized asset collection assets.
*
* @deprecated in drupal:10.2.0 and is removed from drupal:11.0.0. There is
* no replacement.
*
* @see https://www.drupal.org/node/3301744
*/
public function getAll();
/**
* Deletes all optimized asset collections assets.
*/
public function deleteAll();
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Drupal\Core\Asset;
/**
* Interface defining a service that generates a render array to render assets.
*/
interface AssetCollectionRendererInterface {
/**
* Renders an asset collection.
*
* @param array $assets
* An asset collection.
*
* @return array
* A render array to render the asset collection.
*/
public function render(array $assets);
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Drupal\Core\Asset;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
/**
* Dumps a CSS or JavaScript asset.
*/
class AssetDumper implements AssetDumperUriInterface {
/**
* The file system service.
*
* @var \Drupal\Core\File\FileSystemInterface
*/
protected $fileSystem;
/**
* AssetDumper constructor.
*
* @param \Drupal\Core\File\FileSystemInterface $file_system
* The file handler.
*/
public function __construct(FileSystemInterface $file_system) {
$this->fileSystem = $file_system;
}
/**
* {@inheritdoc}
*
* The file name for the CSS or JS cache file is generated from the hash of
* the aggregated contents of the files in $data. This forces proxies and
* browsers to download new CSS when the CSS changes.
*/
public function dump($data, $file_extension) {
$path = 'assets://' . $file_extension;
// Prefix filename to prevent blocking by firewalls which reject files
// starting with "ad*".
$filename = $file_extension . '_' . Crypt::hashBase64($data) . '.' . $file_extension;
$uri = $path . '/' . $filename;
return $this->dumpToUri($data, $file_extension, $uri);
}
/**
* {@inheritdoc}
*/
public function dumpToUri(string $data, string $file_extension, string $uri): string {
$path = 'assets://' . $file_extension;
// Create the CSS or JS file.
$this->fileSystem->prepareDirectory($path, FileSystemInterface::CREATE_DIRECTORY);
try {
if (!file_exists($uri) && !$this->fileSystem->saveData($data, $uri, FileExists::Replace)) {
return FALSE;
}
}
catch (FileException $e) {
return FALSE;
}
// If CSS/JS gzip compression is enabled and the zlib extension is available
// then create a gzipped version of this file. This file is served
// conditionally to browsers that accept gzip using .htaccess rules.
// It's possible that the rewrite rules in .htaccess aren't working on this
// server, but there's no harm (other than the time spent generating the
// file) in generating the file anyway. Sites on servers where rewrite rules
// aren't working can set css.gzip to FALSE in order to skip
// generating a file that won't be used.
if (extension_loaded('zlib') && \Drupal::config('system.performance')->get($file_extension . '.gzip')) {
try {
if (!file_exists($uri . '.gz') && !$this->fileSystem->saveData(gzencode($data, 9, FORCE_GZIP), $uri . '.gz', FileExists::Replace)) {
return FALSE;
}
}
catch (FileException $e) {
return FALSE;
}
}
return $uri;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Drupal\Core\Asset;
/**
* Interface defining a service that dumps an (optimized) asset.
*/
interface AssetDumperInterface {
/**
* Dumps an (optimized) asset to persistent storage.
*
* @param string $data
* An (optimized) asset's contents.
* @param string $file_extension
* The file extension of this asset.
*
* @return string
* A URI to access the dumped asset.
*/
public function dump($data, $file_extension);
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Drupal\Core\Asset;
/**
* Interface defining a service that dumps an asset to a specified location.
*/
interface AssetDumperUriInterface extends AssetDumperInterface {
/**
* Dumps an (optimized) asset to persistent storage.
*
* @param string $data
* The asset's contents.
* @param string $file_extension
* The file extension of this asset.
* @param string $uri
* The URI to dump to.
*
* @return string
* An URI to access the dumped asset.
*/
public function dumpToUri(string $data, string $file_extension, string $uri): string;
}

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