From 45413cbf386ccb5b114bdf956483c7f725b3fd75 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 13 Nov 2026 19:56:41 +0100 Subject: [PATCH] feat(files): register files and folders as resource type Registers `files:node` with the core permissions. A local user has access to a node with the permissions it has in their files, download is taken from the share for nodes received through one. Assisted-by: ClaudeCode:claude-fable-5-2 Signed-off-by: Ferdinand Thiessen --- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + apps/files/lib/AppInfo/Application.php | 3 + apps/files/lib/Resource/NodeResourceType.php | 114 +++++++++++++++ .../tests/Resource/NodeResourceTypeTest.php | 135 ++++++++++++++++++ 5 files changed, 254 insertions(+) create mode 100644 apps/files/lib/Resource/NodeResourceType.php create mode 100644 apps/files/tests/Resource/NodeResourceTypeTest.php diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index 1f488adf145bf..ea4e8a6beb4d2 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -90,6 +90,7 @@ 'OCA\\Files\\Migration\\Version2003Date20241021095629' => $baseDir . '/../lib/Migration/Version2003Date20241021095629.php', 'OCA\\Files\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', 'OCA\\Files\\PublicCapabilities' => $baseDir . '/../lib/PublicCapabilities.php', + 'OCA\\Files\\Resource\\NodeResourceType' => $baseDir . '/../lib/Resource/NodeResourceType.php', 'OCA\\Files\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php', 'OCA\\Files\\Search\\FileAccountScopedSearchProvider' => $baseDir . '/../lib/Search/FileAccountScopedSearchProvider.php', 'OCA\\Files\\Search\\FilesSearchProvider' => $baseDir . '/../lib/Search/FilesSearchProvider.php', diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index 0fc7b8ff788f8..c7b175fd70c96 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -105,6 +105,7 @@ class ComposerStaticInitFiles 'OCA\\Files\\Migration\\Version2003Date20241021095629' => __DIR__ . '/..' . '/../lib/Migration/Version2003Date20241021095629.php', 'OCA\\Files\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', 'OCA\\Files\\PublicCapabilities' => __DIR__ . '/..' . '/../lib/PublicCapabilities.php', + 'OCA\\Files\\Resource\\NodeResourceType' => __DIR__ . '/..' . '/../lib/Resource/NodeResourceType.php', 'OCA\\Files\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php', 'OCA\\Files\\Search\\FileAccountScopedSearchProvider' => __DIR__ . '/..' . '/../lib/Search/FileAccountScopedSearchProvider.php', 'OCA\\Files\\Search\\FilesSearchProvider' => __DIR__ . '/..' . '/../lib/Search/FilesSearchProvider.php', diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php index 5b6e8221fa6a2..de66f429064cb 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -37,6 +37,7 @@ use OCA\Files\Listener\UserFirstTimeLoggedInListener; use OCA\Files\Notification\Notifier; use OCA\Files\PublicCapabilities; +use OCA\Files\Resource\NodeResourceType; use OCA\Files\Search\FileAccountScopedSearchProvider; use OCA\Files\Search\FilesSearchProvider; use OCA\Files\Sharing\Permission\NodeCreateSharePermissionType; @@ -86,6 +87,8 @@ public function register(IRegistrationContext $context): void { $context->registerCapability(DirectEditingCapabilities::class); $context->registerCapability(PublicCapabilities::class); + $context->registerResourceType(NodeResourceType::class); + $context->registerEventListener(LoadSidebar::class, LoadSidebarListener::class); $context->registerEventListener(RenderReferenceEvent::class, RenderReferenceEventListener::class); $context->registerEventListener(BeforeNodeRenamedEvent::class, SyncLivePhotosListener::class); diff --git a/apps/files/lib/Resource/NodeResourceType.php b/apps/files/lib/Resource/NodeResourceType.php new file mode 100644 index 0000000000000..404ab5c4695d5 --- /dev/null +++ b/apps/files/lib/Resource/NodeResourceType.php @@ -0,0 +1,114 @@ + CorePermission::Read, + Constants::PERMISSION_UPDATE => CorePermission::Update, + Constants::PERMISSION_CREATE => CorePermission::Create, + Constants::PERMISSION_DELETE => CorePermission::Delete, + Constants::PERMISSION_SHARE => CorePermission::Share, + ]; + + public function __construct( + private IFactory $l10nFactory, + private IRootFolder $rootFolder, + ) { + } + + #[\Override] + public function getId(): string { + return self::ID; + } + + #[\Override] + public function getDisplayName(): string { + return $this->l10nFactory->get(Application::APP_ID)->t('File or folder'); + } + + #[\Override] + public function getPermissionIds(): array { + return [...array_column(self::PERMISSIONS, 'value'), CorePermission::DOWNLOAD]; + } + + /** + * Only local users have access, with the permissions the node has in their files. + */ + #[\Override] + public function getAccess(Actor $actor, array $resources): array { + $principal = $actor->principal; + if ($principal->type !== PrincipalType::User || $principal->origin !== null) { + return []; + } + + $userFolder = $this->rootFolder->getUserFolder($principal->id); + $access = []; + foreach ($resources as $resource) { + $node = ctype_digit($resource->id) ? $userFolder->getFirstNodeById((int)$resource->id) : null; + if ($node === null) { + continue; + } + + $permissionIds = $this->getGrantedPermissionIds($node); + if ($permissionIds !== []) { + $access[$resource->key()] = [new AccessEntry(AccessEntry::RECIPIENT_USER, $principal->id, $permissionIds)]; + } + } + + return $access; + } + + /** + * @return list + */ + private function getGrantedPermissionIds(Node $node): array { + $mask = $node->getPermissions(); + $permissionIds = []; + foreach (self::PERMISSIONS as $bit => $permission) { + if (($mask & $bit) !== 0) { + $permissionIds[] = $permission->value; + } + } + + if (($mask & Constants::PERMISSION_READ) !== 0 && $this->canDownload($node)) { + $permissionIds[] = CorePermission::DOWNLOAD; + } + + return $permissionIds; + } + + private function canDownload(Node $node): bool { + $storage = $node->getStorage(); + if (!$storage->instanceOfStorage(ISharedStorage::class)) { + return true; + } + + /** @var ISharedStorage $storage */ + return $storage->getShare()->canDownload(); + } +} diff --git a/apps/files/tests/Resource/NodeResourceTypeTest.php b/apps/files/tests/Resource/NodeResourceTypeTest.php new file mode 100644 index 0000000000000..af3893e57cc58 --- /dev/null +++ b/apps/files/tests/Resource/NodeResourceTypeTest.php @@ -0,0 +1,135 @@ +userFolder = $this->createMock(IUserFolder::class); + $this->rootFolder = $this->createMock(IRootFolder::class); + $this->rootFolder->method('getUserFolder')->with('alice')->willReturn($this->userFolder); + $this->resourceType = new NodeResourceType($this->createMock(IFactory::class), $this->rootFolder); + $this->alice = Actor::of(Principal::user('alice')); + } + + private function createNode(int $permissions, ?bool $shareCanDownload = null): File&MockObject { + $storage = $this->createMock($shareCanDownload === null ? IStorage::class : ISharedStorage::class); + $storage->method('instanceOfStorage')->willReturnCallback(static fn (string $class): bool => $storage instanceof $class); + if ($shareCanDownload !== null) { + $share = $this->createMock(IShare::class); + $share->method('canDownload')->willReturn($shareCanDownload); + $storage->method('getShare')->willReturn($share); + } + + $node = $this->createMock(File::class); + $node->method('getPermissions')->willReturn($permissions); + $node->method('getStorage')->willReturn($storage); + + return $node; + } + + public function testIsRegistered(): void { + $resourceType = Server::get(IPermissionManager::class)->getResourceType(NodeResourceType::ID); + + $this->assertInstanceOf(NodeResourceType::class, $resourceType); + $this->assertSame( + ['core:read', 'core:update', 'core:create', 'core:delete', 'core:share', 'core:download'], + $resourceType->getPermissionIds(), + ); + } + + public static function dataPermissions(): array { + return [ + 'all' => [Constants::PERMISSION_ALL, null, ['core:read', 'core:update', 'core:create', 'core:delete', 'core:share', 'core:download']], + 'read only' => [Constants::PERMISSION_READ, null, ['core:read', 'core:download']], + 'file drop' => [Constants::PERMISSION_CREATE, null, ['core:create']], + 'share with download' => [Constants::PERMISSION_READ | Constants::PERMISSION_UPDATE, true, ['core:read', 'core:update', 'core:download']], + 'share without download' => [Constants::PERMISSION_READ | Constants::PERMISSION_SHARE, false, ['core:read', 'core:share']], + ]; + } + + /** + * @param list $expected + */ + #[DataProvider('dataPermissions')] + public function testPermissionsOfTheNodeAreGrantedToTheUser(int $permissions, ?bool $shareCanDownload, array $expected): void { + $this->userFolder->method('getFirstNodeById')->with(42)->willReturn($this->createNode($permissions, $shareCanDownload)); + + $this->assertEquals( + ['files:node/42' => [new AccessEntry(AccessEntry::RECIPIENT_USER, 'alice', $expected)]], + $this->resourceType->getAccess($this->alice, [new ResourceReference(NodeResourceType::ID, '42')]), + ); + } + + public function testNodesWithoutAccessAreLeftOut(): void { + $this->userFolder->method('getFirstNodeById')->willReturnMap([ + [1, $this->createNode(Constants::PERMISSION_READ)], + [2, null], + [3, $this->createNode(0)], + ]); + + $access = $this->resourceType->getAccess($this->alice, [ + new ResourceReference(NodeResourceType::ID, '1'), + new ResourceReference(NodeResourceType::ID, '2'), + new ResourceReference(NodeResourceType::ID, '3'), + new ResourceReference(NodeResourceType::ID, 'not-a-file-id'), + ]); + + $this->assertSame(['files:node/1'], array_keys($access)); + } + + public function testDelegatedActorGetsTheAccessOfTheUser(): void { + $this->userFolder->method('getFirstNodeById')->willReturn($this->createNode(Constants::PERMISSION_READ)); + $actor = Actor::of(Principal::system('retention', 'files_versions'))->onBehalfOf(Principal::user('alice')); + + $this->assertArrayHasKey('files:node/1', $this->resourceType->getAccess($actor, [new ResourceReference(NodeResourceType::ID, '1')])); + } + + public static function dataActorsWithoutAccess(): array { + return [ + 'anonymous' => [Actor::of(Principal::anonymous('token', 'files_sharing:visitor'))], + 'federated user' => [Actor::of(Principal::user('alice', 'cloud.example'))], + 'system' => [Actor::of(Principal::system('scan', 'files'))], + 'bot' => [Actor::of(Principal::bot('summarizer', 'assistant'))], + ]; + } + + #[DataProvider('dataActorsWithoutAccess')] + public function testOnlyLocalUsersHaveAccess(Actor $actor): void { + $this->rootFolder->expects($this->never())->method('getUserFolder'); + + $this->assertSame([], $this->resourceType->getAccess($actor, [new ResourceReference(NodeResourceType::ID, '1')])); + } +}