From 5f1e296ca123788d14beb2162c50431ddee2551d Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Mon, 17 Jun 2024 09:25:16 +0200 Subject: [PATCH 01/43] FEATURE: Extract workspace metadata and user-assignment to Neos Introduces `WorkspacePublishingService` as replacement for the current Neos `Workspace` "active record" model. Introduces `WorkspaceService` as central authority to manage Neos workspaces. Related: #4726 --- .../SharedModel/Workspace/WorkspaceName.php | 25 +- .../Classes/ContentRepositoryRegistry.php | 8 + .../Command/WorkspaceCommandController.php | 88 +++-- .../{Workspace => Model}/DiscardingResult.php | 2 +- .../{Workspace => Model}/PublishingResult.php | 4 +- Neos.Neos/Classes/Domain/Model/User.php | 10 + Neos.Neos/Classes/Domain/Model/UserId.php | 36 ++ .../Classes/Domain/Model/UserInterface.php | 6 + .../Domain/Model/WorkspaceClassification.php | 17 + .../Domain/Model/WorkspaceDescription.php | 44 +++ .../Domain/Model/WorkspaceMetadata.php | 23 ++ .../Domain/Model/WorkspacePermissions.php | 29 ++ .../Classes/Domain/Model/WorkspaceTitle.php | 39 ++ .../Classes/Domain/Service/UserService.php | 133 +------ .../WorkspacePublishingService.php} | 361 ++++++++---------- .../Domain/Service/WorkspaceService.php | 349 +++++++++++++++++ .../Domain/Workspace/DiscardAllChanges.php | 45 --- .../Domain/Workspace/PublishAllChanges.php | 45 --- .../Domain/Workspace/WorkspaceProvider.php | 77 ---- .../Service/EditorContentStreamZookeeper.php | 63 +-- .../Classes/UserIdProvider/UserIdProvider.php | 6 +- .../Backend/ChangeStatsViewHelper.php | 65 ---- .../Mysql/Version20240425223900.php | 50 +++ .../Postgresql/Version20240425223901.php | 44 +++ .../Controller/WorkspaceController.php | 254 +++++------- .../WorkspaceControllerInternals.php | 38 -- .../WorkspaceControllerInternalsFactory.php | 32 -- .../Classes/ViewModel/PendingChanges.php | 46 +++ .../Classes/ViewModel/WorkspaceListItem.php | 34 ++ .../Classes/ViewModel/WorkspaceListItems.php | 52 +++ .../Private/Templates/Workspace/Index.html | 242 ++++++------ .../Private/Templates/Workspace/New.html | 31 +- .../Private/Translations/en/Main.xlf | 3 + 33 files changed, 1265 insertions(+), 1036 deletions(-) rename Neos.Neos/Classes/Domain/{Workspace => Model}/DiscardingResult.php (94%) rename Neos.Neos/Classes/Domain/{Workspace => Model}/PublishingResult.php (79%) create mode 100644 Neos.Neos/Classes/Domain/Model/UserId.php create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspaceDescription.php create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspaceTitle.php rename Neos.Neos/Classes/Domain/{Workspace/Workspace.php => Service/WorkspacePublishingService.php} (51%) create mode 100644 Neos.Neos/Classes/Domain/Service/WorkspaceService.php delete mode 100644 Neos.Neos/Classes/Domain/Workspace/DiscardAllChanges.php delete mode 100644 Neos.Neos/Classes/Domain/Workspace/PublishAllChanges.php delete mode 100644 Neos.Neos/Classes/Domain/Workspace/WorkspaceProvider.php delete mode 100644 Neos.Neos/Classes/ViewHelpers/Backend/ChangeStatsViewHelper.php create mode 100644 Neos.Neos/Migrations/Mysql/Version20240425223900.php create mode 100644 Neos.Neos/Migrations/Postgresql/Version20240425223901.php delete mode 100644 Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternals.php delete mode 100644 Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternalsFactory.php create mode 100644 Neos.Workspace.Ui/Classes/ViewModel/PendingChanges.php create mode 100644 Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItem.php create mode 100644 Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php index 37ac2fc8c24..5725d3fdb89 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php @@ -23,6 +23,8 @@ */ final class WorkspaceName implements \JsonSerializable { + private const PATTERN = '/^[a-z0-9\-]{1,30}$/'; + public const WORKSPACE_NAME_LIVE = 'live'; /** @@ -33,7 +35,7 @@ final class WorkspaceName implements \JsonSerializable private function __construct( public readonly string $value ) { - if (preg_match('/^[\p{L}\p{P}\d \.]{1,200}$/u', $value) !== 1) { + if (!self::hasValidFormat($value)) { throw new \InvalidArgumentException('Invalid workspace name given.', 1505826610318); } } @@ -48,6 +50,11 @@ public static function fromString(string $value): self return self::instance($value); } + public static function tryFromString(string $value): ?self + { + return self::hasValidFormat($value) ? self::instance($value) : null; + } + public static function forLive(): self { return self::instance(self::WORKSPACE_NAME_LIVE); @@ -61,19 +68,16 @@ public static function forLive(): self */ public static function transliterateFromString(string $name): self { - try { - // Check if name already match name pattern to prevent unnecessary transliteration + if (self::hasValidFormat($name)) { return self::fromString($name); - } catch (\InvalidArgumentException $e) { - // Okay, let's transliterate } - $originalName = $name; + $originalName = strtolower($name); // Transliterate (transform 北京 to 'Bei Jing') $name = Transliterator::transliterate($name); - // Urlization (replace spaces with dash, special special characters) + // Urlization (replace spaces with dash, special characters) $name = Transliterator::urlize($name); // Ensure only allowed characters are left @@ -84,7 +88,7 @@ public static function transliterateFromString(string $name): self $name = 'workspace-' . strtolower(md5($originalName)); } - return new self($name); + return self::fromString($name); } public function isLive(): bool @@ -101,4 +105,9 @@ public function equals(self $other): bool { return $this === $other; } + + private static function hasValidFormat(string $value): bool + { + return preg_match(self::PATTERN, $value) === 1; + } } diff --git a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php index 1fd7cc04910..4912e4e29e5 100644 --- a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php +++ b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php @@ -81,6 +81,14 @@ public function get(ContentRepositoryId $contentRepositoryId): ContentRepository return $this->getFactory($contentRepositoryId)->getOrBuild(); } + /** + * @return array + */ + public function getContentRepositoryIds(): array + { + return array_map(ContentRepositoryId::fromString(...), array_keys($this->settings['contentRepositories'])); + } + /** * @internal for test cases only */ diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index 664bbc87914..4c030b1b790 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -36,8 +36,10 @@ use Neos\Flow\Cli\CommandController; use Neos\Flow\Cli\Exception\StopCommandException; use Neos\Flow\Persistence\PersistenceManagerInterface; +use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Service\UserService; -use Neos\Neos\Domain\Workspace\WorkspaceProvider; +use Neos\Neos\Domain\Service\WorkspaceService; +use Neos\Neos\Domain\Service\WorkspacePublishingService; use Neos\Neos\PendingChangesProjection\ChangeFinder; /** @@ -56,7 +58,10 @@ class WorkspaceCommandController extends CommandController protected ContentRepositoryRegistry $contentRepositoryRegistry; #[Flow\Inject] - protected WorkspaceProvider $workspaceProvider; + protected WorkspacePublishingService $workspacePublishingService; + + #[Flow\Inject] + protected WorkspaceService $workspaceService; /** * Publish changes of a workspace @@ -68,16 +73,14 @@ class WorkspaceCommandController extends CommandController */ public function publishCommand(string $workspace, string $contentRepository = 'default'): void { - // @todo: bypass access control - $workspace = $this->workspaceProvider->provideForWorkspaceName( + $this->workspacePublishingService->publishWorkspace( ContentRepositoryId::fromString($contentRepository), WorkspaceName::fromString($workspace) ); - $workspace->publishAllChanges(); $this->outputLine( 'Published all nodes in workspace %s to its base workspace', - [$workspace->name->value] + [$workspace] ); } @@ -92,19 +95,17 @@ public function publishCommand(string $workspace, string $contentRepository = 'd */ public function discardCommand(string $workspace, string $contentRepository = 'default'): void { - // @todo: bypass access control - $workspace = $this->workspaceProvider->provideForWorkspaceName( - ContentRepositoryId::fromString($contentRepository), - WorkspaceName::fromString($workspace) - ); - try { - $workspace->discardAllChanges(); + // @todo: bypass access control + $this->workspacePublishingService->discardAllWorkspaceChanges( + ContentRepositoryId::fromString($contentRepository), + WorkspaceName::fromString($workspace) + ); } catch (WorkspaceDoesNotExist $exception) { - $this->outputLine('Workspace "%s" does not exist', [$workspace->name->value]); + $this->outputLine('Workspace "%s" does not exist', [$workspace]); $this->quit(1); } - $this->outputLine('Discarded all nodes in workspace %s', [$workspace->name->value]); + $this->outputLine('Discarded all nodes in workspace %s', [$workspace]); } /** @@ -121,11 +122,11 @@ public function rebaseCommand(string $workspace, string $contentRepository = 'de { try { // @todo: bypass access control - $workspace = $this->workspaceProvider->provideForWorkspaceName( + $this->workspacePublishingService->rebaseWorkspace( ContentRepositoryId::fromString($contentRepository), - WorkspaceName::fromString($workspace) + WorkspaceName::fromString($workspace), + $force ? RebaseErrorHandlingStrategy::STRATEGY_FORCE : RebaseErrorHandlingStrategy::STRATEGY_FAIL, ); - $workspace->rebase($force ? RebaseErrorHandlingStrategy::STRATEGY_FORCE : RebaseErrorHandlingStrategy::STRATEGY_FAIL); } catch (WorkspaceDoesNotExist $exception) { $this->outputLine('Workspace "%s" does not exist', [$workspace]); $this->quit(1); @@ -134,7 +135,7 @@ public function rebaseCommand(string $workspace, string $contentRepository = 'de $this->quit(1); } - $this->outputLine('Rebased workspace %s', [$workspace->name->value]); + $this->outputLine('Rebased workspace %s', [$workspace]); } /** @@ -178,13 +179,23 @@ public function createCommand( string $contentRepository = 'default' ): void { $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + + $this->workspaceService->createWorkspace( + $contentRepositoryId, + \Neos\Neos\Domain\Model\WorkspaceTitle::fromString($title), + \Neos\Neos\Domain\Model\WorkspaceDescription::fromString($description ?? ''), + WorkspaceName::fromString($baseWorkspace), + \Neos\Neos\Domain\Model\UserId::fromString($owner), + WorkspaceClassification::PERSONAL, + ); + $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); if ($owner === '') { $workspaceOwnerUserId = null; } else { $workspaceOwnerUserId = UserId::fromString($owner); - $workspaceOwner = $this->userService->findByUserIdentifier($workspaceOwnerUserId); + $workspaceOwner = $this->userService->findUserById($workspaceOwnerUserId); if ($workspaceOwner === null) { $this->outputLine('The user "%s" specified as owner does not exist', [$owner]); $this->quit(3); @@ -243,13 +254,14 @@ public function deleteCommand(string $workspace, bool $force = false, string $co $this->quit(2); } - $workspace = $contentRepositoryInstance->getWorkspaceFinder()->findOneByName($workspaceName); - if (!$workspace instanceof Workspace) { + $crWorkspace = $contentRepositoryInstance->getWorkspaceFinder()->findOneByName($workspaceName); + if ($crWorkspace === null) { $this->outputLine('Workspace "%s" does not exist', [$workspaceName->value]); $this->quit(1); } + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspaceName); - if ($workspace->isPersonalWorkspace()) { + if ($workspaceMetadata->classification === WorkspaceClassification::PERSONAL) { $this->outputLine( 'Did not delete workspace "%s" because it is a personal workspace.' . ' Personal workspaces cannot be deleted manually.', @@ -264,28 +276,28 @@ public function deleteCommand(string $workspace, bool $force = false, string $co 'Workspace "%s" cannot be deleted because the following workspaces are based on it:', [$workspaceName->value] ); + $this->outputLine(); $tableRows = []; $headerRow = ['Name', 'Title', 'Description']; foreach ($dependentWorkspaces as $dependentWorkspace) { + $dependentWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $dependentWorkspace->workspaceName); $tableRows[] = [ $dependentWorkspace->workspaceName->value, - $dependentWorkspace->workspaceTitle->value, - $dependentWorkspace->workspaceDescription->value + $dependentWorkspaceMetadata->title->value, + $dependentWorkspaceMetadata->description->value ]; } $this->output->outputTable($tableRows, $headerRow); $this->quit(3); } + try { - $nodesCount = $contentRepositoryInstance->projectionState(ChangeFinder::class) - ->countByContentStreamId( - $workspace->currentContentStreamId - ); + $nodesCount = $this->workspacePublishingService->countPendingWorkspaceChanges($contentRepositoryId, $workspaceName); } catch (\Exception $exception) { - $this->outputLine('Could not fetch unpublished nodes for workspace %s, nothing was deleted. %s', [$workspace->workspaceName->value, $exception->getMessage()]); + $this->outputLine('Could not fetch unpublished nodes for workspace %s, nothing was deleted. %s', [$workspaceName->value, $exception->getMessage()]); $this->quit(4); } @@ -299,11 +311,7 @@ public function deleteCommand(string $workspace, bool $force = false, string $co $this->quit(5); } // @todo bypass access control? - $workspace = $this->workspaceProvider->provideForWorkspaceName( - $contentRepositoryId, - $workspaceName - ); - $workspace->discardAllChanges(); + $this->workspacePublishingService->discardAllWorkspaceChanges($contentRepositoryId, $workspaceName); } $contentRepositoryInstance->handle( @@ -359,16 +367,18 @@ public function listCommand(string $contentRepository = 'default'): void } $tableRows = []; - $headerRow = ['Name', 'Base Workspace', 'Title', 'Owner', 'Description', 'Status', 'Content Stream']; + $headerRow = ['Name', 'Classification', 'Base Workspace', 'Title', 'Description', 'Status', 'Content Stream']; foreach ($workspaces as $workspace) { + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); + /* @var Workspace $workspace */ $tableRows[] = [ $workspace->workspaceName->value, + $workspaceMetadata->classification->name, $workspace->baseWorkspaceName?->value ?: '', - $workspace->workspaceTitle->value, - $workspace->workspaceOwner ?: '', - $workspace->workspaceDescription->value, + $workspaceMetadata->title->value, + $workspaceMetadata->description->value, $workspace->status->value, $workspace->currentContentStreamId->value, ]; diff --git a/Neos.Neos/Classes/Domain/Workspace/DiscardingResult.php b/Neos.Neos/Classes/Domain/Model/DiscardingResult.php similarity index 94% rename from Neos.Neos/Classes/Domain/Workspace/DiscardingResult.php rename to Neos.Neos/Classes/Domain/Model/DiscardingResult.php index b6d448206d9..af1fb4d09c1 100644 --- a/Neos.Neos/Classes/Domain/Workspace/DiscardingResult.php +++ b/Neos.Neos/Classes/Domain/Model/DiscardingResult.php @@ -12,7 +12,7 @@ declare(strict_types=1); -namespace Neos\Neos\Domain\Workspace; +namespace Neos\Neos\Domain\Model; use Neos\Flow\Annotations as Flow; diff --git a/Neos.Neos/Classes/Domain/Workspace/PublishingResult.php b/Neos.Neos/Classes/Domain/Model/PublishingResult.php similarity index 79% rename from Neos.Neos/Classes/Domain/Workspace/PublishingResult.php rename to Neos.Neos/Classes/Domain/Model/PublishingResult.php index 8e7215216da..256f5757c1a 100644 --- a/Neos.Neos/Classes/Domain/Workspace/PublishingResult.php +++ b/Neos.Neos/Classes/Domain/Model/PublishingResult.php @@ -12,8 +12,9 @@ declare(strict_types=1); -namespace Neos\Neos\Domain\Workspace; +namespace Neos\Neos\Domain\Model; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Flow\Annotations as Flow; /** @@ -26,6 +27,7 @@ { public function __construct( public int $numberOfPublishedChanges, + public WorkspaceName $targetWorkspaceName, ) { } } diff --git a/Neos.Neos/Classes/Domain/Model/User.php b/Neos.Neos/Classes/Domain/Model/User.php index c7954eb5b90..331636a9f5f 100644 --- a/Neos.Neos/Classes/Domain/Model/User.php +++ b/Neos.Neos/Classes/Domain/Model/User.php @@ -36,6 +36,11 @@ class User extends Person implements UserInterface */ protected $preferences; + /** + * @var string + */ + protected $Persistence_Object_Identifier; + /** * Constructs this User object */ @@ -45,6 +50,11 @@ public function __construct() $this->preferences = new UserPreferences(); } + public function getId(): UserId + { + return UserId::fromString($this->Persistence_Object_Identifier); + } + /** * Returns a label which can be used as a human-friendly identifier for this user. * diff --git a/Neos.Neos/Classes/Domain/Model/UserId.php b/Neos.Neos/Classes/Domain/Model/UserId.php new file mode 100644 index 00000000000..2011ebb9a56 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/UserId.php @@ -0,0 +1,36 @@ +value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } +} diff --git a/Neos.Neos/Classes/Domain/Model/UserInterface.php b/Neos.Neos/Classes/Domain/Model/UserInterface.php index bc451ca8936..b6cc5b33692 100644 --- a/Neos.Neos/Classes/Domain/Model/UserInterface.php +++ b/Neos.Neos/Classes/Domain/Model/UserInterface.php @@ -21,6 +21,12 @@ */ interface UserInterface { + + /** + * Returns the globally unique identifier for this user + */ + public function getId(): UserId; + /** * Returns a label which can be used as a human-friendly identifier for this user, for example his or her first * and last name. diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php b/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php new file mode 100644 index 00000000000..47f162a0ff3 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php @@ -0,0 +1,17 @@ +value) !== 1) { + throw new \InvalidArgumentException('Invalid workspace description given.', 1505831660363); + } + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public static function empty(): self + { + return new self(''); + } + + public function jsonSerialize(): string + { + return $this->value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } +} diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php new file mode 100644 index 00000000000..de693c701e0 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php @@ -0,0 +1,23 @@ +value) !== 1) { + throw new \InvalidArgumentException('Invalid workspace title given.', 1505827170288); + } + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function jsonSerialize(): string + { + return $this->value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } +} diff --git a/Neos.Neos/Classes/Domain/Service/UserService.php b/Neos.Neos/Classes/Domain/Service/UserService.php index bab470d18b0..c842a5b22ca 100644 --- a/Neos.Neos/Classes/Domain/Service/UserService.php +++ b/Neos.Neos/Classes/Domain/Service/UserService.php @@ -15,7 +15,6 @@ namespace Neos\Neos\Domain\Service; use Neos\ContentRepository\Core\Projection\Workspace\Workspace; -use Neos\ContentRepository\Core\SharedModel\User\UserId; use Neos\Flow\Annotations as Flow; use Neos\Flow\Persistence\Exception\IllegalObjectTypeException; use Neos\Flow\Persistence\PersistenceManagerInterface; @@ -40,6 +39,7 @@ use Neos\Flow\Utility\Now; use Neos\Neos\Domain\Exception; use Neos\Neos\Domain\Model\User; +use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Repository\UserRepository; use Neos\Party\Domain\Model\AbstractParty; use Neos\Party\Domain\Model\PersonName; @@ -247,39 +247,20 @@ public function getCurrentUser(): ?User return null; } - $tokens = $this->securityContext->getAuthenticationTokens(); - $user = array_reduce($tokens, function ($foundUser, TokenInterface $token) { - if ($foundUser !== null) { - return $foundUser; - } - - /** @var ?Account $account */ + foreach ($this->securityContext->getAuthenticationTokens() as $token) { $account = $token->getAccount(); if ($account === null) { - return $foundUser; + continue; } - $user = $this->getNeosUserForAccount($account); - if ($user === null) { - return $foundUser; + if ($user !== null) { + return $user; } - - return $user; - }, null); - - return $user; - } - - public function getCurrentUserIdentifier(): ?UserId - { - $currentUser = $this->getCurrentUser(); - - return $currentUser - ? UserId::fromString($this->persistenceManager->getIdentifierByObject($currentUser)) - : null; + } + return null; } - public function findByUserIdentifier(UserId $userId): ?User + public function findUserById(UserId $userId): ?User { /** @var ?User $user */ $user = $this->partyRepository->findByIdentifier($userId->value); @@ -677,104 +658,6 @@ public function deactivateUser(User $user): void $this->emitUserDeactivated($user); } - /** - * Checks if the current user may publish to the given workspace according to one the roles of the user's accounts - * - * In future versions, this logic may be implemented in Neos in a more generic way (for example, by means of an - * ACL object), but for now, this method exists in order to at least centralize and encapsulate the required logic. - */ - public function currentUserCanPublishToWorkspace(Workspace $workspace): bool - { - if ($workspace->isPublicWorkspace()) { - return $this->securityContext->hasRole('Neos.Neos:LivePublisher'); - } - - $currentUser = $this->getCurrentUser(); - $ownerIdentifier = $currentUser - ? $this->persistenceManager->getIdentifierByObject($currentUser) - : null; - - if ($workspace->workspaceOwner === null || $workspace->workspaceOwner === $ownerIdentifier) { - return true; - } - - return false; - } - - /** - * Checks if the current user may read the given workspace according to one the roles of the user's accounts - * - * In future versions, this logic may be implemented in Neos in a more generic way (for example, by means of an - * ACL object), but for now, this method exists in order to at least centralize and encapsulate the required logic. - */ - public function currentUserCanReadWorkspace(Workspace $workspace): bool - { - if ($workspace->isPublicWorkspace()) { - return true; - } - - $currentUser = $this->getCurrentUser(); - - return $currentUser && $workspace->workspaceOwner - === $this->persistenceManager->getIdentifierByObject($currentUser); - } - - /** - * Checks if the current user may manage the given workspace according to one the roles of the user's accounts - * - * In future versions, this logic may be implemented in Neos in a more generic way (for example, by means of an - * ACL object), but for now, this method exists in order to at least centralize and encapsulate the required logic. - */ - public function currentUserCanManageWorkspace(Workspace $workspace): bool - { - if ($workspace->isPersonalWorkspace()) { - return false; - } - - if ($workspace->isInternalWorkspace()) { - return $this->privilegeManager->isPrivilegeTargetGranted( - 'Neos.Neos:Backend.Module.Management.Workspaces.ManageInternalWorkspaces' - ); - } - - - $currentUser = $this->getCurrentUser(); - if ($workspace->isPrivateWorkspace() && $currentUser !== null && $workspace->workspaceOwner === $this->persistenceManager->getIdentifierByObject($currentUser)) { - return $this->privilegeManager->isPrivilegeTargetGranted( - 'Neos.Neos:Backend.Module.Management.Workspaces.ManageOwnWorkspaces' - ); - } - - if ($workspace->isPrivateWorkspace() && $currentUser !== null && $workspace->workspaceOwner !== $this->persistenceManager->getIdentifierByObject($currentUser)) { - return $this->privilegeManager->isPrivilegeTargetGranted( - 'Neos.Neos:Backend.Module.Management.Workspaces.ManageAllPrivateWorkspaces' - ); - } - - return false; - } - - - /** - * Checks if the current user may transfer ownership of the given workspace - * - * In future versions, this logic may be implemented in Neos in a more generic way (for example, by means of an - * ACL object), but for now, this method exists in order to at least centralize and encapsulate the required logic. - */ - public function currentUserCanTransferOwnershipOfWorkspace(Workspace $workspace): bool - { - if ($workspace->isPersonalWorkspace()) { - return false; - } - - // The privilege to manage shared workspaces is needed, because regular editors should not change ownerships - // of their internal workspaces, even if it was technically possible, because they wouldn't be able to change - // ownership back to themselves. - return $this->privilegeManager->isPrivilegeTargetGranted( - 'Neos.Neos:Backend.Module.Management.Workspaces.ManageInternalWorkspaces' - ); - } - /** * @return bool * @throws NoSuchRoleException diff --git a/Neos.Neos/Classes/Domain/Workspace/Workspace.php b/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php similarity index 51% rename from Neos.Neos/Classes/Domain/Workspace/Workspace.php rename to Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php index 9d3bd125be8..78a71898036 100644 --- a/Neos.Neos/Classes/Domain/Workspace/Workspace.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspacePublishingService.php @@ -12,7 +12,7 @@ declare(strict_types=1); -namespace Neos\Neos\Domain\Workspace; +namespace Neos\Neos\Domain\Service; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeBaseWorkspace; @@ -22,334 +22,285 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdsToPublishOrDiscard; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdToPublishOrDiscard; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindClosestNodeFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\NodeAggregate; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; -use Neos\ContentRepository\Core\Projection\Workspace\WorkspaceStatus; +use Neos\ContentRepository\Core\Projection\Workspace\Workspace as ContentRepositoryWorkspace; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregateCurrentlyDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; +use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; -use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\Neos\Domain\Service\NodeTypeNameFactory; +use Neos\Neos\Domain\Model\DiscardingResult; +use Neos\Neos\Domain\Model\PublishingResult; use Neos\Neos\PendingChangesProjection\Change; use Neos\Neos\PendingChangesProjection\ChangeFinder; /** - * Neos' workspace model - * - * Provides a high-level API to evaluate, publish or discard changes in a given workspace. - * Uses the low-level content repository workspace read model for information retrieval, - * {@see \Neos\ContentRepository\Core\Projection\Workspace\Workspace} - * - * This model is mutable and will for example update itself after publishing, or changing the base workspace. - * Mutations in the content repository that are not triggered by this model (by using the low level API) will not be reflected. + * Central authority for publishing/discarding workspace changes from Neos * * @api */ -#[Flow\Proxy(false)] -final class Workspace +#[Flow\Scope('singleton')] +final class WorkspacePublishingService { - public readonly ContentRepositoryId $contentRepositoryId; - - /** @internal please use the {@see WorkspaceProvider} instead */ public function __construct( - public readonly WorkspaceName $name, - private ContentStreamId $currentContentStreamId, - private WorkspaceStatus $currentStatus, - private ?WorkspaceName $currentBaseWorkspaceName, - private readonly ContentRepository $contentRepository, + private readonly ContentRepositoryRegistry $contentRepositoryRegistry, ) { - $this->contentRepositoryId = $contentRepository->id; - } - - public function getCurrentStatus(): WorkspaceStatus - { - return $this->currentStatus; - } - - public function getCurrentBaseWorkspaceName(): ?WorkspaceName - { - return $this->currentBaseWorkspaceName; - } - - /** @internal experimental api, until actually used by the Neos.Ui */ - public function countAllChanges(): int - { - $changeFinder = $this->contentRepository->projectionState(ChangeFinder::class); - $changes = $changeFinder->findByContentStreamId($this->currentContentStreamId); - - return count($changes); } /** @internal experimental api, until actually used by the Neos.Ui */ - public function countChangesInSite(NodeAggregateId $siteId): int + public function countPendingWorkspaceChanges(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): int { - $ancestorNodeTypeName = NodeTypeNameFactory::forSite(); - $this->requireNodeToBeOfType( - $siteId, - $ancestorNodeTypeName - ); - - $changes = $this->resolveNodeIdsToPublishOrDiscard( - $siteId, - $ancestorNodeTypeName - ); - - return count($changes); + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + return count($this->pendingWorkspaceChanges($contentRepository, $workspaceName)); } - /** @internal experimental api, until actually used by the Neos.Ui */ - public function countChangesInDocument(NodeAggregateId $documentId): int - { - $ancestorNodeTypeName = NodeTypeNameFactory::forDocument(); - $this->requireNodeToBeOfType( - $documentId, - $ancestorNodeTypeName - ); - $changes = $this->resolveNodeIdsToPublishOrDiscard( - $documentId, - $ancestorNodeTypeName - ); - - return count($changes); - } - - /** @internal should not be necessary in user land code */ - public function getCurrentContentStreamId(): ContentStreamId + /** + * @throws WorkspaceDoesNotExist | WorkspaceRebaseFailed + */ + public function rebaseWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, RebaseErrorHandlingStrategy $rebaseErrorHandlingStrategy = RebaseErrorHandlingStrategy::STRATEGY_FAIL): void { - return $this->currentContentStreamId; + $rebaseCommand = RebaseWorkspace::create($workspaceName)->withErrorHandlingStrategy($rebaseErrorHandlingStrategy); + $this->contentRepositoryRegistry->get($contentRepositoryId)->handle($rebaseCommand); } - public function publishAllChanges(): PublishingResult + public function publishWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): PublishingResult { - $changeFinder = $this->contentRepository->projectionState(ChangeFinder::class); - $changes = $changeFinder->findByContentStreamId($this->currentContentStreamId); - - $this->publish(); - - return new PublishingResult( - count($changes) - ); + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $crWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + if ($crWorkspace->baseWorkspaceName === null) { + throw new \InvalidArgumentException(sprintf('Failed to publish workspace "%s" because it has no base workspace', $workspaceName->value), 1717517124); + } + $numberOfPendingChanges = $this->pendingWorkspaceChanges($contentRepository, $workspaceName); + $this->contentRepositoryRegistry->get($contentRepositoryId)->handle(PublishWorkspace::create($workspaceName)); + return new PublishingResult(count($numberOfPendingChanges), $crWorkspace->baseWorkspaceName); } - public function publishChangesInSite(NodeAggregateId $siteId): PublishingResult + public function publishChangesInSite(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $siteId): PublishingResult { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $crWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + if ($crWorkspace->baseWorkspaceName === null) { + throw new \InvalidArgumentException(sprintf('Failed to publish workspace "%s" because it has no base workspace', $workspaceName->value), 1717517240); + } $ancestorNodeTypeName = NodeTypeNameFactory::forSite(); $this->requireNodeToBeOfType( + $contentRepository, + $workspaceName, $siteId, $ancestorNodeTypeName ); $nodeIdsToPublish = $this->resolveNodeIdsToPublishOrDiscard( + $contentRepository, + $workspaceName, $siteId, $ancestorNodeTypeName ); - $this->publishNodes($nodeIdsToPublish); + $this->publishNodes($contentRepository, $workspaceName, $nodeIdsToPublish); return new PublishingResult( - count($nodeIdsToPublish) + count($nodeIdsToPublish), + $crWorkspace->baseWorkspaceName, ); } - public function publishChangesInDocument(NodeAggregateId $documentId): PublishingResult + public function publishChangesInDocument(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $documentId): PublishingResult { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $crWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + if ($crWorkspace->baseWorkspaceName === null) { + throw new \InvalidArgumentException(sprintf('Failed to publish workspace "%s" because it has no base workspace', $workspaceName->value), 1717517467); + } $ancestorNodeTypeName = NodeTypeNameFactory::forDocument(); $this->requireNodeToBeOfType( + $contentRepository, + $workspaceName, $documentId, $ancestorNodeTypeName ); $nodeIdsToPublish = $this->resolveNodeIdsToPublishOrDiscard( + $contentRepository, + $workspaceName, $documentId, $ancestorNodeTypeName ); - $this->publishNodes($nodeIdsToPublish); + $this->publishNodes($contentRepository, $workspaceName, $nodeIdsToPublish); return new PublishingResult( - count($nodeIdsToPublish) + count($nodeIdsToPublish), + $crWorkspace->baseWorkspaceName, ); } - public function discardAllChanges(): DiscardingResult + public function discardAllWorkspaceChanges(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): DiscardingResult { - $changeFinder = $this->contentRepository->projectionState(ChangeFinder::class); - $changes = $changeFinder->findByContentStreamId($this->currentContentStreamId); + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + + $changesToBeDiscarded = $this->pendingWorkspaceChanges($contentRepository, $workspaceName); - $this->discard(); + $contentRepository->handle(DiscardWorkspace::create($workspaceName)); return new DiscardingResult( - count($changes) + count($changesToBeDiscarded) ); } - public function discardChangesInSite(NodeAggregateId $siteId): DiscardingResult + public function discardChangesInSite(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $siteId): DiscardingResult { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); $ancestorNodeTypeName = NodeTypeNameFactory::forSite(); $this->requireNodeToBeOfType( + $contentRepository, + $workspaceName, $siteId, $ancestorNodeTypeName ); $nodeIdsToDiscard = $this->resolveNodeIdsToPublishOrDiscard( + $contentRepository, + $workspaceName, $siteId, NodeTypeNameFactory::forSite() ); - $this->discardNodes($nodeIdsToDiscard); + $this->discardNodes($contentRepository, $workspaceName, $nodeIdsToDiscard); return new DiscardingResult( count($nodeIdsToDiscard) ); } - public function discardChangesInDocument(NodeAggregateId $documentId): DiscardingResult + public function discardChangesInDocument(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $documentId): DiscardingResult { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); $ancestorNodeTypeName = NodeTypeNameFactory::forDocument(); $this->requireNodeToBeOfType( + $contentRepository, + $workspaceName, $documentId, $ancestorNodeTypeName ); $nodeIdsToDiscard = $this->resolveNodeIdsToPublishOrDiscard( + $contentRepository, + $workspaceName, $documentId, $ancestorNodeTypeName ); - $this->discardNodes($nodeIdsToDiscard); + $this->discardNodes($contentRepository, $workspaceName, $nodeIdsToDiscard); return new DiscardingResult( count($nodeIdsToDiscard) ); } - /** - * @throws WorkspaceRebaseFailed - */ - public function rebase(RebaseErrorHandlingStrategy $rebaseErrorHandlingStrategy = RebaseErrorHandlingStrategy::STRATEGY_FAIL): void + public function changeBaseWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceName $newBaseWorkspaceName): void { - $rebaseCommand = RebaseWorkspace::create( - $this->name - )->withErrorHandlingStrategy($rebaseErrorHandlingStrategy); - - $this->contentRepository->handle($rebaseCommand); - - $this->updateCurrentState(); - } - - public function changeBaseWorkspace(WorkspaceName $baseWorkspaceName): void - { - $this->contentRepository->handle( + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + $contentRepository->handle( ChangeBaseWorkspace::create( - $this->name, - $baseWorkspaceName + $workspaceName, + $newBaseWorkspaceName, ) ); - - $this->updateCurrentState(); } - private function requireNodeToBeOfType( - NodeAggregateId $nodeAggregateId, - NodeTypeName $nodeTypeName, + private function discardNodes( + ContentRepository $contentRepository, + WorkspaceName $workspaceName, + NodeIdsToPublishOrDiscard $nodeIdsToDiscard ): void { - $nodeAggregate = $this->contentRepository->getContentGraph($this->name)->findNodeAggregateById( - $nodeAggregateId, + /** + * TODO: only rebase if necessary! + * Also, isn't this already included in @see WorkspaceCommandHandler::handleDiscardIndividualNodesFromWorkspace ? + */ + $contentRepository->handle( + RebaseWorkspace::create($workspaceName) ); - if (!$nodeAggregate instanceof NodeAggregate) { - throw new NodeAggregateCurrentlyDoesNotExist( - 'Node aggregate ' . $nodeAggregateId->value . ' does currently not exist', - 1710967964 - ); - } - if ( - !$this->contentRepository->getNodeTypeManager() - ->getNodeType($nodeAggregate->nodeTypeName) - ?->isOfType($nodeTypeName) - ) { - throw new \DomainException( - 'Node aggregate ' . $nodeAggregateId->value . ' is not of expected type ' . $nodeTypeName->value, - 1710968108 - ); - } - } - - private function publish(): void - { - $this->contentRepository->handle( - PublishWorkspace::create( - $this->name, + $contentRepository->handle( + DiscardIndividualNodesFromWorkspace::create( + $workspaceName, + $nodeIdsToDiscard ) ); - - $this->updateCurrentState(); } private function publishNodes( + ContentRepository $contentRepository, + WorkspaceName $workspaceName, NodeIdsToPublishOrDiscard $nodeIdsToPublish ): void { /** * TODO: only rebase if necessary! * Also, isn't this already included in @see WorkspaceCommandHandler::handlePublishIndividualNodesFromWorkspace ? */ - $this->contentRepository->handle( - RebaseWorkspace::create( - $this->name - ) + $contentRepository->handle( + RebaseWorkspace::create($workspaceName) ); - $this->contentRepository->handle( + $contentRepository->handle( PublishIndividualNodesFromWorkspace::create( - $this->name, + $workspaceName, $nodeIdsToPublish ) ); - - $this->updateCurrentState(); } - private function discard(): void - { - $this->contentRepository->handle( - DiscardWorkspace::create( - $this->name, - ) - ); - - $this->updateCurrentState(); + private function requireContentRepositoryWorkspace( + ContentRepository $contentRepository, + WorkspaceName $workspaceName + ): ContentRepositoryWorkspace { + $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($workspaceName); + if (!$workspace instanceof ContentRepositoryWorkspace) { + throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); + } + return $workspace; } - private function discardNodes( - NodeIdsToPublishOrDiscard $nodeIdsToDiscard + private function requireNodeToBeOfType( + ContentRepository $contentRepository, + WorkspaceName $workspaceName, + NodeAggregateId $nodeAggregateId, + NodeTypeName $nodeTypeName, ): void { - /** - * TODO: only rebase if necessary! - * Also, isn't this already included in @see WorkspaceCommandHandler::handleDiscardIndividualNodesFromWorkspace ? - */ - $this->contentRepository->handle( - RebaseWorkspace::create( - $this->name - ) - ); - - $this->contentRepository->handle( - DiscardIndividualNodesFromWorkspace::create( - $this->name, - $nodeIdsToDiscard - ) + $nodeAggregate = $contentRepository->getContentGraph($workspaceName)->findNodeAggregateById( + $nodeAggregateId, ); + if (!$nodeAggregate instanceof NodeAggregate) { + throw new NodeAggregateCurrentlyDoesNotExist( + 'Node aggregate ' . $nodeAggregateId->value . ' does currently not exist', + 1710967964 + ); + } - $this->updateCurrentState(); + if ( + !$contentRepository->getNodeTypeManager() + ->getNodeType($nodeAggregate->nodeTypeName) + ?->isOfType($nodeTypeName) + ) { + throw new \RuntimeException( + sprintf('Node aggregate %s is not of expected type %s', $nodeAggregateId->value, $nodeTypeName->value), + 1710968108 + ); + } } /** @@ -357,16 +308,17 @@ private function discardNodes( * @param NodeTypeName $ancestorNodeTypeName The type of the ancestor node of all affected nodes */ private function resolveNodeIdsToPublishOrDiscard( + ContentRepository $contentRepository, + WorkspaceName $workspaceName, NodeAggregateId $ancestorId, NodeTypeName $ancestorNodeTypeName ): NodeIdsToPublishOrDiscard { - /** @var ChangeFinder $changeFinder */ - $changeFinder = $this->contentRepository->projectionState(ChangeFinder::class); - $changes = $changeFinder->findByContentStreamId($this->currentContentStreamId); $nodeIdsToPublishOrDiscard = []; - foreach ($changes as $change) { + foreach ($this->pendingWorkspaceChanges($contentRepository, $workspaceName) as $change) { if ( !$this->isChangePublishableWithinAncestorScope( + $contentRepository, + $workspaceName, $change, $ancestorNodeTypeName, $ancestorId @@ -384,7 +336,20 @@ private function resolveNodeIdsToPublishOrDiscard( return NodeIdsToPublishOrDiscard::create(...$nodeIdsToPublishOrDiscard); } + /** + * @return array + */ + private function pendingWorkspaceChanges(ContentRepository $contentRepository, WorkspaceName $workspaceName): array + { + $crWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + /** @var ChangeFinder $changeFinder */ + $changeFinder = $contentRepository->projectionState(ChangeFinder::class); + return $changeFinder->findByContentStreamId($crWorkspace->currentContentStreamId); + } + private function isChangePublishableWithinAncestorScope( + ContentRepository $contentRepository, + WorkspaceName $workspaceName, Change $change, NodeTypeName $ancestorNodeTypeName, NodeAggregateId $ancestorId @@ -397,7 +362,7 @@ private function isChangePublishableWithinAncestorScope( } } - $subgraph = $this->contentRepository->getContentGraph($this->name)->getSubgraph( + $subgraph = $contentRepository->getContentGraph($workspaceName)->getSubgraph( $change->originDimensionSpacePoint->toDimensionSpacePoint(), VisibilityConstraints::withoutRestrictions() ); @@ -414,7 +379,7 @@ private function isChangePublishableWithinAncestorScope( } /** - * Before the introduction of the WorkspacePublisher, the UI only ever + * Before the introduction of the {@see WorkspacePublishingService}, the UI only ever * referenced the closest document node as a removal attachment point. * * Removed document nodes therefore were referencing themselves. @@ -428,7 +393,7 @@ private function isChangePublishableWithinAncestorScope( * located, because we have no reference to their respective site. * * Every document node that matches that description will be published - * or discarded by WorkspacePublisher::publishSite, regardless of what + * or discarded by {@see WorkspacePublishingService::publishChangesInSite()}, regardless of what * the current site is. * * @deprecated remove once we are sure this check is no longer needed due to @@ -440,18 +405,4 @@ private function isChangeWithSelfReferencingRemovalAttachmentPoint(Change $chang { return $change->removalAttachmentPoint?->equals($change->nodeAggregateId) ?? false; } - - private function updateCurrentState(): void - { - /** The workspace projection should have been marked stale via @see WithMarkStaleInterface in the meantime */ - $contentRepositoryWorkspace = $this->contentRepository->getWorkspaceFinder() - ->findOneByName($this->name); - if (!$contentRepositoryWorkspace) { - throw new WorkspaceDoesNotExist('Cannot update state of non-existent workspace ' . $this->name->value, 1711704397); - } - - $this->currentContentStreamId = $contentRepositoryWorkspace->currentContentStreamId; - $this->currentStatus = $contentRepositoryWorkspace->status; - $this->currentBaseWorkspaceName = $contentRepositoryWorkspace->baseWorkspaceName; - } } diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php new file mode 100644 index 00000000000..010f0a3a888 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -0,0 +1,349 @@ +requireWorkspace($contentRepositoryId, $workspaceName); + + $table = self::TABLE_NAME_WORKSPACE_METADATA; + $query = <<dbal->fetchAssociative($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + ]); + if ($metadataRow === false) { + return new WorkspaceMetadata( + $workspaceName, + WorkspaceTitle::fromString(ucfirst($workspaceName->value)), + WorkspaceDescription::fromString(''), + $workspaceName->isLive() ? WorkspaceClassification::ROOT : WorkspaceClassification::UNKNOWN, + ); + } + return new WorkspaceMetadata( + $workspaceName, + WorkspaceTitle::fromString($metadataRow['title']), + WorkspaceDescription::fromString($metadataRow['description']), + WorkspaceClassification::from($metadataRow['classification']) + ); + } + + public function getPersonalWorkspaceForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): Workspace + { + $workspaceName = $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $userId); + if ($workspaceName === null) { + throw new \RuntimeException(sprintf('No workspace is assigned to the user with id "%s")', $userId->value), 1718293801); + } + return $this->requireWorkspace($contentRepositoryId, $workspaceName); + } + + + + public function createRootWorkspace( + ContentRepositoryId $contentRepositoryId, + WorkspaceTitle $title, + WorkspaceDescription $description, + ): WorkspaceName + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $workspaceName = self::getUniqueWorkspaceName($contentRepository, $title->value); + $contentRepository->handle( + CreateRootWorkspace::create( + $workspaceName, + \Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceTitle::fromString($title->value), + \Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceDescription::fromString($description->value), + ContentStreamId::create() + ) + ); + + // TODO catch exceptions + + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject' => 'group:EVERYBODY', + 'role' => 'OWNER', + ]); + + // TODO catch exceptions + + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'title' => $title->value, + 'description' => $description->value, + 'classification' => WorkspaceClassification::ROOT, + ]); + + return $workspaceName; + } + + public function createWorkspace( + ContentRepositoryId $contentRepositoryId, + WorkspaceTitle $title, + WorkspaceDescription $description, + WorkspaceName $baseWorkspaceName, + UserId|null $ownerId, + WorkspaceClassification $classification, + ): WorkspaceName { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $workspaceName = self::getUniqueWorkspaceName($contentRepository, $title->value); + $contentRepository->handle( + CreateWorkspace::create( + $workspaceName, + $baseWorkspaceName, + \Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceTitle::fromString($title->value), + \Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceDescription::fromString($description->value), + ContentStreamId::create() + ) + ); + + // TODO catch exceptions + + if ($ownerId !== null) { + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject' => 'user:' . $ownerId->value, + 'role' => 'OWNER', + ]); + } + + // TODO catch exceptions + + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'title' => $title->value, + 'description' => $description->value, + 'classification' => $classification->name, + ]); + + return $workspaceName; + } + + public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $contentRepositoryId, User $user): void + { + $existingWorkspaceName = $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $user->getId()); + if ($existingWorkspaceName !== null) { + $this->requireWorkspace($contentRepositoryId, $existingWorkspaceName); + return; + } + $this->createWorkspace( + $contentRepositoryId, + WorkspaceTitle::fromString($user->getLabel()), + WorkspaceDescription::empty(), + WorkspaceName::forLive(), + $user->getId(), + WorkspaceClassification::PERSONAL, + ); + } + + public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions + { + $workspaceMetadata = $this->getWorkspaceMetadata($contentRepositoryId, $workspaceName); + + $userRoles = $this->userService->getAllRoles($user); + $userIsAdmin = array_key_exists('Neos.Neos:Administrator', $userRoles); + + $userWorkspaceRoles = $this->getUserWorkspaceRoles($contentRepositoryId, $workspaceName, $user->getId(), array_keys($userRoles)); + + return match ($workspaceMetadata->classification) { + WorkspaceClassification::UNKNOWN => WorkspacePermissions::create( + read: $userIsAdmin, + publish: false, + manage: $userIsAdmin, + ), + WorkspaceClassification::ROOT => WorkspacePermissions::create( + read: true, + publish: $this->privilegeManager->isPrivilegeTargetGrantedForRoles($userRoles, 'Neos.Workspace.Ui:Backend.PublishAllToLiveWorkspace'), + manage: $userIsAdmin, + ), + WorkspaceClassification::PERSONAL => WorkspacePermissions::create( + read: $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $user->getId())?->equals($workspaceName) ?: false, + publish: in_array('MANAGER', $userWorkspaceRoles, true), + manage: $userIsAdmin || in_array('MANAGER', $userWorkspaceRoles, true), + ), + WorkspaceClassification::SHARED => WorkspacePermissions::create( + read: $userIsAdmin || in_array('MANAGER', $userWorkspaceRoles, true), + publish: $userIsAdmin || in_array('MANAGER', $userWorkspaceRoles, true), + manage: $userIsAdmin || in_array('MANAGER', $userWorkspaceRoles, true), + ), + }; + +// $this->privilegeManager->isPrivilegeTargetGrantedForRoles($user->getAllRoles) +// +// // can manage +// +// if ($workspace->isInternalWorkspace()) { +// return $this->privilegeManager->isPrivilegeTargetGranted( +// 'Neos.Neos:Backend.Module.Management.Workspaces.ManageInternalWorkspaces' +// ); +// } +// +// +// $currentUser = $this->getCurrentUser(); +// if ($workspace->isPrivateWorkspace() && $currentUser !== null && $workspace->workspaceOwner === $this->persistenceManager->getIdentifierByObject($currentUser)) { +// return $this->privilegeManager->isPrivilegeTargetGranted( +// 'Neos.Neos:Backend.Module.Management.Workspaces.ManageOwnWorkspaces' +// ); +// } +// +// if ($workspace->isPrivateWorkspace() && $currentUser !== null && $workspace->workspaceOwner !== $this->persistenceManager->getIdentifierByObject($currentUser)) { +// return $this->privilegeManager->isPrivilegeTargetGranted( +// 'Neos.Neos:Backend.Module.Management.Workspaces.ManageAllPrivateWorkspaces' +// ); +// } +// +// +// // can publish +// +// if ($workspace->isPublicWorkspace()) { +// return $this->securityContext->hasRole('Neos.Neos:LivePublisher'); +// } +// +// $currentUser = $this->getCurrentUser(); +// $ownerIdentifier = $currentUser +// ? $this->persistenceManager->getIdentifierByObject($currentUser) +// : null; +// +// if ($workspace->workspaceOwner === null || $workspace->workspaceOwner === $ownerIdentifier) { +// return true; +// } +// +// +// return WorkspacePermissions::create( +// read: false, +// publish: false, +// manage: false, +// ); + } + + private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): ?WorkspaceName + { + $table = self::TABLE_NAME_WORKSPACE_ROLE; + $query = <<dbal->fetchOne($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'subject' => 'user:' . $userId->value, + 'ownerRole' => 'OWNER' + ]); + return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName); + } + + private function getUserWorkspaceRoles(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, UserId $userId, array $userRoles): array + { + $table = self::TABLE_NAME_WORKSPACE_ROLE; + $query = <<dbal->fetchFirstColumn($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + 'subjects' => ['user:' . $userId->value, ...array_map(fn (string $role) => 'group:' . $role, $userRoles)], + ], [ + 'subjects' => Connection::PARAM_STR_ARRAY, + ]); + } + + private static function getUniqueWorkspaceName(ContentRepository $contentRepository, string $candidate): WorkspaceName + { + $workspaceNameCandidate = WorkspaceName::transliterateFromString($candidate); + $workspaceName = $workspaceNameCandidate; + while ($contentRepository->getWorkspaceFinder()->findOneByName($workspaceName) instanceof Workspace) { + $workspaceName = WorkspaceName::fromString( + $workspaceNameCandidate->value . '-' . Algorithms::generateRandomString(5) + ); + } + return $workspaceName; + } + + + private function requireWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): Workspace + { + $workspace = $this->contentRepositoryRegistry + ->get($contentRepositoryId) + ->getWorkspaceFinder() + ->findOneByName($workspaceName); + if ($workspace === null) { + throw new \InvalidArgumentException(sprintf('Failed to find workspace with name "%s"', $workspaceName->value), 1718379722); + } + return $workspace; + } + +} diff --git a/Neos.Neos/Classes/Domain/Workspace/DiscardAllChanges.php b/Neos.Neos/Classes/Domain/Workspace/DiscardAllChanges.php deleted file mode 100644 index 89a4cbb80b5..00000000000 --- a/Neos.Neos/Classes/Domain/Workspace/DiscardAllChanges.php +++ /dev/null @@ -1,45 +0,0 @@ - $values - */ - public static function fromArray(array $values): self - { - return new self( - ContentRepositoryId::fromString($values['contentRepositoryId']), - WorkspaceName::fromString($values['workspaceName']), - ); - } -} diff --git a/Neos.Neos/Classes/Domain/Workspace/PublishAllChanges.php b/Neos.Neos/Classes/Domain/Workspace/PublishAllChanges.php deleted file mode 100644 index 586350f0d8d..00000000000 --- a/Neos.Neos/Classes/Domain/Workspace/PublishAllChanges.php +++ /dev/null @@ -1,45 +0,0 @@ - $values - */ - public static function fromArray(array $values): self - { - return new self( - ContentRepositoryId::fromString($values['contentRepositoryId']), - WorkspaceName::fromString($values['workspaceName']), - ); - } -} diff --git a/Neos.Neos/Classes/Domain/Workspace/WorkspaceProvider.php b/Neos.Neos/Classes/Domain/Workspace/WorkspaceProvider.php deleted file mode 100644 index 257a1233410..00000000000 --- a/Neos.Neos/Classes/Domain/Workspace/WorkspaceProvider.php +++ /dev/null @@ -1,77 +0,0 @@ - - */ - private array $instances; - - public function __construct( - private readonly ContentRepositoryRegistry $contentRepositoryRegistry - ) { - } - - public function provideForWorkspaceName( - ContentRepositoryId $contentRepositoryId, - WorkspaceName $workspaceName, - ): Workspace { - $index = $contentRepositoryId->value . '-' . $workspaceName->value; - if (isset($this->instances[$index])) { - return $this->instances[$index]; - } - - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $contentRepositoryWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); - - return $this->instances[$index] = new Workspace( - $workspaceName, - $contentRepositoryWorkspace->currentContentStreamId, - $contentRepositoryWorkspace->status, - $contentRepositoryWorkspace->baseWorkspaceName, - $contentRepository - ); - } - - private function requireContentRepositoryWorkspace( - ContentRepository $contentRepository, - WorkspaceName $workspaceName - ): ContentRepositoryWorkspace { - $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($workspaceName); - if (!$workspace instanceof ContentRepositoryWorkspace) { - throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); - } - - // @todo: access control goes here - - return $workspace; - } -} diff --git a/Neos.Neos/Classes/Service/EditorContentStreamZookeeper.php b/Neos.Neos/Classes/Service/EditorContentStreamZookeeper.php index f4e18d0a96d..4ea58ba6d74 100644 --- a/Neos.Neos/Classes/Service/EditorContentStreamZookeeper.php +++ b/Neos.Neos/Classes/Service/EditorContentStreamZookeeper.php @@ -14,22 +14,12 @@ namespace Neos\Neos\Service; -use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; -use Neos\ContentRepository\Core\SharedModel\User\UserId; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceDescription; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceTitle; -use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Core\Bootstrap; use Neos\Flow\Http\HttpRequestHandlerInterface; -use Neos\Flow\Persistence\PersistenceManagerInterface; use Neos\Flow\Security\Authentication; use Neos\Flow\Security\Policy\PolicyService; -use Neos\Flow\Security\Policy\Role; -use Neos\Neos\Domain\Model\User; -use Neos\Neos\Domain\Service\WorkspaceNameBuilder; +use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionFailedException; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; use Neos\Party\Domain\Service\PartyService; @@ -46,15 +36,15 @@ final class EditorContentStreamZookeeper { /** * @Flow\Inject - * @var PersistenceManagerInterface + * @var PartyService */ - protected $persistenceManager; + protected $partyService; /** * @Flow\Inject - * @var PartyService + * @var \Neos\Neos\Domain\Service\UserService */ - protected $partyService; + protected $userService; /** * @Flow\Inject @@ -70,9 +60,9 @@ final class EditorContentStreamZookeeper /** * @Flow\Inject - * @var ContentRepositoryRegistry + * @var WorkspaceService */ - protected $contentRepositoryRegistry; + protected $workspaceService; /** * This method is called whenever a login happens (AuthenticationProviderManager::class, 'authenticatedToken'), @@ -97,45 +87,14 @@ public function relayEditorAuthentication(Authentication\TokenInterface $token): } catch (SiteDetectionFailedException) { return; } - $contentRepository = $this->contentRepositoryRegistry->get($siteDetectionResult->contentRepositoryId); - $isEditor = false; - foreach ($token->getAccount()->getRoles() as $role) { - /** @var Role $role */ - if (isset($role->getAllParentRoles()['Neos.Neos:AbstractEditor'])) { - $isEditor = true; - break; - } - } - if (!$isEditor) { + $authenticatedUser = $this->userService->getCurrentUser(); + if ($authenticatedUser === null) { return; } - $user = $this->partyService->getAssignedPartyOfAccount($token->getAccount()); - if (!$user instanceof User) { - return; - } - $workspaceName = WorkspaceNameBuilder::fromAccountIdentifier( - $token->getAccount()->getAccountIdentifier() - ); - $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($workspaceName); - if ($workspace !== null) { - return; - } - - $baseWorkspace = $contentRepository->getWorkspaceFinder()->findOneByName(WorkspaceName::forLive()); - if (!$baseWorkspace) { + if (!array_key_exists('Neos.Neos:AbstractEditor', $this->userService->getAllRoles($authenticatedUser))) { return; } - $editorsNewContentStreamId = ContentStreamId::create(); - $contentRepository->handle( - CreateWorkspace::create( - $workspaceName, - $baseWorkspace->workspaceName, - new WorkspaceTitle((string) $user->getName()), - new WorkspaceDescription(''), - $editorsNewContentStreamId, - UserId::fromString($this->persistenceManager->getIdentifierByObject($user)) - ) - ); + $this->workspaceService->createPersonalWorkspaceForUserIfMissing($siteDetectionResult->contentRepositoryId, $authenticatedUser); } } diff --git a/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php b/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php index 6b1da68e7a9..b81c97e8c5a 100644 --- a/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php +++ b/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php @@ -20,10 +20,10 @@ public function __construct( public function getUserId(): UserId { - $userId = $this->userService->getCurrentUserIdentifier(); - if ($userId === null) { + $user = $this->userService->getCurrentUser(); + if ($user === null) { return UserId::forSystemUser(); } - return UserId::fromString($userId->value); + return UserId::fromString($user->getId()->value); } } diff --git a/Neos.Neos/Classes/ViewHelpers/Backend/ChangeStatsViewHelper.php b/Neos.Neos/Classes/ViewHelpers/Backend/ChangeStatsViewHelper.php deleted file mode 100644 index 6d4493e4773..00000000000 --- a/Neos.Neos/Classes/ViewHelpers/Backend/ChangeStatsViewHelper.php +++ /dev/null @@ -1,65 +0,0 @@ -registerArgument('changeCounts', 'array', 'Expected keys: new, changed, removed', true); - } - - /** - * Expects an array of change count data and adds calculated ratios to the rendered child view - * - * @return string - */ - public function render(): string - { - $changeCounts = $this->arguments['changeCounts']; - - $this->templateVariableContainer->add('newCountRatio', $changeCounts['new'] / $changeCounts['total'] * 100); - $this->templateVariableContainer->add( - 'changedCountRatio', - $changeCounts['changed'] / $changeCounts['total'] * 100 - ); - $this->templateVariableContainer->add( - 'removedCountRatio', - $changeCounts['removed'] / $changeCounts['total'] * 100 - ); - $content = $this->renderChildren(); - $this->templateVariableContainer->remove('newCountRatio'); - $this->templateVariableContainer->remove('changedCountRatio'); - $this->templateVariableContainer->remove('removedCountRatio'); - - return $content; - } -} diff --git a/Neos.Neos/Migrations/Mysql/Version20240425223900.php b/Neos.Neos/Migrations/Mysql/Version20240425223900.php new file mode 100644 index 00000000000..3083611b5c6 --- /dev/null +++ b/Neos.Neos/Migrations/Mysql/Version20240425223900.php @@ -0,0 +1,50 @@ +abortIf( + !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MariaDb1027Platform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MariaDb1027Platform'." + ); + + $tableWorkspaceMetadata = $schema->createTable('neos_neos_workspace_metadata'); + $tableWorkspaceMetadata->addColumn('content_repository_id', 'string', ['length' => 16]); + $tableWorkspaceMetadata->addColumn('workspace_name', 'string', ['length' => 255]); + $tableWorkspaceMetadata->addColumn('title', 'string', ['length' => 255]); + $tableWorkspaceMetadata->addColumn('description', 'text'); + $tableWorkspaceMetadata->addColumn('classification', 'string', ['length' => 255]); + $tableWorkspaceMetadata->setPrimaryKey(['content_repository_id', 'workspace_name']); + + $tableWorkspaceRole = $schema->createTable('neos_neos_workspace_role'); + $tableWorkspaceRole->addColumn('content_repository_id', 'string', ['length' => 16]); + $tableWorkspaceRole->addColumn('workspace_name', 'string', ['length' => 255]); + $tableWorkspaceRole->addColumn('subject', 'string', ['length' => 255]); + $tableWorkspaceRole->addColumn('role', 'string', ['length' => 255]); + $tableWorkspaceRole->setPrimaryKey(['content_repository_id', 'workspace_name', 'subject']); + } + + public function down(Schema $schema): void + { + $this->abortIf( + !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MariaDb1027Platform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MariaDb1027Platform'." + ); + + $schema->dropTable('neos_neos_workspace_role'); + $schema->dropTable('neos_neos_workspace_metadata'); + } +} diff --git a/Neos.Neos/Migrations/Postgresql/Version20240425223901.php b/Neos.Neos/Migrations/Postgresql/Version20240425223901.php new file mode 100644 index 00000000000..dbfc24240ed --- /dev/null +++ b/Neos.Neos/Migrations/Postgresql/Version20240425223901.php @@ -0,0 +1,44 @@ +abortIf($this->connection->getDatabasePlatform()?->getName() !== 'postgresql'); + + $tableWorkspaceMetadata = $schema->createTable('neos_neos_workspace_metadata'); + $tableWorkspaceMetadata->addColumn('content_repository_id', 'string', ['length' => 16]); + $tableWorkspaceMetadata->addColumn('workspace_name', 'string', ['length' => 255]); + $tableWorkspaceMetadata->addColumn('title', 'string', ['length' => 255]); + $tableWorkspaceMetadata->addColumn('description', 'text'); + $tableWorkspaceMetadata->addColumn('classification', 'string', ['length' => 255]); + $tableWorkspaceMetadata->setPrimaryKey(['content_repository_id', 'workspace_name']); + + $tableWorkspaceRole = $schema->createTable('neos_neos_workspace_role'); + $tableWorkspaceRole->addColumn('content_repository_id', 'string', ['length' => 16]); + $tableWorkspaceRole->addColumn('workspace_name', 'string', ['length' => 255]); + $tableWorkspaceRole->addColumn('subject', 'string', ['length' => 255]); + $tableWorkspaceRole->addColumn('role', 'string', ['length' => 255]); + $tableWorkspaceRole->setPrimaryKey(['content_repository_id', 'workspace_name', 'subject']); + } + + public function down(Schema $schema): void + { + $this->abortIf($this->connection->getDatabasePlatform()?->getName() !== 'postgresql'); + + $schema->dropTable('neos_neos_workspace_role'); + $schema->dropTable('neos_neos_workspace_metadata'); + } +} diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index 515fc39f2b7..d4287abc1e3 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -30,12 +30,11 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Nodes; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\Projection\Workspace\Workspace; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; use Neos\ContentRepository\Core\SharedModel\User\UserId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceDescription; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceTitle; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Diff\Diff; use Neos\Diff\Renderer\Html\HtmlArrayRenderer; @@ -49,24 +48,30 @@ use Neos\Flow\Property\PropertyMapper; use Neos\Flow\Security\Account; use Neos\Flow\Security\Context; +use Neos\Flow\Utility\Algorithms; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\ImageInterface; use Neos\Neos\Controller\Module\AbstractModuleController; use Neos\Neos\Controller\Module\ModuleTranslationTrait; use Neos\Neos\Domain\Model\SiteNodeName; use Neos\Neos\Domain\Model\User; +use Neos\Neos\Domain\Model\WorkspaceClassification; +use Neos\Neos\Domain\Model\WorkspaceDescription; +use Neos\Neos\Domain\Model\WorkspaceTitle; use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; use Neos\Neos\Domain\Service\UserService; +use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Neos\Domain\Service\WorkspaceNameBuilder; -use Neos\Neos\Domain\Workspace\DiscardAllChanges; -use Neos\Neos\Domain\Workspace\PublishAllChanges; -use Neos\Neos\Domain\Workspace\WorkspaceProvider; +use Neos\Neos\Domain\Service\WorkspacePublishingService; use Neos\Neos\FrontendRouting\NodeAddress; use Neos\Neos\FrontendRouting\NodeAddressFactory; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; use Neos\Neos\PendingChangesProjection\ChangeFinder; use Neos\Neos\Utility\NodeTypeWithFallbackProvider; +use Neos\Workspace\Ui\ViewModel\PendingChanges; +use Neos\Workspace\Ui\ViewModel\WorkspaceListItem; +use Neos\Workspace\Ui\ViewModel\WorkspaceListItems; /** * The Neos Workspace module controller @@ -90,153 +95,113 @@ class WorkspaceController extends AbstractModuleController protected Context $securityContext; #[Flow\Inject] - protected UserService $domainUserService; + protected UserService $userService; #[Flow\Inject] protected PackageManager $packageManager; #[Flow\Inject] - protected WorkspaceProvider $workspaceProvider; + protected WorkspacePublishingService $workspacePublishingService; + + #[Flow\Inject] + protected WorkspaceService $workspaceService; /** * Display a list of unpublished content */ public function indexAction(): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $currentUser = $this->userService->getCurrentUser(); + if ($currentUser === null) { + throw new \RuntimeException('No user authenticated', 1718308216); + } - /** @var ?Account $currentAccount */ - $currentAccount = $this->securityContext->getAccount(); - if ($currentAccount === null) { - throw new \RuntimeException('No account is authenticated', 1710068839); + $contentRepositoryIds = $this->contentRepositoryRegistry->getContentRepositoryIds(); + $numberOfContentRepositories = count($contentRepositoryIds); + if ($numberOfContentRepositories === 0) { + throw new \RuntimeException('No content repository configured', 1718296290); } - $userWorkspace = $contentRepository->getWorkspaceFinder()->findOneByName( - WorkspaceNameBuilder::fromAccountIdentifier($currentAccount->getAccountIdentifier()) - ); - if (is_null($userWorkspace)) { - throw new \RuntimeException('Current user has no workspace', 1645485990); + if ($this->request->hasArgument('contentRepositoryId')) { + $contentRepositoryId = ContentRepositoryId::fromString($this->request->getArgument('contentRepositoryId')); + } else { + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; } + $this->view->assign('contentRepositoryIds', $contentRepositoryIds); + $this->view->assign('contentRepositoryId', $contentRepositoryId->value); + $this->view->assign('displayContentRepositorySelector', $numberOfContentRepositories > 1); - $workspacesAndCounts = [ - $userWorkspace->workspaceName->value => [ - 'workspace' => $userWorkspace, - 'changesCounts' => $this->computeChangesCount($userWorkspace, $contentRepository), - 'canPublish' => false, - 'canManage' => false, - 'canDelete' => false, - 'workspaceOwnerHumanReadable' => $userWorkspace->workspaceOwner ? $this->domainUserService->findByUserIdentifier(UserId::fromString($userWorkspace->workspaceOwner))?->getLabel() : null - ] - ]; + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $items = []; foreach ($contentRepository->getWorkspaceFinder()->findAll() as $workspace) { - /** @var \Neos\ContentRepository\Core\Projection\Workspace\Workspace $workspace */ - // FIXME: This check should be implemented through a specialized Workspace Privilege or something similar - if (!$workspace->isPersonalWorkspace() && ($workspace->isInternalWorkspace() || $this->domainUserService->currentUserCanManageWorkspace($workspace))) { - $workspaceName = $workspace->workspaceName->value; - $workspacesAndCounts[$workspaceName]['workspace'] = $workspace; - $workspacesAndCounts[$workspaceName]['changesCounts'] = - $this->computeChangesCount($workspace, $contentRepository); - $workspacesAndCounts[$workspaceName]['canPublish'] - = $this->domainUserService->currentUserCanPublishToWorkspace($workspace); - $workspacesAndCounts[$workspaceName]['canManage'] - = $this->domainUserService->currentUserCanManageWorkspace($workspace); - $workspacesAndCounts[$workspaceName]['dependentWorkspacesCount'] = count( - $contentRepository->getWorkspaceFinder()->findByBaseWorkspace($workspace->workspaceName) - ); - $workspacesAndCounts[$workspaceName]['workspaceOwnerHumanReadable'] = $workspace->workspaceOwner ? $this->domainUserService->findByUserIdentifier(UserId::fromString($workspace->workspaceOwner))?->getLabel() : null; + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); + $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $workspace->workspaceName, $currentUser); + if (!$permissions->read) { + continue; } + $items[] = new WorkspaceListItem( + name: $workspace->workspaceName->value, + classification: $workspaceMetadata->classification->name, + title: $workspaceMetadata->title->value, + description: $workspaceMetadata->description->value, + baseWorkspaceName: $workspace->baseWorkspaceName?->value, + pendingChanges: $this->computePendingChanges($workspace, $contentRepository), + hasDependantWorkspaces: count($contentRepository->getWorkspaceFinder()->findByBaseWorkspace($workspace->workspaceName)) > 0, + permissions: $permissions, + ); } - - $this->view->assign('userWorkspace', $userWorkspace); - $this->view->assign('workspacesAndChangeCounts', $workspacesAndCounts); + $this->view->assign('workspaces', WorkspaceListItems::fromArray($items)); } - public function showAction(WorkspaceName $workspace): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $workspaceControllerInternals = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - new WorkspaceControllerInternalsFactory() - ); $workspaceObj = $contentRepository->getWorkspaceFinder()->findOneByName($workspace); if (is_null($workspaceObj)) { /** @todo add flash message */ $this->redirect('index'); } + $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $workspaceObj->workspaceName); $this->view->assignMultiple([ 'selectedWorkspace' => $workspaceObj, 'selectedWorkspaceLabel' => $workspaceObj->workspaceTitle, 'baseWorkspaceName' => $workspaceObj->baseWorkspaceName, 'baseWorkspaceLabel' => $workspaceObj->baseWorkspaceName, // TODO fallback to title - // TODO $this->domainUserService->currentUserCanPublishToWorkspace($workspace->getBaseWorkspace()), - 'canPublishToBaseWorkspace' => true, + 'canPublishToBaseWorkspace' => $permissions->publish, 'siteChanges' => $this->computeSiteChanges($workspaceObj, $contentRepository), - 'contentDimensions' => $workspaceControllerInternals->getContentDimensionsOrderedByPriority() + 'contentDimensions' => $contentRepository->getContentDimensionSource()->getContentDimensionsOrderedByPriority() ]); } - public function newAction(): void + public function newAction(ContentRepositoryId $contentRepositoryId): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); $this->view->assign('baseWorkspaceOptions', $this->prepareBaseWorkspaceOptions($contentRepository)); + $this->view->assign('contentRepositoryId', $contentRepositoryId->value); } - /** - * Create a workspace - * - * @Flow\Validate(argumentName="title", type="\Neos\Flow\Validation\Validator\NotEmptyValidator") - * @param WorkspaceTitle $title Human friendly title of the workspace, for example "Christmas Campaign" - * @param WorkspaceName $baseWorkspace Workspace the new workspace should be based on - * @param string $visibility Visibility of the new workspace, must be either "internal" or "shared" - * @param WorkspaceDescription $description A description explaining the purpose of the new workspace - * @return void - * @throws \Neos\Flow\Mvc\Exception\StopActionException - */ public function createAction( + ContentRepositoryId $contentRepositoryId, WorkspaceTitle $title, WorkspaceName $baseWorkspace, - string $visibility, WorkspaceDescription $description, ): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - - $workspaceName = WorkspaceName::fromString( - WorkspaceName::transliterateFromString($title->value)->value . '-' - . substr(base_convert(microtime(false), 10, 36), -5, 5) - ); - while ($contentRepository->getWorkspaceFinder()->findOneByName($workspaceName) instanceof Workspace) { - $workspaceName = WorkspaceName::fromString( - WorkspaceName::transliterateFromString($title->value)->value . '-' - . substr(base_convert(microtime(false), 10, 36), -5, 5) - ); - } - - $currentUserIdentifier = $this->domainUserService->getCurrentUserIdentifier(); - if (is_null($currentUserIdentifier)) { - throw new \InvalidArgumentException('Cannot create workspace without a current user', 1652155039); + $currentUser = $this->userService->getCurrentUser(); + if ($currentUser === null) { + throw new \RuntimeException('No user authenticated', 1718303756); } try { - $contentRepository->handle( - CreateWorkspace::create( - $workspaceName, - $baseWorkspace, - $title, - $description, - ContentStreamId::create(), - $visibility === 'private' ? $currentUserIdentifier : null - ) + $this->workspaceService->createWorkspace( + $contentRepositoryId, + $title, + $description, + $baseWorkspace, + $currentUser->getId(), + WorkspaceClassification::SHARED, ); } catch (WorkspaceAlreadyExists $exception) { $this->addFlashMessage( @@ -246,7 +211,7 @@ public function createAction( ); $this->redirect('new'); } - + $this->addFlashMessage($this->getModuleLabel('workspaces.workspaceHasBeenCreated', [$title->value])); $this->redirect('index'); } @@ -265,12 +230,12 @@ public function editAction(WorkspaceName $workspaceName): void $this->redirect('index'); } $this->view->assign('workspace', $workspace); - $this->view->assign('baseWorkspaceOptions', $this->prepareBaseWorkspaceOptions($contentRepository, $workspace)); + $this->view->assign('baseWorkspaceOptions', $this->prepareBaseWorkspaceOptions($contentRepository, $workspaceName)); // TODO: $this->view->assign('disableBaseWorkspaceSelector', // $this->publishingService->getUnpublishedNodesCount($workspace) > 0); $this->view->assign( 'showOwnerSelector', - $this->domainUserService->currentUserCanTransferOwnershipOfWorkspace($workspace) + true// TODO fix $this->userService->currentUserCanTransferOwnershipOfWorkspace($workspace) ); $this->view->assign('ownerOptions', $this->prepareOwnerOptions()); } @@ -291,8 +256,7 @@ public function updateAction( WorkspaceDescription $description, ?string $workspaceOwner ): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); if ($title->value === '') { @@ -350,8 +314,7 @@ public function updateAction( */ public function deleteAction(WorkspaceName $workspaceName): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($workspaceName); @@ -646,26 +609,16 @@ public function publishOrDiscardNodesAction(array $nodes, string $action, string */ public function publishWorkspaceAction(WorkspaceName $workspace): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; - /** @todo send from UI */ - $command = new PublishAllChanges( + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $publishingResult = $this->workspacePublishingService->publishWorkspace( $contentRepositoryId, - $workspace + $workspace, ); - - $workspace = $this->workspaceProvider->provideForWorkspaceName( - $command->contentRepositoryId, - $command->workspaceName - ); - $workspace->publishAllChanges(); - /** @var WorkspaceName $baseWorkspaceName Otherwise the command handler would have thrown an exception */ - $baseWorkspaceName = $workspace->getCurrentBaseWorkspaceName(); $this->addFlashMessage($this->translator->translateById( 'workspaces.allChangesInWorkspaceHaveBeenPublished', [ - htmlspecialchars($workspace->name->value), - htmlspecialchars($baseWorkspaceName->value) + htmlspecialchars($workspace->value), + htmlspecialchars($publishingResult->targetWorkspaceName->value) ], null, null, @@ -682,22 +635,14 @@ public function publishWorkspaceAction(WorkspaceName $workspace): void */ public function discardWorkspaceAction(WorkspaceName $workspace): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; - /** @todo send from UI */ - $command = new DiscardAllChanges( + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $this->workspacePublishingService->discardAllWorkspaceChanges( $contentRepositoryId, - $workspace + $workspace, ); - $workspace = $this->workspaceProvider->provideForWorkspaceName( - $command->contentRepositoryId, - $command->workspaceName - ); - $workspace->discardAllChanges(); - $this->addFlashMessage($this->translator->translateById( 'workspaces.allChangesInWorkspaceHaveBeenDiscarded', - [htmlspecialchars($workspace->name->value)], + [htmlspecialchars($workspace->value)], null, null, 'Main', @@ -712,9 +657,9 @@ public function discardWorkspaceAction(WorkspaceName $workspace): void * @return array * @throws \JsonException */ - protected function computeChangesCount(Workspace $selectedWorkspace, ContentRepository $contentRepository): array + protected function computePendingChanges(Workspace $selectedWorkspace, ContentRepository $contentRepository): PendingChanges { - $changesCount = ['new' => 0, 'changed' => 0, 'removed' => 0, 'total' => 0]; + $changesCount = ['new' => 0, 'changed' => 0, 'removed' => 0]; foreach ($this->computeSiteChanges($selectedWorkspace, $contentRepository) as $siteChanges) { foreach ($siteChanges['documents'] as $documentChanges) { foreach ($documentChanges['changes'] as $change) { @@ -725,12 +670,10 @@ protected function computeChangesCount(Workspace $selectedWorkspace, ContentRepo } else { $changesCount['changed']++; } - $changesCount['total']++; } } } - - return $changesCount; + return new PendingChanges(new: $changesCount['new'], changed: $changesCount['changed'], removed: $changesCount['removed']); } /** @@ -1060,31 +1003,36 @@ protected function postProcessDiffArray(array &$diffArray): void /** * Creates an array of workspace names and their respective titles which are possible base workspaces for other * workspaces. - * If $excludedWorkspace is set, this workspace and all its child workspaces will be excluded from the list of returned workspaces + * If $excludedWorkspace is set, this workspace and all its base workspaces will be excluded from the list of returned workspaces * * @param ContentRepository $contentRepository - * @param Workspace|null $excludedWorkspace + * @param WorkspaceName|null $excludedWorkspace * @return array */ protected function prepareBaseWorkspaceOptions( ContentRepository $contentRepository, - Workspace $excludedWorkspace = null, + WorkspaceName $excludedWorkspace = null, ): array { $baseWorkspaceOptions = []; $workspaces = $contentRepository->getWorkspaceFinder()->findAll(); foreach ($workspaces as $workspace) { - - /** @var Workspace $workspace */ - if ( - !$workspace->isPersonalWorkspace() - && $workspace !== $excludedWorkspace - && ($workspace->isPublicWorkspace() - || $workspace->isInternalWorkspace() - || $this->domainUserService->currentUserCanManageWorkspace($workspace)) - && (!$excludedWorkspace || $workspaces->getBaseWorkspaces($workspace->workspaceName)->get($excludedWorkspace->workspaceName) === null) - ) { - $baseWorkspaceOptions[$workspace->workspaceName->value] = $workspace->workspaceTitle->value; + if ($excludedWorkspace !== null) { + if ($workspace->workspaceName->equals($excludedWorkspace)) { + continue; + } + if ($workspaces->getBaseWorkspaces($workspace->workspaceName)->get($excludedWorkspace) !== null) { + continue; + } + } + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepository->id, $workspace->workspaceName); + if (!in_array($workspaceMetadata->classification, [WorkspaceClassification::SHARED, WorkspaceClassification::ROOT], true)) { + continue; + } + $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepository->id, $workspace->workspaceName); + if (!$permissions->manage) { + continue; } + $baseWorkspaceOptions[$workspace->workspaceName->value] = $workspace->workspaceTitle->value; } return $baseWorkspaceOptions; @@ -1098,7 +1046,7 @@ protected function prepareBaseWorkspaceOptions( protected function prepareOwnerOptions(): array { $ownerOptions = ['' => '-']; - foreach ($this->domainUserService->getUsers() as $user) { + foreach ($this->userService->getUsers() as $user) { /** @var User $user */ $ownerOptions[$this->persistenceManager->getIdentifierByObject($user)] = $user->getLabel(); } diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternals.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternals.php deleted file mode 100644 index 0a28cfe7413..00000000000 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternals.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ - public function getContentDimensionsOrderedByPriority(): array - { - return $this->contentDimensionSource->getContentDimensionsOrderedByPriority(); - } -} diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternalsFactory.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternalsFactory.php deleted file mode 100644 index 0ad2b7f3d38..00000000000 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternalsFactory.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ -class WorkspaceControllerInternalsFactory implements ContentRepositoryServiceFactoryInterface -{ - public function build( - ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies - ): WorkspaceControllerInternals { - return new WorkspaceControllerInternals( - $serviceFactoryDependencies->contentDimensionSource, - ); - } -} diff --git a/Neos.Workspace.Ui/Classes/ViewModel/PendingChanges.php b/Neos.Workspace.Ui/Classes/ViewModel/PendingChanges.php new file mode 100644 index 00000000000..ad999538841 --- /dev/null +++ b/Neos.Workspace.Ui/Classes/ViewModel/PendingChanges.php @@ -0,0 +1,46 @@ +total = $this->new + $this->changed + $this->removed; + } + + public function getNewCountRatio(): float + { + return $this->new / $this->total * 100; + } + + public function getChangedCountRatio(): float + { + return $this->changed / $this->total * 100; + } + + public function getRemovedCountRatio(): float + { + return $this->removed / $this->total * 100; + } +} diff --git a/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItem.php b/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItem.php new file mode 100644 index 00000000000..b7c54aa3d6e --- /dev/null +++ b/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItem.php @@ -0,0 +1,34 @@ + + */ +#[Flow\Proxy(false)] +final readonly class WorkspaceListItems implements \IteratorAggregate, \Countable +{ + private function __construct( + private array $items, + ) { + } + + /** + * @param array $items + */ + public static function fromArray(array $items): self + { + foreach ($items as $item) { + if (!$item instanceof WorkspaceListItem) { + throw new \InvalidArgumentException(sprintf('Expected instance of %s, got: %s', WorkspaceListItem::class, get_debug_type($item)), 1718295710); + } + } + return new self($items); + } + + public function getIterator(): \Traversable + { + return yield from $this->items; + } + + public function count(): int + { + return count($this->items); + } +} diff --git a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html index d65d207cbd7..f125042ecfa 100644 --- a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html +++ b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html @@ -3,6 +3,17 @@
+ + +
+ +
+ +
+
+
+
+ @@ -14,146 +25,137 @@ - - - - + + + + + - - + - - - - - +
+ + + + + + + + + + +
 
