1<?php
2
3namespace Drupal\workspaces;
4
5use Drupal\Core\Cache\MemoryCache\MemoryCacheInterface;
6use Drupal\Core\DependencyInjection\ClassResolverInterface;
7use Drupal\Core\Entity\EntityPublishedInterface;
8use Drupal\Core\Entity\EntityTypeInterface;
9use Drupal\Core\Entity\EntityTypeManagerInterface;
10use Drupal\Core\Session\AccountProxyInterface;
11use Drupal\Core\Site\Settings;
12use Drupal\Core\State\StateInterface;
13use Drupal\Core\StringTranslation\StringTranslationTrait;
14use Psr\Log\LoggerInterface;
15use Symfony\Component\HttpFoundation\RequestStack;
16
17/**
18 * Provides the workspace manager.
19 */
20class WorkspaceManager implements WorkspaceManagerInterface {
21
22  use StringTranslationTrait;
23
24  /**
25   * An array of entity type IDs that can not belong to a workspace.
26   *
27   * By default, only entity types which are revisionable and publishable can
28   * belong to a workspace.
29   *
30   * @var string[]
31   */
32  protected $blacklist = [
33    'workspace_association' => 'workspace_association',
34    'workspace' => 'workspace',
35  ];
36
37  /**
38   * The request stack.
39   *
40   * @var \Symfony\Component\HttpFoundation\RequestStack
41   */
42  protected $requestStack;
43
44  /**
45   * The entity type manager.
46   *
47   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
48   */
49  protected $entityTypeManager;
50
51  /**
52   * The entity memory cache service.
53   *
54   * @var \Drupal\Core\Cache\MemoryCache\MemoryCacheInterface
55   */
56  protected $entityMemoryCache;
57
58  /**
59   * The current user.
60   *
61   * @var \Drupal\Core\Session\AccountProxyInterface
62   */
63  protected $currentUser;
64
65  /**
66   * The state service.
67   *
68   * @var \Drupal\Core\State\StateInterface
69   */
70  protected $state;
71
72  /**
73   * A logger instance.
74   *
75   * @var \Psr\Log\LoggerInterface
76   */
77  protected $logger;
78
79  /**
80   * The class resolver.
81   *
82   * @var \Drupal\Core\DependencyInjection\ClassResolverInterface
83   */
84  protected $classResolver;
85
86  /**
87   * The workspace association service.
88   *
89   * @var \Drupal\workspaces\WorkspaceAssociationInterface
90   */
91  protected $workspaceAssociation;
92
93  /**
94   * The workspace negotiator service IDs.
95   *
96   * @var array
97   */
98  protected $negotiatorIds;
99
100  /**
101   * The current active workspace or FALSE if there is no active workspace.
102   *
103   * @var \Drupal\workspaces\WorkspaceInterface|false
104   */
105  protected $activeWorkspace;
106
107  /**
108   * Constructs a new WorkspaceManager.
109   *
110   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
111   *   The request stack.
112   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
113   *   The entity type manager.
114   * @param \Drupal\Core\Cache\MemoryCache\MemoryCacheInterface $entity_memory_cache
115   *   The entity memory cache service.
116   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
117   *   The current user.
118   * @param \Drupal\Core\State\StateInterface $state
119   *   The state service.
120   * @param \Psr\Log\LoggerInterface $logger
121   *   A logger instance.
122   * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
123   *   The class resolver.
124   * @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association
125   *   The workspace association service.
126   * @param array $negotiator_ids
127   *   The workspace negotiator service IDs.
128   */
129  public function __construct(RequestStack $request_stack, EntityTypeManagerInterface $entity_type_manager, MemoryCacheInterface $entity_memory_cache, AccountProxyInterface $current_user, StateInterface $state, LoggerInterface $logger, ClassResolverInterface $class_resolver, WorkspaceAssociationInterface $workspace_association, array $negotiator_ids) {
130    $this->requestStack = $request_stack;
131    $this->entityTypeManager = $entity_type_manager;
132    $this->entityMemoryCache = $entity_memory_cache;
133    $this->currentUser = $current_user;
134    $this->state = $state;
135    $this->logger = $logger;
136    $this->classResolver = $class_resolver;
137    $this->workspaceAssociation = $workspace_association;
138    $this->negotiatorIds = $negotiator_ids;
139  }
140
141  /**
142   * {@inheritdoc}
143   */
144  public function isEntityTypeSupported(EntityTypeInterface $entity_type) {
145    // First, check if we already determined whether this entity type is
146    // supported or not.
147    if (isset($this->blacklist[$entity_type->id()])) {
148      return FALSE;
149    }
150
151    if ($entity_type->entityClassImplements(EntityPublishedInterface::class) && $entity_type->isRevisionable()) {
152      return TRUE;
153    }
154
155    // This entity type can not belong to a workspace, add it to the blacklist.
156    $this->blacklist[$entity_type->id()] = $entity_type->id();
157    return FALSE;
158  }
159
160  /**
161   * {@inheritdoc}
162   */
163  public function getSupportedEntityTypes() {
164    $entity_types = [];
165    foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) {
166      if ($this->isEntityTypeSupported($entity_type)) {
167        $entity_types[$entity_type_id] = $entity_type;
168      }
169    }
170    return $entity_types;
171  }
172
173  /**
174   * {@inheritdoc}
175   */
176  public function hasActiveWorkspace() {
177    return $this->getActiveWorkspace() !== FALSE;
178  }
179
180  /**
181   * {@inheritdoc}
182   */
183  public function getActiveWorkspace() {
184    if (!isset($this->activeWorkspace)) {
185      $request = $this->requestStack->getCurrentRequest();
186      foreach ($this->negotiatorIds as $negotiator_id) {
187        $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
188        if ($negotiator->applies($request)) {
189          // By default, 'view' access is checked when a workspace is activated,
190          // but it should also be checked when retrieving the currently active
191          // workspace.
192          if (($negotiated_workspace = $negotiator->getActiveWorkspace($request)) && $negotiated_workspace->access('view')) {
193            $active_workspace = $negotiated_workspace;
194            break;
195          }
196        }
197      }
198
199      // If no negotiator was able to determine the active workspace, default to
200      // the live version of the site.
201      $this->activeWorkspace = $active_workspace ?? FALSE;
202    }
203
204    return $this->activeWorkspace;
205  }
206
207  /**
208   * {@inheritdoc}
209   */
210  public function setActiveWorkspace(WorkspaceInterface $workspace) {
211    $this->doSwitchWorkspace($workspace);
212
213    // Set the workspace on the proper negotiator.
214    $request = $this->requestStack->getCurrentRequest();
215    foreach ($this->negotiatorIds as $negotiator_id) {
216      $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
217      if ($negotiator->applies($request)) {
218        $negotiator->setActiveWorkspace($workspace);
219        break;
220      }
221    }
222
223    return $this;
224  }
225
226  /**
227   * {@inheritdoc}
228   */
229  public function switchToLive() {
230    $this->doSwitchWorkspace(NULL);
231
232    // Unset the active workspace on all negotiators.
233    foreach ($this->negotiatorIds as $negotiator_id) {
234      $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
235      $negotiator->unsetActiveWorkspace();
236    }
237
238    return $this;
239  }
240
241  /**
242   * Switches the current workspace.
243   *
244   * @param \Drupal\workspaces\WorkspaceInterface|null $workspace
245   *   The workspace to set as active or NULL to switch out of the currently
246   *   active workspace.
247   *
248   * @throws \Drupal\workspaces\WorkspaceAccessException
249   *   Thrown when the current user doesn't have access to view the workspace.
250   */
251  protected function doSwitchWorkspace($workspace) {
252    // If the current user doesn't have access to view the workspace, they
253    // shouldn't be allowed to switch to it.
254    if ($workspace && !$workspace->access('view')) {
255      $this->logger->error('Denied access to view workspace %workspace_label for user %uid', [
256        '%workspace_label' => $workspace->label(),
257        '%uid' => $this->currentUser->id(),
258      ]);
259      throw new WorkspaceAccessException('The user does not have permission to view that workspace.');
260    }
261
262    $this->activeWorkspace = $workspace ?: FALSE;
263
264    // Clear the static entity cache for the supported entity types.
265    $cache_tags_to_invalidate = array_map(function ($entity_type_id) {
266      return 'entity.memory_cache:' . $entity_type_id;
267    }, array_keys($this->getSupportedEntityTypes()));
268    $this->entityMemoryCache->invalidateTags($cache_tags_to_invalidate);
269
270    // Clear the static cache for path aliases. We can't inject the path alias
271    // manager service because it would create a circular dependency.
272    \Drupal::service('path_alias.manager')->cacheClear();
273  }
274
275  /**
276   * {@inheritdoc}
277   */
278  public function executeInWorkspace($workspace_id, callable $function) {
279    /** @var \Drupal\workspaces\WorkspaceInterface $workspace */
280    $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id);
281
282    if (!$workspace) {
283      throw new \InvalidArgumentException('The ' . $workspace_id . ' workspace does not exist.');
284    }
285
286    $previous_active_workspace = $this->getActiveWorkspace();
287    $this->doSwitchWorkspace($workspace);
288    $result = $function();
289    $this->doSwitchWorkspace($previous_active_workspace);
290
291    return $result;
292  }
293
294  /**
295   * {@inheritdoc}
296   */
297  public function executeOutsideWorkspace(callable $function) {
298    $previous_active_workspace = $this->getActiveWorkspace();
299    $this->doSwitchWorkspace(NULL);
300    $result = $function();
301    $this->doSwitchWorkspace($previous_active_workspace);
302
303    return $result;
304  }
305
306  /**
307   * {@inheritdoc}
308   */
309  public function shouldAlterOperations(EntityTypeInterface $entity_type) {
310    return $this->isEntityTypeSupported($entity_type) && $this->hasActiveWorkspace();
311  }
312
313  /**
314   * {@inheritdoc}
315   */
316  public function purgeDeletedWorkspacesBatch() {
317    $deleted_workspace_ids = $this->state->get('workspace.deleted', []);
318
319    // Bail out early if there are no workspaces to purge.
320    if (empty($deleted_workspace_ids)) {
321      return;
322    }
323
324    $batch_size = Settings::get('entity_update_batch_size', 50);
325
326    // Get the first deleted workspace from the list and delete the revisions
327    // associated with it, along with the workspace association records.
328    $workspace_id = reset($deleted_workspace_ids);
329    $tracked_entities = $this->workspaceAssociation->getTrackedEntities($workspace_id);
330
331    $count = 1;
332    foreach ($tracked_entities as $entity_type_id => $entities) {
333      $associated_entity_storage = $this->entityTypeManager->getStorage($entity_type_id);
334      $associated_revisions = $this->workspaceAssociation->getAssociatedRevisions($workspace_id, $entity_type_id);
335      foreach (array_keys($associated_revisions) as $revision_id) {
336        if ($count > $batch_size) {
337          continue 2;
338        }
339
340        // Delete the associated entity revision.
341        $associated_entity_storage->deleteRevision($revision_id);
342        $count++;
343      }
344      // Delete the workspace association entries.
345      $this->workspaceAssociation->deleteAssociations($workspace_id, $entity_type_id, $entities);
346    }
347
348    // The purging operation above might have taken a long time, so we need to
349    // request a fresh list of tracked entities. If it is empty, we can go ahead
350    // and remove the deleted workspace ID entry from state.
351    if (!$this->workspaceAssociation->getTrackedEntities($workspace_id)) {
352      unset($deleted_workspace_ids[$workspace_id]);
353      $this->state->set('workspace.deleted', $deleted_workspace_ids);
354    }
355  }
356
357}
358