Skip to content
Open
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/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@
'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',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -269,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',
Expand Down
2 changes: 2 additions & 0 deletions apps/dav/lib/Connector/Sabre/ServerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ public function createServer(
$this->logger,
$this->eventDispatcher,
\OCP\Server::get(IDateTimeZone::class),
$this->config,
$this->l10n,
));

// Some WebDAV clients do require Class 2 WebDAV support (locking), since
Expand Down
19 changes: 19 additions & 0 deletions apps/dav/lib/Connector/Sabre/StreamByteCounter.php
Original file line number Diff line number Diff line change
@@ -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;
}
129 changes: 113 additions & 16 deletions apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@
*/
namespace OCA\DAV\Connector\Sabre;

use Icewind\Streams\CountWrapper;
use OC\Streamer;
use OCA\DAV\Connector\Sabre\Exception\Forbidden;
use OCP\EventDispatcher\IEventDispatcher;
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\Files\NotPermittedException;
use OCP\IConfig;
use OCP\IDateTimeZone;
use OCP\IL10N;
use OCP\Lock\LockedException;
use Psr\Log\LoggerInterface;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
Expand All @@ -37,6 +42,8 @@ class ZipFolderPlugin extends ServerPlugin {
* Reference to main server object
*/
private ?Server $server = null;
private bool $reportMissingFiles;
private array $missingInfo = [];

/**
* Whether handleDownload has fully streamed an archive for the current request.
Expand All @@ -49,7 +56,10 @@ public function __construct(
private LoggerInterface $logger,
private IEventDispatcher $eventDispatcher,
private IDateTimeZone $timezoneFactory,
private IConfig $config,
private IL10N $l10n,
) {
$this->reportMissingFiles = $this->config->getSystemValueBool('archive_report_missing_files', true);
}

/**
Expand All @@ -69,27 +79,57 @@ public function initialize(Server $server): void {

/**
* 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
* @throws NotPermittedException|LockedException
*/
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 NcFolder) {
$streamer->addEmptyDir($filename, $mtime);
return null;
}

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.');
$nodeSize = $node->getSize();
$stream = $node->fopen('rb');

if ($stream === false) {
return $this->l10n->t('File could not be opened (fopen). Please check the server logs for more information.');
}
$streamer->addFileFromStream($resource, $filename, $node->getSize(), $mtime);
} elseif ($node instanceof NcFolder) {
$streamer->addEmptyDir($filename, $mtime);
$content = $node->getDirectoryListing();
foreach ($content as $subNode) {
$this->streamNode($streamer, $subNode, $rootPath);

$read = 0;
$stream = CountWrapper::wrap($stream, function (int $readCount) use (&$read) {
$read = $readCount;
});

if ($stream === false) {
return $this->l10n->t('Unable to check file for consistency check');
}

$fileAddedToStream = $streamer->addFileFromStream($stream, $filename, $nodeSize, $mtime);
if (!$fileAddedToStream) {
return $this->l10n->t('The archive was already finalized');
}

$streamMetadata = stream_get_meta_data($stream);
if (get_resource_type($stream) !== 'stream') {
return $this->l10n->t('Resource is not a stream or is closed.');
}
fclose($stream);

if ($streamMetadata['timed_out'] ?? false) {
return $this->l10n->t('Timeout while reading from stream.');
}

if (!($streamMetadata['eof'] ?? true) || $read != $nodeSize) {
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.', [$read, $nodeSize]);
}
}

return null;
}

/**
Expand Down Expand Up @@ -144,7 +184,7 @@ public function handleDownload(Request $request, Response $response): ?false {
}

$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();
Expand All @@ -157,12 +197,16 @@ public function handleDownload(Request $request, Response $response): ?false {
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) {
Expand All @@ -176,21 +220,74 @@ public function handleDownload(Request $request, Response $response): ?false {
$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($rootPath) as $path => [$node, $reason]) {
$filename = str_replace($rootPath, '', $path);
if ($node === null) {
if ($this->reportMissingFiles) {
$this->missingInfo[$filename] = $reason;
}
continue;
}

try {
$streamError = $this->streamNode($streamer, $node, $rootPath);
} catch (\Exception $e) {
if (!$this->reportMissingFiles) {
throw $e;
}

$logMessage = $this->l10n->t('Error while streaming the file');
$this->logger->error($logMessage, ['exception' => $e]);
$reason = $this->l10n->t('File could not be added to the archive. Please check the server logs for more information.');
$this->missingInfo[$filename] = $reason;
continue;
}

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();
$this->streamed = true; // archive fully streamed

return false;
}

/**
* 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 sent the response
*/
Expand Down
4 changes: 4 additions & 0 deletions apps/dav/lib/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
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;
Expand Down Expand Up @@ -244,6 +245,7 @@ public function __construct(
\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)));
Expand All @@ -256,6 +258,8 @@ public function __construct(
$logger,
$eventDispatcher,
\OCP\Server::get(IDateTimeZone::class),
$config,
\OCP\Server::get(IFactory::class)->get('dav'),
));
$this->server->addPlugin(\OCP\Server::get(PaginatePlugin::class));
$this->server->addPlugin(new PropFindPreloadNotifyPlugin());
Expand Down
Loading
Loading