Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/files/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/files/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions apps/files/lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
114 changes: 114 additions & 0 deletions apps/files/lib/Resource/NodeResourceType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare(strict_types=1);

namespace OCA\Files\Resource;

use NCU\Resource\IResourceType;
use NCU\Resource\Permission\AccessEntry;
use NCU\Resource\Permission\CorePermission;
use OCA\Files\AppInfo\Application;
use OCP\Actor\Actor;
use OCP\Actor\PrincipalType;
use OCP\Constants;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\Files\Storage\ISharedStorage;
use OCP\L10N\IFactory;

/**
* Files and folders, the ID of the resource is the file ID.
*/
final readonly class NodeResourceType implements IResourceType {
public const string ID = 'files:node';

private const array PERMISSIONS = [
Constants::PERMISSION_READ => 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<non-empty-string>
*/
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();
}
}
135 changes: 135 additions & 0 deletions apps/files/tests/Resource/NodeResourceTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare(strict_types=1);

namespace OCA\Files\Tests\Resource;

use NCU\Resource\Permission\AccessEntry;
use NCU\Resource\Permission\IPermissionManager;
use NCU\Resource\ResourceReference;
use OCA\Files\Resource\NodeResourceType;
use OCP\Actor\Actor;
use OCP\Actor\Principal;
use OCP\Constants;
use OCP\Files\File;
use OCP\Files\IRootFolder;
use OCP\Files\IUserFolder;
use OCP\Files\Storage\ISharedStorage;
use OCP\Files\Storage\IStorage;
use OCP\L10N\IFactory;
use OCP\Server;
use OCP\Share\IShare;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;

class NodeResourceTypeTest extends TestCase {
private IRootFolder&MockObject $rootFolder;
private IUserFolder&MockObject $userFolder;
private NodeResourceType $resourceType;
private Actor $alice;

protected function setUp(): void {
parent::setUp();

$this->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<string> $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')]));
}
}
Loading