- - - - - - + +
+ + + + + + + + + + + + + + + + + + {workspace.title -> f:format.crop(maxCharacters: 25, append: '…')} + + + {workspace.baseWorkspaceName -> f:format.crop(maxCharacters: 25, append: '…')} + + + - + + + + + + TODO + + + TODO + + + + + +
+ - - - - - - - - + - + - +
+
+ + + + {neos:backend.translate(id: 'workspaces.review', source: 'Main', package: 'Neos.Workspace.Ui')} + -
{workspace.workspaceTitle.value -> f:format.crop(maxCharacters: 25, append: '…')} - - - {workspace.baseWorkspaceName.value -> f:format.crop(maxCharacters: 25, append: '…')} - - - - - + + +
+ + + + -
- + + - {workspaceAndCounts.workspaceOwnerHumanReadable} + - {workspaceAndCounts.workspaceOwnerHumanReadable} - - - - - -
- - - - - - - - - - -
-
- - - - {neos:backend.translate(id: 'workspaces.review', source: 'Main', package: 'Neos.Workspace.Ui')} - - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- -
{neos:backend.translate(id: 'workspaces.dialog.confirmWorkspaceDeletion', source: 'Main', package: 'Neos.Workspace.Ui', arguments: {0: workspace.workspaceTitle.value})}
-
-
-

