Skip to content
Closed
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
2 changes: 2 additions & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@
'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',
Expand Down Expand Up @@ -253,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
2 changes: 2 additions & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
31 changes: 31 additions & 0 deletions apps/dav/lib/Connector/Sabre/ByteCounterFilter.php
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 15 in apps/dav/lib/Connector/Sabre/ByteCounterFilter.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

NonInvariantPropertyType

apps/dav/lib/Connector/Sabre/ByteCounterFilter.php:15:16: NonInvariantPropertyType: Property OCA\DAV\Connector\Sabre\ByteCounterFilter::$filtername has type string, not invariant with php_user_filter::$filtername of type <empty> (see https://psalm.dev/265)

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;
}
}
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 @@
$this->logger,
$this->eventDispatcher,
\OCP\Server::get(IDateTimeZone::class),
$this->config,
$this->l10n,

Check failure on line 114 in apps/dav/lib/Connector/Sabre/ServerFactory.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

InvalidArgument

apps/dav/lib/Connector/Sabre/ServerFactory.php:114:4: InvalidArgument: Argument 6 of OCA\DAV\Connector\Sabre\ZipFolderPlugin::__construct expects OCP\L10N\IFactory, but OCP\IL10N provided (see https://psalm.dev/004)
));

// 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;
}
149 changes: 129 additions & 20 deletions apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
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;
Expand All @@ -37,13 +40,25 @@
* 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');
}

/**
Expand All @@ -63,27 +78,77 @@

/**
* 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

Check failure on line 81 in apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

InvalidReturnType

apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php:81:13: InvalidReturnType: Not all code paths of OCA\DAV\Connector\Sabre\ZipFolderPlugin::streamNode end in a return statement, return type null|string expected (see https://psalm.dev/011)
*/
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();
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;
}
$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);

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;
}

/**
Expand Down Expand Up @@ -137,7 +202,7 @@
}

$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 @@ -150,12 +215,16 @@
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 @@ -169,31 +238,71 @@
$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 changes: 6 additions & 0 deletions apps/dav/lib/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -87,12 +88,14 @@
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;
Expand Down Expand Up @@ -242,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 @@ -254,6 +258,8 @@ public function __construct(
$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());
Expand Down
Loading
Loading