Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chunked file uploads #6421

Merged
merged 13 commits into from
Jun 28, 2024
2 changes: 1 addition & 1 deletion config/api/routes/files.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
// move_uploaded_file() not working with unit test
// @codeCoverageIgnoreStart
return $this->upload(function ($source, $filename) use ($path) {
// move the source file from the temp dir
// move the source file to the content folder
return $this->parent($path)->createFile([
'content' => [
'sort' => $this->requestBody('sort')
Expand Down
61 changes: 59 additions & 2 deletions panel/src/helpers/upload.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { random } from "./string.js";

/**
* Uploads a file using XMLHttpRequest.
*
Expand All @@ -8,12 +10,13 @@
* @param {string} params.filename - filename to use for the upload
* @param {Object} params.headers - request headers
* @param {Object} params.attributes - additional attributes
* @param {AbortSignal} params.abort - signal to abort the upload request
* @param {Function} params.progress - callback whenever the progress changes
* @param {Function} params.complete - callback when upload completed
* @param {Function} params.success - callback when upload succeeded
* @param {Function} params.error - callback when upload failed
*/
export default async (file, params) => {
export async function upload(file, params) {
return new Promise((resolve, reject) => {
const defaults = {
url: "/",
Expand Down Expand Up @@ -94,6 +97,60 @@ export default async (file, params) => {
xhr.setRequestHeader(header, options.headers[header]);
}

// abort the XHR when abort signal is triggered
options.abort?.addEventListener("abort", () => {
xhr.abort();
});

xhr.send(data);
});
};
}

/**
* Uploads a file in chunks
* @param {File} file - file to upload
* @param {Object} params - upload options (see `upload` method for details)
* @param {number} size - chunk size in bytes (default: 5 MB)
*/
export async function uploadAsChunks(file, params, size = 5242880) {
const parts = Math.ceil(file.size / size);
const id = random(4).toLowerCase();
let response;

for (let i = 0; i < parts; i++) {
// break if upload got aborted in the meantime
if (params.abort?.aborted) {
break;
}

// slice chunk at the right positions
const start = i * size;
const end = Math.min(start + size, file.size);
const chunk = parts > 1 ? file.slice(start, end, file.type) : file;

// when more than one part, add flag to
// recognize chunked upload and its last chunk
if (parts > 1) {
params.headers = {
...params.headers,
"Upload-Length": file.size,
"Upload-Offset": start,
"Upload-Id": id
};
}

response = await upload(chunk, {
...params,
// calculate the total progress based on chunk progress
progress: (xhr, chunk, percent) => {
const progress = chunk.size * (percent / 100);
const total = (start + progress) / file.size;
params.progress(xhr, file, Math.round(total * 100));
}
});
}

return response;
}

export default upload;
32 changes: 22 additions & 10 deletions panel/src/panel/upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { uuid } from "@/helpers/string";
import State from "./state.js";
import listeners from "./listeners.js";
import queue from "@/helpers/queue.js";
import upload from "@/helpers/upload.js";
import { uploadAsChunks } from "@/helpers/upload.js";
import { extension, name, niceSize } from "@/helpers/file.js";

export const defaults = () => {
return {
abort: null,
accept: "*",
attributes: {},
files: [],
Expand Down Expand Up @@ -41,6 +42,9 @@ export default (panel) => {
cancel() {
this.emit("cancel");

// abort any ongoing requests
this.abort?.abort();

// emit complete event if any files have been completed,
// e.g. when first submit/upload yielded any errors and
// now cancel was clicked, but already some files have
Expand Down Expand Up @@ -266,6 +270,9 @@ export default (panel) => {
throw new Error("The upload URL is missing");
}

// prepare the abort controller
this.abort = new AbortController();

// gather upload tasks for all files
const files = [];

Expand Down Expand Up @@ -312,15 +319,20 @@ export default (panel) => {
},
async upload(file, attributes) {
try {
const response = await upload(file.src, {
attributes: attributes,
headers: { "x-csrf": panel.system.csrf },
filename: file.name + "." + file.extension,
url: this.url,
progress: (xhr, src, progress) => {
file.progress = progress;
}
});
const response = await uploadAsChunks(
file.src,
{
abort: this.abort.signal,
attributes: attributes,
filename: file.name + "." + file.extension,
headers: { "x-csrf": panel.system.csrf },
url: this.url,
progress: (xhr, src, progress) => {
file.progress = progress;
}
},
panel.config.upload
);

file.completed = true;
file.model = response.data;
Expand Down
129 changes: 1 addition & 128 deletions src/Api/Api.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@
use Kirby\Http\Route;
use Kirby\Http\Router;
use Kirby\Toolkit\Collection as BaseCollection;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Pagination;
use Kirby\Toolkit\Str;
use Throwable;

/**
Expand Down Expand Up @@ -611,131 +609,6 @@ public function upload(
bool $single = false,
bool $debug = false
): array {
$trials = 0;
$uploads = [];
$errors = [];
$files = $this->requestFiles();

// get error messages from translation
$errorMessages = [
UPLOAD_ERR_INI_SIZE => I18n::translate('upload.error.iniSize'),
UPLOAD_ERR_FORM_SIZE => I18n::translate('upload.error.formSize'),
UPLOAD_ERR_PARTIAL => I18n::translate('upload.error.partial'),
UPLOAD_ERR_NO_FILE => I18n::translate('upload.error.noFile'),
UPLOAD_ERR_NO_TMP_DIR => I18n::translate('upload.error.tmpDir'),
UPLOAD_ERR_CANT_WRITE => I18n::translate('upload.error.cantWrite'),
UPLOAD_ERR_EXTENSION => I18n::translate('upload.error.extension')
];

if (empty($files) === true) {
$postMaxSize = Str::toBytes(ini_get('post_max_size'));
$uploadMaxFileSize = Str::toBytes(ini_get('upload_max_filesize'));

if ($postMaxSize < $uploadMaxFileSize) {
throw new Exception(
I18n::translate(
'upload.error.iniPostSize',
'The uploaded file exceeds the post_max_size directive in php.ini'
)
);
}

throw new Exception(
I18n::translate(
'upload.error.noFiles',
'No files were uploaded'
)
);
}

foreach ($files as $upload) {
if (
isset($upload['tmp_name']) === false &&
is_array($upload) === true
) {
continue;
}

$trials++;

try {
if ($upload['error'] !== 0) {
throw new Exception(
$errorMessages[$upload['error']] ??
I18n::translate('upload.error.default', 'The file could not be uploaded')
);
}

// get the extension of the uploaded file
$extension = F::extension($upload['name']);

// try to detect the correct mime and add the extension
// accordingly. This will avoid .tmp filenames
if (
empty($extension) === true ||
in_array($extension, ['tmp', 'temp']) === true
) {
$mime = F::mime($upload['tmp_name']);
$extension = F::mimeToExtension($mime);
$filename = F::name($upload['name']) . '.' . $extension;
} else {
$filename = basename($upload['name']);
}

$source = dirname($upload['tmp_name']) . '/' . uniqid() . '.' . $filename;

// move the file to a location including the extension,
// for better mime detection
if (
$debug === false &&
move_uploaded_file($upload['tmp_name'], $source) === false
) {
throw new Exception(
I18n::translate('upload.error.cantMove')
);
}

$data = $callback($source, $filename);

if (is_object($data) === true) {
$data = $this->resolve($data)->toArray();
}

$uploads[$upload['name']] = $data;
} catch (Exception $e) {
$errors[$upload['name']] = $e->getMessage();
}

if ($single === true) {
break;
}
}

// return a single upload response
if ($trials === 1) {
if (empty($errors) === false) {
return [
'status' => 'error',
'message' => current($errors)
];
}

return [
'status' => 'ok',
'data' => current($uploads)
];
}

if (empty($errors) === false) {
return [
'status' => 'error',
'errors' => $errors
];
}

return [
'status' => 'ok',
'data' => $uploads
];
return (new Upload($this, $single, $debug))->process($callback);
}
}
Loading
Loading