From f48cf7a950eeb7a4bb524450d00a18bbb920eea9 Mon Sep 17 00:00:00 2001 From: Stefan Dietrich <5716289+stediefan@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:40:03 +0200 Subject: [PATCH 1/5] feat: add event handling for circle events to auto create teamfolders upon circle creation feat: add team_circle_id flag to teamfolder to uniquely link a team feat: adminUI: add filters for classic teamfolders and the newly linked ones todo: sensible naming for the two types of folders Assisted by: GitHub Copilot:Claude Sonnet 5 Assisted by: GitHub Copilot:GPT-5.6 Terra Assisted by: GitHub Copilot:Kimi K2.7 Code Signed-off-by: Stefan Dietrich <5716289+stediefan@users.noreply.github.com> --- composer.lock | 20 +- lib/AppInfo/Application.php | 5 +- lib/Command/ACL.php | 28 +- lib/Command/Delete.php | 5 + lib/Command/Group.php | 26 +- lib/Command/Quota.php | 9 +- lib/Command/Rename.php | 5 + lib/Controller/FolderController.php | 45 ++- lib/Folder/FolderDefinition.php | 22 ++ lib/Folder/FolderDefinitionWithMappings.php | 5 +- .../FolderDefinitionWithPermissions.php | 5 +- lib/Folder/FolderManager.php | 195 +++++++++++- lib/Folder/FolderWithMappingsAndCache.php | 7 +- .../CircleDestroyedEventListener.php | 36 --- .../Version2300000Date20260720000001.php | 136 +++++++++ lib/ResponseDefinitions.php | 1 + lib/TeamSpace/TeamSpaceProvider.php | 101 +++++++ lib/TeamSpace/TeamSpaceService.php | 277 ++++++++++++++++++ openapi.json | 4 + src/settings/App.scss | 55 ++++ src/settings/App.tsx | 161 +++++++--- src/settings/FolderGroups.tsx | 10 +- src/types/openapi/openapi.ts | 1 + tests/Folder/FolderManagerTest.php | 162 +++++++++- .../CircleDestroyedEventListenerTest.php | 66 ----- tests/TeamSpace/TeamSpaceServiceTest.php | 69 +++++ 26 files changed, 1263 insertions(+), 193 deletions(-) delete mode 100644 lib/Listeners/CircleDestroyedEventListener.php create mode 100644 lib/Migration/Version2300000Date20260720000001.php create mode 100644 lib/TeamSpace/TeamSpaceProvider.php create mode 100644 lib/TeamSpace/TeamSpaceService.php delete mode 100644 tests/Listeners/CircleDestroyedEventListenerTest.php create mode 100644 tests/TeamSpace/TeamSpaceServiceTest.php diff --git a/composer.lock b/composer.lock index 175159715..3c581bb8c 100644 --- a/composer.lock +++ b/composer.lock @@ -71,16 +71,16 @@ "source": { "type": "git", "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "b01a80e20e17c3bf3c5d8a0246289ec465fd0b0f" + "reference": "720f4bf1e1978f837161b12681a42a282a1ad786" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/b01a80e20e17c3bf3c5d8a0246289ec465fd0b0f", - "reference": "b01a80e20e17c3bf3c5d8a0246289ec465fd0b0f", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/720f4bf1e1978f837161b12681a42a282a1ad786", + "reference": "720f4bf1e1978f837161b12681a42a282a1ad786", "shasum": "" }, "require": { - "php": "~8.1 || ~8.2 || ~8.3 || ~8.4 || ~8.5", + "php": "~8.3 || ~8.4 || ~8.5", "psr/clock": "^1.0", "psr/container": "^2.0.2", "psr/event-dispatcher": "^1.0", @@ -113,7 +113,7 @@ "issues": "https://github.com/nextcloud-deps/ocp/issues", "source": "https://github.com/nextcloud-deps/ocp/tree/master" }, - "time": "2026-06-04T02:37:15+00:00" + "time": "2026-07-21T01:28:34+00:00" }, { "name": "psr/clock", @@ -1692,15 +1692,15 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { - "nextcloud/ocp": 20, - "roave/security-advisories": 20 + "roave/security-advisories": 20, + "nextcloud/ocp": 20 }, "prefer-stable": false, "prefer-lowest": false, - "platform": {}, - "platform-dev": {}, + "platform": [], + "platform-dev": [], "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 77fdebecc..7c4680d1c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -8,7 +8,6 @@ namespace OCA\GroupFolders\AppInfo; -use OCA\Circles\Events\CircleDestroyedEvent; use OCA\DAV\Connector\Sabre\Principal; use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; @@ -26,7 +25,6 @@ use OCA\GroupFolders\Command\ExpireGroup\ExpireGroupVersionsTrash; use OCA\GroupFolders\Folder\FolderManager; use OCA\GroupFolders\Listeners\CacheListener; -use OCA\GroupFolders\Listeners\CircleDestroyedEventListener; use OCA\GroupFolders\Listeners\DeleteListener; use OCA\GroupFolders\Listeners\LoadAdditionalScriptsListener; use OCA\GroupFolders\Listeners\NodeRenamedListener; @@ -34,6 +32,7 @@ use OCA\GroupFolders\Mount\MountProvider; use OCA\GroupFolders\Trash\TrashBackend; use OCA\GroupFolders\Trash\TrashManager; +use OCA\GroupFolders\TeamSpace\TeamSpaceProvider; use OCA\GroupFolders\Versions\GroupVersionsExpireManager; use OCA\GroupFolders\Versions\VersionsBackend; use OCP\AppFramework\App; @@ -85,7 +84,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalScriptsListener::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, LoadAdditionalScriptsListener::class); - $context->registerEventListener(CircleDestroyedEvent::class, CircleDestroyedEventListener::class); + $context->registerTeamResourceProvider(TeamSpaceProvider::class); $context->registerEventListener(NodeRenamedEvent::class, NodeRenamedListener::class); $context->registerEventListener(CacheEntryInsertedEvent::class, CacheListener::class, 99999); $context->registerEventListener(CacheEntryUpdatedEvent::class, CacheListener::class, 99999); diff --git a/lib/Command/ACL.php b/lib/Command/ACL.php index df83e4947..8862f6454 100644 --- a/lib/Command/ACL.php +++ b/lib/Command/ACL.php @@ -66,9 +66,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if ($input->getOption('enable')) { - $this->folderManager->setFolderACL($folder->id, true); + try { + $this->folderManager->setFolderACL($folder->id, true); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } } elseif ($input->getOption('disable')) { - $this->folderManager->setFolderACL($folder->id, false); + try { + $this->folderManager->setFolderACL($folder->id, false); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } } elseif ($input->getOption('test')) { if ($input->getOption('user') && ($input->getArgument('path'))) { /** @var string $mappingId */ @@ -108,10 +118,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->printPermissions($input, $output, $folder); } elseif ($input->getOption('manage-add') && ($input->getOption('user') || $input->getOption('group') || $input->getOption('team'))) { [$mappingType, $mappingId] = $this->convertMappingOptions($input); - $this->folderManager->setManageACL($folder->id, $mappingType, $mappingId, true); + try { + $this->folderManager->setManageACL($folder->id, $mappingType, $mappingId, true); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } } elseif ($input->getOption('manage-remove') && ($input->getOption('user') || $input->getOption('group') || $input->getOption('team'))) { [$mappingType, $mappingId] = $this->convertMappingOptions($input); - $this->folderManager->setManageACL($folder->id, $mappingType, $mappingId, false); + try { + $this->folderManager->setManageACL($folder->id, $mappingType, $mappingId, false); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } } elseif (!$input->getArgument('path')) { $output->writeln(' argument has to be set when not using --enable or --disable'); return -3; diff --git a/lib/Command/Delete.php b/lib/Command/Delete.php index e01f03e5b..923f9e8f8 100644 --- a/lib/Command/Delete.php +++ b/lib/Command/Delete.php @@ -31,6 +31,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int return -1; } + if ($folder->isTeamSpace()) { + $output->writeln('This folder belongs to a team and cannot be deleted directly; unlink it from its team first.'); + return 1; + } + /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); $question = new ConfirmationQuestion('Are you sure you want to delete the Team folder ' . $folder->mountPoint . ' and all files within, this cannot be undone (y/N).', false); diff --git a/lib/Command/Group.php b/lib/Command/Group.php index 8eb3ff847..ac20a8a4f 100644 --- a/lib/Command/Group.php +++ b/lib/Command/Group.php @@ -59,20 +59,34 @@ protected function execute(InputInterface $input, OutputInterface $output): int $groupString = $input->getArgument('group'); $group = $this->groupManager->get($groupString); if ($input->getOption('delete')) { - $this->folderManager->removeApplicableGroup($folder->id, $groupString); - return 0; + try { + $this->folderManager->removeApplicableGroup($folder->id, $groupString); + return 0; + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } } elseif ($group || $this->folderManager->isACircle($groupString)) { /** @var list $permissionsString */ $permissionsString = (array)$input->getArgument('permissions'); $permissions = $this->getNewPermissions($permissionsString); if ($permissions > 0) { if (!isset($folder->groups[$groupString])) { - $this->folderManager->addApplicableGroup($folder->id, $groupString); + try { + $this->folderManager->addApplicableGroup($folder->id, $groupString); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } } - $this->folderManager->setGroupPermissions($folder->id, $groupString, $permissions); - - return 0; + try { + $this->folderManager->setGroupPermissions($folder->id, $groupString, $permissions); + return 0; + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } } $output->writeln('Unable to parse permissions input: ' . implode(' ', $permissionsString) . ''); diff --git a/lib/Command/Quota.php b/lib/Command/Quota.php index af817a3de..ee95d807a 100644 --- a/lib/Command/Quota.php +++ b/lib/Command/Quota.php @@ -38,8 +38,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $quotaString = strtolower($quotaString); $quota = ($quotaString === 'unlimited') ? FileInfo::SPACE_UNLIMITED : \OCP\Util::computerFileSize($quotaString); if ($quota) { - $this->folderManager->setFolderQuota($folder->id, (int)$quota); - return 0; + try { + $this->folderManager->setFolderQuota($folder->id, (int)$quota); + return 0; + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } } $output->writeln('Unable to parse quota input: ' . $quotaString . ''); diff --git a/lib/Command/Rename.php b/lib/Command/Rename.php index 0ec5d97a7..dccf76309 100644 --- a/lib/Command/Rename.php +++ b/lib/Command/Rename.php @@ -28,6 +28,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int return -1; } + if ($folder->isTeamSpace()) { + $output->writeln('This folder belongs to a team and cannot be renamed; unlink it from its team first.'); + return 1; + } + /** @var string $name */ $name = $input->getArgument('name'); diff --git a/lib/Controller/FolderController.php b/lib/Controller/FolderController.php index 1efff9642..3fa530a4c 100644 --- a/lib/Controller/FolderController.php +++ b/lib/Controller/FolderController.php @@ -53,6 +53,7 @@ * acl_default_no_permission: bool, * manage: list, * sortIndex?: non-negative-int, + * team_circle_id?: ?string, * } */ class FolderController extends OCSController { @@ -110,6 +111,7 @@ private function formatFolder(FolderWithMappingsAndCache $folder): array { 'groups' => array_map(fn (array $group): int => $group['permissions'], $folder->groups), 'group_details' => $folder->groups, 'manage' => $folder->manage, + 'team_circle_id' => $folder->getTeamCircleId(), ]; } @@ -293,6 +295,10 @@ public function addFolder(string $mountpoint, ?string $bucket = null, bool $acl_ public function removeFolder(int $id): DataResponse { $folder = $this->checkedGetFolder($id); + if ($folder->isTeamSpace()) { + throw new OCSForbiddenException('This folder belongs to a team and cannot be deleted directly; unlink it from its team first'); + } + $this->folderStorageManager->deleteStoragesForFolder($folder); $this->manager->removeFolder($id); @@ -344,6 +350,10 @@ public function setMountPoint(int $id, string $mountPoint): DataResponse { public function addGroup(int $id, string $group): DataResponse { $folder = $this->checkedGetFolder($id); + if ($folder->isTeamSpace()) { + throw new OCSForbiddenException('This folder belongs to a team and cannot be shared with other groups; unlink it from its team first'); + } + if (array_key_exists($group, $folder->groups)) { throw new OCSBadRequestException('Group already assigned to this Groupfolder'); } @@ -370,7 +380,13 @@ public function addGroup(int $id, string $group): DataResponse { #[NoAdminRequired] #[FrontpageRoute(verb: 'DELETE', url: '/folders/{id}/groups/{group}', requirements: ['group' => '.+'])] public function removeGroup(int $id, string $group): DataResponse { - $this->checkedGetFolder($id); + $folder = $this->checkedGetFolder($id); + + // The owning team's access cannot be removed independently; the folder + // must be unlinked from its team first. + if ($folder->isTeamSpace() && $folder->getTeamCircleId() === $group) { + throw new OCSForbiddenException('This folder belongs to this team and its access cannot be removed independently; unlink the folder from the team first'); + } $this->manager->removeApplicableGroup($id, $group); @@ -395,7 +411,12 @@ public function removeGroup(int $id, string $group): DataResponse { #[NoAdminRequired] #[FrontpageRoute(verb: 'POST', url: '/folders/{id}/groups/{group}', requirements: ['group' => '.+'])] public function setPermissions(int $id, string $group, int $permissions): DataResponse { - $this->checkedGetFolder($id); + $folder = $this->checkedGetFolder($id); + + // The owning team's permissions on a team space are fixed. + if ($folder->isTeamSpace() && $folder->getTeamCircleId() === $group) { + throw new OCSForbiddenException('This team space belongs to this team and its permissions cannot be changed independently; unlink the team space from the team first'); + } $this->manager->setGroupPermissions($id, $group, $permissions); @@ -421,7 +442,11 @@ public function setPermissions(int $id, string $group, int $permissions): DataRe #[NoAdminRequired] #[FrontpageRoute(verb: 'POST', url: '/folders/{id}/manageACL')] public function setManageACL(int $id, string $mappingType, string $mappingId, bool $manageAcl): DataResponse { - $this->checkedGetFolder($id); + $folder = $this->checkedGetFolder($id); + + if ($folder->isTeamSpace()) { + throw new OCSForbiddenException('This folder belongs to a team and its ACL management cannot be changed independently; unlink it from its team first'); + } $this->manager->setManageACL($id, $mappingType, $mappingId, $manageAcl); @@ -445,7 +470,7 @@ public function setManageACL(int $id, string $mappingType, string $mappingId, bo #[NoAdminRequired] #[FrontpageRoute(verb: 'POST', url: '/folders/{id}/quota')] public function setQuota(int $id, int $quota): DataResponse { - $this->checkedGetFolder($id); + $folder = $this->checkedGetFolder($id); $this->manager->setFolderQuota($id, $quota); @@ -469,7 +494,11 @@ public function setQuota(int $id, int $quota): DataResponse { #[NoAdminRequired] #[FrontpageRoute(verb: 'POST', url: '/folders/{id}/acl')] public function setACL(int $id, bool $acl): DataResponse { - $this->checkedGetFolder($id); + $folder = $this->checkedGetFolder($id); + + if ($folder->isTeamSpace()) { + throw new OCSForbiddenException('This folder belongs to a team and its advanced permissions cannot be changed independently; unlink it from its team first'); + } $this->manager->setFolderACL($id, $acl); @@ -496,10 +525,12 @@ public function setACL(int $id, bool $acl): DataResponse { public function renameFolder(int $id, string $mountpoint): DataResponse { $mountpoint = $this->manager->trimMountpoint($mountpoint); - $this->checkedGetFolder($id); - $folder = $this->checkedGetFolder($id); + if ($folder->isTeamSpace()) { + throw new OCSForbiddenException('This folder belongs to a team and cannot be renamed; unlink it from its team first'); + } + if ($folder->mountPoint === $mountpoint) { return new DataResponse(['success' => true, 'folder' => $this->formatFolder($folder)]); } diff --git a/lib/Folder/FolderDefinition.php b/lib/Folder/FolderDefinition.php index 6a821e40e..14adad3d1 100644 --- a/lib/Folder/FolderDefinition.php +++ b/lib/Folder/FolderDefinition.php @@ -11,6 +11,7 @@ class FolderDefinition { /** * @param array{separate-storage?: bool} $options + * @param ?string $teamCircleId The circle single id this team space belongs to; null for regular team folders. */ public function __construct( public readonly int $id, @@ -21,10 +22,31 @@ public function __construct( public readonly int $storageId, public readonly int $rootId, public readonly array $options, + public readonly ?string $teamCircleId = null, ) { } public function useSeparateStorage(): bool { return $this->options['separate-storage'] ?? false; } + + /** + * Whether this folder belongs to a team (i.e. is a team space) and + * therefore cannot be deleted or renamed independently of that team. + * + * Replaces the former `isEssential()` check against the `options.essential` + * JSON key. The relationship is now stored as the explicit `team_circle_id` + * column on `group_folders`. + */ + public function isTeamSpace(): bool { + return $this->teamCircleId !== null; + } + + /** + * The circle single id this team space belongs to, or null for regular + * team folders. + */ + public function getTeamCircleId(): ?string { + return $this->teamCircleId; + } } diff --git a/lib/Folder/FolderDefinitionWithMappings.php b/lib/Folder/FolderDefinitionWithMappings.php index eb08b6a45..d7b584037 100644 --- a/lib/Folder/FolderDefinitionWithMappings.php +++ b/lib/Folder/FolderDefinitionWithMappings.php @@ -18,6 +18,7 @@ class FolderDefinitionWithMappings extends FolderDefinition { /** * @param array $groups * @param list $manage + * @param ?string $teamCircleId */ public function __construct( int $id, @@ -30,8 +31,9 @@ public function __construct( array $options, public readonly array $groups, public readonly array $manage, + ?string $teamCircleId = null, ) { - parent::__construct($id, $mountPoint, $quota, $acl, $aclDefaultNoPermission, $storageId, $rootId, $options); + parent::__construct($id, $mountPoint, $quota, $acl, $aclDefaultNoPermission, $storageId, $rootId, $options, $teamCircleId); } /** @@ -50,6 +52,7 @@ public static function fromFolder(FolderDefinition $folder, array $groups, array $folder->options, $groups, $manage, + $folder->teamCircleId, ); } diff --git a/lib/Folder/FolderDefinitionWithPermissions.php b/lib/Folder/FolderDefinitionWithPermissions.php index 6327d6311..d44e031ca 100644 --- a/lib/Folder/FolderDefinitionWithPermissions.php +++ b/lib/Folder/FolderDefinitionWithPermissions.php @@ -27,8 +27,9 @@ public function __construct( array $options, public readonly ICacheEntry $rootCacheEntry, public readonly int $permissions, + ?string $teamCircleId = null, ) { - parent::__construct($id, $mountPoint, $quota, $acl, $aclDefaultNoPermission, $storageId, $rootId, $options); + parent::__construct($id, $mountPoint, $quota, $acl, $aclDefaultNoPermission, $storageId, $rootId, $options, $teamCircleId); } public static function fromFolder(FolderDefinition $folder, ICacheEntry $rootCacheEntry, int $permissions): FolderDefinitionWithPermissions { @@ -43,6 +44,7 @@ public static function fromFolder(FolderDefinition $folder, ICacheEntry $rootCac $folder->options, $rootCacheEntry, $permissions, + $folder->teamCircleId, ); } @@ -85,6 +87,7 @@ public function withAddedPermissions(int $permissions): self { $this->options, $this->rootCacheEntry, $this->permissions | $permissions, + $this->teamCircleId, ); } } diff --git a/lib/Folder/FolderManager.php b/lib/Folder/FolderManager.php index 0634b49af..1cb029b49 100644 --- a/lib/Folder/FolderManager.php +++ b/lib/Folder/FolderManager.php @@ -100,10 +100,10 @@ public function getAllFolders(): array { $query = $this->connection->getQueryBuilder(); - $query->select('folder_id', 'mount_point', 'quota', 'acl', 'acl_default_no_permission', 'storage_id', 'root_id', 'options') + $query->select('folder_id', 'mount_point', 'quota', 'acl', 'acl_default_no_permission', 'storage_id', 'root_id', 'options', 'team_circle_id') ->from('group_folders', 'f'); - /** @var list $rows */ + /** @var list $rows */ $rows = $query->executeQuery()->fetchAll(); $folderMap = []; @@ -134,6 +134,7 @@ private function selectWithFileCache(?IQueryBuilder $query = null): IQueryBuilde 'storage_id', 'root_id', 'options', + 'team_circle_id', 'c.fileid', 'c.storage', 'c.path', @@ -174,7 +175,7 @@ public function getAllFoldersWithSize(int $offset = 0, ?int $limit = null, strin $query->addOrderBy('mount_point', 'ASC'); } - /** @var list $rows */ + /** @var list $rows */ $rows = $query->executeQuery()->fetchAll(); $folderIds = array_map(static fn (array $row): int => (int)$row['folder_id'], $rows); @@ -215,7 +216,7 @@ public function getAllFoldersForUserWithSize(IUser $user): array { ->selectAlias('a.permissions', 'group_permissions') ->where($query->expr()->in('a.group_id', $query->createNamedParameter($groups, IQueryBuilder::PARAM_STR_ARRAY))); - /** @var list $rows */ + /** @var list $rows */ $rows = $query->executeQuery()->fetchAll(); $folderIds = array_map(static fn (array $row): int => (int)$row['folder_id'], $rows); @@ -346,7 +347,7 @@ public function getFolder(int $id): ?FolderWithMappingsAndCache { $query->where($query->expr()->eq('f.folder_id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))); $result = $query->executeQuery(); - /** @var array{folder_id: int|string, mount_point: string, quota: int|string, acl: bool, acl_default_no_permission: bool, storage_id: int|string, root_id: int|string, options: string}|false $row */ + /** @var array{folder_id: int|string, mount_point: string, quota: int|string, acl: bool, acl_default_no_permission: bool, storage_id: int|string, root_id: int|string, options: string, team_circle_id: ?string}|false $row */ $row = $result->fetch(); $result->closeCursor(); if ($row === false) { @@ -682,7 +683,7 @@ private function getFolderOptions(array $row): array { } /** - * @param array{folder_id: int|string, mount_point: string, quota: int|string, acl: bool, acl_default_no_permission: bool, storage_id: int|string, root_id: int|string, options?: string} $row + * @param array{folder_id: int|string, mount_point: string, quota: int|string, acl: bool, acl_default_no_permission: bool, storage_id: int|string, root_id: int|string, options?: string, team_circle_id?: ?string} $row */ private function rowToFolder(array $row): FolderDefinition { return new FolderDefinition( @@ -694,6 +695,7 @@ private function rowToFolder(array $row): FolderDefinition { (int)$row['storage_id'], (int)$row['root_id'], $this->getFolderOptions($row), + $row['team_circle_id'] ?? null, ); } @@ -733,7 +735,7 @@ public function getFoldersForGroups(array $groupIds, ?int $folderId = null, ?arr $query->setParameter('groupIds', $chunk, IQueryBuilder::PARAM_STR_ARRAY); if ($paths === null) { - /** @var list $result */ + /** @var list $result */ $result = array_merge($result, $query->executeQuery()->fetchAll()); continue; } @@ -741,7 +743,7 @@ public function getFoldersForGroups(array $groupIds, ?int $folderId = null, ?arr // When paths are set, we need to chunk these as well foreach (array_chunk($paths, IQueryBuilder::MAX_IN_PARAMETERS) as $pathChunk) { $query->setParameter('path', $pathChunk, IQueryBuilder::PARAM_STR_ARRAY); - /** @var list $result */ + /** @var list $result */ $result = array_merge($result, $query->executeQuery()->fetchAll()); } } @@ -846,11 +848,11 @@ public function getFoldersFromCircleMemberships(IUser $user, ?int $folderId = nu // When paths are set, we need to chunk these as well foreach (array_chunk($paths, 1000) as $pathChunk) { $query->setParameter('path', $pathChunk, IQueryBuilder::PARAM_STR_ARRAY); - /** @var list $result */ + /** @var list $result */ $result = array_merge($result, $query->executeQuery()->fetchAll()); } } else { - /** @var list $result */ + /** @var list $result */ $result = $query->executeQuery()->fetchAll(); } @@ -903,6 +905,7 @@ public function createFolder(string $mountPoint, array $options = [], bool $aclD 'mount_point' => $query->createNamedParameter($mountPoint), 'quota' => self::SPACE_DEFAULT, 'acl_default_no_permission' => $query->createNamedParameter($aclDefaultNoPermission, IQueryBuilder::PARAM_BOOL), + 'team_circle_id' => $query->createNamedParameter(null, IQueryBuilder::PARAM_NULL), 'options' => $query->createNamedParameter(json_encode([ 'separate-storage' => $seperateStorage, ])) @@ -928,6 +931,8 @@ public function createFolder(string $mountPoint, array $options = [], bool $aclD * @throws Exception */ public function addApplicableGroup(int $folderId, string $groupId): void { + $this->assertNotTeamSpace($folderId, 'sharing'); + $query = $this->connection->getQueryBuilder(); if ($this->isACircle($groupId)) { @@ -951,6 +956,17 @@ public function addApplicableGroup(int $folderId, string $groupId): void { * @throws Exception */ public function removeApplicableGroup(int $folderId, string $groupId): void { + // Removing the owning team's access would orphan the team space from + // its team. Block it; the folder must be unlinked from its team first + // (which clears team_circle_id and then removes the applicable group). + $teamCircleId = $this->getTeamCircleId($folderId); + if ($teamCircleId !== null && $teamCircleId === $groupId) { + throw new Exception( + 'This team space belongs to this team and its access cannot be removed independently; ' + . 'unlink the team space from the team first', + ); + } + $query = $this->connection->getQueryBuilder(); $query->delete('group_folders_groups') @@ -974,6 +990,16 @@ public function removeApplicableGroup(int $folderId, string $groupId): void { * @throws Exception */ public function setGroupPermissions(int $folderId, string $groupId, int $permissions): void { + // The owning team's permissions on a team space are fixed; they must + // not be lowered or removed independently of the team-space link. + $teamCircleId = $this->getTeamCircleId($folderId); + if ($teamCircleId !== null && $teamCircleId === $groupId) { + throw new Exception( + 'This team space belongs to this team and its permissions cannot be changed independently; ' + . 'unlink the team space from the team first', + ); + } + $query = $this->connection->getQueryBuilder(); $query->update('group_folders_groups') @@ -999,6 +1025,8 @@ public function setGroupPermissions(int $folderId, string $groupId, int $permiss * @throws Exception */ public function setManageACL(int $folderId, string $type, string $id, bool $manageAcl): void { + $this->assertNotTeamSpace($folderId, 'ACL management'); + $query = $this->connection->getQueryBuilder(); if ($manageAcl === true) { @@ -1027,6 +1055,14 @@ public function setManageACL(int $folderId, string $type, string $id, bool $mana * @throws Exception */ public function removeFolder(int $folderId): void { + // Prevent deletion of team spaces (folders that belong to a team). + // The owning app is responsible for unlinking the team space from its team + // before deleting it. + $folder = $this->getFolder($folderId); + if ($folder !== null && $folder->isTeamSpace()) { + throw new Exception('This team space belongs to a team and cannot be deleted directly; unlink it from its team first'); + } + $this->connection->beginTransaction(); try { $query = $this->connection->getQueryBuilder(); @@ -1079,10 +1115,33 @@ public function setFolderQuota(int $folderId, int $quota): void { $this->eventDispatcher->dispatchTyped(new CriticalActionPerformedEvent('The quota for groupfolder with id %d was set to %d bytes', [$folderId, $quota])); } + /** + * Update the JSON-encoded options of a folder. + * + * @param int $folderId + * @param array{separate-storage?: bool} $options + * @throws Exception + */ + public function setFolderOptions(int $folderId, array $options): void { + $query = $this->connection->getQueryBuilder(); + $query->update('group_folders') + ->set('options', $query->createNamedParameter(json_encode($options, JSON_THROW_ON_ERROR))) + ->where($query->expr()->eq('folder_id', $query->createNamedParameter($folderId, IQueryBuilder::PARAM_INT))); + $query->executeStatement(); + + $this->eventDispatcher->dispatchTyped(new CriticalActionPerformedEvent('The options for groupfolder with id %d were updated', [$folderId])); + } + /** * @throws Exception */ public function renameFolder(int $folderId, string $newMountPoint): void { + // Prevent renaming of team spaces (folders that belong to a team). + $folder = $this->getFolder($folderId); + if ($folder !== null && $folder->isTeamSpace()) { + throw new Exception('This team space belongs to a team and cannot be renamed; unlink it from its team first'); + } + $query = $this->connection->getQueryBuilder(); $query->update('group_folders') @@ -1158,10 +1217,126 @@ public function deleteCircle(string $circleId): void { $query->executeStatement(); } + /** + * Look up the team space that belongs to the given team (circle single id) + * via the `team_circle_id` column. + * + * This is the single source of truth for the "folder belongs to team" + * relationship. The legacy `group_folders_groups.circle_id` column tracks + * applicable-group membership (a separate concern) and must not be used to + * determine team ownership. + * + * @return int|null The folder id, or null if no folder belongs to this team. + */ + public function getFolderIdByTeamCircleId(string $circleId): ?int { + if ($circleId === '') { + return null; + } + + $query = $this->connection->getQueryBuilder(); + $query->select('folder_id') + ->from('group_folders') + ->where($query->expr()->eq('team_circle_id', $query->createNamedParameter($circleId))); + $result = $query->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + if ($row === false || !isset($row['folder_id'])) { + return null; + } + + $folderId = $row['folder_id']; + return is_numeric($folderId) ? (int)$folderId : null; + } + + /** + * Mark a team folder as belonging to a team by setting the `team_circle_id` + * column. + */ + public function setTeamCircleId(int $folderId, string $circleId): void { + $existingFolderId = $this->getFolderIdByTeamCircleId($circleId); + if ($existingFolderId !== null && $existingFolderId !== $folderId) { + throw new Exception('This team already has a team space'); + } + + $query = $this->connection->getQueryBuilder(); + $query->update('group_folders') + ->set('team_circle_id', $query->createNamedParameter($circleId)) + ->where($query->expr()->eq('folder_id', $query->createNamedParameter($folderId, IQueryBuilder::PARAM_INT))); + $query->executeStatement(); + } + + /** + * Clear the team ownership of a team space by resetting the + * `team_circle_id` column to null. + */ + public function clearTeamCircleId(int $folderId): void { + $query = $this->connection->getQueryBuilder(); + $query->update('group_folders') + ->set('team_circle_id', $query->createNamedParameter(null, IQueryBuilder::PARAM_NULL)) + ->where($query->expr()->eq('folder_id', $query->createNamedParameter($folderId, IQueryBuilder::PARAM_INT))); + $query->executeStatement(); + } + + /** + * Reject a mutation that would change the sharing, rights, or ownership of + * a folder that belongs to a team. + * + * Team spaces are meant to host the team-specific apps and data of their + * owning team: they must not be shared with other teams/groups, have their + * permissions or ACL management changed, or have their quota altered + * independently of the team-space policy. The owning team's access can + * only be removed by unlinking the folder from its team first (which + * clears `team_circle_id`). + * + * Uses a lightweight query on `team_circle_id` instead of {@see getFolder()} + * so the guard works even when the folder's filecache row is incomplete + * (e.g. during tests or before storage initialization). + * + * @param int $folderId The folder being mutated. + * @param string $operation Human-readable description of the blocked + * operation, used in the exception message. + * @throws Exception When the folder belongs to a team. + */ + private function assertNotTeamSpace(int $folderId, string $operation): void { + if ($this->getTeamCircleId($folderId) !== null) { + throw new Exception( + 'This team space belongs to a team and its ' . $operation + . ' cannot be changed independently; unlink it from its team first', + ); + } + } + + /** + * Read only the `team_circle_id` column for a folder. + * + * Lighter than {@see getFolder()} (no filecache join), used by the + * team-space guards. Returns null if the folder does not exist or has no + * team owner. + */ + private function getTeamCircleId(int $folderId): ?string { + $query = $this->connection->getQueryBuilder(); + $query->select('team_circle_id') + ->from('group_folders') + ->where($query->expr()->eq('folder_id', $query->createNamedParameter($folderId, IQueryBuilder::PARAM_INT))); + $result = $query->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + if ($row === false || !array_key_exists('team_circle_id', $row)) { + return null; + } + + $value = $row['team_circle_id']; + return is_string($value) && $value !== '' ? $value : null; + } + /** * @throws Exception */ public function setFolderACL(int $folderId, bool $acl): void { + $this->assertNotTeamSpace($folderId, 'advanced permissions'); + $query = $this->connection->getQueryBuilder(); $query->update('group_folders') diff --git a/lib/Folder/FolderWithMappingsAndCache.php b/lib/Folder/FolderWithMappingsAndCache.php index b78dbfc4b..020b9707e 100644 --- a/lib/Folder/FolderWithMappingsAndCache.php +++ b/lib/Folder/FolderWithMappingsAndCache.php @@ -19,6 +19,7 @@ class FolderWithMappingsAndCache extends FolderDefinitionWithMappings { /** * @param array $groups * @param list $manage + * @param ?string $teamCircleId */ public function __construct( int $id, @@ -32,8 +33,9 @@ public function __construct( array $groups, array $manage, public readonly ICacheEntry $rootCacheEntry, + ?string $teamCircleId = null, ) { - parent::__construct($id, $mountPoint, $quota, $acl, $aclDefaultNoPermission, $storageId, $rootId, $options, $groups, $manage); + parent::__construct($id, $mountPoint, $quota, $acl, $aclDefaultNoPermission, $storageId, $rootId, $options, $groups, $manage, $teamCircleId); } public static function fromFolderWithMapping(FolderDefinitionWithMappings $folder, ICacheEntry $rootCacheEntry): FolderWithMappingsAndCache { @@ -49,6 +51,7 @@ public static function fromFolderWithMapping(FolderDefinitionWithMappings $folde $folder->groups, $folder->manage, $rootCacheEntry, + $folder->teamCircleId, ); } @@ -65,6 +68,7 @@ public static function fromFolderWithMapping(FolderDefinitionWithMappings $folde * groups: array, * manage: list, * options: array{separate-storage?: bool}, + * team_circle_id: ?string, * } */ #[\Override] @@ -81,6 +85,7 @@ public function toArray(): array { 'groups' => $this->groups, 'manage' => $this->manage, 'options' => $this->options, + 'team_circle_id' => $this->getTeamCircleId(), ]; } } diff --git a/lib/Listeners/CircleDestroyedEventListener.php b/lib/Listeners/CircleDestroyedEventListener.php deleted file mode 100644 index 7583c9485..000000000 --- a/lib/Listeners/CircleDestroyedEventListener.php +++ /dev/null @@ -1,36 +0,0 @@ - - */ -class CircleDestroyedEventListener implements IEventListener { - public function __construct( - private readonly FolderManager $folderManager, - ) { - } - - #[\Override] - public function handle(Event $event): void { - /** @phpstan-ignore instanceof.alwaysTrue */ - if (!$event instanceof CircleDestroyedEvent) { - return; - } - - $circle = $event->getCircle(); - $this->folderManager->deleteCircle($circle->getSingleId()); - } -} diff --git a/lib/Migration/Version2300000Date20260720000001.php b/lib/Migration/Version2300000Date20260720000001.php new file mode 100644 index 000000000..1507e6b2b --- /dev/null +++ b/lib/Migration/Version2300000Date20260720000001.php @@ -0,0 +1,136 @@ +`). + * + * The backfill in {@see postSchemaChange()} migrates every existing + * essential team space to the new column and strips the obsolete keys + * from the `options` JSON. + */ +#[AddColumn('group_folders', 'team_circle_id', ColumnType::STRING, 'the circle single id this team space belongs to (null for regular team folders)')] +#[AddIndex('group_folders', IndexType::UNIQUE, 'unique team circle id to prevent a team owning multiple folders')] +class Version2300000Date20260720000001 extends SimpleMigrationStep { + public function __construct( + private readonly IDBConnection $connection, + private readonly LoggerInterface $logger, + ) { + } + + #[\Override] + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $table = $schema->getTable('group_folders'); + if (!$table->hasColumn('team_circle_id')) { + $table->addColumn( + 'team_circle_id', Types::STRING, + [ + 'notnull' => false, + 'length' => 31, + 'default' => null, + ] + ); + } + + if (!$table->hasIndex('group_folders_team_circle')) { + $table->addUniqueIndex(['team_circle_id'], 'group_folders_team_circle'); + } + + return $schema; + } + + #[\Override] + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + // Backfill: read the legacy `options.essential` + `options.circle_id` JSON + // keys and migrate them to the new `team_circle_id` column. The obsolete + // keys are stripped from the JSON to leave a clean `options` column. + $query = $this->connection->getQueryBuilder(); + $query->select('folder_id', 'options') + ->from('group_folders'); + $result = $query->executeQuery(); + + $migrated = 0; + $skipped = 0; + while (($row = $result->fetch()) !== false) { + $folderIdRaw = $row['folder_id'] ?? null; + $folderId = is_numeric($folderIdRaw) ? (int)$folderIdRaw : 0; + $rawOptions = $row['options'] ?? null; + if (!is_string($rawOptions) || $rawOptions === '') { + continue; + } + + try { + $options = json_decode($rawOptions, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->logger->warning( + 'Could not decode group_folders options during team_circle_id backfill', + ['folder_id' => $folderId, 'exception' => $e], + ); + $skipped++; + continue; + } + + if (!is_array($options)) { + continue; + } + + $wasEssential = isset($options['essential']) && $options['essential'] === true; + $legacyCircleIdRaw = $options['circle_id'] ?? null; + $legacyCircleId = is_string($legacyCircleIdRaw) ? $legacyCircleIdRaw : ''; + + if (!$wasEssential && $legacyCircleId === '') { + continue; + } + + unset($options['essential'], $options['circle_id']); + + if ($wasEssential && $legacyCircleId === '') { + $this->logger->warning( + 'Essential team space has no circle_id in legacy options; leaving team_circle_id empty', + ['folder_id' => $folderId], + ); + } + + $update = $this->connection->getQueryBuilder(); + $update->update('group_folders') + ->set('team_circle_id', $update->createNamedParameter($legacyCircleId === '' ? null : $legacyCircleId)) + ->set('options', $update->createNamedParameter(json_encode($options, JSON_THROW_ON_ERROR))) + ->where($update->expr()->eq('folder_id', $update->createNamedParameter($folderId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))); + $update->executeStatement(); + $migrated++; + } + $result->closeCursor(); + + if ($migrated > 0 || $skipped > 0) { + $this->logger->info('Migrated team space essential flags to team_circle_id column', [ + 'migrated' => $migrated, + 'skipped' => $skipped, + ]); + } + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index fdbe579a6..0aaae75f2 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -57,6 +57,7 @@ * acl_default_no_permission: bool, * manage: list, * sortIndex?: non-negative-int, + * team_circle_id?: ?string, * } */ class ResponseDefinitions { diff --git a/lib/TeamSpace/TeamSpaceProvider.php b/lib/TeamSpace/TeamSpaceProvider.php new file mode 100644 index 000000000..98ad9e827 --- /dev/null +++ b/lib/TeamSpace/TeamSpaceProvider.php @@ -0,0 +1,101 @@ +l10n->t('Team spaces'); + } + + #[\Override] + public function getIconSvg(): string { + return ''; + } + + #[\Override] + public function getTeamFolder(string $teamId): ?TeamFolder { + return $this->service->getTeamSpaceForCircle($teamId); + } + + #[\Override] + public function createTeamFolder(Team $team, int $quota = 0): TeamFolder { + $this->service->upgradeTeamSpace($team, $quota); + + $folder = $this->getTeamFolder($team->getId()); + if ($folder === null) { + throw new \RuntimeException('Created team space could not be found'); + } + + return $folder; + } + + #[\Override] + public function unlinkTeamFolder(string $teamId): ?TeamFolder { + $folder = $this->getTeamFolder($teamId); + if ($folder === null) { + return null; + } + + $this->service->unlinkTeamSpace($teamId); + return $folder; + } + + #[\Override] + public function removeTeamFolder(string $teamId): bool { + return $this->service->removeTeamSpace($teamId); + } + + #[\Override] + public function getSharedWith(string $teamId): array { + $folder = $this->getTeamFolder($teamId); + if ($folder === null) { + return []; + } + + return [new TeamResource( + $this, + (string)$folder->getId(), + $folder->getMountPoint(), + $this->urlGenerator->getAbsoluteURL('/apps/files/?dir=/' . rawurlencode($folder->getMountPoint())), + iconSvg: $this->getIconSvg(), + )]; + } + + #[\Override] + public function isSharedWithTeam(string $teamId, string $resourceId): bool { + $folder = $this->getTeamFolder($teamId); + return $folder !== null && (string)$folder->getId() === $resourceId; + } + + #[\Override] + public function getTeamsForResource(string $resourceId): array { + return []; + } +} diff --git a/lib/TeamSpace/TeamSpaceService.php b/lib/TeamSpace/TeamSpaceService.php new file mode 100644 index 000000000..5bf36ae60 --- /dev/null +++ b/lib/TeamSpace/TeamSpaceService.php @@ -0,0 +1,277 @@ +folderManager->createFolder($mountPoint); + $teamLinkCreated = false; + + try { + if ($quota > 0) { + $this->folderManager->setFolderQuota($folderId, $quota); + } + + $this->folderManager->addApplicableGroup($folderId, $circleId); + $this->folderManager->setManageACL($folderId, 'circle', $circleId, true); + + $this->folderManager->setTeamCircleId($folderId, $circleId); + $teamLinkCreated = true; + } catch (\Exception $e) { + $existingFolderId = $teamLinkCreated + ? null + : $this->folderManager->getFolderIdByTeamCircleId($circleId); + $this->logger->error( + 'Failed to configure team space, rolling back creation', + [ + 'circleId' => $circleId, + 'folderId' => $folderId, + 'exception' => $e, + ], + ); + try { + // Clear the team link first so the isTeamSpace guard in + // removeFolder() does not reject the rollback. + $this->folderManager->clearTeamCircleId($folderId); + $this->folderManager->removeFolder($folderId); + } catch (\Exception $rollbackException) { + $this->logger->error( + 'Could not roll back team space creation, manual cleanup required', + [ + 'circleId' => $circleId, + 'folderId' => $folderId, + 'exception' => $rollbackException, + ], + ); + } + + if ($existingFolderId !== null) { + $this->logger->info('Another request created the team space first; using the existing folder', [ + 'circleId' => $circleId, + 'folderId' => $existingFolderId, + ]); + return $existingFolderId; + } + throw $e; + } + + return $folderId; + } + + /** + * Unlink the team space from a team without deleting the folder. + * + * Clears the `team_circle_id` column and removes the applicable-group entry + * so the team no longer has access. The folder and its contents are + * preserved and become a regular team folder that can be managed via the + * groupfolders admin UI. + * + * This is the path used when a team is destroyed: the folder is kept so an + * admin can restore access if needed. It is also the admin escape hatch + * exposed via the circles OCS endpoint. + * + * @param string $circleId The circle single id. + * @return int|null The unlinked folder id, or null if no folder was linked. + */ + public function unlinkTeamSpace(string $circleId): ?int { + $folderId = $this->folderManager->getFolderIdByTeamCircleId($circleId); + if ($folderId === null) { + return null; + } + + $this->folderManager->clearTeamCircleId($folderId); + $this->folderManager->removeApplicableGroup($folderId, $circleId); + $this->logger->info('Unlinked team space from circle', [ + 'circleId' => $circleId, + 'folderId' => $folderId, + ]); + + return $folderId; + } + + /** + * Remove the team space that belongs to a team, deleting its contents. + * + * Clears the `team_circle_id` column first so the `isTeamSpace` guard in + * {@see FolderManager::removeFolder()} does not reject the deletion, then + * deletes the folder. + * + * @param string $circleId The circle single id. + * @return bool Whether a folder was found and removed. + */ + public function removeTeamSpace(string $circleId): bool { + $folderId = $this->folderManager->getFolderIdByTeamCircleId($circleId); + if ($folderId === null) { + return false; + } + + $this->folderManager->clearTeamCircleId($folderId); + $this->folderManager->removeApplicableGroup($folderId, $circleId); + $this->folderManager->removeFolder($folderId); + $this->logger->info('Removed team space for circle', [ + 'circleId' => $circleId, + 'folderId' => $folderId, + ]); + + return true; + } + + /** + * Create a team space for a team that predates the feature (or return the + * existing one if the team already has a folder). + * + * Idempotent: if the team already owns a folder, its id is returned without + * creating a new one. + * + * @param Team $team The team to upgrade. + * @param int $quota Quota in bytes (0 = unlimited). + * @return int The folder id (existing or newly created). + * @throws \Exception on failure. + */ + public function upgradeTeamSpace(Team $team, int $quota = 0): int { + $circleId = $team->getId(); + + $existing = $this->folderManager->getFolderIdByTeamCircleId($circleId); + if ($existing !== null) { + return $existing; + } + + $mountPoint = $this->generateUniqueMountPoint($this->pickBaseName($team)); + + return $this->createTeamSpace($circleId, $mountPoint, $quota); + } + + /** + * Find the team space that belongs to the given team. + * + * @return TeamFolder|null + */ + public function getTeamSpaceForCircle(string $circleId): ?TeamFolder { + $folderId = $this->folderManager->getFolderIdByTeamCircleId($circleId); + if ($folderId === null) { + return null; + } + + $folder = $this->folderManager->getFolder($folderId); + if ($folder === null) { + return null; + } + + return new TeamFolder($folder->id, $folder->mountPoint); + } + + /** + * Whether the given circle (looked up by single id) owns a team space. + */ + public function hasTeamSpace(string $circleId): bool { + return $this->folderManager->getFolderIdByTeamCircleId($circleId) !== null; + } + + /** + /** + * Pick a non-empty base name for the team space mount point. + * + * Tries the display name first, then the circle name, then the single id. + */ + public function pickBaseName(Team $team): string { + $displayName = trim($team->getDisplayName()); + if ($displayName !== '') { + return $displayName; + } + + $singleId = trim($team->getId()); + if ($singleId !== '') { + return $singleId; + } + + $this->logger->warning('pickBaseName: falling back to generic team-space'); + return 'team-space'; + } + + /** + * Generate a unique mount point based on the given base name. + * + * Falls back to a generic name if the sanitized base name is empty. + */ + public function generateUniqueMountPoint(string $baseName): string { + $mountPoint = $this->sanitizeMountPoint($baseName); + + if ($mountPoint === '') { + $mountPoint = 'team-space'; + } + + if (!$this->folderManager->mountPointExists($mountPoint)) { + return $mountPoint; + } + + $counter = 1; + do { + $candidate = $mountPoint . ' (' . $counter . ')'; + $counter++; + } while ($this->folderManager->mountPointExists($candidate)); + + return $candidate; + } + + /** + * Sanitize a string so it can be used as a team folder mount point. + * + * Strips control characters, path separators and backslashes, and limits + * the length to a safe value for the `mount_point` column. + */ + public function sanitizeMountPoint(string $name): string { + $name = trim($name); + $name = preg_replace('/[\x00-\x1f\/\\\\]+/', '', $name) ?? ''; + $name = trim($name); + + $max = 255; + if (mb_strlen($name) > $max) { + $name = mb_substr($name, 0, $max); + } + + return $name; + } +} diff --git a/openapi.json b/openapi.json index 946065eac..2510c8db4 100644 --- a/openapi.json +++ b/openapi.json @@ -191,6 +191,10 @@ "type": "integer", "format": "int64", "minimum": 0 + }, + "team_circle_id": { + "type": "string", + "nullable": true } } }, diff --git a/src/settings/App.scss b/src/settings/App.scss index fddf5995e..b339baae2 100644 --- a/src/settings/App.scss +++ b/src/settings/App.scss @@ -275,3 +275,58 @@ margin-bottom: 30px; } } + +.folder-filter-tabs { + display: flex; + gap: var(--default-grid-baseline); + margin: 10px 0; + + button { + padding: 6px 14px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-large); + background: var(--color-main-background); + color: var(--color-text-lighter); + cursor: pointer; + font-size: 0.9em; + + &:hover { + color: var(--color-main-text); + border-color: var(--color-border-dark); + } + + &.active { + color: var(--color-primary-element-text); + background: var(--color-primary-element); + border-color: var(--color-primary-element); + } + } +} + +.folder-list-empty td { + padding: 32px 16px; + color: var(--color-text-maxcontrast); + text-align: center; +} + +.team-space-badge { + display: inline-block; + margin-right: 6px; + padding: 1px 6px; + border-radius: var(--border-radius); + background: var(--color-background-dark); + color: var(--color-text-maxcontrast); + font-size: 0.75em; + font-weight: 600; + vertical-align: middle; +} + +.team-space-locked { + color: var(--color-text-maxcontrast); + cursor: default; +} + +.icon-delete.disabled { + opacity: 0.3 !important; + pointer-events: none; +} diff --git a/src/settings/App.tsx b/src/settings/App.tsx index b81bcc474..aa60dd9ae 100644 --- a/src/settings/App.tsx +++ b/src/settings/App.tsx @@ -40,6 +40,8 @@ const pageSize = 50 export type SortKey = 'mount_point' | 'quota' | 'groups' | 'acl'; +export type FolderFilter = 'all' | 'space' | 'folder'; + export interface AppState { delegatedAdminGroups: DelegationGroup[], delegatedSubAdminGroups: DelegationGroup[], @@ -52,6 +54,7 @@ export interface AppState { editingMountPoint: number; renameMountPoint: string; filter: string; + folderFilter: FolderFilter; sort: SortKey; sortOrder: number; isAdminNextcloud: boolean; @@ -77,6 +80,7 @@ export class App extends Component implements OC.Plugin implements OC.Plugin { + // Tab filter: separate team spaces from regular team folders. + const isTeamSpace = folder.team_circle_id !== null && folder.team_circle_id !== undefined + if (this.state.folderFilter === 'space' && !isTeamSpace) { + return false + } + if (this.state.folderFilter === 'folder' && isTeamSpace) { + return false + } + // Text filter from the global search. if (this.state.filter === '') { return true } return folder.mount_point.toLowerCase().includes(this.state.filter.toLowerCase()) }) .sort((a, b) => a.sortIndex! - b.sortIndex!) - .slice(this.state.currentPage * pageSize, this.state.currentPage * pageSize + pageSize) + + const rows = filteredFolders + .slice(this.state.currentPage * pageSize, this.state.currentPage * pageSize + pageSize) .map(folder => { const id = folder.id + const isTeamSpace = folder.team_circle_id !== null && folder.team_circle_id !== undefined + const teamCircle = isTeamSpace + ? this.state.circles.find(c => c.singleId === folder.team_circle_id) + : undefined return - {this.state.editingMountPoint === id + {isTeamSpace && ( + + {t('groupfolders', 'Team space')} + + )} + {this.state.editingMountPoint === id && !isTeamSpace ? implements OC.Plugin - : + : isTeamSpace + ? + {folder.mount_point} + + : } { + edit={this.state.editingGroup === id && !isTeamSpace} + showEdit={isTeamSpace ? () => {} : event => { event.stopPropagation() this.setState({ editingGroup: id }) }} @@ -346,38 +372,60 @@ export class App extends Component implements OC.Plugin + value={folder.quota} + size={folder.size} + onChange={this.setQuota.bind(this, folder)}/> - this.setAcl(folder, event.target.checked)} - /> - - {folder.acl - && - } + {isTeamSpace ? ( + + {folder.acl ? t('groupfolders', 'Enabled') : t('groupfolders', 'Disabled')} + + ) : ( + <> + this.setAcl(folder, event.target.checked)} + /> + + {folder.acl + && + } + + )} - + + + + @@ -456,10 +525,14 @@ export class App extends Component implements OC.Plugin - {rows} + {filteredFolders.length === 0 + ? + + + : rows}
{emptyMessage}
-