ZipFolderPlugin.php 5.9 KB

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