download: switch to unified API (fix #259, fix #262)

Signed-off-by: Varun Patil <varunpatil@ucla.edu>
cap
Varun Patil 2022-12-06 11:02:37 -08:00
parent aeb73b4a80
commit 67cc205288
4 changed files with 250 additions and 51 deletions

View File

@ -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'],

View File

@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Varun Patil <radialapps@gmail.com>
* @author Varun Patil <radialapps@gmail.com>
* @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 <http://www.gnu.org/licenses/>.
*/
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;
}
}

View File

@ -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 });
}

View File

@ -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<boolean> {
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) */