diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-09-16 16:35:01 +0200 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-09-28 13:18:29 +0200 |
commit | 0f6760c810e370023728d93a31f69c79dc5c3e3d (patch) | |
tree | ec8ac8201ef131b1f1727b33060c731b915414b0 /apps/files | |
parent | 2f66bd5b754f072a3cfeda759d71a479e4538350 (diff) | |
download | nextcloud-server-0f6760c810e370023728d93a31f69c79dc5c3e3d.tar.gz nextcloud-server-0f6760c810e370023728d93a31f69c79dc5c3e3d.zip |
feat(files): Make the files download action use WebDAV zip download
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'apps/files')
-rw-r--r-- | apps/files/ajax/download.php | 55 | ||||
-rw-r--r-- | apps/files/appinfo/routes.php | 340 | ||||
-rw-r--r-- | apps/files/src/actions/downloadAction.spec.ts | 4 | ||||
-rw-r--r-- | apps/files/src/actions/downloadAction.ts | 96 |
4 files changed, 222 insertions, 273 deletions
diff --git a/apps/files/ajax/download.php b/apps/files/ajax/download.php deleted file mode 100644 index fc434f79e2c..00000000000 --- a/apps/files/ajax/download.php +++ /dev/null @@ -1,55 +0,0 @@ -<?php - -/** - * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-FileCopyrightText: 2016 ownCloud, Inc. - * SPDX-License-Identifier: AGPL-3.0-only - */ -// Check if we are a user -OC_Util::checkLoggedIn(); -\OC::$server->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 487f6335d45..a67ec7cbc14 100644 --- a/apps/files/appinfo/routes.php +++ b/apps/files/appinfo/routes.php @@ -9,181 +9,169 @@ declare(strict_types=1); */ 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.spec.ts b/apps/files/src/actions/downloadAction.spec.ts index 2c24625e90e..2d42de5b8b1 100644 --- a/apps/files/src/actions/downloadAction.spec.ts +++ b/apps/files/src/actions/downloadAction.spec.ts @@ -141,7 +141,7 @@ describe('Download action execute tests', () => { // Silent action expect(exec).toBe(null) expect(link.download).toEqual('') - expect(link.href.startsWith('/index.php/apps/files/ajax/download.php?dir=%2F&files=%5B%22FooBar%22%5D&downloadStartSecret=')).toBe(true) + expect(link.href).toMatch('https://cloud.domain.com/remote.php/dav/files/admin/FooBar/?accept=zip') expect(link.click).toHaveBeenCalledTimes(1) }) @@ -166,7 +166,7 @@ describe('Download action execute tests', () => { // Silent action expect(exec).toStrictEqual([null, null]) expect(link.download).toEqual('') - expect(link.href.startsWith('/index.php/apps/files/ajax/download.php?dir=%2FDir&files=%5B%22foo.txt%22%2C%22bar.txt%22%5D&downloadStartSecret=')).toBe(true) + expect(link.href).toMatch('https://cloud.domain.com/remote.php/dav/files/admin/Dir/?accept=zip&files=%5B%22foo.txt%22%2C%22bar.txt%22%5D') expect(link.click).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/files/src/actions/downloadAction.ts b/apps/files/src/actions/downloadAction.ts index 97d1cc773d4..19e0b3502fa 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,57 @@ 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}`, - }) +/** + * Find the longest common path prefix of both input paths + * @param first The first path + * @param second The second path + */ +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}` + } + return base +} + +const downloadNodes = function(nodes: Node[]) { + let url: URL + + if (nodes.length === 1) { + if (nodes[0].type === FileType.File) { + return triggerDownload(nodes[0].encodedSource) + } else { + url = new URL(nodes[0].encodedSource) + url.searchParams.append('accept', '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)), - }) + 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 + + // The URL contains the path encoded so we need to decode as the query.append will re-encode it + const filenames = nodes.map((node) => decodeURI(node.encodedSource.slice(url.href.length + 1))) + url.searchParams.append('accept', 'zip') + url.searchParams.append('files', JSON.stringify(filenames)) } - triggerDownload(url) + + if (url.pathname.at(-1) !== '/') { + url.pathname = `${url.pathname}/` + } + + return triggerDownload(url.href) } export const action = new FileAction({ @@ -51,34 +80,21 @@ export const action = new FileAction({ return false } - // We can download direct dav files. But if we have - // some folders, we need to use the /apps/files/ajax/download.php - // endpoint, which only supports user root folder. - if (nodes.some(node => node.type === FileType.Folder) - && nodes.some(node => !node.root?.startsWith('/files'))) { + // We can only download dav files and folders. + if (nodes.some(node => !node.isDavRessource)) { return false } 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) }, |