content_lock-8.x-2.1/src/ContentLock/ContentLock.php
src/ContentLock/ContentLock.php
<?php
namespace Drupal\content_lock\ContentLock;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\content_lock\Event\ContentLockLockedEvent;
use Drupal\content_lock\Event\ContentLockReleaseEvent;
use Drupal\Core\Config\Config;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Lock\LockBackendInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Link;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Class ContentLock.
*
* The content lock service.
*/
class ContentLock implements ContentLockInterface {
use StringTranslationTrait;
/**
* The content_lock.settings config.
*
* @var \Drupal\Core\Config\Config
*/
protected Config $config;
/**
* The current request.
*
* @var \Symfony\Component\HttpFoundation\Request
*/
protected Request $currentRequest;
public function __construct(
protected Connection $database,
protected ModuleHandlerInterface $moduleHandler,
protected DateFormatterInterface $dateFormatter,
protected AccountProxyInterface $currentUser,
ConfigFactoryInterface $configFactory,
RequestStack $requestStack,
protected EntityTypeManagerInterface $entityTypeManager,
protected TimeInterface $time,
#[Autowire(service: 'lock')]
protected LockBackendInterface $lock,
protected EventDispatcherInterface $eventDispatcher,
) {
$this->config = $configFactory->get('content_lock.settings');
$this->currentRequest = $requestStack->getCurrentRequest();
}
/**
* {@inheritdoc}
*/
public function fetchLock(EntityInterface $entity, ?string $form_op = NULL, bool $include_stale_locks = FALSE): object|false {
$langcode = $entity->language()->getId();
if (!$this->isTranslationLockEnabled($entity->getEntityTypeId())) {
$langcode = LanguageInterface::LANGCODE_NOT_SPECIFIED;
}
if (!$this->isFormOperationLockEnabled($entity->getEntityTypeId())) {
$form_op = '*';
}
$query = $this->database->select('content_lock', 'c');
$query->leftJoin('users_field_data', 'u', '%alias.uid = c.uid');
$query->fields('c')
->fields('u', ['name'])
->condition('c.entity_type', $entity->getEntityTypeId())
->condition('c.entity_id', $entity->id())
->condition('c.langcode', $langcode);
if (isset($form_op)) {
$query->condition('c.form_op', $form_op);
}
if ($include_stale_locks === FALSE) {
// Respect timeouts.
$timeout = $this->config->get('timeout');
if ($timeout > 0) {
$query->condition('timestamp', $this->time->getCurrentTime() - $timeout, '>=');
}
}
return $query->execute()->fetchObject();
}
/**
* {@inheritdoc}
*/
public function displayLockOwner(object $lock, bool $translation_lock): string|TranslatableMarkup {
$username = $this->entityTypeManager->getStorage('user')->load($lock->uid);
$date = $this->dateFormatter->formatInterval($this->time->getRequestTime() - $lock->timestamp);
if ($translation_lock) {
$message = $this->t('This content translation is being edited by the user @name and is therefore locked to prevent other users changes. This lock is in place since @date.', [
'@name' => $username->getDisplayName(),
'@date' => $date,
]);
}
else {
$message = $this->t('This content is being edited by the user @name and is therefore locked to prevent other users changes. This lock is in place since @date.', [
'@name' => $username->getDisplayName(),
'@date' => $date,
]);
}
return $message;
}
/**
* {@inheritdoc}
*/
public function isLockedBy(EntityInterface $entity, string $form_op, int $uid): bool {
$langcode = $entity->language()->getId();
if (!$this->isTranslationLockEnabled($entity->getEntityTypeId())) {
$langcode = LanguageInterface::LANGCODE_NOT_SPECIFIED;
}
if (!$this->isFormOperationLockEnabled($entity->getEntityTypeId())) {
$form_op = '*';
}
/** @var \Drupal\Core\Database\Query\SelectInterface $query */
$query = $this->database->select('content_lock', 'c')
->fields('c')
->condition('entity_id', $entity->id())
->condition('uid', $uid)
->condition('entity_type', $entity->getEntityTypeId())
->condition('langcode', $langcode)
->condition('form_op', $form_op);
// Respect timeouts.
$timeout = $this->config->get('timeout');
if ($timeout > 0) {
$query->condition('timestamp', $this->time->getCurrentTime() - $timeout, '>=');
}
$num_rows = $query->countQuery()->execute()->fetchField();
return (bool) $num_rows;
}
/**
* {@inheritdoc}
*/
public function release(EntityInterface $entity, ?string $form_op = NULL, ?int $uid = NULL): void {
$langcode = $entity->language()->getId();
if (!$this->isTranslationLockEnabled($entity->getEntityTypeId())) {
$langcode = LanguageInterface::LANGCODE_NOT_SPECIFIED;
}
if (!$this->isFormOperationLockEnabled($entity->getEntityTypeId())) {
$form_op = '*';
}
// Delete locking item from database.
$this->lockingDelete($entity, $form_op, $uid);
$event = new ContentLockReleaseEvent($entity->id(), $langcode, $form_op, $entity->getEntityTypeId());
$this->eventDispatcher->dispatch($event, ContentLockReleaseEvent::EVENT_NAME);
}
/**
* {@inheritdoc}
*/
public function releaseAllUserLocks(int $uid): void {
$this->database->delete('content_lock')
->condition('uid', $uid)
->execute();
}
/**
* Save locking into database.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param string $form_op
* The entity form operation.
* @param int $uid
* The user uid.
*
* @return int|null
* One of the following values:
* - Merge::STATUS_INSERT: If the entry does not already exist,
* and an INSERT query is executed.
* - Merge::STATUS_UPDATE: If the entry already exists,
* and an UPDATE query is executed.
*/
protected function lockingSave(EntityInterface $entity, string $form_op, int $uid): ?int {
$langcode = $entity->language()->getId();
if (!$this->isTranslationLockEnabled($entity->getEntityTypeId())) {
$langcode = LanguageInterface::LANGCODE_NOT_SPECIFIED;
}
if (!$this->isFormOperationLockEnabled($entity->getEntityTypeId())) {
$form_op = '*';
}
return $this->database->merge('content_lock')
->keys([
'entity_id' => $entity->id(),
'entity_type' => $entity->getEntityTypeId(),
'langcode' => $langcode,
'form_op' => $form_op,
])
->fields([
'entity_id' => $entity->id(),
'entity_type' => $entity->getEntityTypeId(),
'langcode' => $langcode,
'form_op' => $form_op,
'uid' => $uid,
'timestamp' => $this->time->getRequestTime(),
])
->execute();
}
/**
* Delete locking item from database.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param string|null $form_op
* (optional) The entity form operation.
* @param int|null $uid
* (optional) The user uid.
*
* @return int
* The number of rows affected by the delete query.
*/
protected function lockingDelete(EntityInterface $entity, ?string $form_op = NULL, ?int $uid = NULL): int {
$langcode = $entity->language()->getId();
if (!$this->isTranslationLockEnabled($entity->getEntityTypeId())) {
$langcode = LanguageInterface::LANGCODE_NOT_SPECIFIED;
}
if (!$this->isFormOperationLockEnabled($entity->getEntityTypeId())) {
$form_op = '*';
}
$query = $this->database->delete('content_lock')
->condition('entity_type', $entity->getEntityTypeId())
->condition('entity_id', $entity->id())
->condition('langcode', $langcode);
if (isset($form_op)) {
$query->condition('form_op', $form_op);
}
if (!empty($uid)) {
$query->condition('uid', $uid);
}
return $query->execute();
}
/**
* {@inheritdoc}
*/
public function verbose(): bool {
return $this->config->get('verbose');
}
/**
* {@inheritdoc}
*/
public function locking(EntityInterface $entity, string $form_op, int $uid, bool $quiet = FALSE, ?string $destination = NULL, ?array &$messages = NULL): bool {
$translation_lock = $this->isTranslationLockEnabled($entity->getEntityTypeId());
$langcode = $entity->language()->getId();
if (!$translation_lock) {
$langcode = LanguageInterface::LANGCODE_NOT_SPECIFIED;
}
if (!$this->isFormOperationLockEnabled($entity->getEntityTypeId())) {
$form_op = '*';
}
// Acquire a lock from the lock service to prevent a race condition when
// checking if a lock exists and update the content lock table with the new
// information.
$lock_service_name = "content_lock:{$entity->getEntityTypeId()}:{$entity->id()}";
$lock_service_acquired = $this->lock->acquire($lock_service_name);
if (!$lock_service_acquired) {
$this->lock->wait($lock_service_name, 5);
$lock_service_acquired = $this->lock->acquire($lock_service_name);
}
// Check locking status.
$lock = $this->fetchLock($entity, $form_op, TRUE);
// No lock yet.
if ($lock_service_acquired && ($lock === FALSE || !is_object($lock))) {
// Save locking into database.
$this->lockingSave($entity, $form_op, $uid);
if ($this->verbose() && !$quiet) {
if ($translation_lock) {
$messages[MessengerInterface::TYPE_STATUS][] = $this->t('This content translation is now locked against simultaneous editing. This content translation will remain locked if you navigate away from this page without saving or unlocking it.');
}
else {
$messages[MessengerInterface::TYPE_STATUS][] = $this->t('This content is now locked against simultaneous editing. This content will remain locked if you navigate away from this page without saving or unlocking it.');
}
}
// Post locking hook.
$event = new ContentLockLockedEvent($entity->id(), $langcode, $form_op, $uid, $entity->getEntityTypeId());
$this->eventDispatcher->dispatch($event, ContentLockLockedEvent::EVENT_NAME);
// Send success flag.
$return = TRUE;
}
else {
// Currently locking by other user.
if (is_object($lock) && $lock->uid != $uid && !$this->lockIsStale($lock)) {
// Send message.
$messages[MessengerInterface::TYPE_WARNING][] = $this->displayLockOwner($lock, $translation_lock);
// Higher permission user can unblock.
if ($this->currentUser->hasPermission('break content lock')) {
$link = Link::createFromRoute(
$this->t('Break lock'),
'content_lock.break_lock.' . $entity->getEntityTypeId(),
[
'entity' => $entity->id(),
'langcode' => $langcode,
'form_op' => $form_op,
],
['query' => ['destination' => $destination ?? $this->currentRequest->getRequestUri()]]
)->toString();
// Let user break lock.
$messages[MessengerInterface::TYPE_WARNING][] = $this->t('Click here to @link', ['@link' => $link]);
}
// Return FALSE flag.
$return = FALSE;
}
elseif ($lock_service_acquired) {
// There is a stale lock. Release it.
if (is_object($lock) && $lock->uid != $uid) {
$this->release($entity, $form_op, $uid);
}
// Save locking into database.
$this->lockingSave($entity, $form_op, $uid);
// Locked by current user.
if ($this->verbose() && !$quiet) {
if ($translation_lock) {
$messages[MessengerInterface::TYPE_STATUS][] = $this->t('This content translation is now locked by you against simultaneous editing. This content translation will remain locked if you navigate away from this page without saving or unlocking it.');
}
else {
$messages[MessengerInterface::TYPE_STATUS][] = $this->t('This content is now locked by you against simultaneous editing. This content will remain locked if you navigate away from this page without saving or unlocking it.');
}
}
// Send success flag.
$return = TRUE;
}
else {
$messages[MessengerInterface::TYPE_WARNING][] = $this->t('This content is being edited by another user.');
// Return FALSE flag.
$return = FALSE;
}
}
if ($lock_service_acquired) {
$this->lock->release($lock_service_name);
}
return $return;
}
/**
* Determines if a lock is stale.
*
* @param \StdClass $lock
* The lock information.
*
* @return bool
* TRUE if the lock is stale, FALSE if not.
*/
private function lockIsStale(\StdClass $lock): bool {
$timeout = $this->config->get('timeout');
return $timeout >= 1 && $lock->timestamp < $this->time->getCurrentTime() - $timeout;
}
/**
* {@inheritdoc}
*/
public function isLockable(EntityInterface $entity, ?string $form_op = NULL): bool {
$entity_type = $entity->getEntityTypeId();
$bundle = $entity->bundle();
$config = $this->config->get("types.$entity_type") ?? [];
$allowed = TRUE;
$this->moduleHandler->invokeAllWith('content_lock_entity_lockable', function (callable $hook) use (&$allowed, $entity, $config, $form_op) {
if ($allowed && $hook($entity, $config, $form_op) === FALSE) {
$allowed = FALSE;
}
});
if ($allowed && (in_array($bundle, $config, TRUE) || in_array('*', $config, TRUE))) {
if (isset($form_op) && $this->isFormOperationLockEnabled($entity_type)) {
$mode = $this->config->get("form_op_lock.$entity_type.mode");
$values = $this->config->get("form_op_lock.$entity_type.values");
if ($mode == self::FORM_OP_MODE_DENYLIST) {
return !in_array($form_op, $values);
}
elseif ($mode == self::FORM_OP_MODE_ALLOWLIST) {
return in_array($form_op, $values);
}
}
return TRUE;
}
// Always return FALSE.
return FALSE;
}
/**
* {@inheritdoc}
*/
public function unlockButton(EntityInterface $entity, ?string $form_op, ?string $destination): array {
$unlock_url_options = [];
if ($destination) {
$unlock_url_options['query'] = ['destination' => $destination];
}
$route_parameters = [
'entity' => $entity->id(),
'langcode' => $this->isTranslationLockEnabled($entity->getEntityTypeId()) ? $entity->language()->getId() : LanguageInterface::LANGCODE_NOT_SPECIFIED,
'form_op' => $this->isFormOperationLockEnabled($entity->getEntityTypeId()) ? $form_op : '*',
];
return [
'#type' => 'link',
'#title' => $this->t('Unlock'),
'#access' => TRUE,
'#attributes' => [
'class' => ['button'],
],
'#url' => Url::fromRoute('content_lock.break_lock.' . $entity->getEntityTypeId(), $route_parameters, $unlock_url_options),
'#weight' => 99,
'#gin_action_item' => TRUE,
];
}
/**
* {@inheritdoc}
*/
public function isTranslationLockEnabled(string $entity_type_id): bool {
return $this->moduleHandler->moduleExists('conflict') && in_array($entity_type_id, $this->config->get("types_translation_lock"));
}
/**
* {@inheritdoc}
*/
public function hasLockEnabled(string $entity_type_id): bool {
return !empty($this->config->get("types")[$entity_type_id]);
}
/**
* {@inheritdoc}
*/
public function isFormOperationLockEnabled(string $entity_type_id): bool {
return $this->config->get("form_op_lock.$entity_type_id.mode") != self::FORM_OP_MODE_DISABLED;
}
}
