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