diff --git a/apps/files/ajax/download.php b/apps/files/ajax/download.php deleted file mode 100644 index 445b15dc6a7ab..0000000000000 --- a/apps/files/ajax/download.php +++ /dev/null @@ -1,78 +0,0 @@ - - * @author Björn Schießle - * @author Christoph Wurst - * @author Frank Karlitschek - * @author Jörn Friedrich Dreyer - * @author Lukas Reschke - * @author Morris Jobke - * @author Piotr Filiciak - * @author Robin Appelman - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see - * - */ - -// Check if we are a user -OCP\User::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 03a025cadf5e3..7103ee1f7ec0b 100644 --- a/apps/files/appinfo/routes.php +++ b/apps/files/appinfo/routes.php @@ -101,6 +101,16 @@ 'url' => '/ajax/getstoragestats.php', 'verb' => 'GET', ], + [ + 'name' => 'ajax#registerDownload', + 'url' => '/registerDownload', + 'verb' => 'POST', + ], + [ + 'name' => 'ajax#download', + 'url' => '/ajax/download.php', + 'verb' => 'GET', + ], [ 'name' => 'API#toggleShowFolder', 'url' => '/api/v1/toggleShowFolder/{key}', @@ -174,7 +184,5 @@ /** @var $this \OC\Route\Router */ -$this->create('files_ajax_download', 'apps/files/ajax/download.php') - ->actionInclude('files/ajax/download.php'); $this->create('files_ajax_list', 'apps/files/ajax/list.php') ->actionInclude('files/ajax/list.php'); diff --git a/apps/files/js/fileactions.js b/apps/files/js/fileactions.js index cd23336c2b32c..29297494ebc5a 100644 --- a/apps/files/js/fileactions.js +++ b/apps/files/js/fileactions.js @@ -621,7 +621,7 @@ }; context.fileList.showFileBusyState(filename, true); - OCA.Files.Files.handleDownload(url, disableLoadingState); + OCA.Files.Files.handleDownload(filename, dir, disableLoadingState); } } }); diff --git a/apps/files/js/filelist.js b/apps/files/js/filelist.js index 11d0bc4511dac..5d4a7691a02f4 100644 --- a/apps/files/js/filelist.js +++ b/apps/files/js/filelist.js @@ -1036,11 +1036,11 @@ }; if(this.getSelectedFiles().length > 1) { - OCA.Files.Files.handleDownload(this.getDownloadUrl(files, dir, true), disableLoadingState); + OCA.Files.Files.handleDownload(files, dir, disableLoadingState); } else { var first = this.getSelectedFiles()[0]; - OCA.Files.Files.handleDownload(this.getDownloadUrl(first.name, dir, true), disableLoadingState); + OCA.Files.Files.handleDownload(first.name, dir, disableLoadingState); } event.preventDefault(); }, @@ -1340,8 +1340,7 @@ } else { - var url = this.getDownloadUrl(filename, dir, true); - OCA.Files.Files.handleDownload(url); + OCA.Files.Files.handleDownload(filename, dir, undefined); } } this.triedActionOnce = true; diff --git a/apps/files/js/files.js b/apps/files/js/files.js index 6dbd87c1e2eed..d5a31a0cab304 100644 --- a/apps/files/js/files.js +++ b/apps/files/js/files.js @@ -363,26 +363,24 @@ * - browser now adds this cookie for the domain * - JS periodically checks for this cookie and then knows when the download has started to call the callback * - * @param {string} url download URL + * @param {string} files + * @param {string} dir * @param {Function} callback function to call once the download has started */ - handleDownload: function(url, callback) { + handleDownload: function(files, dir, callback) { var randomToken = Math.random().toString(36).substring(2), checkForDownloadCookie = function() { if (!OC.Util.isCookieSetToValue('ocDownloadStarted', randomToken)){ return false; } else { - callback(); + if (callback !== undefined) { + callback(); + } return true; } }; - if (url.indexOf('?') >= 0) { - url += '&'; - } else { - url += '?'; - } - OC.redirect(url + 'downloadStartSecret=' + randomToken); + OCA.Files.download(files, dir, randomToken); OC.Util.waitFor(checkForDownloadCookie, 500); } }; diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php index 92f29bfe410ad..25760d29b20b9 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -68,6 +68,7 @@ class Application extends App implements IBootstrap { public const APP_ID = 'files'; + public const DL_TOKEN_PREFIX = 'dlToken_'; public function __construct(array $urlParams = []) { parent::__construct(self::APP_ID, $urlParams); diff --git a/apps/files/lib/Controller/AjaxController.php b/apps/files/lib/Controller/AjaxController.php index 51a4a5b2a373d..d3cea96116273 100644 --- a/apps/files/lib/Controller/AjaxController.php +++ b/apps/files/lib/Controller/AjaxController.php @@ -26,15 +26,37 @@ namespace OCA\Files\Controller; +use OC_Files; use OCA\Files\Helper; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\JSONResponse; use OCP\Files\NotFoundException; +use OCP\Files\Utils\IDownloadManager; +use OCP\IConfig; use OCP\IRequest; +use OCP\ISession; class AjaxController extends Controller { - public function __construct(string $appName, IRequest $request) { + /** @var ISession */ + private $session; + /** @var IConfig */ + private $config; + + /** @var IDownloadManager */ + private $downloadManager; + + public function __construct( + string $appName, + IRequest $request, + ISession $session, + IConfig $config, + IDownloadManager $downloadManager + ) { parent::__construct($appName, $request); + $this->session = $session; + $this->request = $request; + $this->config = $config; + $this->downloadManager = $downloadManager; } /** @@ -56,4 +78,46 @@ public function getStorageStats(string $dir = '/'): JSONResponse { ]); } } + + /** + * @NoAdminRequired + */ + public function registerDownload($files, string $dir = '', string $downloadStartSecret = '') { + if (is_string($files)) { + $files = [$files]; + } elseif (!is_array($files)) { + throw new \InvalidArgumentException('Invalid argument for files'); + } + + $token = $this->downloadManager->register([ + 'files' => $files, + 'dir' => $dir, + 'downloadStartSecret' => $downloadStartSecret, + ]); + + return new JSONResponse(['token' => $token]); + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function download(string $token) { + + $data = $this->downloadManager->retrieve($token); + $this->session->close(); + + if (strlen($data['downloadStartSecret']) <= 32 + && (preg_match('!^[a-zA-Z0-9]+$!', $data['downloadStartSecret']) === 1) + ) { + setcookie('ocDownloadStarted', $data['downloadStartSecret'], time() + 20, '/'); + } + + $serverParams = [ 'head' => $this->request->getMethod() === 'HEAD' ]; + if (isset($_SERVER['HTTP_RANGE'])) { + $serverParams['range'] = $this->request->getHeader('Range'); + } + + OC_Files::get($data['dir'], $data['files'], $serverParams); + } } diff --git a/apps/files/lib/Controller/ViewController.php b/apps/files/lib/Controller/ViewController.php index efaf2dc602f0e..8885fd43f0cc7 100644 --- a/apps/files/lib/Controller/ViewController.php +++ b/apps/files/lib/Controller/ViewController.php @@ -191,6 +191,7 @@ public function index($dir = '', $view = '', $fileid = null, $fileNotFound = fal \OCP\Util::addStyle('files', 'merged'); \OCP\Util::addScript('files', 'merged-index'); \OCP\Util::addScript('files', 'dist/templates'); + \OCP\Util::addScript('files', 'dist/download'); // mostly for the home storage's free space // FIXME: Make non static diff --git a/apps/files/src/download.js b/apps/files/src/download.js new file mode 100644 index 0000000000000..f6d7117f67063 --- /dev/null +++ b/apps/files/src/download.js @@ -0,0 +1,28 @@ +/** + * @copyright Copyright (c) 2021 Arthur Schiwon + * + * @author Arthur Schiwon + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import download from './services/Download' + +if (!window.OCA.Files) { + window.OCA.Files = {} +} +Object.assign(window.OCA.Files, { download }) diff --git a/apps/files/src/services/Download.js b/apps/files/src/services/Download.js new file mode 100644 index 0000000000000..b18ec3a17e61e --- /dev/null +++ b/apps/files/src/services/Download.js @@ -0,0 +1,39 @@ +/** + * @copyright Copyright (c) 2021 Arthur Schiwon + * + * @author Arthur Schiwon + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import { generateUrl } from '@nextcloud/router' +import axios from '@nextcloud/axios' + +export default async function(files, dir, downloadStartSecret) { + const res = await axios.post(generateUrl('apps/files/registerDownload'), { + files, + dir, + downloadStartSecret, + }) + + if (res.status === 200 && res.data.token) { + const dlUrl = generateUrl('apps/files/ajax/download.php?token={token}', { + token: res.data.token, + }) + OC.redirect(dlUrl) + } +} diff --git a/apps/files/webpack.js b/apps/files/webpack.js index d3a047f5a586d..792f0d41e1248 100644 --- a/apps/files/webpack.js +++ b/apps/files/webpack.js @@ -26,6 +26,7 @@ const path = require('path') module.exports = { entry: { + download: path.join(__dirname, 'src', 'download.js'), sidebar: path.join(__dirname, 'src', 'sidebar.js'), templates: path.join(__dirname, 'src', 'templates.js'), 'files-app-settings': path.join(__dirname, 'src', 'files-app-settings.js'), diff --git a/apps/files_sharing/js/public.js b/apps/files_sharing/js/public.js index 3aa4ed788902b..e352f83c43110 100644 --- a/apps/files_sharing/js/public.js +++ b/apps/files_sharing/js/public.js @@ -295,6 +295,20 @@ OCA.Sharing.PublicApp = { $('#download').click(function (e) { e.preventDefault(); OC.redirect(FileList.getDownloadUrl()); + + var path = dir || this.getCurrentDirectory(); + if (_.isArray(filename)) { + filename = JSON.stringify(filename); + } + var params = { + path: path + }; + if (filename) { + params.files = filename; + } + + + OCA.Files.Download.get() }); if (hideDownload === 'true') { diff --git a/apps/files_sharing/lib/Controller/ShareController.php b/apps/files_sharing/lib/Controller/ShareController.php index 7e83ffaa7dc97..79b000466b966 100644 --- a/apps/files_sharing/lib/Controller/ShareController.php +++ b/apps/files_sharing/lib/Controller/ShareController.php @@ -52,17 +52,23 @@ use OCA\Viewer\Event\LoadViewer; use OCP\Accounts\IAccountManager; use OCP\AppFramework\AuthPublicShareController; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\Template\ExternalShareMenuAction; use OCP\AppFramework\Http\Template\LinkMenuAction; use OCP\AppFramework\Http\Template\PublicTemplateResponse; use OCP\AppFramework\Http\Template\SimpleMenuAction; use OCP\AppFramework\Http\TemplateResponse; +use OCP\Constants; use OCP\Defaults; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Folder; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; +use OCP\Files\Utils\IDownloadManager; use OCP\IConfig; use OCP\IL10N; use OCP\ILogger; @@ -112,6 +118,8 @@ class ShareController extends AuthPublicShareController { /** @var Share\IShare */ protected $share; + /** @var IDownloadManager */ + private $downloadManager; /** * @param string $appName @@ -146,7 +154,9 @@ public function __construct(string $appName, IAccountManager $accountManager, IEventDispatcher $eventDispatcher, IL10N $l10n, - Defaults $defaults) { + Defaults $defaults, + IDownloadManager $downloadManager + ) { parent::__construct($appName, $request, $session, $urlGenerator); $this->config = $config; @@ -161,6 +171,7 @@ public function __construct(string $appName, $this->l10n = $l10n; $this->defaults = $defaults; $this->shareManager = $shareManager; + $this->downloadManager = $downloadManager; } /** @@ -387,14 +398,14 @@ public function showShare($path = ''): TemplateResponse { $freeSpace = (INF > 0) ? INF: PHP_INT_MAX; // work around https://bugs.php.net/bug.php?id=69188 } - $hideFileList = !($share->getPermissions() & \OCP\Constants::PERMISSION_READ); + $hideFileList = !($share->getPermissions() & Constants::PERMISSION_READ); $maxUploadFilesize = $freeSpace; $folder = new Template('files', 'list', ''); $folder->assign('dir', $shareNode->getRelativePath($folderNode->getPath())); $folder->assign('dirToken', $this->getToken()); - $folder->assign('permissions', \OCP\Constants::PERMISSION_READ); + $folder->assign('permissions', Constants::PERMISSION_READ); $folder->assign('isPublic', true); $folder->assign('hideFileList', $hideFileList); $folder->assign('publicUploadEnabled', 'no'); @@ -462,6 +473,7 @@ public function showShare($path = ''): TemplateResponse { \OCP\Util::addScript('files', 'fileactionsmenu'); \OCP\Util::addScript('files', 'jquery.fileupload'); \OCP\Util::addScript('files_sharing', 'files_drop'); + \OCP\Util::addScript('files', 'dist/download'); if (isset($shareTmpl['folder'])) { // JS required for folders @@ -502,7 +514,7 @@ public function showShare($path = ''): TemplateResponse { $response->setHeaderDetails($this->l10n->t('shared by %s', [$shareTmpl['shareOwner']])); } - $isNoneFileDropFolder = $shareIsFolder === false || $share->getPermissions() !== \OCP\Constants::PERMISSION_CREATE; + $isNoneFileDropFolder = $shareIsFolder === false || $share->getPermissions() !== Constants::PERMISSION_CREATE; if ($isNoneFileDropFolder && !$share->getHideDownload()) { \OCP\Util::addScript('files_sharing', 'public_note'); @@ -538,33 +550,58 @@ public function showShare($path = ''): TemplateResponse { return $response; } + /** + * @PublicPage + * @NoCSRFRequired + * @NoSameSiteCookieRequired + */ + public function registerDownload(string $token, string $files = '', string $path = '', string $downloadStartSecret = ''): Response { + + \OC_User::setIncognitoMode(true); + + $share = $this->shareManager->getShareByToken($token); + if (!($share->getPermissions() & Constants::PERMISSION_READ)) { + return new DataResponse('Share has no read permission', Http::STATUS_FORBIDDEN); + } + if (!$this->validateShare($share)) { + return new DataResponse('Share has not been found', Http::STATUS_NOT_FOUND); + } + + $token = $this->downloadManager->register([ + 'token' => $token, + 'files' => $files, + 'path' => $path, + 'downloadStartSecret' => $downloadStartSecret, + ]); + + return new JSONResponse(['token' => $token]); + } + /** * @PublicPage * @NoCSRFRequired * @NoSameSiteCookieRequired * - * @param string $token - * @param string $files - * @param string $path - * @param string $downloadStartSecret * @return void|\OCP\AppFramework\Http\Response * @throws NotFoundException */ - public function downloadShare($token, $files = null, $path = '', $downloadStartSecret = '') { + public function downloadShare(string $downloadToken) { + $data = $this->downloadManager->retrieve($downloadToken); + \OC_User::setIncognitoMode(true); - $share = $this->shareManager->getShareByToken($token); + $share = $this->shareManager->getShareByToken($data['token']); - if (!($share->getPermissions() & \OCP\Constants::PERMISSION_READ)) { - return new \OCP\AppFramework\Http\DataResponse('Share has no read permission'); + if (!($share->getPermissions() & Constants::PERMISSION_READ)) { + return new DataResponse('Share has no read permission'); } $files_list = null; - if (!is_null($files)) { // download selected files - $files_list = json_decode($files); + if (!is_null($data['files'])) { // download selected files + $files_list = json_decode($data['files']); // in case we get only a single file if ($files_list === null) { - $files_list = [$files]; + $files_list = [$data['files']]; } // Just in case $files is a single int like '1234' if (!is_array($files_list)) { @@ -579,7 +616,6 @@ public function downloadShare($token, $files = null, $path = '', $downloadStartS $userFolder = $this->rootFolder->getUserFolder($share->getShareOwner()); $originalSharePath = $userFolder->getRelativePath($share->getNode()->getPath()); - // Single file share if ($share->getNode() instanceof \OCP\Files\File) { // Single file download @@ -591,9 +627,9 @@ public function downloadShare($token, $files = null, $path = '', $downloadStartS $node = $share->getNode(); // Try to get the path - if ($path !== '') { + if ($data['path'] !== '') { try { - $node = $node->get($path); + $node = $node->get($data['path']); } catch (NotFoundException $e) { $this->emitAccessShareHook($share, 404, 'Share not found'); return new NotFoundResponse(); @@ -628,12 +664,12 @@ public function downloadShare($token, $files = null, $path = '', $downloadStartS * the content must not be longer than 32 characters and must only contain * alphanumeric characters */ - if (!empty($downloadStartSecret) - && !isset($downloadStartSecret[32]) - && preg_match('!^[a-zA-Z0-9]+$!', $downloadStartSecret) === 1) { + if (!empty($data['downloadStartSecret']) + && !isset($data['downloadStartSecret'][32]) + && preg_match('!^[a-zA-Z0-9]+$!', $data['downloadStartSecret']) === 1) { // FIXME: set on the response once we use an actual app framework response - setcookie('ocDownloadStarted', $downloadStartSecret, time() + 20, '/'); + setcookie('ocDownloadStarted', $data['downloadStartSecret'], time() + 20, '/'); } $this->emitAccessShareHook($share); @@ -648,7 +684,7 @@ public function downloadShare($token, $files = null, $path = '', $downloadStartS } // download selected files - if (!is_null($files) && $files !== '') { + if (!is_null($data['files']) && $data['files'] !== '') { // FIXME: The exit is required here because otherwise the AppFramework is trying to add headers as well // after dispatching the request which results in a "Cannot modify header information" notice. OC_Files::get($originalSharePath, $files_list, $server_params); diff --git a/core/BackgroundJobs/CleanupDownloadTokens.php b/core/BackgroundJobs/CleanupDownloadTokens.php new file mode 100644 index 0000000000000..f9fb9a61cf5b0 --- /dev/null +++ b/core/BackgroundJobs/CleanupDownloadTokens.php @@ -0,0 +1,47 @@ + + * + * @author Arthur Schiwon + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Core\BackgroundJobs; + +use OC\BackgroundJob\TimedJob; +use OC\Files\Utils\DownloadManager; +use OCP\IConfig; + +class CleanupDownloadTokens extends TimedJob { + private const INTERVAL_MINUTES = 24 * 60; + /** @var IConfig */ + private $config; + /** @var DownloadManager */ + private $downloadManager; + + public function __construct(IConfig $config, DownloadManager $downloadManager) { + $this->interval = self::INTERVAL_MINUTES; + $this->config = $config; + $this->downloadManager = $downloadManager; + } + + protected function run($argument) { + $this->downloadManager->cleanupTokens(); + } +} diff --git a/lib/private/Files/Utils/DownloadManager.php b/lib/private/Files/Utils/DownloadManager.php new file mode 100644 index 0000000000000..f5c7991b945df --- /dev/null +++ b/lib/private/Files/Utils/DownloadManager.php @@ -0,0 +1,116 @@ + + * + * @author Arthur Schiwon + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Files\Utils; + +use OCP\Files\NotFoundException; +use OCP\Files\Utils\IDownloadManager; +use OCP\IConfig; +use OCP\Security\ISecureRandom; +use RuntimeException; +use function json_decode; +use function json_encode; + +class DownloadManager implements IDownloadManager { + /** + * Lifetime of tokens. Period of 2 days are chosen to allow continuation + * of downloads with network interruptions in mind + */ + protected const TOKEN_TTL = 24 * 60 * 2; + protected const TOKEN_PREFIX = 'dl_token_'; + + protected const FIELD_DATA = 'downloadData'; + protected const FIELD_ACTIVITY = 'lastActivity'; + + /** @var IConfig */ + private $config; + /** @var ISecureRandom */ + private $secureRandom; + + public function __construct(IConfig $config, ISecureRandom $secureRandom) { + $this->config = $config; + $this->secureRandom = $secureRandom; + } + + /** + * @inheritDoc + */ + public function register(array $data): string { + $attempts = 0; + do { + if ($attempts === 10) { + throw new RuntimeException('Failed to create unique download token'); + } + $token = $this->secureRandom->generate(15); + $attempts++; + } while ($this->config->getAppValue('core', self::TOKEN_PREFIX . $token, '') !== ''); + + $this->config->setAppValue( + 'core', + self::TOKEN_PREFIX . $token, + json_encode([ + self::FIELD_DATA => $data, + self::FIELD_ACTIVITY => time() + ]) + ); + + return $token; + } + + /** + * @inheritDoc + */ + public function retrieve(string $token): array { + $dataStr = $this->config->getAppValue('core', self::TOKEN_PREFIX . $token, ''); + if ($dataStr === '') { + throw new NotFoundException(); + } + + $data = json_decode($dataStr, true); + $data[self::FIELD_ACTIVITY] = time(); + $this->config->setAppValue('core', self::TOKEN_PREFIX . $token, json_encode($data)); + + return $data[self::FIELD_DATA]; + } + + public function cleanupTokens(): void { + $appKeys = $this->config->getAppKeys('core'); + foreach ($appKeys as $key) { + if (strpos($key, self::TOKEN_PREFIX) !== 0) { + continue; + } + $dataStr = $this->config->getAppValue('core', $key, ''); + if ($dataStr === '') { + $this->config->deleteAppValue('core', $key); + continue; + } + $data = json_decode($dataStr, true); + if (!isset($data[self::FIELD_ACTIVITY]) + || (time() - $data[self::FIELD_ACTIVITY]) > self::TOKEN_TTL + ) { + $this->config->deleteAppValue('core', $key); + } + } + } +} diff --git a/lib/private/Repair/NC21/AddCleanupDownloadTokenJob.php b/lib/private/Repair/NC21/AddCleanupDownloadTokenJob.php new file mode 100644 index 0000000000000..1be3b76a52c47 --- /dev/null +++ b/lib/private/Repair/NC21/AddCleanupDownloadTokenJob.php @@ -0,0 +1,47 @@ + + * + * @author Arthur Schiwon + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Repair\NC21; + +use OC\Core\BackgroundJobs\CleanupDownloadTokens; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class AddCleanupDownloadTokenJob implements IRepairStep { + /** @var IJobList */ + protected $jobList; + + public function __construct(IJobList $jobList) { + $this->jobList = $jobList; + } + + public function getName(): string { + return 'Add background job to cleanup download tokens'; + } + + public function run(IOutput $output) { + $this->jobList->add(CleanupDownloadTokens::class); + } +} diff --git a/lib/private/Server.php b/lib/private/Server.php index 6a1550f83e0e7..8cb73fc509f15 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -97,6 +97,7 @@ use OC\Files\Storage\StorageFactory; use OC\Files\Template\TemplateManager; use OC\Files\Type\Loader; +use OC\Files\Utils\DownloadManager; use OC\Files\View; use OC\FullTextSearch\FullTextSearchManager; use OC\Http\Client\ClientService; @@ -170,6 +171,7 @@ use OCP\Files\NotFoundException; use OCP\Files\Storage\IStorageFactory; use OCP\Files\Template\ITemplateManager; +use OCP\Files\Utils\IDownloadManager; use OCP\FullTextSearch\IFullTextSearchManager; use OCP\GlobalScale\IConfig; use OCP\Group\Events\BeforeGroupCreatedEvent; @@ -1345,6 +1347,8 @@ public function __construct($webRoot, \OC\Config $config) { $this->registerAlias(\OCP\UserStatus\IManager::class, \OC\UserStatus\Manager::class); + $this->registerAlias(IDownloadManager::class, DownloadManager::class); + $this->connectDispatcher(); } diff --git a/lib/public/Files/Utils/IDownloadManager.php b/lib/public/Files/Utils/IDownloadManager.php new file mode 100644 index 0000000000000..b2ac2fadc8eb2 --- /dev/null +++ b/lib/public/Files/Utils/IDownloadManager.php @@ -0,0 +1,64 @@ + + * + * @author Arthur Schiwon + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCP\Files\Utils; + +use OCP\Files\NotFoundException; +use RuntimeException; + +/** + * Interface IDownloadManager + * + * The usual process is to prepare a file download via POST request. Follow + * up with a GET request providing the token for best browser integration. + * Announcing the download via POST enables to hide the payload in the + * request body rather then the URL, which also lifts limitations on + * data length. + * + * @package OCP\Files + * + * @since 22.0.0 + */ +interface IDownloadManager { + + /** + * Register download data and receive a token to access it later on. + * + * The provided data will be returned on retrieve() again. The structure + * is up to the consumer, it is not being processed, but only stored by + * the manager. + * + * @throws RuntimeException + * @since 22.0.0 + */ + public function register(array $data): string; + + /** + * Retrieves the download data for the provided token. + * + * @throws NotFoundException + * @since 22.0.0 + */ + public function retrieve(string $token): array; +}