Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Move to ZipFolderPlugin for downloading multiple-nodes #48098

Merged
merged 6 commits into from
Sep 28, 2024
Merged
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 @@ -213,6 +213,7 @@
'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',
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 @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions apps/dav/lib/Connector/Sabre/ServerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ public function createServer(string $baseUri,

$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([
Expand Down
155 changes: 155 additions & 0 deletions apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php
Original file line number Diff line number Diff line change
@@ -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;
Fixed Show fixed Hide fixed
$this->server->on('method:GET', $this->handleDownload(...), 100);
skjnldsv marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* 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));
Dismissed Show dismissed Hide dismissed
}
$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;
}
}
5 changes: 5 additions & 0 deletions apps/dav/lib/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
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;
Expand Down Expand Up @@ -209,6 +210,10 @@ public function __construct(IRequest $request, string $baseUri) {
$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);
Expand Down
55 changes: 0 additions & 55 deletions apps/files/ajax/download.php

This file was deleted.

Loading
Loading