diff --git a/appinfo/routes.php b/appinfo/routes.php index a0b7c578..ffb3fde3 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -62,6 +62,9 @@ return [ ['name' => 'Video#transcode', 'url' => '/api/video/transcode/{client}/{fileid}/{profile}', 'verb' => 'GET'], ['name' => 'Video#livephoto', 'url' => '/api/video/livephoto/{fileid}', 'verb' => 'GET'], + ['name' => 'Download#request', 'url' => '/api/download', 'verb' => 'POST'], + ['name' => 'Download#file', 'url' => '/api/download/{handle}', 'verb' => 'GET'], + // Config API ['name' => 'Other#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'], diff --git a/lib/Controller/DownloadController.php b/lib/Controller/DownloadController.php new file mode 100644 index 00000000..9dc21838 --- /dev/null +++ b/lib/Controller/DownloadController.php @@ -0,0 +1,227 @@ + + * @author Varun Patil + * @license AGPL-3.0-or-later + * + * 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 OCA\Memories\Controller; + +use bantu\IniGetWrapper\IniGetWrapper; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\ISession; +use OCP\Security\ISecureRandom; + +class DownloadController extends ApiBase +{ + /** + * @NoAdminRequired + * + * @PublicPage + * + * Request to download one or more files + */ + public function request(): JSONResponse + { + // Get ids from body + $files = $this->request->getParam('files'); + if (null === $files || !\is_array($files)) { + return new JSONResponse([], Http::STATUS_BAD_REQUEST); + } + + // Store in session + $session = \OC::$server->get(ISession::class); + + // Generate random id + $handle = \OC::$server->get(ISecureRandom::class)->generate(16, ISecureRandom::CHAR_ALPHANUMERIC); + $session->set("memories_download_ids_{$handle}", $files); + + // Return id + return new JSONResponse(['handle' => $handle]); + } + + /** + * @NoAdminRequired + * + * @NoCSRFRequired + * + * @PublicPage + * + * Download one or more files + */ + public function file(string $handle): Http\Response + { + // Get ids from request + $session = \OC::$server->get(ISession::class); + $key = "memories_download_ids_{$handle}"; + $fileIds = $session->get($key); + $session->remove($key); + + if (null === $fileIds) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + /** @var int[] $fileIds */ + $fileIds = array_filter(array_map('intval', $fileIds), function (int $id): bool { + return $id > 0; + }); + + // Check if we have any valid ids + if (0 === \count($fileIds)) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + // Download single file + if (1 === \count($fileIds)) { + return $this->one($fileIds[0]); + } + + // Download multiple files + $this->multiple($fileIds); // exits + } + + /** + * Download a single file. + */ + private function one(int $fileid): Http\Response + { + $file = $this->getUserFile($fileid); + if (null === $file) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + $response = new Http\StreamResponse($file->fopen('rb')); + $response->addHeader('Content-Type', $file->getMimeType()); + $response->addHeader('Content-Disposition', 'attachment; filename="'.$file->getName().'"'); + $response->addHeader('Content-Length', $file->getSize()); + + return $response; + } + + /** + * Download multiple files. + * + * @param int[] $fileIds + */ + private function multiple(array &$fileIds) + { + // Disable time limit + $executionTime = (int) \OC::$server->get(IniGetWrapper::class)->getNumeric('max_execution_time'); + @set_time_limit(0); + + // Ensure we can abort the request if user stops it + ignore_user_abort(true); + + // Pretend the size is huge so forced zip64 + // Lookup the constructor of \OC\Streamer for more info + $size = \count($fileIds) * 1024 * 1024 * 1024 * 8; + $streamer = new \OC\Streamer($this->request, $size, \count($fileIds)); + + // Create a zip file + $streamer->sendHeaders('download'); + + // Multiple files might have the same name + // So we need to add a number to the end of the name + $nameCounts = []; + + // Send each file + foreach ($fileIds as $fileId) { + if (connection_aborted()) { + break; + } + + /** @var bool|resource */ + $handle = false; + + /** @var ?File */ + $file = null; + + try { + // This checks permissions + $file = $this->getUserFile($fileId); + if (null === $file) { + throw new \Exception('File not found'); + } + + // Open file + $handle = $file->fopen('rb'); + if (false === $handle) { + throw new \Exception('Failed to open file'); + } + + // Handle duplicate names + $name = $file->getName(); + if (isset($nameCounts[$name])) { + $nameCounts[$name] += 1; + + // add count before extension + $extpos = strrpos($name, '.'); + if (false === $extpos) { + $name .= " ({$nameCounts[$name]})"; + } else { + $name = substr($name, 0, $extpos)." ({$nameCounts[$name]})".substr($name, $extpos); + } + } else { + $nameCounts[$name] = 0; + } + + // Add file to zip + if (!$streamer->addFileFromStream( + $handle, + $name, + $file->getSize(), + $file->getMTime(), + )) { + throw new \Exception('Failed to add file to zip'); + } + } catch (\Exception $e) { + // Let the user know that something went wrong + $name = $file->getName() ?: (string) $fileId; + + // create a dummy memory file with the error message + $dummy = fopen('php://memory', 'rw+'); + fwrite($dummy, $e->getMessage()); + rewind($dummy); + + $streamer->addFileFromStream( + $dummy, + "{$name}_error.txt", + \strlen($e->getMessage()), + $file->getMTime(), + ); + + // close the dummy file + fclose($dummy); + } finally { + if (false !== $handle) { + fclose($handle); + } + } + } + + // Restore time limit + @set_time_limit($executionTime); + + // Done + $streamer->finalize(); + + exit; + } +} diff --git a/src/services/API.ts b/src/services/API.ts index 418a4a91..3af7d8fb 100644 --- a/src/services/API.ts +++ b/src/services/API.ts @@ -87,6 +87,14 @@ export class API { return tok(gen(`${BASE}/video/livephoto/{fileid}`, { fileid })); } + static DOWNLOAD_REQUEST() { + return tok(gen(`${BASE}/download`)); + } + + static DOWNLOAD_FILE(handle: number) { + return tok(gen(`${BASE}/download/{handle}`, { handle })); + } + static CONFIG(setting: string) { return gen(`${BASE}/config/{setting}`, { setting }); } diff --git a/src/services/dav/download.ts b/src/services/dav/download.ts index 3c47c1e2..727076e2 100644 --- a/src/services/dav/download.ts +++ b/src/services/dav/download.ts @@ -1,42 +1,24 @@ -import * as base from "./base"; import { generateUrl } from "@nextcloud/router"; +import axios from "@nextcloud/axios"; import { showError } from "@nextcloud/dialogs"; import { translate as t } from "@nextcloud/l10n"; import { IPhoto } from "../../types"; import { getAlbumFileInfos } from "./albums"; +import { API } from "../API"; /** - * Download a file - * - * @param fileNames - The file's names + * Download files */ -export async function downloadFiles(fileNames: string[]): Promise { - const randomToken = Math.random().toString(36).substring(2); +export async function downloadFiles(fileIds: number[]) { + if (!fileIds.length) return; - const params = new URLSearchParams(); - params.append("files", JSON.stringify(fileNames)); - params.append("downloadStartSecret", randomToken); + const res = await axios.post(API.DOWNLOAD_REQUEST(), { files: fileIds }); + if (res.status !== 200 || !res.data.handle) { + showError(t("memories", "Failed to download files")); + return; + } - let downloadURL = generateUrl(`/apps/files/ajax/download.php?${params}`); - - window.location.href = `${downloadURL}downloadStartSecret=${randomToken}`; - - return new Promise((resolve) => { - const waitForCookieInterval = setInterval(() => { - const cookieIsSet = document.cookie - .split(";") - .map((cookie) => cookie.split("=")) - .findIndex( - ([cookieName, cookieValue]) => - cookieName === "ocDownloadStarted" && cookieValue === randomToken - ); - - if (cookieIsSet) { - clearInterval(waitForCookieInterval); - resolve(true); - } - }, 50); - }); + window.location.href = API.DOWNLOAD_FILE(res.data.handle); } /** @@ -52,28 +34,7 @@ export async function downloadPublicPhoto(photo: IPhoto) { * @param photos list of photos */ export async function downloadFilesByPhotos(photos: IPhoto[]) { - if (photos.length === 0) { - return; - } - - // Public files - if (vuerouter.currentRoute.name === "folder-share") { - for (const photo of photos) { - await downloadPublicPhoto(photo); - } - return; - } - - // Get files to download - const fileInfos = await base.getFiles(photos); - if (fileInfos.length !== photos.length) { - showError(t("memories", "Failed to download some files.")); - } - if (fileInfos.length === 0) { - return; - } - - await downloadFiles(fileInfos.map((f) => f.filename)); + await downloadFiles(photos.map((f) => f.fileid)); } /** Get URL to download one file (e.g. for video streaming) */