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