diff --git a/lib/DAV/ACLPlugin.php b/lib/DAV/ACLPlugin.php index 0fa0851bc..c877895a2 100644 --- a/lib/DAV/ACLPlugin.php +++ b/lib/DAV/ACLPlugin.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2019-2025 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ @@ -17,6 +17,7 @@ use OCA\GroupFolders\Mount\GroupMountPoint; use OCP\Constants; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\FileInfo; use OCP\IL10N; use OCP\IUser; use OCP\IUserSession; @@ -29,6 +30,23 @@ use Sabre\DAV\ServerPlugin; use Sabre\Xml\Reader; +/** + * SabreDAV plugin for exposing ACL (advanced permissions) properties. + * + * Handles WebDAV PROPFIND and PROPPATCH events for Nextcloud Teams/Group Folders with granular access controls. + * + * These handlers: + * - Ensures only relevant information is returned/modifiable for the target node. + * - Support both admin and user-level requests. + * + * Admins have a full overview and control: + * - can see and manage all inherited permission entries. + * - can see and manage rules for other users/groups. + * + * Standard users see only their own effective inherited permissions: + * - only see inherited permissions that affect them specifically. + * - can't view or manage rules for other users/groups. + */ class ACLPlugin extends ServerPlugin { public const ACL_ENABLED = '{http://nextcloud.org/ns}acl-enabled'; public const ACL_CAN_MANAGE = '{http://nextcloud.org/ns}acl-can-manage'; @@ -39,8 +57,8 @@ class ACLPlugin extends ServerPlugin { private ?Server $server = null; private ?IUser $user = null; - /** @var array */ - private array $canManageACL = []; + /** @var array Folder ID => can manage ACLs */ + private array $canManageACLForFolder = []; public function __construct( private readonly RuleManager $ruleManager, @@ -52,44 +70,27 @@ public function __construct( ) { } - private function isAdmin(IUser $user, string $path): bool { - $folderId = $this->folderManager->getFolderByPath($path); - - if (!isset($this->canManageACL[$folderId])) { - $this->canManageACL[$folderId] = $this->folderManager->canManageACL($folderId, $user); - } - - return $this->canManageACL[$folderId]; - } - public function initialize(Server $server): void { $this->server = $server; + + // Note: a null user is permitted (i.e. for public links / federated shares); handler logic must account for this. $this->user = $this->userSession->getUser(); $this->server->on('propFind', $this->propFind(...)); $this->server->on('propPatch', $this->propPatch(...)); - $this->server->xml->elementMap[Rule::ACL] = Rule::class; - $this->server->xml->elementMap[self::ACL_LIST] = fn (Reader $reader): array => \Sabre\Xml\Deserializer\repeatingElements($reader, Rule::ACL); + $this->server->xml->elementMap[Rule::ACL] + = Rule::class; + $this->server->xml->elementMap[self::ACL_LIST] + = fn (Reader $reader): array + => \Sabre\Xml\Deserializer\repeatingElements($reader, Rule::ACL); } /** - * @return string[] + * WebDAV PROPFIND event handler for ACL-related properties. + * Provides read-only access to ACL information for the current node. + * If the session is unauthenticated, safe defaults are returned. */ - private function getParents(string $path): array { - $paths = []; - while ($path !== '') { - $path = dirname($path); - if ($path === '.' || $path === '/') { - $path = ''; - } - - $paths[] = $path; - } - - return $paths; - } - public function propFind(PropFind $propFind, INode $node): void { if (!$node instanceof Node) { return; @@ -101,189 +102,412 @@ public function propFind(PropFind $propFind, INode $node): void { return; } - $propFind->handle(self::ACL_LIST, function () use ($fileInfo, $mount): ?array { - // Happens when sharing with a remote instance - if ($this->user === null) { - return []; - } - - $path = trim($mount->getSourcePath() . '/' . $fileInfo->getInternalPath(), '/'); - if ($this->isAdmin($this->user, $fileInfo->getPath())) { - $rules = $this->ruleManager->getAllRulesForPaths($mount->getNumericStorageId(), [$path]); - } else { - $rules = $this->ruleManager->getRulesForFilesByPath($this->user, $mount->getNumericStorageId(), [$path]); - } + // Handler to provide ACL rules directly assigned to the file or folder. + $propFind->handle( + self::ACL_LIST, + fn () => $this->getDirectAclRulesForPath($fileInfo, $mount) + ); - return array_pop($rules); - }); + // Handler to provide the ACL rules inherited from parent folders (not set directly). + $propFind->handle( + self::INHERITED_ACL_LIST, + fn () => $this->getInheritedAclRulesForPath($fileInfo, $mount) + ); - $propFind->handle(self::INHERITED_ACL_LIST, function () use ($fileInfo, $mount): array { - // Happens when sharing with a remote instance - if ($this->user === null) { - return []; - } + // Handler to provide the group folder ID for the current file or folder. + $propFind->handle( + self::GROUP_FOLDER_ID, + fn (): int => $this->folderManager->getFolderByPath($fileInfo->getPath()) + ); - $parentInternalPaths = $this->getParents($fileInfo->getInternalPath()); - $parentPaths = array_map(fn (string $internalPath): string => trim($mount->getSourcePath() . '/' . $internalPath, '/'), $parentInternalPaths); - if ($this->isAdmin($this->user, $fileInfo->getPath())) { - $rulesByPath = $this->ruleManager->getAllRulesForPaths($mount->getNumericStorageId(), $parentPaths); - } else { - $rulesByPath = $this->ruleManager->getRulesForFilesByPath($this->user, $mount->getNumericStorageId(), $parentPaths); + // Handler to provide whether ACLs are enabled for the current group folder. + $propFind->handle( + self::ACL_ENABLED, + function () use ($fileInfo): bool { + $folderId = $this->folderManager->getFolderByPath($fileInfo->getPath()); + return $this->folderManager->getFolderAclEnabled($folderId); } + ); - $aclManager = $this->aclManagerFactory->getACLManager($this->user); - - ksort($rulesByPath); - $inheritedPermissionsByMapping = []; - $inheritedMaskByMapping = []; - $mappings = []; - foreach ($rulesByPath as $rules) { - foreach ($rules as $rule) { - $mappingKey = $rule->getUserMapping()->getType() . '::' . $rule->getUserMapping()->getId(); - if (!isset($mappings[$mappingKey])) { - $mappings[$mappingKey] = $rule->getUserMapping(); - } - - if (!isset($inheritedPermissionsByMapping[$mappingKey])) { - $inheritedPermissionsByMapping[$mappingKey] = $aclManager->getBasePermission($mount->getFolderId()); - } - - if (!isset($inheritedMaskByMapping[$mappingKey])) { - $inheritedMaskByMapping[$mappingKey] = 0; - } - - $inheritedPermissionsByMapping[$mappingKey] = $rule->applyPermissions($inheritedPermissionsByMapping[$mappingKey]); - $inheritedMaskByMapping[$mappingKey] |= $rule->getMask(); + // Handler to determine and return if the current user can manage ACLs for this group folder. + $propFind->handle( + self::ACL_CAN_MANAGE, + function () use ($fileInfo): bool { + // Gracefully handle non-user sessions + if ($this->user === null) { + return false; } + return $this->isAdmin($this->user, $fileInfo->getPath()); } + ); - return array_map(fn (IUserMapping $mapping, int $permissions, int $mask): Rule => new Rule( - $mapping, - $fileInfo->getId(), - $mask, - $permissions - ), $mappings, $inheritedPermissionsByMapping, $inheritedMaskByMapping); - }); - - $propFind->handle(self::GROUP_FOLDER_ID, fn (): int => $this->folderManager->getFolderByPath($fileInfo->getPath())); - - $propFind->handle(self::ACL_ENABLED, function () use ($fileInfo): bool { - $folderId = $this->folderManager->getFolderByPath($fileInfo->getPath()); - return $this->folderManager->getFolderAclEnabled($folderId); - }); - - $propFind->handle(self::ACL_CAN_MANAGE, function () use ($fileInfo): bool { - // Happens when sharing with a remote instance - if ($this->user === null) { - return false; - } - - return $this->isAdmin($this->user, $fileInfo->getPath()); - }); - - $propFind->handle(self::ACL_BASE_PERMISSION_PROPERTYNAME, function () use ($mount): int { - // Happens when sharing with a remote instance - if ($this->user === null) { - return Constants::PERMISSION_ALL; + // Handler to provide the effective base permissions for the current group folder. + $propFind->handle( + self::ACL_BASE_PERMISSION_PROPERTYNAME, + function () use ($mount): int { + // Gracefully handle non-user sessions + if ($this->user === null) { + return Constants::PERMISSION_ALL; + } + return $this->aclManagerFactory + ->getACLManager($this->user) + ->getBasePermission($mount->getFolderId()); } - - return $this->aclManagerFactory->getACLManager($this->user)->getBasePermission($mount->getFolderId()); - } ); } + /** + * WebDAV PROPPATCH event handler for ACL-related properties. + * Enables modification of ACL assignments if the user has admin rights on the current node. + */ public function propPatch(string $path, PropPatch $propPatch): void { if ($this->server === null) { return; } - // Happens when sharing with a remote instance + // Non-user sessions (public link or federated share); no update handling is supported. if ($this->user === null) { return; } $node = $this->server->tree->getNodeForPath($path); + if (!$node instanceof Node) { return; } $fileInfo = $node->getFileInfo(); $mount = $fileInfo->getMountPoint(); - if (!$mount instanceof GroupMountPoint || !$this->isAdmin($this->user, $fileInfo->getPath())) { + + if (!$mount instanceof GroupMountPoint) { return; } - // Mapping the old property to the new property. - $propPatch->handle(self::ACL_LIST, function (array $rawRules) use ($path): bool { - $node = $this->server->tree->getNodeForPath($path); - if (!$node instanceof Node) { - return false; - } - - $fileInfo = $node->getFileInfo(); - $mount = $fileInfo->getMountPoint(); - if (!$mount instanceof GroupMountPoint) { - return false; - } + // Only allow if user has admin rights for this group folder + if (!$this->isAdmin($this->user, $fileInfo->getPath())) { + return; + } - if ($this->user === null) { - return false; - } + // Handler to update (replace) the direct ACL rules for the specified file/folder. + $propPatch->handle( + self::ACL_LIST, + fn (array $submittedRules) => $this->updateAclRulesForPath($submittedRules, $node, $fileInfo, $mount) + ); + } - $path = trim($mount->getSourcePath() . '/' . $fileInfo->getInternalPath(), '/'); - - // populate fileid in rules - $rules = array_values(array_map(fn (Rule $rule): Rule => new Rule( - $rule->getUserMapping(), - $fileInfo->getId(), - $rule->getMask(), - $rule->getPermissions() - ), $rawRules)); - - $formattedRules = array_map(fn (Rule $rule): string => $rule->getUserMapping()->getType() . ' ' . $rule->getUserMapping()->getDisplayName() . ': ' . $rule->formatPermissions(), $rules); - if (count($formattedRules)) { - $formattedRules = implode(', ', $formattedRules); - $this->eventDispatcher->dispatchTyped(new CriticalActionPerformedEvent('The advanced permissions for "%s" in Team folder with ID %d was set to "%s"', [ - $fileInfo->getInternalPath(), - $mount->getFolderId(), - $formattedRules, - ])); - } else { - $this->eventDispatcher->dispatchTyped(new CriticalActionPerformedEvent('The advanced permissions for "%s" in Team folder with ID %d was cleared', [ - $fileInfo->getInternalPath(), - $mount->getFolderId(), - ])); - } + /** + * Retrieves ACL rules assigned directly (not inherited) to the given file or folder. + * + * - For admins/managers: returns all direct rules for the node. + * - For non-admins/users: returns only direct rules relevant to the user. + * - For public/federated sessions: returns an empty array. + * + * Example: Granting "Group X" write access to `/Documents/Reports` is a direct ACL rule for that folder. + * + * Note: Direct rules alone do not determine effective access: + * - Read/list access must be permitted by all parent folders, regardless of direct rules. + * - For other permissions, direct rules take priority; missing permissions may be filled via inheritance. + * + * Example: If "Group X" has no read access on `/Documents`, they still can't access `/Documents/Reports`. + * + * @return list Direct ACL rules for the file/folder (may be empty). + */ + private function getDirectAclRulesForPath(FileInfo $fileInfo, GroupMountPoint $mount): array { + if ($this->user === null) { + return []; + } - $aclManager = $this->aclManagerFactory->getACLManager($this->user); - $newPermissions = $aclManager->testACLPermissionsForPath($mount->getFolderId(), $mount->getNumericStorageId(), $path, $rules); - if (!($newPermissions & Constants::PERMISSION_READ)) { - throw new BadRequest($this->l10n->t('You cannot remove your own read permission.')); - } + $aclRelativePath = trim($mount->getSourcePath() . '/' . $fileInfo->getInternalPath(), '/'); - $existingRules = array_reduce( - $this->ruleManager->getAllRulesForPaths($mount->getNumericStorageId(), [$path]), - array_merge(...), - [] + // Retrieve the direct rules + if ($this->isAdmin($this->user, $fileInfo->getPath())) { + // Admin + $rules = $this->ruleManager->getAllRulesForPaths( + $mount->getNumericStorageId(), + [$aclRelativePath] + ); + } else { + // Standard user + $rules = $this->ruleManager->getRulesForFilesByPath( + $this->user, + $mount->getNumericStorageId(), + [$aclRelativePath] ); + } + // Return the rules for the requested path (only one path is queried, so take the single result) + return array_values(array_pop($rules) ?? []); + } - $deletedRules = array_udiff($existingRules, $rules, fn (Rule $obj_a, Rule $obj_b): int => ( - $obj_a->getUserMapping()->getType() === $obj_b->getUserMapping()->getType() - && $obj_a->getUserMapping()->getId() === $obj_b->getUserMapping()->getId() - ) ? 0 : -1); - foreach ($deletedRules as $deletedRule) { - $this->ruleManager->deleteRule($deletedRule); - } + /** + * Retrieves ACL rules inherited from parent folders (not set directly) for the given file or folder. + * + * - For admins/managers: returns all inherited rules affecting the node. + * - For non-admins/users: returns only inherited rules relevant to the user. + * - For public/federated sessions: returns an empty array. + * + * Note: Inherited rules are merged from all ancestor folders; direct rules for this node are excluded. + * - Effective read/list access requires all parent folders to permit access. + * - Other permissions are determined by merging inherited and direct rules separately. + * + * @return list Inherited ACL rules affecting the file/folder (may be empty). + */ + private function getInheritedAclRulesForPath(FileInfo $fileInfo, GroupMountPoint $mount): array { + // Fail softly for non-user sessions + if ($this->user === null) { + return []; + } + + $parentInternalPaths = $this->getParents($fileInfo->getInternalPath()); + $parentAclRelativePaths = array_map( + fn (string $internalPath): string + => trim($mount->getSourcePath() . '/' . $internalPath, '/'), + $parentInternalPaths + ); + // Include the mount root + $parentAclRelativePaths[] = $mount->getSourcePath(); + + // Retrieve the inherited rules + if ($this->isAdmin($this->user, $fileInfo->getPath())) { + // Admin + $rulesByPath = $this->ruleManager->getAllRulesForPaths( + $mount->getNumericStorageId(), + $parentAclRelativePaths + ); + } else { + // Standard user + $rulesByPath = $this->ruleManager->getRulesForFilesByPath( + $this->user, + $mount->getNumericStorageId(), + $parentAclRelativePaths + ); + } + /* + * Aggregate inherited permissions for each relevant user/group/team across all parent paths. + * + * For each mapping (identified by type + ID): + * - Initialize the mapping if it hasn't been seen yet. + * - Accumulate permissions by applying each parent rule in order + * (to correctly resolve permissions as they cascade from ancestor to descendant). + * - Bitwise-OR the masks to track all inherited permission bits. + */ + ksort($rulesByPath); // Ensure parent paths are applied from root down + $inheritedPermissionsByUserKey = []; // Effective permissions per mapping + $inheritedMaskByUserKey = []; // Combined permission masks per mapping + $userMappingsByKey = []; // Mapping reference for later rule creation + $aclManager = $this->aclManagerFactory->getACLManager($this->user); + + foreach ($rulesByPath as $rules) { foreach ($rules as $rule) { - $this->ruleManager->saveRule($rule); + // Create a unique key for each user/group/team mapping + $userMappingKey = $rule->getUserMapping()->getType() . '::' . $rule->getUserMapping()->getId(); + + // Store mapping object if first encounter + if (!isset($userMappingsByKey[$userMappingKey])) { + $userMappingsByKey[$userMappingKey] = $rule->getUserMapping(); + } + + // Initialize inherited permissions if not set + if (!isset($inheritedPermissionsByUserKey[$userMappingKey])) { + $inheritedPermissionsByUserKey[$userMappingKey] = $aclManager->getBasePermission($mount->getFolderId()); + } + + // Initialize mask if not set + if (!isset($inheritedMaskByUserKey[$userMappingKey])) { + $inheritedMaskByUserKey[$userMappingKey] = 0; + } + + // Apply rule's permissions to current inherited permissions + $inheritedPermissionsByUserKey[$userMappingKey] = $rule->applyPermissions($inheritedPermissionsByUserKey[$userMappingKey]); + + // Accumulate mask bits + $inheritedMaskByUserKey[$userMappingKey] |= $rule->getMask(); } + } + + $fileId = $fileInfo->getId(); + if ($fileId === null) { + // shouldn't ever happen (only part files can return null) + throw new \LogicException('File ID cannot be null'); + } + + // Build and return Rule objects representing the effective inherited permissions for each mapping + return array_map( + fn (IUserMapping $mapping, int $permissions, int $mask): Rule => new Rule( + $mapping, + $fileId, + $mask, + $permissions + ), + $userMappingsByKey, + $inheritedPermissionsByUserKey, + $inheritedMaskByUserKey + ); + } + + /** + * Update (replace) the entire set of direct ACL rules for the given file or folder. + * + * This method overwrites all existing direct ACL rules for the node with a new set. + * Only users with ACL management rights (admins/managers) can perform this operation. + * + * - All provided Rule objects are written as the new direct ACLs for this file/folder. + * - Inherited ACL rules from parent folders are not modified. + * - If the rules array is empty, all direct ACLs on this node are removed. + * - Logs critical actions and dispatches audit events for ACL changes. + * + * @param Rule[] $submittedRules Array of new direct ACL Rule objects to apply. + * @param $node object of file/folder being updated. + * @param FileInfo $fileInfo object of file or folder being updated. + * @param GroupMountPoint $mount context for storage and folder resolution. + * + * @throws BadRequest if the operation is invalid (e.g. user's own read access would be removed) + */ + private function updateAclRulesForPath(array $submittedRules, Node $node, FileInfo $fileInfo, GroupMountPoint $mount): bool { + $aclRelativePath = trim($mount->getSourcePath() . '/' . $fileInfo->getInternalPath(), '/'); + + $fileId = $fileInfo->getId(); + if ($fileId === null) { + // shouldn't ever happen (only part files can return null) + throw new \LogicException('File ID cannot be null'); + } + // Make sure each submitted rule is associated with the current file's ID + $preparedRules = array_values( + array_map( + fn (Rule $rule): Rule => new Rule( + $rule->getUserMapping(), + $fileId, + $rule->getMask(), + $rule->getPermissions() + ), + $submittedRules + ) + ); + + // Generate a display-friendly description string for each rule + $rulesDescriptions = array_map( + fn (Rule $rule): string + => $rule->getUserMapping()->getType() + . ' ' + . $rule->getUserMapping()->getDisplayName() + . ': ' . $rule->formatPermissions(), + $preparedRules + ); - $node->getNode()->getStorage()->getPropagator()->propagateChange($fileInfo->getInternalPath(), $fileInfo->getMtime()); + // Record changes in the audit log + if (count($rulesDescriptions)) { + $rulesDescriptionsStr = implode(', ', $rulesDescriptions); + $this->eventDispatcher->dispatchTyped( + new CriticalActionPerformedEvent( + 'The advanced permissions for "%s" in Team folder with ID %d was set to "%s"', + [ $fileInfo->getInternalPath(), $mount->getFolderId(), $rulesDescriptionsStr ] + ) + ); + } else { + $this->eventDispatcher->dispatchTyped( + new CriticalActionPerformedEvent( + 'The advanced permissions for "%s" in Team folder with ID %d was cleared', + [ $fileInfo->getInternalPath(), $mount->getFolderId() ] + ) + ); + } + + // Simulate new ACL rules to ensure the user does not remove their own read access before saving changes + /** @psalm-suppress PossiblyNullArgument already checked by caller */ + $aclManager = $this->aclManagerFactory->getACLManager($this->user); + $newPermissions = $aclManager->testACLPermissionsForPath( + $mount->getFolderId(), + $mount->getNumericStorageId(), + $aclRelativePath, + $preparedRules + ); + if (!($newPermissions & Constants::PERMISSION_READ)) { + throw new BadRequest($this->l10n->t('You cannot remove your own read permission.')); + } + + // Compute all existing ACL rules associated with the file path + $existingRules = array_reduce( + $this->ruleManager->getAllRulesForPaths( + $mount->getNumericStorageId(), + [$aclRelativePath] + ), + array_merge(...), + [] + ); + + // If a mapping is missing in the new set, it means its rule should be deleted, regardless of its old permissions. + $rulesToDelete = array_udiff( + $existingRules, + $preparedRules, + fn (Rule $existingRule, Rule $submittedRule): int => ( + // Only compare by mapping (type + ID) since all rules here are already contextual to the same path. + ($existingRule->getUserMapping()->getType() <=> $submittedRule->getUserMapping()->getType()) + ?: ($existingRule->getUserMapping()->getId() <=> $submittedRule->getUserMapping()->getId()) + ) + ); + + // Delete no longer present rules + foreach ($rulesToDelete as $ruleToDelete) { + $this->ruleManager->deleteRule($ruleToDelete); + } + + // Save new rules + foreach ($preparedRules as $rule) { + $this->ruleManager->saveRule($rule); + } + + // Propagate changes to file cache + $node->getNode() + ->getStorage() + ->getPropagator() + ->propagateChange( + $fileInfo->getInternalPath(), + $fileInfo->getMtime() + ); + + return true; + } + + /** + * Checks if a user has admin (ACL management) rights for the group folder at the provided path. + * Results are cached per folder for efficiency. + * + * @param IUser $user The user to check. + * @param string $path The full path to a file or folder inside a group folder. + * + * @throws \OCP\Files\NotFoundException If the path does not exist or is not part of a group folder. + */ + private function isAdmin(IUser $user, string $path): bool { + // TODO: catch/handle gracefully if folder disappeared between node fetch and this check (i.e. by another user / session) + $folderId = $this->folderManager->getFolderByPath($path); + + if (isset($this->canManageACLForFolder[$folderId])) { + return $this->canManageACLForFolder[$folderId]; + } + + $canManage = $this->folderManager->canManageACL($folderId, $user); + $this->canManageACLForFolder[$folderId] = $canManage; + return $canManage; + } + + /** + * Returns all parent directory paths of the given path, from nearest to root. + * Excludes the original path itself. + * E.g.: 'a/b/c.txt' → ['a/b', 'a'] + * + * @param string $path Path to a file or directory. + * @return list Parent directory paths, from closest to furthest. + */ + private function getParents(string $path): array { + $parents = []; + $parent = dirname($path); + while ($parent !== '' && $parent !== '.' && $parent !== '/') { + $parents[] = $parent; + $parent = dirname($parent); + } - return true; - }); + return $parents; } } diff --git a/lib/DAV/GroupFolderNode.php b/lib/DAV/GroupFolderNode.php index e63adc038..ca9b5c8e2 100644 --- a/lib/DAV/GroupFolderNode.php +++ b/lib/DAV/GroupFolderNode.php @@ -12,6 +12,13 @@ use OCA\DAV\Connector\Sabre\Directory; use OCP\Files\FileInfo; +/** + * WebDAV node representing a group folder directory. + * + * Extends the standard Directory node to track the associated group folder ID, + * allowing the system to identify and apply group folder-specific permissions + * and logic when accessed via WebDAV. + */ class GroupFolderNode extends Directory { public function __construct( View $view, diff --git a/lib/DAV/GroupFoldersHome.php b/lib/DAV/GroupFoldersHome.php index 72c92e1f9..1b5369469 100644 --- a/lib/DAV/GroupFoldersHome.php +++ b/lib/DAV/GroupFoldersHome.php @@ -18,6 +18,13 @@ use Sabre\DAV\Exception\NotFound; use Sabre\DAV\ICollection; +/** + * WebDAV collection representing a user's group folders home directory. + * + * Serves as a container for all group folders accessible to a specific user, + * providing read-only access to the list of group folders they can access. + * Each child node is a GroupFolderNode representing an individual group folder. + */ class GroupFoldersHome implements ICollection { public function __construct( private array $principalInfo, @@ -28,7 +35,7 @@ public function __construct( } public function delete(): never { - throw new Forbidden(); + throw new Forbidden('Permission denied to delete this folder'); } public function getName(): string { @@ -36,49 +43,21 @@ public function getName(): string { return $name; } - public function setName($name): never { + public function setName(string $name): never { throw new Forbidden('Permission denied to rename this folder'); } - public function createFile($name, $data = null): never { - throw new Forbidden('Not allowed to create files in this folder'); + public function createFile(string $name, $data = null): never { + throw new Forbidden('Permission denied to create files in this folder'); } - public function createDirectory($name): never { + public function createDirectory(string $name): never { throw new Forbidden('Permission denied to create folders in this folder'); } - private function getFolder(string $name): ?FolderDefinition { - $storageId = $this->rootFolder->getMountPoint()->getNumericStorageId(); - if ($storageId === null) { - return null; - } - - $folders = $this->folderManager->getFoldersForUser($this->user); - foreach ($folders as $folder) { - if (basename($folder->mountPoint) === $name) { - return $folder; - } - } - - return null; - } - - private function getDirectoryForFolder(FolderDefinition $folder): GroupFolderNode { - $userHome = '/' . $this->user->getUID() . '/files'; - $node = $this->rootFolder->get($userHome . '/' . $folder->mountPoint); - - $view = Filesystem::getView(); - if ($view === null) { - throw new RuntimeException('Unable to create view.'); - } - - return new GroupFolderNode($view, $node, $folder->id); - } - - public function getChild($name): GroupFolderNode { + public function getChild(string $name): GroupFolderNode { $folder = $this->getFolder($name); - if ($folder) { + if ($folder !== null) { return $this->getDirectoryForFolder($folder); } @@ -89,24 +68,58 @@ public function getChild($name): GroupFolderNode { * @return GroupFolderNode[] */ public function getChildren(): array { - $storageId = $this->rootFolder->getMountPoint()->getNumericStorageId(); - if ($storageId === null) { - return []; - } - $folders = $this->folderManager->getFoldersForUser($this->user); // Filter out non top-level folders - $folders = array_filter($folders, fn (FolderDefinition $folder): bool => !str_contains($folder->mountPoint, '/')); + $topLevelFolders = array_filter( + $folders, + fn (FolderDefinition $folder): bool => !str_contains($folder->mountPoint, '/') + ); - return array_map($this->getDirectoryForFolder(...), $folders); + return array_map($this->getDirectoryForFolder(...), $topLevelFolders); } - public function childExists($name): bool { + public function childExists(string $name): bool { return $this->getFolder($name) !== null; } public function getLastModified(): int { return 0; } + + /** + * Finds a group folder definition among the user's folders by folder name. + * + * @param string $name The name (basename of mountPoint) to match. + * @return FolderDefinition|null The folder definition if found, or null if no matching folder exists. + */ + private function getFolder(string $name): ?FolderDefinition { + $folders = $this->folderManager->getFoldersForUser($this->user); + foreach ($folders as $folder) { + if (basename($folder->mountPoint) === $name) { + return $folder; + } + } + + return null; + } + + /** + * Returns a GroupFolderNode representing the given group folder in the user's home directory. + * + * @param FolderDefinition $folder The group folder to represent. + * @return GroupFolderNode The DAV node for the specified group folder. + * @throws RuntimeException If the filesystem view cannot be obtained. + */ + private function getDirectoryForFolder(FolderDefinition $folder): GroupFolderNode { + $userHome = '/' . $this->user->getUID() . '/files'; + $node = $this->rootFolder->get($userHome . '/' . $folder->mountPoint); + + $view = Filesystem::getView(); + if ($view === null) { + throw new RuntimeException('Unable to create view.'); + } + + return new GroupFolderNode($view, $node, $folder->id); + } } diff --git a/lib/DAV/PropFindPlugin.php b/lib/DAV/PropFindPlugin.php index 630a848ac..4128d2ce1 100644 --- a/lib/DAV/PropFindPlugin.php +++ b/lib/DAV/PropFindPlugin.php @@ -16,45 +16,71 @@ use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; +/** + * SabreDAV plugin that adds group folder metadata to PROPFIND responses. + * + * Adds the mount point and group folder ID as custom WebDAV properties for group folder nodes. + */ class PropFindPlugin extends ServerPlugin { private ?Folder $userFolder = null; public const MOUNT_POINT_PROPERTYNAME = '{http://nextcloud.org/ns}mount-point'; public const GROUP_FOLDER_ID_PROPERTYNAME = '{http://nextcloud.org/ns}group-folder-id'; - public function __construct(IRootFolder $rootFolder, IUserSession $userSession) { - $user = $userSession->getUser(); - if ($user === null) { - return; - } - - $this->userFolder = $rootFolder->getUserFolder($user->getUID()); + public function __construct( + private readonly IRootFolder $rootFolder, + private readonly IUserSession $userSession, + ) { } - public function getPluginName(): string { return 'groupFoldersDavPlugin'; } public function initialize(Server $server): void { + $user = $this->userSession->getUser(); + // Gracefully handle non-user sessions + if ($user !== null) { + $this->userFolder = $this->rootFolder->getUserFolder($user->getUID()); + } + $server->on('propFind', $this->propFind(...)); } public function propFind(PropFind $propFind, INode $node): void { + // non-user sessions aren't supported for any of these handlers currently if ($this->userFolder === null) { return; } - if ($node instanceof GroupFolderNode) { - $propFind->handle( - self::MOUNT_POINT_PROPERTYNAME, - /** @psalm-suppress PossiblyNullReference Null already checked above */ - fn () => $this->userFolder->getRelativePath($node->getFileInfo()->getMountPoint()->getMountPoint()) - ); - $propFind->handle( - self::GROUP_FOLDER_ID_PROPERTYNAME, - fn (): int => $node->getFolderId() - ); + if (!($node instanceof GroupFolderNode)) { + return; + } + + $propFind->handle( + self::MOUNT_POINT_PROPERTYNAME, + fn () => $this->getRelativeMountPointPath($node) + ); + + $propFind->handle( + self::GROUP_FOLDER_ID_PROPERTYNAME, + fn (): int => $node->getFolderId() + ); + } + + /** + * Compute the path of the mount point relative to the root of the current user's folder. + * + * TODO: This may be a candidate for a utility function in GF or API addition in core. + */ + private function getRelativeMountPointPath(GroupFolderNode $node): ?string { + if ($this->userFolder === null) { // make psalm happy + throw new \LogicException('userFolder cannot be null'); } + // TODO: Seems there could be some more defensive null/error handling here (perhaps throwing a 404/not found + logging) + $fileInfo = $node->getFileInfo(); + $mount = $fileInfo->getMountPoint(); + $mountPointPath = $mount->getMountPoint(); + return $this->userFolder->getRelativePath($mountPointPath); } } diff --git a/lib/DAV/RootCollection.php b/lib/DAV/RootCollection.php index 1861870a0..85a9f6b33 100644 --- a/lib/DAV/RootCollection.php +++ b/lib/DAV/RootCollection.php @@ -11,9 +11,15 @@ use OCA\GroupFolders\Folder\FolderManager; use OCP\Files\IRootFolder; use OCP\IUserSession; +use Sabre\DAV\Exception\Forbidden; use Sabre\DAVACL\AbstractPrincipalCollection; use Sabre\DAVACL\PrincipalBackend; +/** + * WebDAV root collection for the GroupFolders app. + * + * Provides access to user principal nodes representing each user's group folders home. + */ class RootCollection extends AbstractPrincipalCollection { public function __construct( private readonly IUserSession $userSession, @@ -25,17 +31,20 @@ public function __construct( } /** - * This method returns a node for a principal. + * Returns a GroupFoldersHome for the principal if the authenticated user matches. * * The passed array contains principal information, and is guaranteed to * at least contain a uri item. Other properties may or may not be * supplied by the authentication backend. + * + * @throws \Sabre\DAV\Exception\Forbidden If the principal does not match the currently logged-in user. */ public function getChildForPrincipal(array $principalInfo): GroupFoldersHome { [, $name] = \Sabre\Uri\split($principalInfo['uri']); $user = $this->userSession->getUser(); + if (is_null($user) || $name !== $user->getUID()) { - throw new \Sabre\DAV\Exception\Forbidden(); + throw new Forbidden('Access to this groupfolders principal is not allowed for this user.'); } return new GroupFoldersHome($principalInfo, $this->folderManager, $this->rootFolder, $user);