Skip to content

Commit 36577b1

Browse files
Kocsalmart-dev
authored andcommitted
feat(files_sharing): Improve user experience when user attempts to download folder with non-downloadable files
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
1 parent 35606bc commit 36577b1

6 files changed

Lines changed: 103 additions & 125 deletions

File tree

apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,31 @@ public function initialize(Server $server): void {
6161
$this->server->on('afterMethod:GET', $this->afterDownload(...), 999);
6262
}
6363

64+
/**
65+
* @return iterable<NcNode>
66+
*/
67+
protected function createIterator(array $rootNodes): iterable {
68+
foreach ($rootNodes as $rootNode) {
69+
yield from $this->iterateNodes($rootNode);
70+
}
71+
}
72+
73+
/**
74+
* Recursively iterate over all nodes in a folder.
75+
* @return iterable<NcNode>
76+
*/
77+
protected function iterateNodes(NcNode $node): iterable {
78+
yield $node;
79+
80+
if ($node instanceof NcFolder) {
81+
foreach ($node->getDirectoryListing() as $childNode) {
82+
yield from $this->iterateNodes($childNode);
83+
}
84+
}
85+
}
86+
6487
/**
6588
* Adding a node to the archive streamer.
66-
* This will recursively add new nodes to the stream if the node is a directory.
6789
*/
6890
protected function streamNode(Streamer $streamer, NcNode $node, string $rootPath): void {
6991
// Remove the root path from the filename to make it relative to the requested folder
@@ -79,10 +101,6 @@ protected function streamNode(Streamer $streamer, NcNode $node, string $rootPath
79101
$streamer->addFileFromStream($resource, $filename, $node->getSize(), $mtime);
80102
} elseif ($node instanceof NcFolder) {
81103
$streamer->addEmptyDir($filename, $mtime);
82-
$content = $node->getDirectoryListing();
83-
foreach ($content as $subNode) {
84-
$this->streamNode($streamer, $subNode, $rootPath);
85-
}
86104
}
87105
}
88106

@@ -137,7 +155,14 @@ public function handleDownload(Request $request, Response $response): ?bool {
137155
}
138156

