Skip to content

Commit 5fe8953

Browse files
committed
WIP [skip ci]
Signed-off-by: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com>
1 parent 0e6c8ec commit 5fe8953

11 files changed

Lines changed: 257 additions & 24 deletions

File tree

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@
215215
'OCA\\DAV\\Connector\\Sabre\\Auth' => $baseDir . '/../lib/Connector/Sabre/Auth.php',
216216
'OCA\\DAV\\Connector\\Sabre\\BearerAuth' => $baseDir . '/../lib/Connector/Sabre/BearerAuth.php',
217217
'OCA\\DAV\\Connector\\Sabre\\BlockLegacyClientPlugin' => $baseDir . '/../lib/Connector/Sabre/BlockLegacyClientPlugin.php',
218+
'OCA\\DAV\\Connector\\Sabre\\ByteCounterFilter' => $baseDir . '/../lib/Connector/Sabre/ByteCounterFilter.php',
218219
'OCA\\DAV\\Connector\\Sabre\\CachingTree' => $baseDir . '/../lib/Connector/Sabre/CachingTree.php',
219220
'OCA\\DAV\\Connector\\Sabre\\ChecksumList' => $baseDir . '/../lib/Connector/Sabre/ChecksumList.php',
220221
'OCA\\DAV\\Connector\\Sabre\\ChecksumUpdatePlugin' => $baseDir . '/../lib/Connector/Sabre/ChecksumUpdatePlugin.php',
@@ -253,6 +254,7 @@
253254
'OCA\\DAV\\Connector\\Sabre\\ShareTypeList' => $baseDir . '/../lib/Connector/Sabre/ShareTypeList.php',
254255
'OCA\\DAV\\Connector\\Sabre\\ShareeList' => $baseDir . '/../lib/Connector/Sabre/ShareeList.php',
255256
'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => $baseDir . '/../lib/Connector/Sabre/SharesPlugin.php',
257+
'OCA\\DAV\\Connector\\Sabre\\StreamByteCounter' => $baseDir . '/../lib/Connector/Sabre/StreamByteCounter.php',
256258
'OCA\\DAV\\Connector\\Sabre\\TagList' => $baseDir . '/../lib/Connector/Sabre/TagList.php',
257259
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => $baseDir . '/../lib/Connector/Sabre/TagsPlugin.php',
258260
'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => $baseDir . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ class ComposerStaticInitDAV
230230
'OCA\\DAV\\Connector\\Sabre\\Auth' => __DIR__ . '/..' . '/../lib/Connector/Sabre/Auth.php',
231231
'OCA\\DAV\\Connector\\Sabre\\BearerAuth' => __DIR__ . '/..' . '/../lib/Connector/Sabre/BearerAuth.php',
232232
'OCA\\DAV\\Connector\\Sabre\\BlockLegacyClientPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/BlockLegacyClientPlugin.php',
233+
'OCA\\DAV\\Connector\\Sabre\\ByteCounterFilter' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ByteCounterFilter.php',
233234
'OCA\\DAV\\Connector\\Sabre\\CachingTree' => __DIR__ . '/..' . '/../lib/Connector/Sabre/CachingTree.php',
234235
'OCA\\DAV\\Connector\\Sabre\\ChecksumList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ChecksumList.php',
235236
'OCA\\DAV\\Connector\\Sabre\\ChecksumUpdatePlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ChecksumUpdatePlugin.php',
@@ -268,6 +269,7 @@ class ComposerStaticInitDAV
268269
'OCA\\DAV\\Connector\\Sabre\\ShareTypeList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ShareTypeList.php',
269270
'OCA\\DAV\\Connector\\Sabre\\ShareeList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ShareeList.php',
270271
'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/SharesPlugin.php',
272+
'OCA\\DAV\\Connector\\Sabre\\StreamByteCounter' => __DIR__ . '/..' . '/../lib/Connector/Sabre/StreamByteCounter.php',
271273
'OCA\\DAV\\Connector\\Sabre\\TagList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagList.php',
272274
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagsPlugin.php',
273275
'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php',
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
namespace OCA\DAV\Connector\Sabre;
8+
9+
/**
10+
* A stream filter to track how many bytes have been streamed from a stream.
11+
*/
12+
class ByteCounterFilter extends \php_user_filter {
13+
public string $filtername = 'ByteCounter';
14+
15+
public function filter($in, $out, &$consumed, bool $closing): int {
16+
$counter = $this->params['counter'] ?? null;
17+
18+
while ($bucket = stream_bucket_make_writeable($in)) {
19+
$length = $bucket->datalen;
20+
$consumed += $length;
21+
if ($counter instanceof StreamByteCounter) {
22+
$counter->bytes += $length;
23+
}
24+
stream_bucket_append($out, $bucket);
25+
}
26+
27+
return PSFS_PASS_ON;
28+
}
29+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ public function createServer(
110110
$this->logger,
111111
$this->eventDispatcher,
112112
\OCP\Server::get(IDateTimeZone::class),
113+
$this->config,
114+
$this->l10n,
113115
));
114116

