From 8b6c19d0c5baf1e882dd32a8300f0aaa9a2312e9 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 16 Sep 2024 16:35:01 +0200 Subject: [PATCH] chore: Make the files download action use WebDAV zip download Signed-off-by: Ferdinand Thiessen --- apps/files/ajax/download.php | 55 ---- apps/files/appinfo/routes.php | 340 +++++++++++------------ apps/files/src/actions/downloadAction.ts | 82 +++--- 3 files changed, 210 insertions(+), 267 deletions(-) delete mode 100644 apps/files/ajax/download.php diff --git a/apps/files/ajax/download.php b/apps/files/ajax/download.php deleted file mode 100644 index fc434f79e2c2c..0000000000000 --- a/apps/files/ajax/download.php +++ /dev/null @@ -1,55 +0,0 @@ -getSession()->close(); - -$files = isset($_GET['files']) ? (string)$_GET['files'] : ''; -$dir = isset($_GET['dir']) ? (string)$_GET['dir'] : ''; - -$files_list = json_decode($files); -// in case we get only a single file -if (!is_array($files_list)) { - $files_list = [$files]; -} - -/** - * @psalm-taint-escape cookie - */ -function cleanCookieInput(string $value): string { - if (strlen($value) > 32) { - return ''; - } - if (preg_match('!^[a-zA-Z0-9]+$!', $_GET['downloadStartSecret']) !== 1) { - return ''; - } - return $value; -} - -/** - * this sets a cookie to be able to recognize the start of the download - * the content must not be longer than 32 characters and must only contain - * alphanumeric characters - */ -if (isset($_GET['downloadStartSecret'])) { - $value = cleanCookieInput($_GET['downloadStartSecret']); - if ($value !== '') { - setcookie('ocDownloadStarted', $value, time() + 20, '/'); - } -} - -$server_params = [ 'head' => \OC::$server->getRequest()->getMethod() === 'HEAD' ]; - -/** - * Http range requests support - */ -if (isset($_SERVER['HTTP_RANGE'])) { - $server_params['range'] = \OC::$server->getRequest()->getHeader('Range'); -} - -OC_Files::get($dir, $files_list, $server_params); diff --git a/apps/files/appinfo/routes.php b/apps/files/appinfo/routes.php index 487f6335d4569..a67ec7cbc14ee 100644 --- a/apps/files/appinfo/routes.php +++ b/apps/files/appinfo/routes.php @@ -9,181 +9,169 @@ */ namespace OCA\Files\AppInfo; -use OCA\Files\Controller\OpenLocalEditorController; - -// Legacy routes above -/** @var \OC\Route\Router $this */ -$this->create('files_ajax_download', 'apps/files/ajax/download.php') - ->actionInclude('files/ajax/download.php'); - -/** @var Application $application */ -$application = \OC::$server->get(Application::class); -$application->registerRoutes( - $this, - [ - 'routes' => [ - [ - 'name' => 'view#index', - 'url' => '/', - 'verb' => 'GET', - ], - [ - 'name' => 'View#showFile', - 'url' => '/f/{fileid}', - 'verb' => 'GET', - 'root' => '', - ], - [ - 'name' => 'Api#getThumbnail', - 'url' => '/api/v1/thumbnail/{x}/{y}/{file}', - 'verb' => 'GET', - 'requirements' => ['file' => '.+'] - ], - [ - 'name' => 'Api#updateFileTags', - 'url' => '/api/v1/files/{path}', - 'verb' => 'POST', - 'requirements' => ['path' => '.+'], - ], - [ - 'name' => 'Api#getRecentFiles', - 'url' => '/api/v1/recent/', - 'verb' => 'GET' - ], - [ - 'name' => 'Api#getStorageStats', - 'url' => '/api/v1/stats', - 'verb' => 'GET' - ], - [ - 'name' => 'Api#setViewConfig', - 'url' => '/api/v1/views/{view}/{key}', - 'verb' => 'PUT' - ], - [ - 'name' => 'Api#setViewConfig', - 'url' => '/api/v1/views', - 'verb' => 'PUT' - ], - [ - 'name' => 'Api#getViewConfigs', - 'url' => '/api/v1/views', - 'verb' => 'GET' - ], - [ - 'name' => 'Api#setConfig', - 'url' => '/api/v1/config/{key}', - 'verb' => 'PUT' - ], - [ - 'name' => 'Api#getConfigs', - 'url' => '/api/v1/configs', - 'verb' => 'GET' - ], - [ - 'name' => 'Api#showHiddenFiles', - 'url' => '/api/v1/showhidden', - 'verb' => 'POST' - ], - [ - 'name' => 'Api#cropImagePreviews', - 'url' => '/api/v1/cropimagepreviews', - 'verb' => 'POST' - ], - [ - 'name' => 'Api#showGridView', - 'url' => '/api/v1/showgridview', - 'verb' => 'POST' - ], - [ - 'name' => 'Api#getGridView', - 'url' => '/api/v1/showgridview', - 'verb' => 'GET' - ], - [ - 'name' => 'DirectEditingView#edit', - 'url' => '/directEditing/{token}', - 'verb' => 'GET' - ], - [ - 'name' => 'Api#serviceWorker', - 'url' => '/preview-service-worker.js', - 'verb' => 'GET' - ], - [ - 'name' => 'view#indexView', - 'url' => '/{view}', - 'verb' => 'GET', - ], - [ - 'name' => 'view#indexViewFileid', - 'url' => '/{view}/{fileid}', - 'verb' => 'GET', - ], - ], - 'ocs' => [ - [ - 'name' => 'DirectEditing#info', - 'url' => '/api/v1/directEditing', - 'verb' => 'GET' - ], - [ - 'name' => 'DirectEditing#templates', - 'url' => '/api/v1/directEditing/templates/{editorId}/{creatorId}', - 'verb' => 'GET' - ], - [ - 'name' => 'DirectEditing#open', - 'url' => '/api/v1/directEditing/open', - 'verb' => 'POST' - ], - [ - 'name' => 'DirectEditing#create', - 'url' => '/api/v1/directEditing/create', - 'verb' => 'POST' - ], - [ - 'name' => 'Template#list', - 'url' => '/api/v1/templates', - 'verb' => 'GET' - ], - [ - 'name' => 'Template#create', - 'url' => '/api/v1/templates/create', - 'verb' => 'POST' - ], - [ - 'name' => 'Template#path', - 'url' => '/api/v1/templates/path', - 'verb' => 'POST' - ], - [ - 'name' => 'TransferOwnership#transfer', - 'url' => '/api/v1/transferownership', - 'verb' => 'POST', - ], - [ - 'name' => 'TransferOwnership#accept', - 'url' => '/api/v1/transferownership/{id}', - 'verb' => 'POST', - ], - [ - 'name' => 'TransferOwnership#reject', - 'url' => '/api/v1/transferownership/{id}', - 'verb' => 'DELETE', - ], - [ - /** @see OpenLocalEditorController::create() */ - 'name' => 'OpenLocalEditor#create', - 'url' => '/api/v1/openlocaleditor', - 'verb' => 'POST', - ], - [ - /** @see OpenLocalEditorController::validate() */ - 'name' => 'OpenLocalEditor#validate', - 'url' => '/api/v1/openlocaleditor/{token}', - 'verb' => 'POST', - ], +return [ + 'routes' => [ + [ + 'name' => 'view#index', + 'url' => '/', + 'verb' => 'GET', + ], + [ + 'name' => 'View#showFile', + 'url' => '/f/{fileid}', + 'verb' => 'GET', + 'root' => '', + ], + [ + 'name' => 'Api#getThumbnail', + 'url' => '/api/v1/thumbnail/{x}/{y}/{file}', + 'verb' => 'GET', + 'requirements' => ['file' => '.+'] + ], + [ + 'name' => 'Api#updateFileTags', + 'url' => '/api/v1/files/{path}', + 'verb' => 'POST', + 'requirements' => ['path' => '.+'], + ], + [ + 'name' => 'Api#getRecentFiles', + 'url' => '/api/v1/recent/', + 'verb' => 'GET' + ], + [ + 'name' => 'Api#getStorageStats', + 'url' => '/api/v1/stats', + 'verb' => 'GET' + ], + [ + 'name' => 'Api#setViewConfig', + 'url' => '/api/v1/views/{view}/{key}', + 'verb' => 'PUT' + ], + [ + 'name' => 'Api#setViewConfig', + 'url' => '/api/v1/views', + 'verb' => 'PUT' + ], + [ + 'name' => 'Api#getViewConfigs', + 'url' => '/api/v1/views', + 'verb' => 'GET' + ], + [ + 'name' => 'Api#setConfig', + 'url' => '/api/v1/config/{key}', + 'verb' => 'PUT' + ], + [ + 'name' => 'Api#getConfigs', + 'url' => '/api/v1/configs', + 'verb' => 'GET' + ], + [ + 'name' => 'Api#showHiddenFiles', + 'url' => '/api/v1/showhidden', + 'verb' => 'POST' + ], + [ + 'name' => 'Api#cropImagePreviews', + 'url' => '/api/v1/cropimagepreviews', + 'verb' => 'POST' + ], + [ + 'name' => 'Api#showGridView', + 'url' => '/api/v1/showgridview', + 'verb' => 'POST' + ], + [ + 'name' => 'Api#getGridView', + 'url' => '/api/v1/showgridview', + 'verb' => 'GET' + ], + [ + 'name' => 'DirectEditingView#edit', + 'url' => '/directEditing/{token}', + 'verb' => 'GET' + ], + [ + 'name' => 'Api#serviceWorker', + 'url' => '/preview-service-worker.js', + 'verb' => 'GET' + ], + [ + 'name' => 'view#indexView', + 'url' => '/{view}', + 'verb' => 'GET', + ], + [ + 'name' => 'view#indexViewFileid', + 'url' => '/{view}/{fileid}', + 'verb' => 'GET', + ], + ], + 'ocs' => [ + [ + 'name' => 'DirectEditing#info', + 'url' => '/api/v1/directEditing', + 'verb' => 'GET' + ], + [ + 'name' => 'DirectEditing#templates', + 'url' => '/api/v1/directEditing/templates/{editorId}/{creatorId}', + 'verb' => 'GET' + ], + [ + 'name' => 'DirectEditing#open', + 'url' => '/api/v1/directEditing/open', + 'verb' => 'POST' + ], + [ + 'name' => 'DirectEditing#create', + 'url' => '/api/v1/directEditing/create', + 'verb' => 'POST' + ], + [ + 'name' => 'Template#list', + 'url' => '/api/v1/templates', + 'verb' => 'GET' + ], + [ + 'name' => 'Template#create', + 'url' => '/api/v1/templates/create', + 'verb' => 'POST' + ], + [ + 'name' => 'Template#path', + 'url' => '/api/v1/templates/path', + 'verb' => 'POST' + ], + [ + 'name' => 'TransferOwnership#transfer', + 'url' => '/api/v1/transferownership', + 'verb' => 'POST', + ], + [ + 'name' => 'TransferOwnership#accept', + 'url' => '/api/v1/transferownership/{id}', + 'verb' => 'POST', + ], + [ + 'name' => 'TransferOwnership#reject', + 'url' => '/api/v1/transferownership/{id}', + 'verb' => 'DELETE', + ], + [ + /** @see OpenLocalEditorController::create() */ + 'name' => 'OpenLocalEditor#create', + 'url' => '/api/v1/openlocaleditor', + 'verb' => 'POST', + ], + [ + /** @see OpenLocalEditorController::validate() */ + 'name' => 'OpenLocalEditor#validate', + 'url' => '/api/v1/openlocaleditor/{token}', + 'verb' => 'POST', ], ] -); +]; diff --git a/apps/files/src/actions/downloadAction.ts b/apps/files/src/actions/downloadAction.ts index 97d1cc773d407..f715bc9e26b2e 100644 --- a/apps/files/src/actions/downloadAction.ts +++ b/apps/files/src/actions/downloadAction.ts @@ -2,11 +2,8 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { FileAction, Node, FileType, View, DefaultType } from '@nextcloud/files' +import { FileAction, Node, FileType, DefaultType } from '@nextcloud/files' import { t } from '@nextcloud/l10n' -import { generateUrl } from '@nextcloud/router' -import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public' -import { basename } from 'path' import { isDownloadable } from '../utils/permissions' import ArrowDownSvg from '@mdi/svg/svg/arrow-down.svg?raw' @@ -18,25 +15,48 @@ const triggerDownload = function(url: string) { hiddenElement.click() } -const downloadNodes = function(dir: string, nodes: Node[]) { - const secret = Math.random().toString(36).substring(2) - let url: string - if (isPublicShare()) { - url = generateUrl('/s/{token}/download/{filename}?path={dir}&files={files}&downloadStartSecret={secret}', { - dir, - secret, - files: JSON.stringify(nodes.map(node => node.basename)), - token: getSharingToken(), - filename: `${basename(dir)}.zip}`, - }) - } else { - url = generateUrl('/apps/files/ajax/download.php?dir={dir}&files={files}&downloadStartSecret={secret}', { - dir, - secret, - files: JSON.stringify(nodes.map(node => node.basename)), - }) +/** + * Find the longest common path prefix of both input paths + */ +function longestCommonPath(first: string, second: string): string { + const firstSegments = first.split('/').filter(Boolean) + const secondSegments = second.split('/').filter(Boolean) + let base = '' + for (const [index, segment] of firstSegments.entries()) { + if (index >= second.length) { + break + } + if (segment !== secondSegments[index]) { + break + } + const sep = base === '' ? '' : '/' + base = `${base}${sep}${segment}` } - triggerDownload(url) + return base +} + +const downloadNodes = function(nodes: Node[]) { + if (nodes.length === 1) { + if (nodes[0].type === FileType.File) { + return triggerDownload(nodes[0].encodedSource) + } else { + const url = new URL(nodes[0].encodedSource) + url.searchParams.append('accept', 'zip') + return triggerDownload(url.href) + } + } + + const url = new URL(nodes[0].source) + let base = url.pathname + for (const node of nodes.slice(1)) { + base = longestCommonPath(base, (new URL(node.source).pathname)) + } + url.pathname = base + + const filenames = nodes.map((node) => node.source.slice(url.href.length + 1)) + url.searchParams.append('accept', 'zip') + url.searchParams.append('files', filenames.join(',')) + triggerDownload(url.href) } export const action = new FileAction({ @@ -62,23 +82,13 @@ export const action = new FileAction({ return nodes.every(isDownloadable) }, - async exec(node: Node, view: View, dir: string) { - if (node.type === FileType.Folder) { - downloadNodes(dir, [node]) - return null - } - - triggerDownload(node.encodedSource) + async exec(node: Node) { + downloadNodes([node]) return null }, - async execBatch(nodes: Node[], view: View, dir: string) { - if (nodes.length === 1) { - this.exec(nodes[0], view, dir) - return [null] - } - - downloadNodes(dir, nodes) + async execBatch(nodes: Node[]) { + downloadNodes(nodes) return new Array(nodes.length).fill(null) },