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