115117
// Some WebDAV clients do require Class 2 WebDAV support (locking), since
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
namespace OCA\DAV\Connector\Sabre;
8+
9+
/**
10+
* Class to use in combination with ByteCounterFilter to keep track of how much
11+
* has been read from a stream.
12+
*
13+
* @see ByteCounterFilter
14+
*/
15+
class StreamByteCounter {
16+
public float|int $bytes = 0;
17+
}

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

Lines changed: 108 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
use OCP\Files\File as NcFile;
1616
use OCP\Files\Folder as NcFolder;
1717
use OCP\Files\Node as NcNode;
18+
use OCP\IConfig;
1819
use OCP\IDateTimeZone;
20+
use OCP\IL10N;
1921
use Psr\Log\LoggerInterface;
2022
use Sabre\DAV\Server;
2123
use Sabre\DAV\ServerPlugin;
@@ -37,13 +39,22 @@ class ZipFolderPlugin extends ServerPlugin {
3739
* Reference to main server object
3840
*/
3941
private ?Server $server = null;
42+
private bool $reportMissingFiles;
43+
private array $missingInfo = [];
4044

4145
public function __construct(
4246
private Tree $tree,
4347
private LoggerInterface $logger,
4448
private IEventDispatcher $eventDispatcher,
4549
private IDateTimeZone $timezoneFactory,
50+
private IConfig $config,
51+
private IL10N $l10n,
4652
) {
53+
$this->reportMissingFiles = $this->config->getSystemValueBool('archive.report_missing_files', false);
54+
55+
if ($this->reportMissingFiles) {
56+
stream_filter_register('count.bytes', ByteCounterFilter::class);
57+
}
4758
}
4859

4960
/**
@@ -63,26 +74,70 @@ public function initialize(Server $server): void {
6374

6475
/**
6576
* Adding a node to the archive streamer.
66-
* This will recursively add new nodes to the stream if the node is a directory.
6777
*/
6878
protected function streamNode(Streamer $streamer, NcNode $node, string $rootPath): void {
6979
// Remove the root path from the filename to make it relative to the requested folder
7080
$filename = str_replace($rootPath, '', $node->getPath());
7181

7282
$mtime = $node->getMTime();
83+
if ($node instanceof NcFolder) {
84+
$streamer->addEmptyDir($filename, $mtime);
85+
return;
86+
}
87+
7388
if ($node instanceof NcFile) {
74-
$resource = $node->fopen('rb');
75-
if ($resource === false) {
76-
$this->logger->info('Cannot read file for zip stream', ['filePath' => $node->getPath()]);
77-
throw new \Sabre\DAV\Exception\ServiceUnavailable('Requested file can currently not be accessed.');
89+
$path = $node->getPath();
90+
$nodeSize = $node->getSize();
91+
try {
92+
$stream = $node->fopen('rb');
93+
} catch (\Exception $e) {
94+
// opening failed, log the failure as reason for the missing file
95+
$exceptionClass = get_class($e);
96+
$this->missingInfo[$path] = $this->l10n->t('Error while opening the file: %s', [$exceptionClass]);
97+
return;
7898
}
79-
$streamer->addFileFromStream($resource, $filename, $node->getSize(), $mtime);
80-
} elseif ($node instanceof NcFolder) {
81-
$streamer->addEmptyDir($filename, $mtime);
82-
$content = $node->getDirectoryListing();
83-
foreach ($content as $subNode) {
84-
$this->streamNode($streamer, $subNode, $rootPath);
99+
100+
if ($stream === false) {
101+
$this->missingInfo[$path] = $this->l10n->t('File could not be opened (fopen). Please check the server logs for more information.');
102+
return;
103+
}
104+
105+
$byteCounter = new StreamByteCounter();
106+
$wrapped = stream_filter_append($stream, 'count.bytes', STREAM_FILTER_READ, ['counter' => $byteCounter]);
107+
if ($wrapped === false) {
108+
$this->missingInfo[$path] = $this->l10n->t('Unable to check file for consistency check');
109+
return;
85110
}
111+
112+
$fileAddedToStream = $streamer->addFileFromStream($stream, $filename, $nodeSize, $mtime);
113+
if (!$fileAddedToStream) {
114+
$this->missingInfo[$path] = $this->l10n->t('The archive was already finalized');
115+
return;
116+
}
117+
118+
$this->logStreamErrors($stream, $path, $nodeSize, $byteCounter->bytes);
119+
}
120+
}
121+
122+
/**
123+
* Checks whether $stream was fully streamed or if there were other issues
124+
* with the stream, logging the error if necessary.
125+
*
126+
* @param resource $stream
127+
* @return void
128+
*/
129+
private function logStreamErrors(mixed $stream, string $path, float|int $expectedFileSize, float|int $readFileSize): void {
130+
if (!$this->reportMissingFiles) {
131+
return;
132+
}
133+
134+
$streamMetadata = stream_get_meta_data($stream);
135+
if (!is_resource($stream) || get_resource_type($stream) !== 'stream') {
136+
$this->missingInfo[$path] = $this->l10n->t('Resource is not a stream or is closed.');
137+
} elseif ($streamMetadata['timed_out']) {
138+
$this->missingInfo[$path] = $this->l10n->t('Timeout while reading from stream.');
139+
} elseif (!$streamMetadata['eof'] || $readFileSize != $expectedFileSize) {
140+
$this->missingInfo[$path] = $this->l10n->t('Read %d out of %d bytes from storage. This means the connection may have been closed due to a network/storage error.', [$expectedFileSize, $readFileSize]);
86141
}
87142
}
88143

@@ -137,7 +192,7 @@ public function handleDownload(Request $request, Response $response): ?bool {
137192
}
138193

139194
$folder = $node->getNode();
140-
$event = new BeforeZipCreatedEvent($folder, $files);
195+
$event = new BeforeZipCreatedEvent($folder, $files, $this->reportMissingFiles);
141196
$this->eventDispatcher->dispatchTyped($event);
142197
if ((!$event->isSuccessful()) || $event->getErrorMessage() !== null) {
143198
$errorMessage = $event->getErrorMessage();
@@ -150,12 +205,16 @@ public function handleDownload(Request $request, Response $response): ?bool {
150205
throw new Forbidden($errorMessage);
151206
}
152207

208+
// At this point either the event handlers did not block the download
209+
// or they support the new mechanism that filters out nodes that are not
210+
// downloadable, in either case we can use the new API to set the iterator
153211
$content = empty($files) ? $folder->getDirectoryListing() : [];
154212
foreach ($files as $path) {
155213
$child = $node->getChild($path);
156214
assert($child instanceof Node);
157215
$content[] = $child->getNode();
158216
}
217+
$event->setNodesIterable($this->getIterableFromNodes($content));
159218

160219
$archiveName = $folder->getName();
161220
if (count(explode('/', trim($folder->getPath(), '/'), 3)) === 2) {
@@ -169,19 +228,54 @@ public function handleDownload(Request $request, Response $response): ?bool {
169228
$rootPath = dirname($folder->getPath());
170229
}
171230

172-
$streamer = new Streamer($tarRequest, -1, count($content), $this->timezoneFactory);
231+
// FIXME: numberOfFiles is supposed to be the count of ALL files in the
232+
// archive, not just the root directory.
233+
$numberOfFiles = count($content) + ($this->reportMissingFiles ? 1 : 0);
234+
$streamer = new Streamer($tarRequest, -1, $numberOfFiles, $this->timezoneFactory);
173235
$streamer->sendHeaders($archiveName);
174236
// For full folder downloads we also add the folder itself to the archive
175237
if (empty($files)) {
176238
$streamer->addEmptyDir($archiveName);
177239
}
178-
foreach ($content as $node) {
240+
241+
foreach ($event->getNodes() as $path => [$node, $reason]) {
242+
if ($node === null) {
243+
$this->missingInfo[$path] = $reason;
244+
continue;
245+
}
246+
179247
$this->streamNode($streamer, $node, $rootPath);
180248
}
249+
250+
if ($this->reportMissingFiles && !empty($this->missingInfo)) {
251+
$stream = fopen('php://temp', 'r+');
252+
fwrite($stream, json_encode($this->missingInfo, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
253+
rewind($stream);
254+
$streamer->addFileFromStream($stream, 'missing_files.json', 0, false);
255+
}
181256
$streamer->finalize();
182257
return false;
183258
}
184259

260+
/**
261+
* Given a set of nodes, produces a list of all nodes contained in them
262+
* recursively.
263+
*
264+
* @param NcNode[] $nodes
265+
* @return iterable<NcNode>
266+
*/
267+
private function getIterableFromNodes(array $nodes): iterable {
268+
foreach ($nodes as $node) {
269+
yield $node;
270+
271+
if ($node instanceof NcFolder) {
272+
foreach ($node->getDirectoryListing() as $child) {
273+
yield from $this->getIterableFromNodes([$child]);
274+
}
275+
}
276+
}
277+
}
278+
185279
/**
186280
* Tell sabre/dav not to trigger it's own response sending logic as the handleDownload will have already send the response
187281
*

apps/dav/lib/Server.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
use OCP\IDateTimeZone;
8888
use OCP\IDBConnection;
8989
use OCP\IGroupManager;
90-
use OCP\IPreview;
90+
use OCP\IL10N;use OCP\IPreview;
9191
use OCP\IRequest;
9292
use OCP\ISession;
9393
use OCP\ITagManager;
@@ -242,6 +242,7 @@ public function __construct(
242242
\OCP\Server::get(IUserSession::class)
243243
));
244244

245+
$config = \OCP\Server::get(IConfig::class);
245246
// performance improvement plugins
246247
$this->server->addPlugin(new CopyEtagHeaderPlugin());
247248
$this->server->addPlugin(new RequestIdHeaderPlugin(\OCP\Server::get(IRequest::class)));
@@ -254,6 +255,8 @@ public function __construct(
254255
$logger,
255256
$eventDispatcher,
256257
\OCP\Server::get(IDateTimeZone::class),
258+
$config,
259+
\OCP\Server::get(IL10N::class),
257260
));
258261
$this->server->addPlugin(\OCP\Server::get(PaginatePlugin::class));
259262
$this->server->addPlugin(new PropFindPreloadNotifyPlugin());

apps/files_sharing/lib/Listener/BeforeZipCreatedListener.php

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
namespace OCA\Files_Sharing\Listener;
1111

12+
use OC\Files\Node\Node;
1213
use OCA\Files_Sharing\ViewOnly;
1314
use OCP\EventDispatcher\Event;
1415
use OCP\EventDispatcher\IEventListener;
@@ -51,14 +52,35 @@ public function handle(Event $event): void {
5152
$viewOnlyHandler = new ViewOnly(
5253
$this->rootFolder->getUserFolder($user->getUID())
5354
);
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);
55+
56+
if ($event->reportBlockedFiles) {
57+
// check only the top directory / file and set the nodes filter
58+
$node = $this->rootFolder->getUserFolder($user->getUID())->get($dir);
59+
$isRootDownloadable = $viewOnlyHandler->isDownloadable($node);
60+
$event->setSuccessful($isRootDownloadable);
61+
if ($isRootDownloadable) {
62+
$event->addNodeFilter(fn(Node $node): array => [
63+
$viewOnlyHandler->isDownloadable($node),
64+
'Download is disabled for this resource'
65+
]);
66+
} else {
67+
$event->setErrorMessage('Access to this resource and its children has been denied.');
68+
}
5769
} else {
58-
$event->setSuccessful(true);
70+
// keep the old behaviour
71+
if (!$viewOnlyHandler->check($pathsToCheck)) {
72+
$event->setErrorMessage('Access to this resource or one of its sub-items has been denied.');
73+
$event->setSuccessful(false);
74+
} else {
75+
$event->setSuccessful(true);
76+
// passthrough, to keep backwards-compatibility
77+
$event->addNodeFilter(fn (Node $_node): array => [true, null]);
78+
}
5979
}
6080
} else {
6181
$event->setSuccessful(true);
82+
// passthrough, to keep backwards-compatibility
83+
$event->addNodeFilter(fn (Node $_node): array => [true, null]);
6284
}
6385
}
6486
}

apps/files_sharing/lib/ViewOnly.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public function check(array $pathsToCheck): bool {
3434
$info = $this->userFolder->get($file);
3535
if ($info instanceof File) {
3636
// access to filecache is expensive in the loop
37-
if (!$this->checkFileInfo($info)) {
37+
if (!$this->isDownloadable($info)) {
3838
return false;
3939
}
4040
} elseif ($info instanceof Folder) {
@@ -56,14 +56,14 @@ public function check(array $pathsToCheck): bool {
5656
* @throws NotFoundException
5757
*/
5858
private function dirRecursiveCheck(Folder $dirInfo): bool {
59-
if (!$this->checkFileInfo($dirInfo)) {
59+
if (!$this->isDownloadable($dirInfo)) {
6060
return false;
6161
}
6262
// If any of elements cannot be downloaded, prevent whole download
6363
$files = $dirInfo->getDirectoryListing();
6464
foreach ($files as $file) {
6565
if ($file instanceof File) {
66-
if (!$this->checkFileInfo($file)) {
66+
if (!$this->isDownloadable($file)) {
6767
return false;
6868
}
6969
} elseif ($file instanceof Folder) {
@@ -79,7 +79,7 @@ private function dirRecursiveCheck(Folder $dirInfo): bool {
7979
* @return bool
8080
* @throws NotFoundException
8181
*/
82-
private function checkFileInfo(Node $fileInfo): bool {
82+
public function isDownloadable(Node $fileInfo): bool {
8383
// Restrict view-only to nodes which are shared
8484
$storage = $fileInfo->getStorage();
8585
if (!$storage->instanceOfStorage(SharedStorage::class)) {

0 commit comments

Comments
 (0)