aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/dav/composer/composer/autoload_classmap.php1
-rw-r--r--apps/dav/composer/composer/autoload_static.php1
-rw-r--r--apps/dav/lib/Connector/Sabre/ServerFactory.php5
-rw-r--r--apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php155
-rw-r--r--apps/dav/lib/Server.php5
-rw-r--r--build/integration/dav_features/dav-v2-public.feature20
-rw-r--r--build/integration/dav_features/dav-v2.feature15
-rw-r--r--build/integration/features/bootstrap/Download.php20
-rw-r--r--build/integration/features/bootstrap/WebDav.php27
9 files changed, 247 insertions, 2 deletions
diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php
index 4e59f50d8d7..2045c5e1fac 100644
--- a/apps/dav/composer/composer/autoload_classmap.php
+++ b/apps/dav/composer/composer/autoload_classmap.php
@@ -213,6 +213,7 @@ return array(
'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => $baseDir . '/../lib/Connector/Sabre/SharesPlugin.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\\ZipFolderPlugin' => $baseDir . '/../lib/Connector/Sabre/ZipFolderPlugin.php',
'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php',
'OCA\\DAV\\Controller\\DirectController' => $baseDir . '/../lib/Controller/DirectController.php',
'OCA\\DAV\\Controller\\InvitationResponseController' => $baseDir . '/../lib/Controller/InvitationResponseController.php',
diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php
index f241c660140..31f0974e8e3 100644
--- a/apps/dav/composer/composer/autoload_static.php
+++ b/apps/dav/composer/composer/autoload_static.php
@@ -228,6 +228,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/SharesPlugin.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\\ZipFolderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ZipFolderPlugin.php',
'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php',
'OCA\\DAV\\Controller\\DirectController' => __DIR__ . '/..' . '/../lib/Controller/DirectController.php',
'OCA\\DAV\\Controller\\InvitationResponseController' => __DIR__ . '/..' . '/../lib/Controller/InvitationResponseController.php',
diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php
index 53ce0ec3eee..4235a02831a 100644
--- a/apps/dav/lib/Connector/Sabre/ServerFactory.php
+++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php
@@ -92,6 +92,11 @@ class ServerFactory {
$server->addPlugin(new RequestIdHeaderPlugin(\OC::$server->get(IRequest::class)));
+ $server->addPlugin(new ZipFolderPlugin(
+ $objectTree,
+ $this->logger,
+ ));
+
// Some WebDAV clients do require Class 2 WebDAV support (locking), since
// we do not provide locking we emulate it using a fake locking plugin.
if ($this->request->isUserAgent([
diff --git a/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php b/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php
new file mode 100644
index 00000000000..a5820f80538
--- /dev/null
+++ b/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php
@@ -0,0 +1,155 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\Connector\Sabre;
+
+use OC\Streamer;
+use OCP\Files\File as NcFile;
+use OCP\Files\Folder as NcFolder;
+use OCP\Files\Node as NcNode;
+use Psr\Log\LoggerInterface;
+use Sabre\DAV\Server;
+use Sabre\DAV\ServerPlugin;
+use Sabre\DAV\Tree;
+use Sabre\HTTP\Request;
+use Sabre\HTTP\Response;
+
+/**
+ * This plugin allows to download folders accessed by GET HTTP requests on DAV.
+ * The WebDAV standard explicitly say that GET is not covered and should return what ever the application thinks would be a good representation.
+ *
+ * When a collection is accessed using GET, this will provide the content as a archive.
+ * The type can be set by the `Accept` header (MIME type of zip or tar), or as browser fallback using a `accept` GET parameter.
+ * It is also possible to only include some child nodes (from the collection it self) by providing a `filter` GET parameter or `X-NC-Files` custom header.
+ */
+class ZipFolderPlugin extends ServerPlugin {
+
+ /**
+ * Reference to main server object
+ */
+ private ?Server $server = null;
+
+ public function __construct(
+ private Tree $tree,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * This initializes the plugin.
+ *
+ * This function is called by \Sabre\DAV\Server, after
+ * addPlugin is called.
+ *
+ * This method should set up the required event subscriptions.
+ */
+ public function initialize(Server $server): void {
+ $this->server = $server;
+ $this->server->on('method:GET', $this->handleDownload(...), 100);
+ }
+
+ /**
+ * Adding a node to the archive streamer.
+ * This will recursively add new nodes to the stream if the node is a directory.
+ */
+ protected function streamNode(Streamer $streamer, NcNode $node, string $rootPath): void {
+ // Remove the root path from the filename to make it relative to the requested folder
+ $filename = str_replace($rootPath, '', $node->getPath());
+
+ 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(), $node->getMTime());
+ } elseif ($node instanceof NcFolder) {
+ $streamer->addEmptyDir($filename);
+ $content = $node->getDirectoryListing();
+ foreach ($content as $subNode) {
+ $this->streamNode($streamer, $subNode, $rootPath);
+ }
+ }
+ }
+
+ /**
+ * Download a folder as an archive.
+ * It is possible to filter / limit the files that should be downloaded,
+ * either by passing (multiple) `X-NC-Files: the-file` headers
+ * or by setting a `files=JSON_ARRAY_OF_FILES` URL query.
+ *
+ * @return false|null
+ */
+ public function handleDownload(Request $request, Response $response): ?bool {
+ $node = $this->tree->getNodeForPath($request->getPath());
+ if (!($node instanceof \OCA\DAV\Connector\Sabre\Directory)) {
+ // only handle directories
+ return null;
+ }
+
+ $query = $request->getQueryParameters();
+
+ // Get accept header - or if set overwrite with accept GET-param
+ $accept = $request->getHeaderAsArray('Accept');
+ $acceptParam = $query['accept'] ?? '';
+ if ($acceptParam !== '') {
+ $accept = array_map(fn (string $name) => strtolower(trim($name)), explode(',', $acceptParam));
+ }
+ $zipRequest = !empty(array_intersect(['application/zip', 'zip'], $accept));
+ $tarRequest = !empty(array_intersect(['application/x-tar', 'tar'], $accept));
+ if (!$zipRequest && !$tarRequest) {
+ // does not accept zip or tar stream
+ return null;
+ }
+
+ $files = $request->getHeaderAsArray('X-NC-Files');
+ $filesParam = $query['files'] ?? '';
+ // The preferred way would be headers, but this is not possible for simple browser requests ("links")
+ // so we also need to support GET parameters
+ if ($filesParam !== '') {
+ $files = json_decode($filesParam);
+ if (!is_array($files)) {
+ if (!is_string($files)) {
+ // no valid parameter so continue with Sabre behavior
+ $this->logger->debug('Invalid files filter parameter for ZipFolderPlugin', ['filter' => $filesParam]);
+ return null;
+ }
+
+ $files = [$files];
+ }
+ }
+
+ $folder = $node->getNode();
+ $content = empty($files) ? $folder->getDirectoryListing() : [];
+ foreach ($files as $path) {
+ $child = $node->getChild($path);
+ assert($child instanceof Node);
+ $content[] = $child->getNode();
+ }
+
+ $archiveName = 'download';
+ $rootPath = $folder->getPath();
+ if (empty($files)) {
+ // We download the full folder so keep it in the tree
+ $rootPath = dirname($folder->getPath());
+ // Full folder is loaded to rename the archive to the folder name
+ $archiveName = $folder->getName();
+ }
+ $streamer = new Streamer($tarRequest, -1, count($content));
+ $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);
+ }
+ $streamer->finalize();
+ return false;
+ }
+}
diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php
index daf180aec83..69f05395cb5 100644
--- a/apps/dav/lib/Server.php
+++ b/apps/dav/lib/Server.php
@@ -38,6 +38,7 @@ use OCA\DAV\Connector\Sabre\QuotaPlugin;
use OCA\DAV\Connector\Sabre\RequestIdHeaderPlugin;
use OCA\DAV\Connector\Sabre\SharesPlugin;
use OCA\DAV\Connector\Sabre\TagsPlugin;
+use OCA\DAV\Connector\Sabre\ZipFolderPlugin;
use OCA\DAV\DAV\CustomPropertiesBackend;
use OCA\DAV\DAV\PublicAuth;
use OCA\DAV\DAV\ViewOnlyPlugin;
@@ -209,6 +210,10 @@ class Server {
$this->server->addPlugin(new RequestIdHeaderPlugin(\OC::$server->get(IRequest::class)));
$this->server->addPlugin(new ChunkingV2Plugin(\OCP\Server::get(ICacheFactory::class)));
$this->server->addPlugin(new ChunkingPlugin());
+ $this->server->addPlugin(new ZipFolderPlugin(
+ $this->server->tree,
+ $logger,
+ ));
// allow setup of additional plugins
$dispatcher->dispatch('OCA\DAV\Connector\Sabre::addPlugin', $event);
diff --git a/build/integration/dav_features/dav-v2-public.feature b/build/integration/dav_features/dav-v2-public.feature
index e35f7b9101f..1a167ed4ceb 100644
--- a/build/integration/dav_features/dav-v2-public.feature
+++ b/build/integration/dav_features/dav-v2-public.feature
@@ -20,3 +20,23 @@ Feature: dav-v2-public
Given using new public dav path
When Requesting share note on dav endpoint
Then the single response should contain a property "{http://nextcloud.org/ns}note" with value "Hello"
+
+ Scenario: Download a folder
+ Given using new dav path
+ And As an "admin"
+ And user "user0" exists
+ And user "user0" created a folder "/testshare"
+ And user "user0" created a folder "/testshare/testFolder"
+ When User "user0" uploads file "data/textfile.txt" to "/testshare/testFolder/text.txt"
+ When User "user0" uploads file "data/green-square-256.png" to "/testshare/testFolder/image.png"
+ And as "user0" creating a share with
+ | path | testshare |
+ | shareType | 3 |
+ | permissions | 1 |
+ And As an "user1"
+ Given using new public dav path
+ When Downloading public folder "testFolder"
+ Then the downloaded file is a zip file
+ Then the downloaded zip file contains a folder named "testFolder/"
+ And the downloaded zip file contains a file named "testFolder/text.txt" with the contents of "/testshare/testFolder/text.txt" from "user0" data
+ And the downloaded zip file contains a file named "testFolder/image.png" with the contents of "/testshare/testFolder/image.png" from "user0" data
diff --git a/build/integration/dav_features/dav-v2.feature b/build/integration/dav_features/dav-v2.feature
index d62f7d8fa94..02d90242a05 100644
--- a/build/integration/dav_features/dav-v2.feature
+++ b/build/integration/dav_features/dav-v2.feature
@@ -1,5 +1,6 @@
# SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
+
Feature: dav-v2
Background:
Given using api version "1"
@@ -45,6 +46,20 @@ Feature: dav-v2
Then Downloaded content should start with "Welcome to your Nextcloud account!"
Then the HTTP status code should be "200"
+ Scenario: Download a folder
+ Given using new dav path
+ And As an "admin"
+ And user "user0" exists
+ And user "user0" created a folder "/testFolder"
+ When User "user0" uploads file "data/textfile.txt" to "/testFolder/text.txt"
+ When User "user0" uploads file "data/green-square-256.png" to "/testFolder/image.png"
+ And As an "user0"
+ When Downloading folder "/testFolder"
+ Then the downloaded file is a zip file
+ Then the downloaded zip file contains a folder named "testFolder/"
+ And the downloaded zip file contains a file named "testFolder/text.txt" with the contents of "/testFolder/text.txt" from "user0" data
+ And the downloaded zip file contains a file named "testFolder/image.png" with the contents of "/testFolder/image.png" from "user0" data
+
Scenario: Doing a PROPFIND with a web login should not work without CSRF token on the new backend
Given Logging in using web as "admin"
When Sending a "PROPFIND" to "/remote.php/dav/files/admin/welcome.txt" without requesttoken
diff --git a/build/integration/features/bootstrap/Download.php b/build/integration/features/bootstrap/Download.php
index ec3b79363e4..1434e182e7d 100644
--- a/build/integration/features/bootstrap/Download.php
+++ b/build/integration/features/bootstrap/Download.php
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
use PHPUnit\Framework\Assert;
+use Psr\Http\Message\StreamInterface;
require __DIR__ . '/../../vendor/autoload.php';
@@ -23,13 +24,12 @@ trait Download {
$this->asAn($user);
$this->sendingToDirectUrl('GET', '/index.php/apps/files/ajax/download.php?dir=' . $folder . '&files=[' . $entries . ']');
$this->theHTTPStatusCodeShouldBe('200');
-
- $this->getDownloadedFile();
}
private function getDownloadedFile() {
$this->downloadedFile = '';
+ /** @var StreamInterface */
$body = $this->response->getBody();
while (!$body->eof()) {
$this->downloadedFile .= $body->read(8192);
@@ -38,9 +38,23 @@ trait Download {
}
/**
+ * @Then the downloaded file is a zip file
+ */
+ public function theDownloadedFileIsAZipFile() {
+ $this->getDownloadedFile();
+
+ Assert::assertTrue(
+ strpos($this->downloadedFile, "\x50\x4B\x01\x02") !== false,
+ 'File does not contain the central directory file header'
+ );
+ }
+
+ /**
* @Then the downloaded zip file is a zip32 file
*/
public function theDownloadedZipFileIsAZip32File() {
+ $this->theDownloadedFileIsAZipFile();
+
// assertNotContains is not used to prevent the whole file from being
// printed in case of error.
Assert::assertTrue(
@@ -53,6 +67,8 @@ trait Download {
* @Then the downloaded zip file is a zip64 file
*/
public function theDownloadedZipFileIsAZip64File() {
+ $this->theDownloadedFileIsAZipFile();
+
// assertNotContains is not used to prevent the whole file from being
// printed in case of error.
Assert::assertTrue(
diff --git a/build/integration/features/bootstrap/WebDav.php b/build/integration/features/bootstrap/WebDav.php
index 6c0c1767e73..4388c7c8eeb 100644
--- a/build/integration/features/bootstrap/WebDav.php
+++ b/build/integration/features/bootstrap/WebDav.php
@@ -239,6 +239,33 @@ trait WebDav {
}
/**
+ * @When Downloading folder :folderName
+ */
+ public function downloadingFolder(string $folderName) {
+ try {
+ $this->response = $this->makeDavRequest($this->currentUser, 'GET', $folderName, ['Accept' => 'application/zip']);
+ } catch (\GuzzleHttp\Exception\ClientException $e) {
+ $this->response = $e->getResponse();
+ }
+ }
+
+ /**
+ * @When Downloading public folder :folderName
+ */
+ public function downloadPublicFolder(string $folderName) {
+ $token = $this->lastShareData->data->token;
+ $fullUrl = substr($this->baseUrl, 0, -4) . "public.php/dav/files/$token/$folderName";
+
+ $client = new GClient();
+ $options = [];
+ $options['headers'] = [
+ 'Accept' => 'application/zip'
+ ];
+
+ $this->response = $client->request('GET', $fullUrl, $options);
+ }
+
+ /**
* @When Downloading file :fileName
* @param string $fileName
*/