139157
$folder = $node->getNode();
140-
$event = new BeforeZipCreatedEvent($folder, $files);
158+
$rootNodes = empty($files) ? $folder->getDirectoryListing() : [];
159+
foreach ($files as $path) {
160+
$child = $node->getChild($path);
161+
assert($child instanceof Node);
162+
$rootNodes[] = $child->getNode();
163+
}
164+
165+
$event = new BeforeZipCreatedEvent($folder, $files, $this->createIterator($rootNodes));
141166
$this->eventDispatcher->dispatchTyped($event);
142167
if ((!$event->isSuccessful()) || $event->getErrorMessage() !== null) {
143168
$errorMessage = $event->getErrorMessage();
@@ -150,13 +175,6 @@ public function handleDownload(Request $request, Response $response): ?bool {
150175
throw new Forbidden($errorMessage);
151176
}
152177

153-
$content = empty($files) ? $folder->getDirectoryListing() : [];
154-
foreach ($files as $path) {
155-
$child = $node->getChild($path);
156-
assert($child instanceof Node);
157-
$content[] = $child->getNode();
158-
}
159-
160178
$archiveName = $folder->getName();
161179
if (count(explode('/', trim($folder->getPath(), '/'), 3)) === 2) {
162180
// this is a download of the root folder
@@ -169,13 +187,13 @@ public function handleDownload(Request $request, Response $response): ?bool {
169187
$rootPath = dirname($folder->getPath());
170188
}
171189

172-
$streamer = new Streamer($tarRequest, -1, count($content), $this->timezoneFactory);
190+
$streamer = new Streamer($tarRequest, -1, count($rootNodes), $this->timezoneFactory);
173191
$streamer->sendHeaders($archiveName);
174192
// For full folder downloads we also add the folder itself to the archive
175193
if (empty($files)) {
176194
$streamer->addEmptyDir($archiveName);
177195
}
178-
foreach ($content as $node) {
196+
foreach ($event->getNodes() as $node) {
179197
$this->streamNode($streamer, $node, $rootPath);
180198
}
181199
$streamer->finalize();

apps/files_sharing/lib/Listener/BeforeDirectFileDownloadListener.php

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class BeforeDirectFileDownloadListener implements IEventListener {
2424
public function __construct(
2525
private IUserSession $userSession,
2626
private IRootFolder $rootFolder,
27+
private ViewOnly $viewOnly,
2728
) {
2829
}
2930

@@ -32,17 +33,17 @@ public function handle(Event $event): void {
3233
return;
3334
}
3435

35-
$pathsToCheck = [$event->getPath()];
36-
// Check only for user/group shares. Don't restrict e.g. share links
3736
$user = $this->userSession->getUser();
38-
if ($user) {
39-
$viewOnlyHandler = new ViewOnly(
40-
$this->rootFolder->getUserFolder($user->getUID())
41-
);
42-
if (!$viewOnlyHandler->check($pathsToCheck)) {
43-
$event->setSuccessful(false);
44-
$event->setErrorMessage('Access to this resource or one of its sub-items has been denied.');
45-
}
37+
// Check only for user/group shares. Don't restrict e.g. share links
38+
if (!$user) {
39+
return;
40+
41+
}
42+
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
43+
$node = $userFolder->get($event->getPath());
44+
if (!$this->viewOnly->isNodeCanBeDownloaded($node)) {
45+
$event->setSuccessful(false);
46+
$event->setErrorMessage('Access to this resource or one of its sub-items has been denied.');
4647
}
4748
}
4849
}

apps/files_sharing/lib/Listener/BeforeZipCreatedListener.php

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
use OCP\EventDispatcher\Event;
1414
use OCP\EventDispatcher\IEventListener;
1515
use OCP\Files\Events\BeforeZipCreatedEvent;
16-
use OCP\Files\IRootFolder;
16+
use OCP\Files\Node;
1717
use OCP\IUserSession;
1818

1919
/**
@@ -23,7 +23,7 @@ class BeforeZipCreatedListener implements IEventListener {
2323

2424
public function __construct(
2525
private IUserSession $userSession,
26-
private IRootFolder $rootFolder,
26+
private ViewOnly $viewOnly,
2727
) {
2828
}
2929

@@ -32,33 +32,19 @@ public function handle(Event $event): void {
3232
return;
3333
}
3434

35-
/** @psalm-suppress DeprecatedMethod should be migrated to getFolder but for now it would just duplicate code */
36-
$dir = $event->getDirectory();
37-
$files = $event->getFiles();
38-
39-
if (empty($files)) {
40-
$pathsToCheck = [$dir];
41-
} else {
42-
$pathsToCheck = [];
43-
foreach ($files as $file) {
44-
$pathsToCheck[] = $dir . '/' . $file;
45-
}
35+
$user = $this->userSession->getUser();
36+
if (!$user) {
37+
return;
4638
}
4739

48-
// Check only for user/group shares. Don't restrict e.g. share links
49-
$user = $this->userSession->getUser();
50-
if ($user) {
51-
$viewOnlyHandler = new ViewOnly(
52-
$this->rootFolder->getUserFolder($user->getUID())
53-
);
54-
if (!$viewOnlyHandler->check($pathsToCheck)) {
55-
$event->setErrorMessage('Access to this resource or one of its sub-items has been denied.');
56-
$event->setSuccessful(false);
57-
} else {
58-
$event->setSuccessful(true);
59-
}
60-
} else {
61-
$event->setSuccessful(true);
40+
// Check whether the user can download the requested folder
41+
if (!$this->viewOnly->isNodeCanBeDownloaded($event->getFolder())) {
42+
$event->setSuccessful(false);
43+
$event->setErrorMessage('Access to this resource has been denied.');
44+
return;
6245
}
46+
47+
// Check recursively whether the user can download nested nodes
48+
$event->addNodeFilter(fn (Node $node) => $this->viewOnly->isNodeCanBeDownloaded($node));
6349
}
6450
}

apps/files_sharing/lib/ViewOnly.php

Lines changed: 2 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -8,80 +8,15 @@
88

99
namespace OCA\Files_Sharing;
1010

11-
use OCP\Files\File;
12-
use OCP\Files\Folder;
1311
use OCP\Files\Node;
14-
use OCP\Files\NotFoundException;
1512

1613
/**
1714
* Handles restricting for download of files
1815
*/
1916
class ViewOnly {
20-
21-
public function __construct(
22-
private Folder $userFolder,
23-
) {
24-
}
25-
26-
/**
27-
* @param string[] $pathsToCheck paths to check, relative to the user folder
28-
* @return bool
29-
*/
30-
public function check(array $pathsToCheck): bool {
31-
// If any of elements cannot be downloaded, prevent whole download
32-
foreach ($pathsToCheck as $file) {
33-
try {
34-
$info = $this->userFolder->get($file);
35-
if ($info instanceof File) {
36-
// access to filecache is expensive in the loop
37-
if (!$this->checkFileInfo($info)) {
38-
return false;
39-
}
40-
} elseif ($info instanceof Folder) {
41-
// get directory content is rather cheap query
42-
if (!$this->dirRecursiveCheck($info)) {
43-
return false;
44-
}
45-
}
46-
} catch (NotFoundException $e) {
47-
continue;
48-
}
49-
}
50-
return true;
51-
}
52-
53-
/**
54-
* @param Folder $dirInfo
55-
* @return bool
56-
* @throws NotFoundException
57-
*/
58-
private function dirRecursiveCheck(Folder $dirInfo): bool {
59-
if (!$this->checkFileInfo($dirInfo)) {
60-
return false;
61-
}
62-
// If any of elements cannot be downloaded, prevent whole download
63-
$files = $dirInfo->getDirectoryListing();
64-
foreach ($files as $file) {
65-
if ($file instanceof File) {
66-
if (!$this->checkFileInfo($file)) {
67-
return false;
68-
}
69-
} elseif ($file instanceof Folder) {
70-
return $this->dirRecursiveCheck($file);
71-
}
72-
}
73-
74-
return true;
75-
}
76-
77-
/**
78-
* @param Node $fileInfo
79-
* @return bool
80-
* @throws NotFoundException
81-
*/
82-
private function checkFileInfo(Node $fileInfo): bool {
17+
public function isNodeCanBeDownloaded(Node $node): bool {
8318
// Restrict view-only to nodes which are shared
84-
$storage = $fileInfo->getStorage();
19+
$storage = $node->getStorage();
8520
if (!$storage->instanceOfStorage(SharedStorage::class)) {
8621
return true;
8722
}

apps/files_sharing/tests/ApplicationTest.php

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use OCA\Files_Sharing\Listener\BeforeDirectFileDownloadListener;
1414
use OCA\Files_Sharing\Listener\BeforeZipCreatedListener;
1515
use OCA\Files_Sharing\SharedStorage;
16+
use OCA\Files_Sharing\ViewOnly;
1617
use OCP\Files\Events\BeforeDirectFileDownloadEvent;
1718
use OCP\Files\Events\BeforeZipCreatedEvent;
1819
use OCP\Files\File;
@@ -91,7 +92,8 @@ public function testCheckDirectCanBeDownloaded(
9192
$event = new BeforeDirectFileDownloadEvent($path);
9293
$listener = new BeforeDirectFileDownloadListener(
9394
$this->userSession,
94-
$this->rootFolder
95+
$this->rootFolder,
96+
new ViewOnly(),
9597
);
9698
$listener->handle($event);
9799

@@ -161,6 +163,13 @@ function (string $fileStorage) use ($nonSharedStorage, $secureSharedStorage) {
161163
);
162164
$folder->method('getDirectoryListing')->willReturn($directoryListing);
163165
}
166+
167+
// If the folder contains any secure-shared files, make it appear as a secure-shared folder
168+
// so that ViewOnly::isNodeCanBeDownloaded() will return false
169+
$containsSecureSharedFiles = in_array('secureSharedStorage', $directoryListing);
170+
if ($containsSecureSharedFiles && $folderStorage === 'nonSharedStorage') {
171+
$folder->method('getStorage')->willReturn($secureSharedStorage);
172+
}
164173

165174
$rootFolder = $this->createMock(Folder::class);
166175
$rootFolder->method('getStorage')->willReturn($nonSharedStorage);
@@ -177,10 +186,11 @@ function (string $fileStorage) use ($nonSharedStorage, $secureSharedStorage) {
177186
$this->rootFolder->method('getUserFolder')->with('test')->willReturn($userFolder);
178187

179188
// Simulate zip download of folder folder
180-
$event = new BeforeZipCreatedEvent($dir, $files);
189+
$event = new BeforeZipCreatedEvent($folder, $files, $directoryListing);
190+
181191
$listener = new BeforeZipCreatedListener(
182192
$this->userSession,
183-
$this->rootFolder
193+
new ViewOnly(),
184194
);
185195
$listener->handle($event);
186196

@@ -192,10 +202,10 @@ public function testCheckFileUserNotFound(): void {
192202
$this->userSession->method('isLoggedIn')->willReturn(false);
193203

194204
// Simulate zip download of folder folder
195-
$event = new BeforeZipCreatedEvent('/test', ['test.txt']);
205+
$event = new BeforeZipCreatedEvent('/test', ['test.txt'], []);
196206
$listener = new BeforeZipCreatedListener(
197207
$this->userSession,
198-
$this->rootFolder
208+
new ViewOnly(),
199209
);
200210
$listener->handle($event);
201211

lib/public/Files/Events/BeforeZipCreatedEvent.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
use OCP\EventDispatcher\Event;
1313
use OCP\Files\Folder;
14+
use OCP\Files\Node;
1415

1516
/**
1617
* This event is triggered before a archive is created when a user requested
@@ -25,16 +26,19 @@ class BeforeZipCreatedEvent extends Event {
2526
private bool $successful = true;
2627
private ?string $errorMessage = null;
2728
private ?Folder $folder = null;
29+
private array $nodeFilters = [];
2830

2931
/**
3032
* @param string|Folder $directory Folder instance, or (deprecated) string path relative to user folder
31-
* @param list<string> $files
33+
* @param list<string> $files Selected files, empty for folder selection
34+
* @param iterable<Node> $nodes Recursively collected nodes
3235
* @since 25.0.0
3336
* @since 31.0.0 support `OCP\Files\Folder` as `$directory` parameter - passing a string is deprecated now
3437
*/
3538
public function __construct(
3639
string|Folder $directory,
3740
private array $files,
41+
private iterable $nodes,
3842
) {
3943
parent::__construct();
4044
if ($directory instanceof Folder) {
@@ -70,6 +74,30 @@ public function getFiles(): array {
7074
return $this->files;
7175
}
7276

77+
/**
78+
* @return iterable<Node>
79+
*/
80+
public function getNodes(): iterable {
81+
foreach ($this->nodes as $node) {
82+
$pass = true;
83+
foreach ($this->nodeFilters as $filter) {
84+
$pass = $pass && $filter($node);
85+
}
86+
87+
if ($pass) {
88+
yield $node;
89+
}
90+
}
91+
}
92+
93+
/**
94+
* @param callable $filter
95+
* @return void
96+
*/
97+
public function addNodeFilter(callable $filter): void {
98+
$this->nodeFilters[] = $filter;
99+
}
100+
73101
/**
74102
* @since 25.0.0
75103
*/

0 commit comments

Comments
 (0)