Compare commits

...

2 Commits

Author SHA1 Message Date
Salvatore Martire a783e8ec6d fix: improve ZipFolderPlugin behaviour for different cases
Signed-off-by: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com>
2026-03-18 18:23:31 +01:00
Kostiantyn Miakshyn adfe20c67a feat(files_sharing): Improve user experience when user attempts to download folder with non-downloadable files
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
2026-03-13 13:11:02 +01:00
15 changed files with 930 additions and 58 deletions
@@ -215,6 +215,7 @@ return array(
'OCA\\DAV\\Connector\\Sabre\\Auth' => $baseDir . '/../lib/Connector/Sabre/Auth.php',
'OCA\\DAV\\Connector\\Sabre\\BearerAuth' => $baseDir . '/../lib/Connector/Sabre/BearerAuth.php',
'OCA\\DAV\\Connector\\Sabre\\BlockLegacyClientPlugin' => $baseDir . '/../lib/Connector/Sabre/BlockLegacyClientPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\ByteCounterFilter' => $baseDir . '/../lib/Connector/Sabre/ByteCounterFilter.php',
'OCA\\DAV\\Connector\\Sabre\\CachingTree' => $baseDir . '/../lib/Connector/Sabre/CachingTree.php',
'OCA\\DAV\\Connector\\Sabre\\ChecksumList' => $baseDir . '/../lib/Connector/Sabre/ChecksumList.php',
'OCA\\DAV\\Connector\\Sabre\\ChecksumUpdatePlugin' => $baseDir . '/../lib/Connector/Sabre/ChecksumUpdatePlugin.php',
@@ -253,6 +254,7 @@ return array(
'OCA\\DAV\\Connector\\Sabre\\ShareTypeList' => $baseDir . '/../lib/Connector/Sabre/ShareTypeList.php',
'OCA\\DAV\\Connector\\Sabre\\ShareeList' => $baseDir . '/../lib/Connector/Sabre/ShareeList.php',
'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => $baseDir . '/../lib/Connector/Sabre/SharesPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\StreamByteCounter' => $baseDir . '/../lib/Connector/Sabre/StreamByteCounter.php',
'OCA\\DAV\\Connector\\Sabre\\TagList' => $baseDir . '/../lib/Connector/Sabre/TagList.php',
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => $baseDir . '/../lib/Connector/Sabre/TagsPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => $baseDir . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php',
@@ -230,6 +230,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Connector\\Sabre\\Auth' => __DIR__ . '/..' . '/../lib/Connector/Sabre/Auth.php',
'OCA\\DAV\\Connector\\Sabre\\BearerAuth' => __DIR__ . '/..' . '/../lib/Connector/Sabre/BearerAuth.php',
'OCA\\DAV\\Connector\\Sabre\\BlockLegacyClientPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/BlockLegacyClientPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\ByteCounterFilter' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ByteCounterFilter.php',
'OCA\\DAV\\Connector\\Sabre\\CachingTree' => __DIR__ . '/..' . '/../lib/Connector/Sabre/CachingTree.php',
'OCA\\DAV\\Connector\\Sabre\\ChecksumList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ChecksumList.php',
'OCA\\DAV\\Connector\\Sabre\\ChecksumUpdatePlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ChecksumUpdatePlugin.php',
@@ -268,6 +269,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Connector\\Sabre\\ShareTypeList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ShareTypeList.php',
'OCA\\DAV\\Connector\\Sabre\\ShareeList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ShareeList.php',
'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/SharesPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\StreamByteCounter' => __DIR__ . '/..' . '/../lib/Connector/Sabre/StreamByteCounter.php',
'OCA\\DAV\\Connector\\Sabre\\TagList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagList.php',
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagsPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php',
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Connector\Sabre;
/**
* A stream filter to track how many bytes have been streamed from a stream.
*/
class ByteCounterFilter extends \php_user_filter {
public string $filtername = 'ByteCounter';
public function filter($in, $out, &$consumed, bool $closing): int {
$counter = $this->params['counter'] ?? null;
while ($bucket = stream_bucket_make_writeable($in)) {
$length = $bucket->datalen;
$consumed += $length;
if ($counter instanceof StreamByteCounter) {
$counter->bytes += $length;
}
stream_bucket_append($out, $bucket);
}
return PSFS_PASS_ON;
}
}
@@ -110,6 +110,8 @@ class ServerFactory {
$this->logger,
$this->eventDispatcher,
\OCP\Server::get(IDateTimeZone::class),
$this->config,
$this->l10n,
));
// Some WebDAV clients do require Class 2 WebDAV support (locking), since
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Connector\Sabre;
/**
* Class to use in combination with ByteCounterFilter to keep track of how much
* has been read from a stream.
*
* @see ByteCounterFilter
*/
class StreamByteCounter {
public float|int $bytes = 0;
}
+131 -22
View File
@@ -15,7 +15,10 @@ use OCP\Files\Events\BeforeZipCreatedEvent;
use OCP\Files\File as NcFile;
use OCP\Files\Folder as NcFolder;
use OCP\Files\Node as NcNode;
use OCP\IConfig;
use OCP\IDateTimeZone;
use OCP\IL10N;
use OCP\L10N\IFactory;
use Psr\Log\LoggerInterface;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
@@ -37,13 +40,25 @@ class ZipFolderPlugin extends ServerPlugin {
* Reference to main server object
*/
private ?Server $server = null;
private bool $reportMissingFiles;
private array $missingInfo = [];
private IL10N $l10n;
public function __construct(
private Tree $tree,
private LoggerInterface $logger,
private IEventDispatcher $eventDispatcher,
private IDateTimeZone $timezoneFactory,
private IConfig $config,
private IFactory $l10nFactory,
) {
$this->reportMissingFiles = $this->config->getSystemValueBool('archive_report_missing_files', false);
if ($this->reportMissingFiles) {
stream_filter_register('count.bytes', ByteCounterFilter::class);
}
$this->l10n = $this->l10nFactory->get('dav');
}
/**
@@ -63,27 +78,77 @@ class ZipFolderPlugin extends ServerPlugin {
/**
* Adding a node to the archive streamer.
* This will recursively add new nodes to the stream if the node is a directory.
* @return ?string an error message if an error occurred and reporting is enabled, null otherwise
*/
protected function streamNode(Streamer $streamer, NcNode $node, string $rootPath): void {
protected function streamNode(Streamer $streamer, NcNode $node, string $rootPath): ?string {
// Remove the root path from the filename to make it relative to the requested folder
$filename = str_replace($rootPath, '', $node->getPath());
$mtime = $node->getMTime();
if ($node instanceof NcFile) {
$resource = $node->fopen('rb');
if ($resource === false) {
$this->logger->info('Cannot read file for zip stream', ['filePath' => $node->getPath()]);
throw new \Sabre\DAV\Exception\ServiceUnavailable('Requested file can currently not be accessed.');
}
$streamer->addFileFromStream($resource, $filename, $node->getSize(), $mtime);
} elseif ($node instanceof NcFolder) {
if ($node instanceof NcFolder) {
$streamer->addEmptyDir($filename, $mtime);
$content = $node->getDirectoryListing();
foreach ($content as $subNode) {
$this->streamNode($streamer, $subNode, $rootPath);
}
return null;
}
if ($node instanceof NcFile) {
$nodeSize = $node->getSize();
try {
$stream = $node->fopen('rb');
} catch (\Exception $e) {
// opening failed, log the failure as reason for the missing file
if ($this->reportMissingFiles) {
$exceptionClass = get_class($e);
return $this->l10n->t('Error while opening the file: %s', [$exceptionClass]);
}
throw $e;
}
if ($this->reportMissingFiles) {
if ($stream === false) {
return $this->l10n->t('File could not be opened (fopen). Please check the server logs for more information.');
}
$byteCounter = new StreamByteCounter();
$wrapped = stream_filter_append($stream, 'count.bytes', STREAM_FILTER_READ, ['counter' => $byteCounter]);
if ($wrapped === false) {
return $this->l10n->t('Unable to check file for consistency check');
}
}
$fileAddedToStream = $streamer->addFileFromStream($stream, $filename, $nodeSize, $mtime);
if ($this->reportMissingFiles) {
if (!$fileAddedToStream) {
return $this->l10n->t('The archive was already finalized');
}
return $this->logStreamErrors($stream, $filename, $nodeSize, $byteCounter->bytes);
}
return null;
}
}
/**
* Checks whether $stream was fully streamed or if there were other issues
* with the stream, logging the error if necessary.
*
*/
private function logStreamErrors(mixed $stream, string $path, float|int $expectedFileSize, float|int $readFileSize): ?string {
$streamMetadata = stream_get_meta_data($stream);
if (!is_resource($stream) || get_resource_type($stream) !== 'stream') {
return $this->l10n->t('Resource is not a stream or is closed.');
}
if ($streamMetadata['timed_out'] ?? false) {
return $this->l10n->t('Timeout while reading from stream.');
}
if (!($streamMetadata['eof'] ?? true) || $readFileSize != $expectedFileSize) {
return $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]);
}
return null;
}
/**
@@ -137,7 +202,7 @@ class ZipFolderPlugin extends ServerPlugin {
}
$folder = $node->getNode();
$event = new BeforeZipCreatedEvent($folder, $files);
$event = new BeforeZipCreatedEvent($folder, $files, $this->reportMissingFiles);
$this->eventDispatcher->dispatchTyped($event);
if ((!$event->isSuccessful()) || $event->getErrorMessage() !== null) {
$errorMessage = $event->getErrorMessage();
@@ -150,12 +215,16 @@ class ZipFolderPlugin extends ServerPlugin {
throw new Forbidden($errorMessage);
}
// At this point either the event handlers did not block the download
// or they support the new mechanism that filters out nodes that are not
// downloadable, in either case we can use the new API to set the iterator
$content = empty($files) ? $folder->getDirectoryListing() : [];
foreach ($files as $path) {
$child = $node->getChild($path);
assert($child instanceof Node);
$content[] = $child->getNode();
}
$event->setNodesIterable($this->getIterableFromNodes($content));
$archiveName = $folder->getName();
if (count(explode('/', trim($folder->getPath(), '/'), 3)) === 2) {
@@ -169,31 +238,71 @@ class ZipFolderPlugin extends ServerPlugin {
$rootPath = dirname($folder->getPath());
}
$streamer = new Streamer($tarRequest, -1, count($content), $this->timezoneFactory);
// numberOfFiles is irrelevant as size=-1 forces the use of zip64 already
$streamer = new Streamer($tarRequest, -1, 0, $this->timezoneFactory);
$streamer->sendHeaders($archiveName);
// For full folder downloads we also add the folder itself to the archive
if (empty($files)) {
$streamer->addEmptyDir($archiveName);
}
foreach ($content as $node) {
$this->streamNode($streamer, $node, $rootPath);
foreach ($event->getNodes() as $path => [$node, $reason]) {
$filename = str_replace($rootPath, '', $path);
if ($node === null) {
if ($this->reportMissingFiles) {
$this->missingInfo[$filename] = $reason;
}
continue;
}
$streamError = $this->streamNode($streamer, $node, $rootPath);
if ($this->reportMissingFiles && $streamError !== null) {
$this->missingInfo[$filename] = $streamError;
}
}
if ($this->reportMissingFiles && !empty($this->missingInfo)) {
$json = json_encode($this->missingInfo, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
$stream = fopen('php://temp', 'r+');
fwrite($stream, $json);
rewind($stream);
$streamer->addFileFromStream($stream, 'missing_files.json', (float)strlen($json), false);
}
$streamer->finalize();
return false;
}
/**
* Tell sabre/dav not to trigger it's own response sending logic as the handleDownload will have already send the response
* Given a set of nodes, produces a list of all nodes contained in them
* recursively.
*
* @param NcNode[] $nodes
* @return iterable<NcNode>
*/
private function getIterableFromNodes(array $nodes): iterable {
foreach ($nodes as $node) {
yield $node;
if ($node instanceof NcFolder) {
foreach ($node->getDirectoryListing() as $child) {
yield from $this->getIterableFromNodes([$child]);
}
}
}
}
/**
* Tell sabre/dav not to trigger its own response sending logic as the handleDownload will have already send the response
*
* @return false|null
*/
public function afterDownload(Request $request, Response $response): ?bool {
$node = $this->tree->getNodeForPath($request->getPath());
if (!($node instanceof Directory)) {
if ($node instanceof Directory) {
// only handle directories
return null;
} else {
return false;
}
return null;
}
}
+6
View File
@@ -8,6 +8,7 @@
namespace OCA\DAV;
use OC\Files\Filesystem;
use OC\L10N\L10N;
use OCA\DAV\AppInfo\PluginManager;
use OCA\DAV\BulkUpload\BulkUploadPlugin;
use OCA\DAV\CalDAV\BirthdayCalendar\EnablePlugin;
@@ -87,12 +88,14 @@ use OCP\IConfig;
use OCP\IDateTimeZone;
use OCP\IDBConnection;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IPreview;
use OCP\IRequest;
use OCP\ISession;
use OCP\ITagManager;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Mail\IEmailValidator;
use OCP\Mail\IMailer;
use OCP\Profiler\IProfiler;
@@ -242,6 +245,7 @@ class Server {
\OCP\Server::get(IUserSession::class)
));
$config = \OCP\Server::get(IConfig::class);
// performance improvement plugins
$this->server->addPlugin(new CopyEtagHeaderPlugin());
$this->server->addPlugin(new RequestIdHeaderPlugin(\OCP\Server::get(IRequest::class)));
@@ -254,6 +258,8 @@ class Server {
$logger,
$eventDispatcher,
\OCP\Server::get(IDateTimeZone::class),
$config,
\OCP\Server::get(IFactory::class),
));
$this->server->addPlugin(\OCP\Server::get(PaginatePlugin::class));
$this->server->addPlugin(new PropFindPreloadNotifyPlugin());
@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\Connector\Sabre;
use Exception;
use OC\L10N\L10N;
use OCA\DAV\Connector\Sabre\Directory;
use OCA\DAV\Connector\Sabre\Exception\Forbidden;
use OCA\DAV\Connector\Sabre\Node;
use OCA\DAV\Connector\Sabre\ZipFolderPlugin;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Events\BeforeZipCreatedEvent;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\Node as OCPNode;
use OCP\IConfig;
use OCP\IDateTimeZone;
use OCP\IL10N;
use OCP\L10N\IFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Sabre\DAV\Tree;
use Sabre\HTTP\Request;
use Sabre\HTTP\Response;
use Test\TestCase;
class ZipFolderPluginTest extends TestCase {
private Tree&MockObject $tree;
private LoggerInterface&MockObject $logger;
private IEventDispatcher&MockObject $eventDispatcher;
private IDateTimeZone&MockObject $timezoneFactory;
private IConfig&MockObject $config;
private IFactory&MockObject $l10nFactory;
private Response&MockObject $response;
private IL10N&MockObject $l10n;
protected function setUp(): void {
parent::setUp();
$this->tree = $this->createMock(Tree::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
$this->timezoneFactory = $this->createMock(IDateTimeZone::class);
$this->config = $this->createMock(IConfig::class);
$this->response = $this->createMock(Response::class);
$this->l10n = $this->createMock(IL10N::class);
$this->l10n->method('t')->willReturnCallback(static fn (string $text, array $parameters = []): string => vsprintf($text, $parameters));
$this->l10nFactory = $this->createMock(IFactory::class);
$this->l10nFactory->method('get')->willReturn($this->l10n);
}
public static function dataDownloadingABlockedFolderShouldFail(): array {
return [
'missing files reporting feature off' => [ false ],
'missing files reporting feature on' => [ true ],
];
}
/*
* Tests that the plugin throws a Forbidden exception when the user is trying
* to download a collection they have no access to.
*/
#[DataProvider(methodName: 'dataDownloadingABlockedFolderShouldFail')]
public function testDownloadingABlockedFolderShouldFail(bool $reportMissingFiles): void {
$plugin = $this->createPlugin($reportMissingFiles);
$folderPath = '/user/files/folder';
$folder = $this->createFolderNode($folderPath, []);
$directory = $this->createDirectoryNode($folder);
$this->tree->expects($this->once())
->method('getNodeForPath')
->with($folderPath)
->willReturn($directory);
$errorMessage = 'Blocked by ACL';
$this->eventDispatcher->expects($this->once())
->method('dispatchTyped')
->willReturnCallback(function (BeforeZipCreatedEvent $event) use ($reportMissingFiles, $errorMessage): BeforeZipCreatedEvent {
$this->assertSame([], $event->getFiles());
$this->assertEquals($reportMissingFiles, $event->allowPartialArchive);
$event->setSuccessful(false);
$event->setErrorMessage($errorMessage);
return $event;
});
$this->expectException(Forbidden::class);
$this->expectExceptionMessage($errorMessage);
$directory->expects($this->never())->method('getChild');
$folder->expects($this->never())->method('getDirectoryListing');
$plugin->handleDownload($this->createRequest($folderPath), $this->response);
}
public static function dataDownloadingAFolderShouldFailWhenItemsAreBlocked(): array {
return [
'no files filtering' => [ [] ],
'files filtering' => [ ['allowed.txt', 'blocked.txt'] ],
];
}
/*
* Tests that when `archive_report_missing_files` is disabled, downloading
* a directory which contains a non-downloadable item stops the entire
* download.
*/
#[DataProvider(methodName: 'dataDownloadingAFolderShouldFailWhenItemsAreBlocked')]
public function testDownloadingAFolderShouldFailWhenItemsAreBlocked(array $filesFilter): void {
$plugin = $this->createPlugin(false);
$folderPath = '/user/files/folder';
$allowedFile = $this->createFile("{$folderPath}/allowed.txt", 'allowed');
$blockedFile = $this->createFile("{$folderPath}/blocked.txt", 'secret');
$files = [$allowedFile, $blockedFile];
$childNodes = [
'allowed.txt' => $this->createNode($allowedFile),
'blocked.txt' => $this->createNode($blockedFile),
];
$folder = $this->createFolderNode($folderPath, $files);
$directory = $this->createDirectoryNode($folder, $childNodes);
$this->tree->expects($this->once())
->method('getNodeForPath')
->with($folderPath)
->willReturn($directory);
$errorMessage = 'Blocked by ACL';
$this->eventDispatcher->expects($this->once())
->method('dispatchTyped')
->willReturnCallback(function (BeforeZipCreatedEvent $event) use ($errorMessage, $filesFilter): BeforeZipCreatedEvent {
$this->assertSame($filesFilter, $event->getFiles());
$this->assertFalse($event->allowPartialArchive);
$event->setSuccessful(false);
$event->setErrorMessage($errorMessage);
return $event;
});
$this->expectException(Forbidden::class);
$this->expectExceptionMessage($errorMessage);
$plugin->handleDownload($this->createRequest($folderPath, $filesFilter), $this->response);
}
public static function dataDownloadingAFolderWithMissingFilesReportingShouldSucceed(): array {
return [
// files are reporting as missing either because they are download-blocked or because some error happened
'full directory download' => [
'children' => ['allowed.txt' => 'allowed', 'blocked.txt' => 'blocked', 'error.txt' => new \RuntimeException('read error')],
'filesFilter' => [],
'downloadBlocked' => [ 'blocked.txt' ],
'expectedMissingFiles' => [ 'blocked.txt' => 'blocked', 'error.txt' => 'Error while opening the file: RuntimeException' ],
],
// files filtered out should not be reported as missing
'filtering some files' => [
'children' => [ 'allowed.txt' => 'allowed', 'blocked.txt' => 'blocked', 'error.txt' => new \RuntimeException('read error') ],
'filesFilter' => ['allowed.txt', 'blocked.txt'],
'downloadBlocked' => ['blocked.txt'],
'expectedMissingFiles' => [ 'blocked.txt' => 'blocked' ],
],
];
}
/*
* Tests that when files in a directory cannot be downloaded and the
* `archive_report_missing_files` is enabled, an entry is added to the
* `missing_files.json` file.
*/
#[DataProvider(methodName: 'dataDownloadingAFolderWithMissingFilesReportingShouldSucceed')]
public function testDownloadingAFolderWithMissingFilesReportingShouldSucceed(array $children, array $filesFilter, array $downloadBlocked, array $expectedMissingFiles): void {
$plugin = $this->createPlugin(true);
$folderPath = '/user/files/folder';
$childFiles = [];
$childNodes = [];
foreach ($children as $childName => $content) {
$childFile = $this->createFile("{$folderPath}/{$childName}", $content);
$childFiles[$childName] = $childFile;
$childNodes[$childName] = $this->createNode($childFile);
}
$folder = $this->createFolderNode($folderPath, array_values($childFiles));
$directory = $this->createDirectoryNode($folder, $childNodes);
$this->tree->expects($this->once())
->method('getNodeForPath')
->with($folderPath)
->willReturn($directory);
$this->eventDispatcher->expects($this->once())
->method('dispatchTyped')
->willReturnCallback(function (BeforeZipCreatedEvent $event) use ($downloadBlocked, $filesFilter): BeforeZipCreatedEvent {
$this->assertSame($filesFilter, $event->getFiles());
$this->assertTrue($event->allowPartialArchive);
$event->addNodeFilter(static function ($node) use ($downloadBlocked): array {
if (in_array($node->getName(), $downloadBlocked)) {
return [false, 'blocked'];
}
return [true, null];
});
return $event;
});
ob_start();
$request = $this->createRequest($folderPath, $filesFilter);
$continueHandling = $plugin->handleDownload($request, $this->response);
$output = $this->getActualOutputForAssertion();
$this->assertStringContainsString('missing_files.json', $output, "$output does not contain missin_files.json");
foreach ($expectedMissingFiles as $file => $error) {
$stringToMatch = sprintf('%s": "%s"', $file, $error);
$this->assertStringContainsString($stringToMatch, $output, "$output does not contain $stringToMatch");
}
// assert that the handling should be stopped
$this->assertFalse($continueHandling);
}
private function createPlugin(bool $reportMissingFiles): ZipFolderPlugin {
$this->config->method('getSystemValueBool')
->with('archive_report_missing_files', false)
->willReturn($reportMissingFiles);
return new ZipFolderPlugin(
$this->tree,
$this->logger,
$this->eventDispatcher,
$this->timezoneFactory,
$this->config,
$this->l10nFactory,
);
}
/**
* @param list<string> $filesFilter
* @throws \JsonException
*/
private function createRequest(string $resource, array $filesFilter = []): Request&MockObject {
$query = [];
if ($filesFilter !== []) {
$query['files'] = json_encode($filesFilter, JSON_THROW_ON_ERROR);
}
$request = $this->createMock(Request::class);
$request->method('getPath')->willReturn($resource);
$request->method('getQueryParameters')->willReturn($query);
// file filtering can be done via header or QS parameters. Use only one.
$request->method('getHeaderAsArray')->willReturnMap([
['Accept', ['application/zip']],
['X-NC-Files', []],
]);
return $request;
}
/**
* @param list<File> $children
* @param array<string, Node&MockObject> $childNodes
*/
private function createDirectoryNode(Folder $folder, array $childNodes = []): Directory&MockObject {
$directory = $this->createMock(Directory::class);
$directory->method('getNode')->willReturn($folder);
$directory->method('getChild')->willReturnCallback(static fn (string $name, ...$_): Node => $childNodes[$name]);
return $directory;
}
/**
* @param list<OCPNode> $children
*/
private function createFolderNode(string $path, array $children): Folder&MockObject {
$folder = $this->createMock(Folder::class);
$folder->method('getPath')->willReturn($path);
$folder->method('getName')->willReturn(basename($path));
$folder->method('getDirectoryListing')->willReturn($children);
return $folder;
}
private function createNode(File $node): Node&MockObject {
$child = $this->createMock(Node::class);
$child->method('getNode')->willReturn($node);
$child->method('getPath')->willReturn($node->getPath());
return $child;
}
private function createFile(string $path, string|Exception $contents): File&MockObject {
$length = is_string($contents) ? strlen($contents) : 0;
$file = $this->createMock(File::class);
$file->method('getPath')->willReturn($path);
$file->method('getName')->willReturn(basename($path));
$file->method('getSize')->willReturn($length);
$file->method('getMTime')->willReturn(123);
$file->method('fopen')->with('rb')->willReturnCallback(static function () use ($contents) {
if (!is_string($contents)) {
throw $contents;
}
$stream = fopen('php://temp', 'r+');
fwrite($stream, $contents);
rewind($stream);
return $stream;
});
return $file;
}
}
@@ -24,6 +24,7 @@ class BeforeDirectFileDownloadListener implements IEventListener {
public function __construct(
private IUserSession $userSession,
private IRootFolder $rootFolder,
private ViewOnly $viewOnly,
) {
}
@@ -32,17 +33,17 @@ class BeforeDirectFileDownloadListener implements IEventListener {
return;
}
$pathsToCheck = [$event->getPath()];
// Check only for user/group shares. Don't restrict e.g. share links
$user = $this->userSession->getUser();
if ($user) {
$viewOnlyHandler = new ViewOnly(
$this->rootFolder->getUserFolder($user->getUID())
);
if (!$viewOnlyHandler->check($pathsToCheck)) {
$event->setSuccessful(false);
$event->setErrorMessage('Access to this resource or one of its sub-items has been denied.');
}
// Check only for user/group shares. Don't restrict e.g. share links
if (!$user) {
return;
}
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$node = $userFolder->get($event->getPath());
if (!$this->viewOnly->isDownloadable($node)) {
$event->setSuccessful(false);
$event->setErrorMessage('Access to this resource has been denied.');
}
}
}
@@ -14,6 +14,7 @@ use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\BeforeZipCreatedEvent;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\IUserSession;
/**
@@ -47,18 +48,36 @@ class BeforeZipCreatedListener implements IEventListener {
// Check only for user/group shares. Don't restrict e.g. share links
$user = $this->userSession->getUser();
if ($user) {
$viewOnlyHandler = new ViewOnly(
$this->rootFolder->getUserFolder($user->getUID())
);
if (!$viewOnlyHandler->check($pathsToCheck)) {
$event->setErrorMessage('Access to this resource or one of its sub-items has been denied.');
$event->setSuccessful(false);
} else {
$event->setSuccessful(true);
}
} else {
if (!$user) {
$event->setSuccessful(true);
return;
}
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$viewOnlyHandler = new ViewOnly($userFolder);
$node = $userFolder->get($dir);
$isRootDownloadable = $viewOnlyHandler->isDownloadable($node);
if (!$isRootDownloadable) {
$message = $event->allowPartialArchive ? 'Access to this resource and its children has been denied.' : 'Access to this resource or one of its sub-items has been denied.';
$event->setErrorMessage($message);
$event->setSuccessful(false);
return;
}
if ($event->allowPartialArchive) {
$event->setSuccessful(true);
$event->addNodeFilter(fn (Node $node): array => [
$viewOnlyHandler->isDownloadable($node),
'Download is disabled for this resource'
]);
} elseif ($viewOnlyHandler->check($pathsToCheck)) {
// keep the old behaviour
$event->setSuccessful(true);
} else {
$event->setErrorMessage('Access to this resource or one of its sub-items has been denied.');
$event->setSuccessful(false);
}
}
}
+11 -7
View File
@@ -34,7 +34,7 @@ class ViewOnly {
$info = $this->userFolder->get($file);
if ($info instanceof File) {
// access to filecache is expensive in the loop
if (!$this->checkFileInfo($info)) {
if (!$this->isDownloadable($info)) {
return false;
}
} elseif ($info instanceof Folder) {
@@ -43,7 +43,7 @@ class ViewOnly {
return false;
}
}
} catch (NotFoundException $e) {
} catch (NotFoundException) {
continue;
}
}
@@ -56,14 +56,14 @@ class ViewOnly {
* @throws NotFoundException
*/
private function dirRecursiveCheck(Folder $dirInfo): bool {
if (!$this->checkFileInfo($dirInfo)) {
if (!$this->isDownloadable($dirInfo)) {
return false;
}
// If any of elements cannot be downloaded, prevent whole download
$files = $dirInfo->getDirectoryListing();
foreach ($files as $file) {
if ($file instanceof File) {
if (!$this->checkFileInfo($file)) {
if (!$this->isDownloadable($file)) {
return false;
}
} elseif ($file instanceof Folder) {
@@ -77,11 +77,15 @@ class ViewOnly {
/**
* @param Node $fileInfo
* @return bool
* @throws NotFoundException
*/
private function checkFileInfo(Node $fileInfo): bool {
public function isDownloadable(Node $fileInfo): bool {
// Restrict view-only to nodes which are shared
$storage = $fileInfo->getStorage();
try {
$storage = $fileInfo->getStorage();
} catch (NotFoundException) {
return true;
}
if (!$storage->instanceOfStorage(SharedStorage::class)) {
return true;
}
+16 -6
View File
@@ -13,6 +13,7 @@ use OCA\Files_Sharing\AppInfo\Application;
use OCA\Files_Sharing\Listener\BeforeDirectFileDownloadListener;
use OCA\Files_Sharing\Listener\BeforeZipCreatedListener;
use OCA\Files_Sharing\SharedStorage;
use OCA\Files_Sharing\ViewOnly;
use OCP\Files\Events\BeforeDirectFileDownloadEvent;
use OCP\Files\Events\BeforeZipCreatedEvent;
use OCP\Files\File;
@@ -91,7 +92,8 @@ class ApplicationTest extends TestCase {
$event = new BeforeDirectFileDownloadEvent($path);
$listener = new BeforeDirectFileDownloadListener(
$this->userSession,
$this->rootFolder
$this->rootFolder,
new ViewOnly(),
);
$listener->handle($event);
@@ -162,6 +164,13 @@ class ApplicationTest extends TestCase {
$folder->method('getDirectoryListing')->willReturn($directoryListing);
}
// If the folder contains any secure-shared files, make it appear as a secure-shared folder
// so that ViewOnly::isDownloadable() will return false
$containsSecureSharedFiles = in_array('secureSharedStorage', $directoryListing);
if ($containsSecureSharedFiles && $folderStorage === 'nonSharedStorage') {
$folder->method('getStorage')->willReturn($secureSharedStorage);
}
$rootFolder = $this->createMock(Folder::class);
$rootFolder->method('getStorage')->willReturn($nonSharedStorage);
$rootFolder->method('getDirectoryListing')->willReturn([$folder]);
@@ -176,11 +185,12 @@ class ApplicationTest extends TestCase {
$this->rootFolder->method('getUserFolder')->with('test')->willReturn($userFolder);
// Simulate zip download of folder folder
$event = new BeforeZipCreatedEvent($dir, $files);
// Simulate zip download of folder
$event = new BeforeZipCreatedEvent($folder, $files);
$listener = new BeforeZipCreatedListener(
$this->userSession,
$this->rootFolder
$this->rootFolder,
);
$listener->handle($event);
@@ -192,10 +202,10 @@ class ApplicationTest extends TestCase {
$this->userSession->method('isLoggedIn')->willReturn(false);
// Simulate zip download of folder folder
$event = new BeforeZipCreatedEvent('/test', ['test.txt']);
$event = new BeforeZipCreatedEvent('/test', ['test.txt'], []);
$listener = new BeforeZipCreatedListener(
$this->userSession,
$this->rootFolder
$this->rootFolder,
);
$listener->handle($event);
@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_Sharing\Tests\unit\Listener;
use OCA\Files_Sharing\Listener\BeforeZipCreatedListener;
use OCA\Files_Sharing\SharedStorage;
use OCP\Files\Events\BeforeZipCreatedEvent;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\IUser;
use OCP\IUserSession;
use OCP\Share\IAttributes;
use OCP\Share\IShare;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class BeforeZipCreatedListenerTest extends TestCase {
private IUserSession&MockObject $userSession;
private IRootFolder&MockObject $rootFolder;
private Folder&MockObject $userFolder;
private BeforeZipCreatedListener $listener;
protected function setUp(): void {
parent::setUp();
$this->userSession = $this->createMock(IUserSession::class);
$this->rootFolder = $this->createMock(IRootFolder::class);
$this->userFolder = $this->createMock(Folder::class);
$this->listener = new BeforeZipCreatedListener($this->userSession, $this->rootFolder);
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('user');
$this->userSession->method('getUser')->willReturn($user);
$this->rootFolder->method('getUserFolder')->with('user')->willReturn($this->userFolder);
}
public static function dataHandle(): array {
$rootFromFolder = '/folder';
// files are relative to $folderPath
// filesFilter are relative to $folderPath but without leading /
// expectedNodeList are ...
return [
'partial archive disabled, no filtering, 1 blocked file => should fail event' => [
'folderPath' => $rootFromFolder,
'rootDownloadable' => true,
'files' => ['blocked.txt' => false],
'filesFilter' => [],
'allowPartialArchive' => false,
'expectedSuccess' => false,
'expectedMessage' => 'Access to this resource or one of its sub-items has been denied.',
'expectedNodeList' => [],
],
'partial archive disabled, no filtering, 1 blocked 1 non-blocked file => should fail event' => [
'folderPath' => $rootFromFolder,
'rootDownloadable' => true,
'files' => ["blocked.txt" => false, "allowed.txt" => true],
'filesFilter' => [],
'allowPartialArchive' => false,
'expectedSuccess' => false,
'expectedMessage' => 'Access to this resource or one of its sub-items has been denied.',
'expectedNodeList' => [],
],
'partial archive enabled, no filtering, 1 blocked file => should not fail event' => [
'folderPath' => $rootFromFolder,
'rootDownloadable' => true,
'files' => ['blocked.txt' => false],
'filesFilter' => [],
'allowPartialArchive' => true,
'expectedSuccess' => true,
'expectedMessage' => null,
'expectedNodeList' => ['blocked.txt' => [null, 'Download is disabled for this resource']],
],
'partial archive enabled, no filtering, 1 blocked 1 non-blocked file => should not fail event' => [
'folderPath' => $rootFromFolder,
'rootDownloadable' => true,
'files' => ['blocked.txt' => false, 'allowed.txt' => true],
'filesFilter' => [],
'allowPartialArchive' => true,
'expectedSuccess' => true,
'expectedMessage' => null,
'expectedNodeList' => ['blocked.txt' => [null, 'Download is disabled for this resource'], 'allowed.txt' => null],
],
'partial archive disabled, with filtering, 1 blocked 2 non-blocked files => should fail event' => [
'folderPath' => $rootFromFolder,
'rootDownloadable' => true,
'files' => ['blocked.txt' => false, 'allowed.txt' => true, 'notinfilter.txt' => true],
'filesFilter' => ['blocked.txt', 'allowed.txt'],
'allowPartialArchive' => false,
'expectedSuccess' => false,
'expectedMessage' => 'Access to this resource or one of its sub-items has been denied.',
'expectedNodeList' => [],
],
'partial archive enabled, with filtering, 1 blocked 2 non-blocked files => should not fail event' => [
'folderPath' => $rootFromFolder,
'rootDownloadable' => true,
'files' => ['blocked.txt' => false, 'allowed.txt' => true, 'notinfilter.txt' => true],
'filesFilter' => ['blocked.txt', 'allowed.txt'],
'allowPartialArchive' => true,
'expectedSuccess' => true,
'expectedMessage' => null,
'expectedNodeList' => ['blocked.txt' => [null, 'Download is disabled for this resource'], 'allowed.txt' => null],
],
'partial archive disabled, with filtering on non-blocked file, 1 blocked 1 non-blocked file => should succeed event' => [
'folderPath' => $rootFromFolder,
'rootDownloadable' => true,
'files' => ['allowed.txt' => true, 'notinfilter.txt' => false],
'filesFilter' => ['allowed.txt'],
'allowPartialArchive' => false,
'expectedSuccess' => true,
'expectedMessage' => null,
'expectedNodeList' => ['allowed.txt' => null],
],
'partial archive enabled, with filtering on non-blocked file, 1 downloadable 1 non-blocked file => should succeed event' => [
'folderPath' => $rootFromFolder,
'rootDownloadable' => true,
'files' => ['allowed.txt' => true, 'notinfilter.txt' => false],
'filesFilter' => ['allowed.txt'],
'allowPartialArchive' => true,
'expectedSuccess' => true,
'expectedMessage' => null,
'expectedNodeList' => ['allowed.txt' => null],
],
'partial archive disabled, root (containing) folder not downloadable, with filtering' => [
'folderPath' => $rootFromFolder,
'rootDownloadable' => false,
'files' => ['allowed.txt' => true, 'notinfilter.txt' => false],
'filesFilter' => ['allowed.txt'],
'allowPartialArchive' => false,
'expectedSuccess' => false,
'expectedMessage' => 'Access to this resource or one of its sub-items has been denied.',
'expectedNodeList' => [],
],
'partial archive enabled, root (containing) folder not downloadable, with filtering' => [
'folderPath' => $rootFromFolder,
'rootDownloadable' => false,
'files' => ['allowed.txt' => true, 'notinfilter.txt' => false],
'filesFilter' => ['allowed.txt'],
'allowPartialArchive' => true,
'expectedSuccess' => false,
'expectedMessage' => 'Access to this resource and its children has been denied.',
'expectedNodeList' => [],
],
'partial archive disabled, root (containing) folder not downloadable, no filtering' => [
'folderPath' => $rootFromFolder,
'rootDownloadable' => false,
'files' => ['allowed.txt' => true, 'notinfilter.txt' => false],
'filesFilter' => [],
'allowPartialArchive' => false,
'expectedSuccess' => false,
'expectedMessage' => 'Access to this resource or one of its sub-items has been denied.',
'expectedNodeList' => [],
],
'partial archive enabled, root (containing) folder not downloadable, no filtering' => [
'folderPath' => $rootFromFolder,
'rootDownloadable' => false,
'files' => ['allowed.txt' => true, 'notinfilter.txt' => false],
'filesFilter' => [],
'allowPartialArchive' => true,
'expectedSuccess' => false,
'expectedMessage' => 'Access to this resource and its children has been denied.',
'expectedNodeList' => [],
],
];
}
#[DataProvider(methodName: 'dataHandle')]
public function testHandle(
string $folderPath,
bool $rootDownloadable,
array $files,
array $filesFilter,
bool $allowPartialArchive,
bool $expectedSuccess,
?string $expectedMessage,
array $expectedNodeList,
): void {
$fileNodes = [];
$fileNodesByName = [];
foreach ($files as $relativePath => $downloadable) {
$pathWithFolder = "{$folderPath}/{$relativePath}";
$file = $this->createSharedFile($downloadable, $pathWithFolder);
$fileNodesByName[$pathWithFolder] = $file;
$fileNodes[] = $file;
}
$folderPathFromUserRoot = "/user/files{$folderPath}";
$folder = $this->createSharedFolder($rootDownloadable, $folderPathFromUserRoot, $fileNodes);
$this->userFolder->method('get')->willReturnCallback(function (string $path) use ($folderPath, $fileNodesByName, $folder) {
return match (true) {
$path === $folderPath => $folder,
isset($fileNodesByName[$path]) => $fileNodesByName[$path],
default => throw new \RuntimeException("Mock node not set for {$path}"),
};
});
$event = new BeforeZipCreatedEvent($folder, $filesFilter, $allowPartialArchive);
$this->listener->handle($event);
$this->assertEquals($expectedSuccess, $event->isSuccessful());
$this->assertSame($expectedMessage, $event->getErrorMessage());
$event->setNodesIterable($fileNodes);
$actualNodes = iterator_to_array($event->getNodes());
$this->assertCount(count($expectedNodeList), $actualNodes);
foreach ($expectedNodeList as $relativePath => $expectedValue) {
$path = "{$folderPath}/{$relativePath}";
if ($expectedValue === null) {
// cannot reference the node in the data provider, add it here
$node = $fileNodesByName[$path] ?? null;
$this->assertNotNull($node, 'Node mock must be present for the test to be correct.');
$expectedValue = [$node, null];
}
$this->assertEquals($expectedValue, $actualNodes[$path]);
}
}
private function createSharedFile(bool $downloadable, string $path): File&MockObject {
$file = $this->createMock(File::class);
$file->method('getStorage')->willReturn($this->createSharedStorage($downloadable));
$file->method('getPath')->willReturn($path);
$file->method('getName')->willReturn(basename($path));
return $file;
}
/**
* @param list<Node> $children
*/
private function createSharedFolder(bool $downloadable, string $path, array $children = []): Folder&MockObject {
$folder = $this->createMock(Folder::class);
$folder->method('getStorage')->willReturn($this->createSharedStorage($downloadable));
$folder->method('getDirectoryListing')->willReturn($children);
$folder->method('getPath')->willReturn($path);
$folder->method('getName')->willReturn(basename($path));
return $folder;
}
private function createSharedStorage(bool $downloadable): SharedStorage&MockObject {
$attributes = $this->createMock(IAttributes::class);
$attributes->method('getAttribute')->with('permissions', 'download')->willReturn($downloadable);
$share = $this->createMock(IShare::class);
$share->method('getAttributes')->willReturn($attributes);
$storage = $this->getMockBuilder(SharedStorage::class)
->disableOriginalConstructor()
->onlyMethods(['instanceOfStorage', 'getShare'])
->getMock();
$storage->method('instanceOfStorage')->with(SharedStorage::class)->willReturn(true);
$storage->method('getShare')->willReturn($share);
return $storage;
}
}
+9
View File
@@ -387,6 +387,15 @@ $CONFIG = [
*/
'session_keepalive' => true,
/**
* When enabled, this flag allows the download of a directory to proceed
* even if some files or subdirectories are marked as non-downloadable.
* If files are corrupted, missing, or the server encounter temporary errors
* they are logged in a `missing_files.json` within the archive, along with
* the cause.
*/
'archive_report_missing_files' => false,
/**
* Enable or disable the automatic logout after session_lifetime, even if session
* keepalive is enabled. This will make sure that an inactive browser will log itself out
@@ -11,13 +11,19 @@ namespace OCP\Files\Events;
use OCP\EventDispatcher\Event;
use OCP\Files\Folder;
use OCP\Files\Node;
/**
* This event is triggered before a archive is created when a user requested
* This event is triggered before an archive is created when a user requested
* downloading a folder or multiple files.
*
* By setting `successful` to false the tar creation can be aborted and the download denied.
*
* If `allowPartialArchive` is set to true, the archive creation should be blocked only
* if access to the entire directory/all files is to be blocked. To block
* archiving of certain files only, `addNodeFilter` should be used to add a callable
* to filter out nodes.
*
* @since 25.0.0
*/
class BeforeZipCreatedEvent extends Event {
@@ -25,16 +31,22 @@ class BeforeZipCreatedEvent extends Event {
private bool $successful = true;
private ?string $errorMessage = null;
private ?Folder $folder = null;
/** @var iterable<Node>|null */
private ?iterable $nodesIterable;
/** @var array<callable(Node): array{0: bool, 1: ?string}> */
private array $nodeFilters = [];
/**
* @param string|Folder $directory Folder instance, or (deprecated) string path relative to user folder
* @param list<string> $files
* @param list<string> $files Selected files, empty for folder selection
* @param ?bool $allowPartialArchive True if missing/blocked files should not block the creation of the archive
* @since 25.0.0
* @since 31.0.0 support `OCP\Files\Folder` as `$directory` parameter - passing a string is deprecated now
*/
public function __construct(
string|Folder $directory,
private array $files,
public ?bool $allowPartialArchive = true,
) {
parent::__construct();
if ($directory instanceof Folder) {
@@ -94,6 +106,65 @@ class BeforeZipCreatedEvent extends Event {
return $this->errorMessage;
}
/**
* Sets the iterable that will be used to yield nodes to be included in the
* archive. Nodes can be filtered out by adding filters via `addNodeFilter`.
*
* @param iterable<Node> $iterable
* @return void
*/
public function setNodesIterable(iterable $iterable): void {
$this->nodesIterable = $iterable;
}
/**
* @param callable(Node): array{0: bool, 1: ?string} $filter filter that
* receives a Node and returns an array with a bool telling if the file is
* to be included in the archive and an optional reason string.
*
* @return void
*/
public function addNodeFilter(callable $filter): void {
$this->nodeFilters[] = $filter;
}
/**
* Returns a generator yielding a string key with the node's path relative
* to the downloaded folder and an array which contains a node or null in
* the first position (indicating whether the node should be skipped) and a
* reason for skipping in the second position.
*
* @return iterable<string, array{0: ?Node, 1: ?string}>
*/
public function getNodes(): iterable {
if (!isset($this->nodesIterable)) {
throw new \LogicException('No nodes iterable set');
}
if (!$this->successful) {
return;
}
$directory = $this->getDirectory();
foreach ($this->nodesIterable as $node) {
$relativePath = $directory . '/' . $node->getName();
if (!empty($this->files) && !in_array($node->getName(), $this->files)) {
// the node is supposed to be filtered out
continue;
}
foreach ($this->nodeFilters as $filter) {
[$include, $reason] = $filter($node);
if (!$include) {
yield $relativePath => [null, $reason];
continue 2;
}
}
yield $relativePath => [$node, null];
}
}
/**
* @since 25.0.0
*/