{neos:backend.translate(id: 'workspaces.dialog.thisWillDeleteTheWorkspace', source: 'Main', package: 'Neos.Workspace.Ui')}

-
+ + + + + + + + + + + + +
+
+
+
+ +
{neos:backend.translate(id: 'workspaces.dialog.confirmWorkspaceDeletion', source: 'Main', package: 'Neos.Workspace.Ui', arguments: {0: workspace.workspaceTitle.value})}
+
+
+

{neos:backend.translate(id: 'workspaces.dialog.thisWillDeleteTheWorkspace', source: 'Main', package: 'Neos.Workspace.Ui')}

- +
+
-
- - - - - - - -
-
diff --git a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/New.html b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/New.html index 6c4e619e1b4..65ad3753f05 100644 --- a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/New.html +++ b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/New.html @@ -6,12 +6,12 @@

{neos:backend.translate(id: 'workspaces.createNewWorkspace', source: 'Main', - +
- +
@@ -27,36 +27,13 @@

{neos:backend.translate(id: 'workspaces.createNewWorkspace', source: 'Main',
- +
- - - -
- -
- -
-
- -
-
-
- - - -

diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf index 8786d9093b1..da39fa6b61e 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf @@ -159,6 +159,9 @@ A workspace with this title already exists. + + The workspace "{0}" has been created. + The workspace "{0}" has been updated. From 8975a5fbd2c5915842bff0647d9c228aecb54937 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Mon, 8 Jul 2024 10:17:43 +0200 Subject: [PATCH 02/43] wip --- .../ContentRepositoryIds.php | 60 +++++++ .../SharedModel/Workspace/WorkspaceName.php | 23 ++- .../Workspace/WorkspaceNameTest.php | 154 ++++++++++++++++++ .../Classes/ContentRepositoryRegistry.php | 8 +- .../Command/WorkspaceCommandController.php | 12 +- .../Classes/Domain/Model/UserInterface.php | 1 - .../Domain/Model/WorkspaceMetadata.php | 15 ++ .../Domain/Model/WorkspacePermissions.php | 6 +- .../Classes/Domain/Model/WorkspaceRole.php | 23 +++ .../Domain/Model/WorkspaceSubjectType.php | 14 ++ .../Classes/Domain/Service/UserService.php | 1 + .../Domain/Service/WorkspaceService.php | 147 +++++++---------- .../Mysql/Version20240425223900.php | 5 +- .../Postgresql/Version20240425223901.php | 5 +- .../Controller/WorkspaceController.php | 67 ++++---- .../Classes/ViewModel/WorkspaceListItems.php | 3 + 16 files changed, 391 insertions(+), 153 deletions(-) create mode 100644 Neos.ContentRepository.Core/Classes/SharedModel/ContentRepository/ContentRepositoryIds.php create mode 100644 Neos.ContentRepository.Core/Tests/Unit/SharedModel/Workspace/WorkspaceNameTest.php create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspaceRole.php create mode 100644 Neos.Neos/Classes/Domain/Model/WorkspaceSubjectType.php diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/ContentRepository/ContentRepositoryIds.php b/Neos.ContentRepository.Core/Classes/SharedModel/ContentRepository/ContentRepositoryIds.php new file mode 100644 index 00000000000..031638864b7 --- /dev/null +++ b/Neos.ContentRepository.Core/Classes/SharedModel/ContentRepository/ContentRepositoryIds.php @@ -0,0 +1,60 @@ + + * @api + */ +final readonly class ContentRepositoryIds implements \IteratorAggregate, \Countable +{ + /** + * @var array + */ + private array $ids; + + private function __construct(ContentRepositoryId ...$ids) + { + $this->ids = $ids; + } + + /** + * @param array $ids + */ + public static function fromArray(array $ids): self + { + $processedIds = []; + foreach ($ids as $id) { + if (is_string($id)) { + $id = ContentRepositoryId::fromString($id); + } + if (!$id instanceof ContentRepositoryId) { + throw new \InvalidArgumentException(sprintf('Expected string or instance of %s, got: %s', ContentRepositoryId::class, get_debug_type($id)), 1720424666); + } + $processedIds[] = $id; + } + return new self(...$processedIds); + } + + public function getIterator(): \Traversable + { + return yield from $this->ids; + } + + public function count(): int + { + return count($this->ids); + } +} diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php index 5725d3fdb89..512c52aa368 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceName.php @@ -23,7 +23,9 @@ */ final class WorkspaceName implements \JsonSerializable { - private const PATTERN = '/^[a-z0-9\-]{1,30}$/'; + public const MAX_LENGTH = 30; + + private const PATTERN = '/^[a-z][a-z0-9\-]{0,' . (self::MAX_LENGTH - 1) . '}$/'; public const WORKSPACE_NAME_LIVE = 'live'; @@ -36,7 +38,7 @@ private function __construct( public readonly string $value ) { if (!self::hasValidFormat($value)) { - throw new \InvalidArgumentException('Invalid workspace name given.', 1505826610318); + throw new \InvalidArgumentException('Invalid workspace name given.', 1505826610); } } @@ -77,15 +79,18 @@ public static function transliterateFromString(string $name): self // Transliterate (transform 北京 to 'Bei Jing') $name = Transliterator::transliterate($name); - // Urlization (replace spaces with dash, special characters) - $name = Transliterator::urlize($name); - // Ensure only allowed characters are left - $name = preg_replace('/[^a-z0-9\-]/', '', $name); + $name = (string)preg_replace('/[^a-z0-9\-]/', '', $name); + + // Ensure max length... + if (strlen($name) > self::MAX_LENGTH) { + $name = substr($name, 0, self::MAX_LENGTH); + } - // Make sure we don't have an empty string left. - if (empty($name)) { - $name = 'workspace-' . strtolower(md5($originalName)); + // If the name is still invalid at this point, we fall back to md5 + if (!self::hasValidFormat($name)) { + $prefix = 'workspace-'; + $name = $prefix . substr(md5($originalName), 0, self::MAX_LENGTH - strlen($prefix)); } return self::fromString($name); diff --git a/Neos.ContentRepository.Core/Tests/Unit/SharedModel/Workspace/WorkspaceNameTest.php b/Neos.ContentRepository.Core/Tests/Unit/SharedModel/Workspace/WorkspaceNameTest.php new file mode 100644 index 00000000000..6d01e897715 --- /dev/null +++ b/Neos.ContentRepository.Core/Tests/Unit/SharedModel/Workspace/WorkspaceNameTest.php @@ -0,0 +1,154 @@ +value, $value); + } + + /** + * @test + * @dataProvider validWorkspaceNames + */ + public function tryFromStringReturnsInstanceForValidValues(string $value): void + { + self::assertSame(WorkspaceName::tryFromString($value)->value, $value); + } + + private static function invalidWorkspaceNames(): iterable + { + yield 'empty string' => ['']; + yield 'only digits' => ['123']; + yield 'leading dash' => ['-invalid']; + yield 'upper case characters' => ['thisIsNotAllowed']; + yield 'whitespace' => ['this neither']; + yield 'exceeding max length' => ['this-is-just-a-little-too-long-']; + } + + /** + * @test + * @dataProvider invalidWorkspaceNames + */ + public function fromStringFailsForInvalidValues(string $value): void + { + $this->expectException(\InvalidArgumentException::class); + WorkspaceName::fromString($value); + } + + /** + * @test + * @dataProvider invalidWorkspaceNames + */ + public function tryFromStringReturnsNullForInvalidValues(string $value): void + { + self::assertNull(WorkspaceName::tryFromString($value)); + } + + /** + * @test + */ + public function forLiveReturnsAConstantInstance(): void + { + self::assertSame(WorkspaceName::fromString(WorkspaceName::WORKSPACE_NAME_LIVE), WorkspaceName::forLive()); + } + + private static function transliterateFromStringDataProvider(): iterable + { + yield 'valid name is not changed' => ['value' => 'already-valid', 'expectedResult' => 'already-valid']; + yield 'name is lower-cased' => ['value' => 'mixedCase', 'expectedResult' => 'mixedcase']; + yield 'chinese characters' => ['value' => '北京', 'expectedResult' => 'bei-jing']; + yield 'german umlauts' => ['value' => 'ümläute', 'expectedResult' => 'umlaute']; + yield 'white space' => ['value' => ' Contains spaces ', 'expectedResult' => 'contains-spaces']; + yield 'exceeding max length' => ['value' => 'This name is just a little too long', 'expectedResult' => 'this-name-is-just-a-little-too']; + yield 'only special characters' => ['value' => '-', 'expectedResult' => 'workspace-336d5ebc5436534e61d1']; + } + + /** + * @test + * @dataProvider transliterateFromStringDataProvider + */ + public function transliterateFromStringTests(string $value, string $expectedResult): void + { + self::assertSame($expectedResult, WorkspaceName::transliterateFromString($value)->value); + } + + /** + * @test + */ + public function isLiveReturnsFalseByDefault(): void + { + self::assertFalse(WorkspaceName::fromString('not-live')->isLive()); + } + + /** + * @test + */ + public function isLiveReturnsTrueForLiveWorkspace(): void + { + self::assertTrue(WorkspaceName::forLive()->isLive()); + } + + /** + * @test + */ + public function jsonSerializeReturnsPlainValue(): void + { + self::assertJsonStringEqualsJsonString(json_encode(WorkspaceName::forLive()), '"live"'); + } + + /** + * @test + */ + public function equalsReturnsFalseIfTwoInstancesDontMatch(): void + { + self::assertFalse(WorkspaceName::fromString('some-workspace')->equals(WorkspaceName::fromString('some-other-workspace'))); + } + + /** + * @test + */ + public function equalsReturnsTrueIfTwoInstancesMatch(): void + { + self::assertTrue(WorkspaceName::fromString('some-workspace')->equals(WorkspaceName::fromString('some-workspace'))); + } +} diff --git a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php index 4912e4e29e5..517ea017ff9 100644 --- a/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php +++ b/Neos.ContentRepositoryRegistry/Classes/ContentRepositoryRegistry.php @@ -17,6 +17,7 @@ use Neos\ContentRepository\Core\Projection\ProjectionCatchUpTriggerInterface; use Neos\ContentRepository\Core\Projection\ProjectionFactoryInterface; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryIds; use Neos\ContentRepository\Core\SharedModel\User\UserIdProviderInterface; use Neos\ContentRepositoryRegistry\Exception\ContentRepositoryNotFoundException; use Neos\ContentRepositoryRegistry\Exception\InvalidConfigurationException; @@ -81,12 +82,9 @@ public function get(ContentRepositoryId $contentRepositoryId): ContentRepository return $this->getFactory($contentRepositoryId)->getOrBuild(); } - /** - * @return array - */ - public function getContentRepositoryIds(): array + public function getContentRepositoryIds(): ContentRepositoryIds { - return array_map(ContentRepositoryId::fromString(...), array_keys($this->settings['contentRepositories'])); + return ContentRepositoryIds::fromArray(array_keys($this->settings['contentRepositories'] ?? [])); } /** diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index 4c030b1b790..ba7abc670f5 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -22,11 +22,9 @@ use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\ContentRepository\Core\Projection\Workspace\Workspace; -use Neos\ContentRepository\Core\Projection\Workspace\WorkspaceStatus; use Neos\ContentRepository\Core\Service\WorkspaceMaintenanceServiceFactory; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; -use Neos\ContentRepository\Core\SharedModel\User\UserId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceDescription; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -36,11 +34,11 @@ use Neos\Flow\Cli\CommandController; use Neos\Flow\Cli\Exception\StopCommandException; use Neos\Flow\Persistence\PersistenceManagerInterface; +use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Service\UserService; -use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Neos\Domain\Service\WorkspacePublishingService; -use Neos\Neos\PendingChangesProjection\ChangeFinder; +use Neos\Neos\Domain\Service\WorkspaceService; /** * The Workspace Command Controller @@ -182,7 +180,7 @@ public function createCommand( $this->workspaceService->createWorkspace( $contentRepositoryId, - \Neos\Neos\Domain\Model\WorkspaceTitle::fromString($title), + \Neos\Neos\Domain\Model\WorkspaceTitle::fromString($title ?? $workspace), \Neos\Neos\Domain\Model\WorkspaceDescription::fromString($description ?? ''), WorkspaceName::fromString($baseWorkspace), \Neos\Neos\Domain\Model\UserId::fromString($owner), @@ -209,7 +207,7 @@ public function createCommand( WorkspaceTitle::fromString($title ?: $workspace), WorkspaceDescription::fromString($description ?: $workspace), ContentStreamId::create(), - $workspaceOwnerUserId + $workspaceOwnerUserId !== null ? \Neos\ContentRepository\Core\SharedModel\User\UserId::fromString($workspaceOwnerUserId->value) : null, )); } catch (WorkspaceAlreadyExists $workspaceAlreadyExists) { $this->outputLine('Workspace "%s" already exists', [$workspace]); @@ -219,7 +217,7 @@ public function createCommand( $this->quit(2); } - if ($workspaceOwnerUserId instanceof UserId) { + if ($workspaceOwnerUserId !== null) { $this->outputLine( 'Created a new workspace "%s", based on workspace "%s", owned by "%s".', [$workspace, $baseWorkspace, $owner] diff --git a/Neos.Neos/Classes/Domain/Model/UserInterface.php b/Neos.Neos/Classes/Domain/Model/UserInterface.php index b6cc5b33692..7998656c49f 100644 --- a/Neos.Neos/Classes/Domain/Model/UserInterface.php +++ b/Neos.Neos/Classes/Domain/Model/UserInterface.php @@ -21,7 +21,6 @@ */ interface UserInterface { - /** * Returns the globally unique identifier for this user */ diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php index de693c701e0..d445b80a9f2 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php @@ -20,4 +20,19 @@ public function __construct( public WorkspaceClassification $classification, ) { } + + /** + * Note: To be used with named arguments! + */ + public function with( + WorkspaceTitle $title = null, + WorkspaceDescription $description = null, + ): self { + return new self( + $this->workspaceName, + $title ?? $this->title, + $description ?? $this->description, + $this->classification + ); + } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php index 39de79eb0a1..96ea78c4c4e 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php @@ -14,16 +14,16 @@ { private function __construct( public bool $read, - public bool $publish, + public bool $write, public bool $manage, ) { } public static function create( bool $read, - bool $publish, + bool $write, bool $manage, ): self { - return new self($read, $publish, $manage); + return new self($read, $write, $manage); } } diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php new file mode 100644 index 00000000000..5f256133db4 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRole.php @@ -0,0 +1,23 @@ +value >= $role->value; + } +} diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceSubjectType.php b/Neos.Neos/Classes/Domain/Model/WorkspaceSubjectType.php new file mode 100644 index 00000000000..ba05191d4d9 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceSubjectType.php @@ -0,0 +1,14 @@ +securityContext->getAuthenticationTokens() as $token) { + /** @var Account|null $account */ $account = $token->getAccount(); if ($account === null) { continue; diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 3ff1200ec9d..742a44b818e 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -15,6 +15,8 @@ namespace Neos\Neos\Domain\Service; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Exception; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; @@ -24,16 +26,15 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; -use Neos\Flow\Security\Account; -use Neos\Flow\Security\Authorization\PrivilegeManagerInterface; use Neos\Flow\Utility\Algorithms; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\UserId; -use Neos\Neos\Domain\Model\UserInterface; use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceMetadata; use Neos\Neos\Domain\Model\WorkspacePermissions; +use Neos\Neos\Domain\Model\WorkspaceRole; +use Neos\Neos\Domain\Model\WorkspaceSubjectType; use Neos\Neos\Domain\Model\WorkspaceTitle; /** @@ -47,7 +48,6 @@ final class WorkspaceService public function __construct( private readonly ContentRepositoryRegistry $contentRepositoryRegistry, - private readonly PrivilegeManagerInterface $privilegeManager, private readonly UserService $userService, private readonly Connection $dbal, ) { @@ -87,6 +87,27 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W ); } + public function updateWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle, WorkspaceDescription $newWorkspaceDescription): void + { + $this->requireWorkspace($contentRepositoryId, $workspaceName); + try { + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'title' => $newWorkspaceTitle->value, + 'description' => $newWorkspaceDescription->value, + ]); + } catch (UniqueConstraintViolationException $e) { + $this->dbal->update(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'title' => $newWorkspaceTitle->value, + 'description' => $newWorkspaceDescription->value, + ], [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + ]); + } + } + public function getPersonalWorkspaceForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): Workspace { $workspaceName = $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $userId); @@ -100,8 +121,7 @@ public function createRootWorkspace( ContentRepositoryId $contentRepositoryId, WorkspaceTitle $title, WorkspaceDescription $description, - ): WorkspaceName - { + ): WorkspaceName { $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); $workspaceName = self::getUniqueWorkspaceName($contentRepository, $title->value); $contentRepository->handle( @@ -115,12 +135,13 @@ public function createRootWorkspace( // TODO catch exceptions - $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ - 'content_repository_id' => $contentRepositoryId->value, - 'workspace_name' => $workspaceName->value, - 'subject' => 'group:EVERYBODY', - 'role' => 'OWNER', - ]); +// $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ +// 'content_repository_id' => $contentRepositoryId->value, +// 'workspace_name' => $workspaceName->value, +// 'subject_type' => WorkspaceSubjectType::GROUP->name, +// 'subject' => 'Neos.Flow:Everybody', +// 'role' => WorkspaceRole::COLLABORATOR, +// ]); // TODO catch exceptions @@ -161,8 +182,9 @@ public function createWorkspace( $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ 'content_repository_id' => $contentRepositoryId->value, 'workspace_name' => $workspaceName->value, - 'subject' => 'user:' . $ownerId->value, - 'role' => 'OWNER', + 'subject_type' => WorkspaceSubjectType::USER->name, + 'subject' => $ownerId->value, + 'role' => WorkspaceRole::OWNER->name, ]); } @@ -198,82 +220,16 @@ public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $con public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions { - $workspaceMetadata = $this->getWorkspaceMetadata($contentRepositoryId, $workspaceName); - $userRoles = $this->userService->getAllRoles($user); $userIsAdmin = array_key_exists('Neos.Neos:Administrator', $userRoles); - $userWorkspaceRoles = $this->getUserWorkspaceRoles($contentRepositoryId, $workspaceName, $user->getId(), array_keys($userRoles)); - - return match ($workspaceMetadata->classification) { - WorkspaceClassification::UNKNOWN => WorkspacePermissions::create( - read: $userIsAdmin, - publish: false, - manage: $userIsAdmin, - ), - WorkspaceClassification::ROOT => WorkspacePermissions::create( - read: true, - publish: $this->privilegeManager->isPrivilegeTargetGrantedForRoles($userRoles, 'Neos.Workspace.Ui:Backend.PublishAllToLiveWorkspace'), - manage: $userIsAdmin, - ), - WorkspaceClassification::PERSONAL => WorkspacePermissions::create( - read: $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $user->getId())?->equals($workspaceName) ?: false, - publish: in_array('MANAGER', $userWorkspaceRoles, true), - manage: $userIsAdmin || in_array('MANAGER', $userWorkspaceRoles, true), - ), - WorkspaceClassification::SHARED => WorkspacePermissions::create( - read: $userIsAdmin || in_array('MANAGER', $userWorkspaceRoles, true), - publish: $userIsAdmin || in_array('MANAGER', $userWorkspaceRoles, true), - manage: $userIsAdmin || in_array('MANAGER', $userWorkspaceRoles, true), - ), - }; + $userWorkspaceRole = $this->getWorkspaceRoleOfUser($contentRepositoryId, $workspaceName, $user->getId(), array_keys($userRoles)); -// $this->privilegeManager->isPrivilegeTargetGrantedForRoles($user->getAllRoles) -// -// // can manage -// -// if ($workspace->isInternalWorkspace()) { -// return $this->privilegeManager->isPrivilegeTargetGranted( -// 'Neos.Neos:Backend.Module.Management.Workspaces.ManageInternalWorkspaces' -// ); -// } -// -// -// $currentUser = $this->getCurrentUser(); -// if ($workspace->isPrivateWorkspace() && $currentUser !== null && $workspace->workspaceOwner === $this->persistenceManager->getIdentifierByObject($currentUser)) { -// return $this->privilegeManager->isPrivilegeTargetGranted( -// 'Neos.Neos:Backend.Module.Management.Workspaces.ManageOwnWorkspaces' -// ); -// } -// -// if ($workspace->isPrivateWorkspace() && $currentUser !== null && $workspace->workspaceOwner !== $this->persistenceManager->getIdentifierByObject($currentUser)) { -// return $this->privilegeManager->isPrivilegeTargetGranted( -// 'Neos.Neos:Backend.Module.Management.Workspaces.ManageAllPrivateWorkspaces' -// ); -// } -// -// -// // can publish -// -// if ($workspace->isPublicWorkspace()) { -// return $this->securityContext->hasRole('Neos.Neos:LivePublisher'); -// } -// -// $currentUser = $this->getCurrentUser(); -// $ownerIdentifier = $currentUser -// ? $this->persistenceManager->getIdentifierByObject($currentUser) -// : null; -// -// if ($workspace->workspaceOwner === null || $workspace->workspaceOwner === $ownerIdentifier) { -// return true; -// } -// -// -// return WorkspacePermissions::create( -// read: false, -// publish: false, -// manage: false, -// ); + return WorkspacePermissions::create( + read: $userIsAdmin || $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), + write: $userIsAdmin || $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), + manage: $userIsAdmin || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), + ); } private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): ?WorkspaceName @@ -297,7 +253,10 @@ private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRep return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName); } - private function getUserWorkspaceRoles(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, UserId $userId, array $userRoles): array + /** + * @param array $userRoles + */ + private function getWorkspaceRoleOfUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, UserId $userId, array $userRoles): WorkspaceRole { $table = self::TABLE_NAME_WORKSPACE_ROLE; $query = <<dbal->fetchFirstColumn($query, [ + $role = $this->dbal->fetchOne($query, [ 'contentRepositoryId' => $contentRepositoryId->value, 'workspaceName' => $workspaceName->value, 'subjects' => ['user:' . $userId->value, ...array_map(fn (string $role) => 'group:' . $role, $userRoles)], ], [ 'subjects' => Connection::PARAM_STR_ARRAY, ]); + if ($role === false) { + return WorkspaceRole::NONE; + } + return WorkspaceRole::from($role); } private static function getUniqueWorkspaceName(ContentRepository $contentRepository, string $candidate): WorkspaceName @@ -343,5 +313,4 @@ private function requireWorkspace(ContentRepositoryId $contentRepositoryId, Work } return $workspace; } - } diff --git a/Neos.Neos/Migrations/Mysql/Version20240425223900.php b/Neos.Neos/Migrations/Mysql/Version20240425223900.php index 3083611b5c6..3438212364a 100644 --- a/Neos.Neos/Migrations/Mysql/Version20240425223900.php +++ b/Neos.Neos/Migrations/Mysql/Version20240425223900.php @@ -32,9 +32,10 @@ public function up(Schema $schema): void $tableWorkspaceRole = $schema->createTable('neos_neos_workspace_role'); $tableWorkspaceRole->addColumn('content_repository_id', 'string', ['length' => 16]); $tableWorkspaceRole->addColumn('workspace_name', 'string', ['length' => 255]); + $tableWorkspaceRole->addColumn('subject_type', 'string', ['length' => 20]); $tableWorkspaceRole->addColumn('subject', 'string', ['length' => 255]); - $tableWorkspaceRole->addColumn('role', 'string', ['length' => 255]); - $tableWorkspaceRole->setPrimaryKey(['content_repository_id', 'workspace_name', 'subject']); + $tableWorkspaceRole->addColumn('role', 'string', ['length' => 20]); + $tableWorkspaceRole->setPrimaryKey(['content_repository_id', 'workspace_name', 'subject_type', 'subject']); } public function down(Schema $schema): void diff --git a/Neos.Neos/Migrations/Postgresql/Version20240425223901.php b/Neos.Neos/Migrations/Postgresql/Version20240425223901.php index dbfc24240ed..5218bf5bcd9 100644 --- a/Neos.Neos/Migrations/Postgresql/Version20240425223901.php +++ b/Neos.Neos/Migrations/Postgresql/Version20240425223901.php @@ -29,9 +29,10 @@ public function up(Schema $schema): void $tableWorkspaceRole = $schema->createTable('neos_neos_workspace_role'); $tableWorkspaceRole->addColumn('content_repository_id', 'string', ['length' => 16]); $tableWorkspaceRole->addColumn('workspace_name', 'string', ['length' => 255]); + $tableWorkspaceRole->addColumn('subject_type', 'string', ['length' => 20]); $tableWorkspaceRole->addColumn('subject', 'string', ['length' => 255]); - $tableWorkspaceRole->addColumn('role', 'string', ['length' => 255]); - $tableWorkspaceRole->setPrimaryKey(['content_repository_id', 'workspace_name', 'subject']); + $tableWorkspaceRole->addColumn('role', 'string', ['length' => 20]); + $tableWorkspaceRole->setPrimaryKey(['content_repository_id', 'workspace_name', 'subject_type', 'subject']); } public function down(Schema $schema): void diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index ea5b6d5e1fc..289521560bf 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -16,7 +16,6 @@ use Doctrine\DBAL\DBALException; use Neos\ContentRepository\Core\ContentRepository; -use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeWorkspaceOwner; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; @@ -33,7 +32,6 @@ use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; -use Neos\ContentRepository\Core\SharedModel\User\UserId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; @@ -49,7 +47,6 @@ use Neos\Flow\Property\PropertyMapper; use Neos\Flow\Security\Account; use Neos\Flow\Security\Context; -use Neos\Flow\Utility\Algorithms; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\ImageInterface; use Neos\Neos\Controller\Module\AbstractModuleController; @@ -62,10 +59,9 @@ use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; use Neos\Neos\Domain\Service\UserService; -use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Neos\Domain\Service\WorkspaceNameBuilder; use Neos\Neos\Domain\Service\WorkspacePublishingService; -use Neos\Neos\FrontendRouting\NodeAddress; +use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Neos\Domain\Workspace\DiscardAllChanges; use Neos\Neos\Domain\Workspace\PublishAllChanges; use Neos\Neos\Domain\Workspace\WorkspaceProvider; @@ -126,12 +122,14 @@ public function indexAction(): void } $contentRepositoryIds = $this->contentRepositoryRegistry->getContentRepositoryIds(); - $numberOfContentRepositories = count($contentRepositoryIds); + $numberOfContentRepositories = $contentRepositoryIds->count(); if ($numberOfContentRepositories === 0) { throw new \RuntimeException('No content repository configured', 1718296290); } if ($this->request->hasArgument('contentRepositoryId')) { - $contentRepositoryId = ContentRepositoryId::fromString($this->request->getArgument('contentRepositoryId')); + $contentRepositoryIdArgument = $this->request->getArgument('contentRepositoryId'); + assert(is_string($contentRepositoryIdArgument)); + $contentRepositoryId = ContentRepositoryId::fromString($contentRepositoryIdArgument); } else { $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; } @@ -164,6 +162,10 @@ classification: $workspaceMetadata->classification->name, public function showAction(WorkspaceName $workspace): void { + $currentUser = $this->userService->getCurrentUser(); + if ($currentUser === null) { + throw new \RuntimeException('No user authenticated', 1720371024); + } $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -172,13 +174,21 @@ public function showAction(WorkspaceName $workspace): void /** @todo add flash message */ $this->redirect('index'); } - $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $workspaceObj->workspaceName); + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace); + $baseWorkspaceMetadata = null; + $baseWorkspacePermissions = null; + if ($workspaceObj->baseWorkspaceName !== null) { + $baseWorkspace = $contentRepository->getWorkspaceFinder()->findOneByName($workspaceObj->baseWorkspaceName); + assert($baseWorkspace !== null); + $baseWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $baseWorkspace->workspaceName); + $baseWorkspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $baseWorkspace->workspaceName, $currentUser); + } $this->view->assignMultiple([ 'selectedWorkspace' => $workspaceObj, - 'selectedWorkspaceLabel' => $workspaceObj->workspaceTitle, + 'selectedWorkspaceLabel' => $workspaceMetadata->title->value, 'baseWorkspaceName' => $workspaceObj->baseWorkspaceName, - 'baseWorkspaceLabel' => $workspaceObj->baseWorkspaceName, // TODO fallback to title - 'canPublishToBaseWorkspace' => $permissions->publish, + 'baseWorkspaceLabel' => $baseWorkspaceMetadata?->title->value, + 'canPublishToBaseWorkspace' => $baseWorkspacePermissions?->write ?? false, 'siteChanges' => $this->computeSiteChanges($workspaceObj, $contentRepository), 'contentDimensions' => $contentRepository->getContentDimensionSource()->getContentDimensionsOrderedByPriority() ]); @@ -281,26 +291,12 @@ public function updateAction( ); $this->redirect('index'); } - - if (!$workspace->workspaceTitle->equals($title) || !$workspace->workspaceDescription->equals($description)) { - $contentRepository->handle( - RenameWorkspace::create( - $workspaceName, - $title, - $description - ) - ); - } - - if ($workspace->workspaceOwner !== $workspaceOwner) { - $contentRepository->handle( - ChangeWorkspaceOwner::create( - $workspaceName, - $workspaceOwner ?: null, - ) - ); - } - + $this->workspaceService->updateWorkspaceMetadata( + $contentRepositoryId, + $workspaceName, + $title, + $description, + ); $this->addFlashMessage($this->translator->translateById( 'workspaces.workspaceHasBeenUpdated', [$title->value], @@ -672,9 +668,6 @@ public function discardWorkspaceAction(WorkspaceName $workspace): void /** * Computes the number of added, changed and removed nodes for the given workspace - * - * @return array - * @throws \JsonException */ protected function computePendingChanges(Workspace $selectedWorkspace, ContentRepository $contentRepository): PendingChanges { @@ -1032,6 +1025,7 @@ protected function prepareBaseWorkspaceOptions( ContentRepository $contentRepository, WorkspaceName $excludedWorkspace = null, ): array { + $user = $this->userService->getCurrentUser(); $baseWorkspaceOptions = []; $workspaces = $contentRepository->getWorkspaceFinder()->findAll(); foreach ($workspaces as $workspace) { @@ -1047,7 +1041,10 @@ protected function prepareBaseWorkspaceOptions( if (!in_array($workspaceMetadata->classification, [WorkspaceClassification::SHARED, WorkspaceClassification::ROOT], true)) { continue; } - $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepository->id, $workspace->workspaceName); + if ($user === null) { + continue; + } + $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepository->id, $workspace->workspaceName, $user); if (!$permissions->manage) { continue; } diff --git a/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php b/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php index bfdcda600c7..7c875cf7d2a 100644 --- a/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php +++ b/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php @@ -22,6 +22,9 @@ #[Flow\Proxy(false)] final readonly class WorkspaceListItems implements \IteratorAggregate, \Countable { + /** + * @param array $items + */ private function __construct( private array $items, ) { From 54df32a8a8d0b402de527e6aac37e05d6c4beeb7 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Fri, 9 Aug 2024 14:01:01 +0200 Subject: [PATCH 03/43] Mini tweaks --- Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php | 3 --- Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index 196fe3b3e0b..7b0fd9fe7c1 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -62,9 +62,6 @@ use Neos\Neos\Domain\Service\WorkspaceNameBuilder; use Neos\Neos\Domain\Service\WorkspacePublishingService; use Neos\Neos\Domain\Service\WorkspaceService; -use Neos\Neos\Domain\Workspace\DiscardAllChanges; -use Neos\Neos\Domain\Workspace\PublishAllChanges; -use Neos\Neos\Domain\Workspace\WorkspaceProvider; use Neos\Neos\FrontendRouting\NodeAddress as LegacyNodeAddress; use Neos\Neos\FrontendRouting\NodeAddressFactory; use Neos\Neos\FrontendRouting\NodeUriBuilderFactory; diff --git a/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php b/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php index 7c875cf7d2a..39408c94bf9 100644 --- a/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php +++ b/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php @@ -45,7 +45,7 @@ public static function fromArray(array $items): self public function getIterator(): \Traversable { - return yield from $this->items; + yield from $this->items; } public function count(): int From dccfb50837425d97bf918740fff4f3c330311735 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Fri, 16 Aug 2024 11:07:53 +0200 Subject: [PATCH 04/43] wip --- .../Projection/Workspace/Workspace.php | 64 +++++++++++++++---- .../Command/WorkspaceCommandController.php | 34 +++++++++- .../Domain/Service/WorkspaceService.php | 2 +- .../Controller/WorkspaceController.php | 22 +++---- 4 files changed, 97 insertions(+), 25 deletions(-) diff --git a/Neos.ContentRepository.Core/Classes/Projection/Workspace/Workspace.php b/Neos.ContentRepository.Core/Classes/Projection/Workspace/Workspace.php index e2a294126d5..ff85e8c5da8 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/Workspace/Workspace.php +++ b/Neos.ContentRepository.Core/Classes/Projection/Workspace/Workspace.php @@ -24,31 +24,72 @@ * * @api */ -class Workspace +final readonly class Workspace { /** * This prefix determines if a given workspace (name) is a user workspace. */ public const PERSONAL_WORKSPACE_PREFIX = 'user-'; + /** + * @var WorkspaceName Workspace identifier, unique within one Content Repository instance + */ + public WorkspaceName $workspaceName; + + /** + * @var WorkspaceName|null Workspace identifier of the base workspace (i.e. the target when publishing changes) – if null this instance is considered a root (aka public) workspace + */ + public ?WorkspaceName $baseWorkspaceName; + + /** + * @deprecated with 9.0.0-beta12 metadata should be assigned to workspaces outside the Content Repository core + */ + public WorkspaceTitle $workspaceTitle; + + /** + * @deprecated with 9.0.0-beta12metadata should be assigned to workspaces outside the Content Repository core + */ + public WorkspaceDescription $workspaceDescription; + + /** + * The Content Stream this workspace currently points to – usually it is set to a new, empty content stream after publishing/rebasing the workspace + */ + public ContentStreamId $currentContentStreamId; + + /** + * The current status of this workspace + */ + public WorkspaceStatus $status; + + /** + * @deprecated with 9.0.0-beta12 owners/collaborators should be assigned to workspaces outside the Content Repository core + */ + public string|null $workspaceOwner; + /** * @internal */ public function __construct( - public readonly WorkspaceName $workspaceName, - public readonly ?WorkspaceName $baseWorkspaceName, - public readonly WorkspaceTitle $workspaceTitle, - public readonly WorkspaceDescription $workspaceDescription, - public readonly ContentStreamId $currentContentStreamId, - public readonly WorkspaceStatus $status, - public readonly ?string $workspaceOwner + WorkspaceName $workspaceName, + ?WorkspaceName $baseWorkspaceName, + WorkspaceTitle $workspaceTitle, + WorkspaceDescription $workspaceDescription, + ContentStreamId $currentContentStreamId, + WorkspaceStatus $status, + ?string $workspaceOwner ) { + $this->workspaceName = $workspaceName; + $this->baseWorkspaceName = $baseWorkspaceName; + $this->workspaceTitle = $workspaceTitle; + $this->workspaceDescription = $workspaceDescription; + $this->currentContentStreamId = $currentContentStreamId; + $this->status = $status; + $this->workspaceOwner = $workspaceOwner; } - /** * Checks if this workspace is a user's personal workspace - * @api + * @deprecated with 9.0.0-beta12 owners/collaborators should be assigned to workspaces outside the Content Repository core */ public function isPersonalWorkspace(): bool { @@ -59,7 +100,7 @@ public function isPersonalWorkspace(): bool * Checks if this workspace is shared only across users with access to internal workspaces, for example "reviewers" * * @return bool - * @api + * @deprecated with 9.0.0-beta12 owners/collaborators should be assigned to workspaces outside the Content Repository core */ public function isPrivateWorkspace(): bool { @@ -70,6 +111,7 @@ public function isPrivateWorkspace(): bool * Checks if this workspace is shared across all editors * * @return boolean + * @deprecated with 9.0.0-beta12 owners/collaborators should be assigned to workspaces outside the Content Repository core */ public function isInternalWorkspace(): bool { diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index ba7abc670f5..a48f668b137 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -373,8 +373,8 @@ public function listCommand(string $contentRepository = 'default'): void /* @var Workspace $workspace */ $tableRows[] = [ $workspace->workspaceName->value, - $workspaceMetadata->classification->name, - $workspace->baseWorkspaceName?->value ?: '', + $workspaceMetadata->classification->value, + $workspace->baseWorkspaceName?->value ?: '-', $workspaceMetadata->title->value, $workspaceMetadata->description->value, $workspace->status->value, @@ -383,4 +383,34 @@ public function listCommand(string $contentRepository = 'default'): void } $this->output->outputTable($tableRows, $headerRow); } + + /** + * Display details for the specified workspace + * + * @param string $workspace Name of the workspace to show + * @param string $contentRepository The name of the content repository. (Default: 'default') + * @throws StopCommandException + */ + public function showCommand(string $workspace, string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); + + $workspaceName = WorkspaceName::fromString($workspace); + $workspacesInstance = $contentRepositoryInstance->getWorkspaceFinder()->findOneByName($workspaceName); + + if ($workspacesInstance === null) { + $this->outputLine('Workspace "%s" not found.', [$workspaceName->value]); + $this->quit(); + } + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspaceName); + + $this->outputFormatted('Name: %s', [$workspacesInstance->workspaceName->value]); + $this->outputFormatted('Classification: %s', [$workspaceMetadata->classification->value]); + $this->outputFormatted('Base Workspace: %s', [$workspacesInstance->baseWorkspaceName?->value ?: '-']); + $this->outputFormatted('Title: %s', [$workspaceMetadata->title->value]); + $this->outputFormatted('Description: %s', [$workspaceMetadata->description->value]); + $this->outputFormatted('Status: %s', [$workspacesInstance->status->value]); + $this->outputFormatted('Content Stream: %s', [$workspacesInstance->currentContentStreamId->value]); + } } diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 742a44b818e..362d9d224be 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -279,7 +279,7 @@ private function getWorkspaceRoleOfUser(ContentRepositoryId $contentRepositoryId $role = $this->dbal->fetchOne($query, [ 'contentRepositoryId' => $contentRepositoryId->value, 'workspaceName' => $workspaceName->value, - 'subjects' => ['user:' . $userId->value, ...array_map(fn (string $role) => 'group:' . $role, $userRoles)], + 'subjects' => ['user:' . $userId->value, ...array_map(static fn (string $role) => 'group:' . $role, $userRoles)], ], [ 'subjects' => Connection::PARAM_STR_ARRAY, ]); diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index 7b0fd9fe7c1..ad69ab8f39f 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -263,7 +263,7 @@ public function editAction(WorkspaceName $workspaceName): void * @param WorkspaceName $workspaceName * @param WorkspaceTitle $title Human friendly title of the workspace, for example "Christmas Campaign" * @param WorkspaceDescription $description A description explaining the purpose of the new workspace - * @param string $workspaceOwner Id of the owner of the workspace + * @param string|null $workspaceOwner Id of the owner of the workspace * @return void */ public function updateAction( @@ -329,22 +329,23 @@ public function deleteAction(WorkspaceName $workspaceName): void $this->redirect('index'); } - if ($workspace->isPersonalWorkspace()) { + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); + + if ($workspaceMetadata->classification === WorkspaceClassification::PERSONAL) { $this->redirect('index'); } - $dependentWorkspaces = $contentRepository->getWorkspaceFinder() - ->findByBaseWorkspace($workspace->workspaceName); + $dependentWorkspaces = $contentRepository->getWorkspaceFinder()->findByBaseWorkspace($workspace->workspaceName); if (count($dependentWorkspaces) > 0) { $dependentWorkspaceTitles = []; - /** @var Workspace $dependentWorkspace */ foreach ($dependentWorkspaces as $dependentWorkspace) { - $dependentWorkspaceTitles[] = $dependentWorkspace->workspaceTitle->value; + $dependentWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $dependentWorkspace->workspaceName); + $dependentWorkspaceTitles[] = $dependentWorkspaceMetadata->title->value; } $message = $this->translator->translateById( 'workspaces.workspaceCannotBeDeletedBecauseOfDependencies', - [$workspace->workspaceTitle->value, implode(', ', $dependentWorkspaceTitles)], + [$workspaceMetadata->title->value, implode(', ', $dependentWorkspaceTitles)], null, null, 'Main', @@ -364,7 +365,7 @@ public function deleteAction(WorkspaceName $workspaceName): void } catch (\Exception $exception) { $message = $this->translator->translateById( 'workspaces.notDeletedErrorWhileFetchingUnpublishedNodes', - [$workspace->workspaceTitle->value], + [$workspaceMetadata->title->value], null, null, 'Main', @@ -376,7 +377,7 @@ public function deleteAction(WorkspaceName $workspaceName): void if ($nodesCount > 0) { $message = $this->translator->translateById( 'workspaces.workspaceCannotBeDeletedBecauseOfUnpublishedNodes', - [$workspace->workspaceTitle->value, $nodesCount], + [$workspaceMetadata->title->value, $nodesCount], $nodesCount, null, 'Main', @@ -394,7 +395,7 @@ public function deleteAction(WorkspaceName $workspaceName): void $this->addFlashMessage($this->translator->translateById( 'workspaces.workspaceHasBeenRemoved', - [$workspace->workspaceTitle->value], + [$workspaceMetadata->title->value], null, null, 'Main', @@ -459,7 +460,6 @@ public function rebaseAndRedirectAction(string $targetNode, Workspace $targetWor $targetNodeAddressInPersonalWorkspace->workspaceName ); $mainRequest = $this->controllerContext->getRequest()->getMainRequest(); - /** @var ActionRequest $mainRequest */ $this->uriBuilder->setRequest($mainRequest); $this->redirect( From 0af1fa293cdbad05a9fcdedfca430dbf47fe7b32 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Mon, 9 Sep 2024 10:04:43 +0200 Subject: [PATCH 05/43] WIP: Fix WorkspaceService as discussed with basti & christian --- Neos.Neos/Classes/Domain/Service/WorkspaceService.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 362d9d224be..53a2eb44912 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -15,7 +15,6 @@ namespace Neos\Neos\Domain\Service; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; @@ -243,12 +242,14 @@ private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRep WHERE content_repository_id = :contentRepositoryId AND subject = :subject + AND subject_type = :subjectType AND role = :ownerRole SQL; $workspaceName = $this->dbal->fetchOne($query, [ 'contentRepositoryId' => $contentRepositoryId->value, - 'subject' => 'user:' . $userId->value, - 'ownerRole' => 'OWNER' + 'subject' => $userId->value, + 'subjectType' => WorkspaceSubjectType::USER->name, + 'ownerRole' => WorkspaceRole::OWNER->name, ]); return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName); } @@ -295,7 +296,7 @@ private static function getUniqueWorkspaceName(ContentRepository $contentReposit $workspaceName = $workspaceNameCandidate; while ($contentRepository->getWorkspaceFinder()->findOneByName($workspaceName) instanceof Workspace) { $workspaceName = WorkspaceName::fromString( - $workspaceNameCandidate->value . '-' . Algorithms::generateRandomString(5) + $workspaceNameCandidate->value . '-' . strtolower(Algorithms::generateRandomString(5)) ); } return $workspaceName; From e70592466cf8967011dbe092a20945134d78988c Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 10 Sep 2024 13:00:39 +0200 Subject: [PATCH 06/43] Fix WorkspaceService::createRootWorkspace() --- Neos.Neos/Classes/Domain/Service/WorkspaceService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 53a2eb44912..588aa65a49e 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -149,7 +149,7 @@ public function createRootWorkspace( 'workspace_name' => $workspaceName->value, 'title' => $title->value, 'description' => $description->value, - 'classification' => WorkspaceClassification::ROOT, + 'classification' => WorkspaceClassification::ROOT->name, ]); return $workspaceName; From 11aa9b30c5596152b991564631122b8bc836cf23 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 10 Sep 2024 13:01:11 +0200 Subject: [PATCH 07/43] Use `WorkspaceService` in workspace::createroot CLI command --- .../Command/WorkspaceCommandController.php | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index a48f668b137..416a9f33aa9 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -14,7 +14,6 @@ namespace Neos\Neos\Command; -use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\BaseWorkspaceDoesNotExist; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; @@ -36,6 +35,8 @@ use Neos\Flow\Persistence\PersistenceManagerInterface; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; +use Neos\Neos\Domain\Model\WorkspaceDescription as NeosWorkspaceDescription; +use Neos\Neos\Domain\Model\WorkspaceTitle as NeosWorkspaceTitle; use Neos\Neos\Domain\Service\UserService; use Neos\Neos\Domain\Service\WorkspacePublishingService; use Neos\Neos\Domain\Service\WorkspaceService; @@ -141,18 +142,17 @@ public function rebaseCommand(string $workspace, string $contentRepository = 'de * * @param string $name Name of the new root * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @param string|null $description Optional description of the workspace */ - public function createRootCommand(string $name, string $contentRepository = 'default'): void + public function createRootCommand(string $name, string $contentRepository = 'default', string $description = null): void { $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); - $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); - - $contentRepositoryInstance->handle(CreateRootWorkspace::create( - WorkspaceName::fromString($name), - WorkspaceTitle::fromString($name), - WorkspaceDescription::fromString($name), - ContentStreamId::create() - )); + $workspaceName = $this->workspaceService->createRootWorkspace( + $contentRepositoryId, + NeosWorkspaceTitle::fromString($name), + NeosWorkspaceDescription::fromString($description ?? $name) + ); + $this->outputLine('Created root workspace "%s" in content repository "%s"', [$workspaceName->value, $contentRepositoryId->value]); } /** @@ -180,8 +180,8 @@ public function createCommand( $this->workspaceService->createWorkspace( $contentRepositoryId, - \Neos\Neos\Domain\Model\WorkspaceTitle::fromString($title ?? $workspace), - \Neos\Neos\Domain\Model\WorkspaceDescription::fromString($description ?? ''), + NeosWorkspaceTitle::fromString($title ?? $workspace), + NeosWorkspaceDescription::fromString($description ?? ''), WorkspaceName::fromString($baseWorkspace), \Neos\Neos\Domain\Model\UserId::fromString($owner), WorkspaceClassification::PERSONAL, From bdfb9549bc8be0ff6a215af7d1176621f855288b Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Tue, 10 Sep 2024 17:10:05 +0200 Subject: [PATCH 08/43] Stabilize unique workspace name creation and add initial behat tests --- .../Bootstrap/Features/WorkspaceCreation.php | 2 +- .../Bootstrap/Helpers/FakeUserIdProvider.php | 2 +- .../Command/WorkspaceCommandController.php | 7 +- .../Domain/Service/WorkspaceService.php | 102 +++++++++++------- .../Features/Bootstrap/FeatureContext.php | 3 +- .../Bootstrap/WorkspaceServiceTrait.php | 101 +++++++++++++++++ .../WorkspaceService.feature | 90 ++++++++++++++++ .../Controller/WorkspaceController.php | 3 +- 8 files changed, 264 insertions(+), 46 deletions(-) create mode 100644 Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php create mode 100644 Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php index fec6f30e5a5..bdfcd1dc5e4 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php @@ -68,7 +68,7 @@ public function theCommandCreateRootWorkspaceIsExecutedWithPayload(TableNode $pa $command = CreateRootWorkspace::create( WorkspaceName::fromString($commandArguments['workspaceName']), new WorkspaceTitle($commandArguments['workspaceTitle'] ?? ucfirst($commandArguments['workspaceName'])), - new WorkspaceDescription($commandArguments['workspaceDescription'] ?? 'The workspace "' . $commandArguments['workspaceName'] . '"'), + new WorkspaceDescription($commandArguments['workspaceDescription'] ?? ('The workspace "' . $commandArguments['workspaceName'] . '"')), ContentStreamId::fromString($commandArguments['newContentStreamId']) ); diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeUserIdProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeUserIdProvider.php index f52f569ceb2..88614645a23 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeUserIdProvider.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeUserIdProvider.php @@ -9,7 +9,7 @@ final class FakeUserIdProvider implements UserIdProviderInterface { - private static ?UserId $userId = null; + public static ?UserId $userId = null; public static function setUserId(UserId $userId): void { diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index 416a9f33aa9..32c5c44ef61 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -158,7 +158,7 @@ public function createRootCommand(string $name, string $contentRepository = 'def /** * Create a new workspace * - * This command creates a new workspace. + * This command creates a new personal workspace. * * @param string $workspace Name of the workspace, for example "christmas-campaign" * @param string $baseWorkspace Name of the base workspace. If none is specified, "live" is assumed. @@ -178,13 +178,12 @@ public function createCommand( ): void { $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); - $this->workspaceService->createWorkspace( + $this->workspaceService->createPersonalWorkspace( $contentRepositoryId, NeosWorkspaceTitle::fromString($title ?? $workspace), NeosWorkspaceDescription::fromString($description ?? ''), WorkspaceName::fromString($baseWorkspace), - \Neos\Neos\Domain\Model\UserId::fromString($owner), - WorkspaceClassification::PERSONAL, + UserId::fromString($owner), ); $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 588aa65a49e..53e3fb552e1 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -25,7 +25,6 @@ use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; -use Neos\Flow\Utility\Algorithms; use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Model\WorkspaceClassification; @@ -155,7 +154,57 @@ public function createRootWorkspace( return $workspaceName; } - public function createWorkspace( + public function createPersonalWorkspace( + ContentRepositoryId $contentRepositoryId, + WorkspaceTitle $title, + WorkspaceDescription $description, + WorkspaceName $baseWorkspaceName, + UserId $ownerId, + ): WorkspaceName { + return $this->createWorkspace($contentRepositoryId, $title, $description, $baseWorkspaceName, $ownerId, WorkspaceClassification::PERSONAL); + } + + public function createSharedWorkspace( + ContentRepositoryId $contentRepositoryId, + WorkspaceTitle $title, + WorkspaceDescription $description, + WorkspaceName $baseWorkspaceName, + UserId|null $ownerId = null, + ): WorkspaceName { + return $this->createWorkspace($contentRepositoryId, $title, $description, $baseWorkspaceName, $ownerId, WorkspaceClassification::SHARED); + } + + public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $contentRepositoryId, User $user): void + { + $existingWorkspaceName = $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $user->getId()); + if ($existingWorkspaceName !== null) { + $this->requireWorkspace($contentRepositoryId, $existingWorkspaceName); + return; + } + $this->createPersonalWorkspace( + $contentRepositoryId, + WorkspaceTitle::fromString($user->getLabel()), + WorkspaceDescription::empty(), + WorkspaceName::forLive(), + $user->getId(), + ); + } + + public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions + { + $userRoles = $this->userService->getAllRoles($user); + $userIsAdmin = array_key_exists('Neos.Neos:Administrator', $userRoles); + + $userWorkspaceRole = $this->getWorkspaceRoleOfUser($contentRepositoryId, $workspaceName, $user->getId(), array_keys($userRoles)); + + return WorkspacePermissions::create( + read: $userIsAdmin || $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), + write: $userIsAdmin || $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), + manage: $userIsAdmin || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), + ); + } + + private function createWorkspace( ContentRepositoryId $contentRepositoryId, WorkspaceTitle $title, WorkspaceDescription $description, @@ -200,37 +249,6 @@ public function createWorkspace( return $workspaceName; } - public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $contentRepositoryId, User $user): void - { - $existingWorkspaceName = $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $user->getId()); - if ($existingWorkspaceName !== null) { - $this->requireWorkspace($contentRepositoryId, $existingWorkspaceName); - return; - } - $this->createWorkspace( - $contentRepositoryId, - WorkspaceTitle::fromString($user->getLabel()), - WorkspaceDescription::empty(), - WorkspaceName::forLive(), - $user->getId(), - WorkspaceClassification::PERSONAL, - ); - } - - public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions - { - $userRoles = $this->userService->getAllRoles($user); - $userIsAdmin = array_key_exists('Neos.Neos:Administrator', $userRoles); - - $userWorkspaceRole = $this->getWorkspaceRoleOfUser($contentRepositoryId, $workspaceName, $user->getId(), array_keys($userRoles)); - - return WorkspacePermissions::create( - read: $userIsAdmin || $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), - write: $userIsAdmin || $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), - manage: $userIsAdmin || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), - ); - } - private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): ?WorkspaceName { $table = self::TABLE_NAME_WORKSPACE_ROLE; @@ -294,12 +312,22 @@ private static function getUniqueWorkspaceName(ContentRepository $contentReposit { $workspaceNameCandidate = WorkspaceName::transliterateFromString($candidate); $workspaceName = $workspaceNameCandidate; - while ($contentRepository->getWorkspaceFinder()->findOneByName($workspaceName) instanceof Workspace) { + $attempt = 1; + do { + if ($contentRepository->getWorkspaceFinder()->findOneByName($workspaceName) === null) { + return $workspaceName; + } + if ($attempt === 1) { + $suffix = ''; + } else { + $suffix = '-' . ($attempt - 1); + } $workspaceName = WorkspaceName::fromString( - $workspaceNameCandidate->value . '-' . strtolower(Algorithms::generateRandomString(5)) + substr($workspaceNameCandidate->value, 0, WorkspaceName::MAX_LENGTH - strlen($suffix)) . $suffix ); - } - return $workspaceName; + $attempt++; + } while ($attempt <= 10); + throw new \RuntimeException(sprintf('Failed to find unique workspace name for "%s" after %d attempts.', $candidate, $attempt - 1), 1725975479); } diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php index ac1b37f4ba5..08699ce17ca 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php @@ -44,6 +44,8 @@ class FeatureContext implements BehatContext use ContentCacheTrait; use AssetTrait; + use WorkspaceServiceTrait; + protected Environment $environment; protected ContentRepositoryRegistry $contentRepositoryRegistry; @@ -55,7 +57,6 @@ public function __construct() $this->environment = $this->getObject(Environment::class); $this->contentRepositoryRegistry = $this->getObject(ContentRepositoryRegistry::class); $this->persistenceManager = $this->getObject(PersistenceManagerInterface::class); - } /* diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php new file mode 100644 index 00000000000..1462d889cc4 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -0,0 +1,101 @@ + $className + * + * @return T + */ + abstract private function getObject(string $className): object; + + /** + * @When the root workspace :workspaceTitle is created + */ + public function theRootWorkspaceIsCreated(string $workspaceTitle): void + { + $this->getObject(WorkspaceService::class)->createRootWorkspace( + $this->currentContentRepository->id, + WorkspaceTitle::fromString($workspaceTitle), + WorkspaceDescription::fromString(''), + ); + } + + /** + * @When the personal workspace :workspaceTitle is created with the target workspace :targetWorkspace + */ + public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceTitle, string $targetWorkspace): void + { + $this->getObject(WorkspaceService::class)->createPersonalWorkspace( + $this->currentContentRepository->id, + WorkspaceTitle::fromString($workspaceTitle), + WorkspaceDescription::fromString(''), + WorkspaceName::fromString($targetWorkspace), + UserId::fromString(FakeUserIdProvider::$userId?->value ?? ''), + ); + } + + /** + * @When the shared workspace :workspaceTitle is created with the target workspace :targetWorkspace + */ + public function theSharedWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceTitle, string $targetWorkspace): void + { + $this->getObject(WorkspaceService::class)->createSharedWorkspace( + $this->currentContentRepository->id, + WorkspaceTitle::fromString($workspaceTitle), + WorkspaceDescription::fromString(''), + WorkspaceName::fromString($targetWorkspace), + null + ); + } + + /** + * @Then the following workspaces should exist: + */ + public function theFollowingWorkspacesShouldExist(TableNode $expectedWorkspacesTable): void + { + $expectedWorkspaces = $expectedWorkspacesTable->getHash(); + $actualWorkspaces = []; + $workspaceFinder = $this->currentContentRepository->getWorkspaceFinder(); + $workspaceService = $this->getObject(WorkspaceService::class); + foreach ($workspaceFinder->findAll() as $workspace) { + $workspaceMetadata = $workspaceService->getWorkspaceMetadata($this->currentContentRepository->id, $workspace->workspaceName); + $actualWorkspaces[] = [ + 'Name' => $workspace->workspaceName->value, + 'Base workspace' => $workspace->baseWorkspaceName?->value ?? '', + 'Title' => $workspaceMetadata->title->value, + 'Classification' => $workspaceMetadata->classification->value, + ]; + } + Assert::same($expectedWorkspaces, $actualWorkspaces); + } +} diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature new file mode 100644 index 00000000000..5c8eaac6faa --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -0,0 +1,90 @@ +@flowEntities +Feature: Neos WorkspaceService related features + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository:Root': {} + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + And I am user identified by "editor" + + Scenario: Create single root workspace + When the root workspace "Some root workspace" is created + Then the following workspaces should exist: + | Name | Base workspace | Title | Classification | + | some-root-workspace | | Some root workspace | ROOT | + + Scenario: Create root workspace with a title that exceeds the workspace name max length + When the root workspace "Some root workspace with a title that exceeds the max name length" is created + Then the following workspaces should exist: + | Name | Base workspace | Title | Classification | + | some-root-workspace-with-a-title-tha | | Some root workspace with a title that exceeds the max name length | ROOT | + + Scenario: Create multiple root workspaces with the same derived name + When the root workspace "Root" is created + And the root workspace "Root 5" is created + And the root workspace "root" is created + And the root workspace "-Root" is created + And the root workspace "Root" is created + And the root workspace "Root" is created + And the root workspace "Root" is created + And the root workspace "Root" is created + And the root workspace "Root" is created + Then the following workspaces should exist: + | Name | Base workspace | Title | Classification | + | root | | Root | ROOT | + | root-1 | | root | ROOT | + | root-2 | | -Root | ROOT | + | root-3 | | Root | ROOT | + | root-4 | | Root | ROOT | + | root-5 | | Root 5 | ROOT | + | root-6 | | Root | ROOT | + | root-7 | | Root | ROOT | + | root-8 | | Root | ROOT | + + Scenario: Create multiple root workspaces with the same derived name with a lenght that exceeds the allowed max length + And the root workspace "some-root-workspace-with-a-long-title" is created + And the root workspace "some-root-workspace-with-a-long-title" is created + And the root workspace "some-root-workspace-with-a-long-title" is created + Then the following workspaces should exist: + | Name | Base workspace | Title | Classification | + | some-root-workspace-with-a-long-ti-1 | | some-root-workspace-with-a-long-title | ROOT | + | some-root-workspace-with-a-long-ti-2 | | some-root-workspace-with-a-long-title | ROOT | + | some-root-workspace-with-a-long-titl | | some-root-workspace-with-a-long-title | ROOT | + + Scenario: Create a single personal workspace + When the root workspace "Some root workspace" is created + And the personal workspace "Some user workspace" is created with the target workspace "some-root-workspace" + Then the following workspaces should exist: + | Name | Base workspace | Title | Classification | + | some-root-workspace | | Some root workspace | ROOT | + | some-user-workspace | some-root-workspace | Some user workspace | PERSONAL | + + Scenario: Create a single shared workspace + When the root workspace "Some root workspace" is created + And the shared workspace "Some shared workspace" is created with the target workspace "some-root-workspace" + Then the following workspaces should exist: + | Name | Base workspace | Title | Classification | + | some-root-workspace | | Some root workspace | ROOT | + | some-shared-workspace | some-root-workspace | Some shared workspace | SHARED | + + Scenario: Creating several workspaces with the same derived names + When the root workspace "root 1" is created + And the root workspace "Root 2" is created + And the personal workspace "User 1" is created with the target workspace "root-1" + And the personal workspace "User 2" is created with the target workspace "root-2" + And the personal workspace "root 1" is created with the target workspace "root-2" + And the shared workspace "Root 1" is created with the target workspace "root-2" + And the shared workspace "Shared 2" is created with the target workspace "root-1" + Then the following workspaces should exist: + | Name | Base workspace | Title | Classification | + | root-1 | | root 1 | ROOT | + | root-1-1 | root-2 | root 1 | PERSONAL | + | root-1-2 | root-2 | Root 1 | SHARED | + | root-2 | | Root 2 | ROOT | + | shared-2 | root-1 | Shared 2 | SHARED | + | user-1 | root-1 | User 1 | PERSONAL | + | user-2 | root-2 | User 2 | PERSONAL | diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index ad69ab8f39f..e217817b4c3 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -211,13 +211,12 @@ public function createAction( } try { - $this->workspaceService->createWorkspace( + $this->workspaceService->createSharedWorkspace( $contentRepositoryId, $title, $description, $baseWorkspace, $currentUser->getId(), - WorkspaceClassification::SHARED, ); } catch (WorkspaceAlreadyExists $exception) { $this->addFlashMessage( From 0f20b89231532a166e6312f8e6370320b5eea704 Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Thu, 19 Sep 2024 11:01:25 +0200 Subject: [PATCH 09/43] Provide CLI to synchronize workspace metadata and roles Usage: ``` ./flow workspace:syncall --content-repository foo ``` --- .../Command/WorkspaceCommandController.php | 29 ++++++++++ .../Domain/Service/WorkspaceService.php | 54 +++++++++++++------ .../Controller/WorkspaceController.php | 14 +++-- .../Private/Templates/Workspace/Index.html | 6 +-- 4 files changed, 76 insertions(+), 27 deletions(-) diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index 32c5c44ef61..2cf14aaa8d3 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -412,4 +412,33 @@ public function showCommand(string $workspace, string $contentRepository = 'defa $this->outputFormatted('Status: %s', [$workspacesInstance->status->value]); $this->outputFormatted('Content Stream: %s', [$workspacesInstance->currentContentStreamId->value]); } + + + /** + * Synchronizes metadata and role assignments of all workspaces for the specified Content Repository + * + * @param string $contentRepository The name of the content repository. (Default: 'default') + * @throws StopCommandException + */ + public function syncAllCommand(string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); + + $workspaces = $contentRepositoryInstance->getWorkspaceFinder()->findAll(); + + if (count($workspaces) === 0) { + $this->outputLine('No workspaces found.'); + $this->quit(); + } + foreach ($workspaces as $workspace) { + try { + $this->workspaceService->synchronizeWorkspaceMetadataAndRoles($contentRepositoryId, $workspace->workspaceName); + $this->outputLine('Synchronized workspace "%s"', [$workspace->workspaceName->value]); + } catch (\Exception $exception) { + $this->outputLine('Failed to synchronize workspace "%s": %s', [$workspace->workspaceName->value, $exception->getMessage()]); + } + } + $this->outputLine('Done.'); + } } diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php index 53e3fb552e1..6369fb3af39 100644 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -70,12 +70,7 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W 'workspaceName' => $workspaceName->value, ]); if ($metadataRow === false) { - return new WorkspaceMetadata( - $workspaceName, - WorkspaceTitle::fromString(ucfirst($workspaceName->value)), - WorkspaceDescription::fromString(''), - $workspaceName->isLive() ? WorkspaceClassification::ROOT : WorkspaceClassification::UNKNOWN, - ); + throw new \RuntimeException(sprintf('Failed to load metadata for workspace "%s" (Content Repository "%s"). Maybe workspace metadata and roles have to be synchronized', $workspaceName->value, $contentRepositoryId->value), 1726736384); } return new WorkspaceMetadata( $workspaceName, @@ -85,24 +80,51 @@ public function getWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, W ); } - public function updateWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle, WorkspaceDescription $newWorkspaceDescription): void + public function updateWorkspaceTitleAndDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle, WorkspaceDescription $newWorkspaceDescription): void { $this->requireWorkspace($contentRepositoryId, $workspaceName); + $this->dbal->update(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'title' => $newWorkspaceTitle->value, + 'description' => $newWorkspaceDescription->value, + ], [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + ]); + } + + public function synchronizeWorkspaceMetadataAndRoles(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): void + { + $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); + if ($workspace->baseWorkspaceName === null) { + $classification = WorkspaceClassification::ROOT; + } elseif ($workspace->workspaceOwner !== null) { + $classification = WorkspaceClassification::PERSONAL; + } else { + $classification = WorkspaceClassification::SHARED; + } try { $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ 'content_repository_id' => $contentRepositoryId->value, 'workspace_name' => $workspaceName->value, - 'title' => $newWorkspaceTitle->value, - 'description' => $newWorkspaceDescription->value, + 'title' => WorkspaceTitle::fromString($workspace->workspaceTitle->value)->value, + 'description' => WorkspaceDescription::fromString($workspace->workspaceDescription->value)->value, + 'classification' => $classification->value, ]); } catch (UniqueConstraintViolationException $e) { - $this->dbal->update(self::TABLE_NAME_WORKSPACE_METADATA, [ - 'title' => $newWorkspaceTitle->value, - 'description' => $newWorkspaceDescription->value, - ], [ - 'content_repository_id' => $contentRepositoryId->value, - 'workspace_name' => $workspaceName->value, - ]); + // Metadata already exists + } + if ($workspace->workspaceOwner !== null) { + try { + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject_type' => WorkspaceSubjectType::USER->name, + 'subject' => $workspace->workspaceOwner, + 'role' => WorkspaceRole::OWNER->name, + ]); + } catch (UniqueConstraintViolationException $e) { + // Owner role assignment already exists + } } } diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index e217817b4c3..6b5244e8341 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -241,17 +241,17 @@ public function editAction(WorkspaceName $workspaceName): void $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($workspaceName); if (is_null($workspace)) { - // @todo add flash message + $this->addFlashMessage('Failed to find workspace "%s"', 'Error', Message::SEVERITY_ERROR, [$workspaceName->value]); $this->redirect('index'); } $this->view->assign('workspace', $workspace); $this->view->assign('baseWorkspaceOptions', $this->prepareBaseWorkspaceOptions($contentRepository, $workspaceName)); // TODO: $this->view->assign('disableBaseWorkspaceSelector', // $this->publishingService->getUnpublishedNodesCount($workspace) > 0); - $this->view->assign( - 'showOwnerSelector', - true// TODO fix $this->userService->currentUserCanTransferOwnershipOfWorkspace($workspace) - ); + + // TODO fix $this->userService->currentUserCanTransferOwnershipOfWorkspace($workspace) + $this->view->assign('showOwnerSelector', false); + $this->view->assign('ownerOptions', $this->prepareOwnerOptions()); } @@ -262,14 +262,12 @@ public function editAction(WorkspaceName $workspaceName): void * @param WorkspaceName $workspaceName * @param WorkspaceTitle $title Human friendly title of the workspace, for example "Christmas Campaign" * @param WorkspaceDescription $description A description explaining the purpose of the new workspace - * @param string|null $workspaceOwner Id of the owner of the workspace * @return void */ public function updateAction( WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, - ?string $workspaceOwner ): void { $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); @@ -287,7 +285,7 @@ public function updateAction( ); $this->redirect('index'); } - $this->workspaceService->updateWorkspaceMetadata( + $this->workspaceService->updateWorkspaceTitleAndDescription( $contentRepositoryId, $workspaceName, $title, diff --git a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html index f125042ecfa..b76c5a2a837 100644 --- a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html +++ b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html @@ -82,7 +82,7 @@ - + {neos:backend.translate(id: 'workspaces.review', source: 'Main', package: 'Neos.Workspace.Ui')} @@ -92,7 +92,7 @@
- + @@ -130,7 +130,7 @@