Skip to content

Commit 31ad1c5

Browse files
authored
Merge pull request #48098 from nextcloud/feat/zip-folder-plugin
feat: Move to ZipFolderPlugin for downloading multiple-nodes
2 parents c470ef0 + ca8d576 commit 31ad1c5

File tree

24 files changed

+554
-846
lines changed

24 files changed

+554
-846
lines changed

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@
213213
'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => $baseDir . '/../lib/Connector/Sabre/SharesPlugin.php',
214214
'OCA\\DAV\\Connector\\Sabre\\TagList' => $baseDir . '/../lib/Connector/Sabre/TagList.php',
215215
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => $baseDir . '/../lib/Connector/Sabre/TagsPlugin.php',
216+
'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => $baseDir . '/../lib/Connector/Sabre/ZipFolderPlugin.php',
216217
'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php',
217218
'OCA\\DAV\\Controller\\DirectController' => $baseDir . '/../lib/Controller/DirectController.php',
218219
'OCA\\DAV\\Controller\\InvitationResponseController' => $baseDir . '/../lib/Controller/InvitationResponseController.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ class ComposerStaticInitDAV
228228
'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/SharesPlugin.php',
229229
'OCA\\DAV\\Connector\\Sabre\\TagList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagList.php',
230230
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagsPlugin.php',
231+
'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ZipFolderPlugin.php',
231232
'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php',
232233
'OCA\\DAV\\Controller\\DirectController' => __DIR__ . '/..' . '/../lib/Controller/DirectController.php',
233234
'OCA\\DAV\\Controller\\InvitationResponseController' => __DIR__ . '/..' . '/../lib/Controller/InvitationResponseController.php',

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ public function createServer(string $baseUri,
9292

9393
$server->addPlugin(new RequestIdHeaderPlugin(\OC::$server->get(IRequest::class)));
9494

95+
$server->addPlugin(new ZipFolderPlugin(
96+
$objectTree,
97+
$this->logger,
98+
));
99+
95100
// Some WebDAV clients do require Class 2 WebDAV support (locking), since
96101
// we do not provide locking we emulate it using a fake locking plugin.
97102
if ($this->request->isUserAgent([
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OCA\DAV\Connector\Sabre;
10+
11+
use OC\Streamer;
12+
use OCP\Files\File as NcFile;
13+
use OCP\Files\Folder as NcFolder;
14+
use OCP\Files\Node as NcNode;
15+
use Psr\Log\LoggerInterface;
16+
use Sabre\DAV\Server;
17+
use Sabre\DAV\ServerPlugin;
18+
use Sabre\DAV\Tree;
19+
use Sabre\HTTP\Request;
20+
use Sabre\HTTP\Response;
21+
22+
/**
23+
* This plugin allows to download folders accessed by GET HTTP requests on DAV.
24+
* The WebDAV standard explicitly say that GET is not covered and should return what ever the application thinks would be a good representation.
25+
*
26+
* When a collection is accessed using GET, this will provide the content as a archive.
27+
* The type can be set by the `Accept` header (MIME type of zip or tar), or as browser fallback using a `accept` GET parameter.
28+
* 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.
29+
*/
30+
class ZipFolderPlugin extends ServerPlugin {
31+
32+
/**
33+
* Reference to main server object
34+
*/
35+
private ?Server $server = null;
36+
37+
public function __construct(
38+
private Tree $tree,
39+
private LoggerInterface $logger,
40+
) {
41+
}
42+
43+
/**
44+
* This initializes the plugin.
45+
*
46+
* This function is called by \Sabre\DAV\Server, after
47+
* addPlugin is called.
48+
*
49+
* This method should set up the required event subscriptions.
50+
*/
51+
public function initialize(Server $server): void {
52+
$this->server = $server;
53+
$this->server->on('method:GET', $this->handleDownload(...), 100);
54+
}
55+
56+
/**
57+
* Adding a node to the archive streamer.
58+
* This will recursively add new nodes to the stream if the node is a directory.
59+
*/
60+
protected function streamNode(Streamer $streamer, NcNode $node, string $rootPath): void {
61+
// Remove the root path from the filename to make it relative to the requested folder
62+
$filename = str_replace($rootPath, '', $node->getPath());
63+
64+
if ($node instanceof NcFile) {
65+
$resource = $node->fopen('rb');
66+
if ($resource === false) {
67+
$this->logger->info('Cannot read file for zip stream', ['filePath' => $node->getPath()]);
68+
throw new \Sabre\DAV\Exception\ServiceUnavailable('Requested file can currently not be accessed.');
69+
}
70+
$streamer->addFileFromStream($resource, $filename, $node->getSize(), $node->getMTime());
71+
} elseif ($node instanceof NcFolder) {
72+
$streamer->addEmptyDir($filename);
73+
$content = $node->getDirectoryListing();
74+
foreach ($content as $subNode) {
75+
$this->streamNode($streamer, $subNode, $rootPath);
76+
}
77+
}
78+
}
79+
80+
/**
81+
* Download a folder as an archive.
82+
* It is possible to filter / limit the files that should be downloaded,
83+
* either by passing (multiple) `X-NC-Files: the-file` headers
84+
* or by setting a `files=JSON_ARRAY_OF_FILES` URL query.
85+
*
86+
* @return false|null
87+
*/
88+
public function handleDownload(Request $request, Response $response): ?bool {
89+
$node = $this->tree->getNodeForPath($request->getPath());
90+
if (!($node instanceof \OCA\DAV\Connector\Sabre\Directory)) {
91+
// only handle directories
92+
return null;
93+
}
94+
95+
$query = $request->getQueryParameters();
96+
97+
// Get accept header - or if set overwrite with accept GET-param
98+
$accept = $request->getHeaderAsArray('Accept');
99+
$acceptParam = $query['accept'] ?? '';
100+
if ($acceptParam !== '') {
101+
$accept = array_map(fn (string $name) => strtolower(trim($name)), explode(',', $acceptParam));
102+
}
103+
$zipRequest = !empty(array_intersect(['application/zip', 'zip'], $accept));
104+
$tarRequest = !empty(array_intersect(['application/x-tar', 'tar'], $accept));
105+
if (!$zipRequest && !$tarRequest) {
106+
// does not accept zip or tar stream
107+
return null;
108+
}
109+
110+
$files = $request->getHeaderAsArray('X-NC-Files');
111+
$filesParam = $query['files'] ?? '';
112+
// The preferred way would be headers, but this is not possible for simple browser requests ("links")
113+
// so we also need to support GET parameters
114+
if ($filesParam !== '') {
115+
$files = json_decode($filesParam);
116+
if (!is_array($files)) {
117+
if (!is_string($files)) {
118+
// no valid parameter so continue with Sabre behavior
119+
$this->logger->debug('Invalid files filter parameter for ZipFolderPlugin', ['filter' => $filesParam]);
120+
return null;
121+
}
122+
123+
$files = [$files];
124+
}
125+
}
126+
127+
$folder = $node->getNode();
128+
$content = empty($files) ? $folder->getDirectoryListing() : [];
129+
foreach ($files as $path) {
130+
$child = $node->getChild($path);
131+
assert($child instanceof Node);
132+
$content[] = $child->getNode();
133+
}
134+
135+
$archiveName = 'download';
136+
$rootPath = $folder->getPath();
137+
if (empty($files)) {
138+
// We download the full folder so keep it in the tree
139+
$rootPath = dirname($folder->getPath());
140+
// Full folder is loaded to rename the archive to the folder name
141+
$archiveName = $folder->getName();
142+
}
143+
$streamer = new Streamer($tarRequest, -1, count($content));
144+
$streamer->sendHeaders($archiveName);
145+
// For full folder downloads we also add the folder itself to the archive
146+
if (empty($files)) {
147+
$streamer->addEmptyDir($archiveName);
148+
}
149+
foreach ($content as $node) {
150+
$this->streamNode($streamer, $node, $rootPath);
151+
}
152+
$streamer->finalize();
153+
return false;
154+
}
155+
}

apps/dav/lib/Server.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
use OCA\DAV\Connector\Sabre\RequestIdHeaderPlugin;
3939
use OCA\DAV\Connector\Sabre\SharesPlugin;
4040
use OCA\DAV\Connector\Sabre\TagsPlugin;
41+
use OCA\DAV\Connector\Sabre\ZipFolderPlugin;
4142
use OCA\DAV\DAV\CustomPropertiesBackend;
4243
use OCA\DAV\DAV\PublicAuth;
4344
use OCA\DAV\DAV\ViewOnlyPlugin;
@@ -209,6 +210,10 @@ public function __construct(IRequest $request, string $baseUri) {
209210
$this->server->addPlugin(new RequestIdHeaderPlugin(\OC::$server->get(IRequest::class)));
210211
$this->server->addPlugin(new ChunkingV2Plugin(\OCP\Server::get(ICacheFactory::class)));
211212
$this->server->addPlugin(new ChunkingPlugin());
213+
$this->server->addPlugin(new ZipFolderPlugin(
214+
$this->server->tree,
215+
$logger,
216+
));
212217

213218
// allow setup of additional plugins
214219
$dispatcher->dispatch('OCA\DAV\Connector\Sabre::addPlugin', $event);

apps/files/ajax/download.php

Lines changed: 0 additions & 55 deletions
This file was deleted.

0 commit comments

Comments
 (0)