frontend/app/DeliverableUploader/UploadService.ts (232 lines of code) (raw):
import axios from "axios";
import { FileEntry } from "./FileEntry";
import shastream from "sha1-stream";
import crypto from "crypto";
//corresponds to UploadSlot in the deliverable-receiver project
interface UploadSlot {
uuid: string;
upload_path_relative: string;
project_id: number;
expiry: number;
}
/**
* attempts to initiate an upload and return the uuid of it.
* the promise fails on error and must be caught
* @param projectId project id that the upload is for
* @param forPath path that the files should go to
*/
async function InitiateUpload(
projectId: number,
forPath: string
): Promise<string> {
try {
const payload = JSON.stringify({
project_id: projectId,
drop_folder: forPath,
});
const response = await axios({
method: "post",
url: "/deliverable-receiver/initiate",
baseURL: "/",
data: payload,
headers: {
"Content-Type": "application/json",
},
});
if (response.status === 200) {
const slotInfo = <UploadSlot>response.data.result;
return slotInfo.uuid;
} else {
throw `unexpected response code ${response.status}`;
}
} catch (error) {
console.error("Could not initiate upload: ", error);
if (error.response) {
throw `server error ${error.response.status}: ${error.response.data}`;
} else if (error.request) {
throw `no response from server`;
} else {
throw `internal error, see console log`;
}
}
}
async function ChunkedUploadFromEntry(
entry: FileEntry,
index: number,
uploadSlotId: string,
chunkSize: number,
updateCb: (updated: FileEntry, index: number) => void
) {
const uploadChunk = async (chunkIndex: number) => {
const lastByte =
(chunkIndex + 1) * chunkSize > entry.rawFile.size
? entry.rawFile.size
: (chunkIndex + 1) * chunkSize;
const blob = entry.rawFile.slice(chunkIndex * chunkSize, lastByte);
let localEntry = Object.assign([], entry);
let response: Response;
const targetUrl = `/deliverable-receiver/upload?uploadId=${uploadSlotId}&fileName=${entry.filename}`;
try {
const token = localStorage.getItem("pluto:access-token");
response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
Authorization: `Bearer ${token}`,
Range: `bytes=${chunkIndex * chunkSize}-${lastByte}/${
entry.rawFile.size
}`,
},
body: blob,
});
} catch (err) {
console.error("Upload failed: ", err);
localEntry.lastError = err.toString();
updateCb(localEntry, index);
throw "upload failed";
}
switch (response.status) {
case 413:
localEntry.lastError = "chunk size is too large";
updateCb(localEntry, index);
throw "upload failed";
case 500 | 400:
try {
const responseBody = await response.json();
if (responseBody.detail) {
localEntry.lastError = responseBody.detail;
} else {
localEntry.lastError = responseBody.toString();
}
updateCb(localEntry, index);
} catch (err) {
console.warn("could not parse error json: ", err);
localEntry.lastError = "server error, see browser console";
updateCb(localEntry, index);
}
throw "upload failed";
case 503:
localEntry.lastError = "server not responding, retrying...";
updateCb(localEntry, index);
return new Promise((resolve, reject) =>
window.setTimeout(() => {
uploadChunk(chunkIndex)
.then(resolve)
.catch((err) => reject(err));
}, 2000)
);
case 403:
localEntry.lastError = "permission denied";
updateCb(localEntry, index);
throw "upload failed";
case 200:
const responseBody = await response.json();
console.log(responseBody);
const fractionComplete =
((chunkIndex + 1) * chunkSize) / entry.rawFile.size;
localEntry.progress = Math.round(fractionComplete * 100.0);
updateCb(localEntry, index);
}
};
const chunksCount = Math.ceil(entry.rawFile.size / chunkSize);
console.log(
"chunked upload: there are ",
chunksCount,
" chunks for file of size ",
entry.rawFile.size
);
for (let i = 0; i < chunksCount; i++) {
console.log("uploading chunk ", i);
await uploadChunk(i);
}
}
async function GetSHA(entry: FileEntry): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash("sha1");
const reader = entry.rawFile.stream().getReader();
reader.read().then(function processContent({ done, value }): Promise<void> {
if (done) {
resolve(hash.digest().toString("base64"));
return new Promise<void>((resolve, reject) => resolve());
}
hash.update(value);
return reader
.read()
.then(processContent)
.catch((err) => reject(err));
});
});
}
async function RequestValidation(
entry: FileEntry,
uploadSlotId: string,
sha1sum: string,
attempt: number = 0
): Promise<boolean> {
const targetUrl = `/deliverable-receiver/validate?uploadId=${uploadSlotId}&fileName=${
entry.filename
}&sum=${encodeURIComponent(sha1sum)}`;
const token = localStorage.getItem("pluto:access-token");
const response = await fetch(targetUrl, {
method: "GET",
headers: {
"Content-Type": "application/octet-stream",
Authorization: `Bearer ${token}`,
},
});
const content = await response.text();
switch (response.status) {
case 200:
console.log("Server reported successful validation for ", entry.filename);
return true;
case 409:
console.log(
"Server reported that checksums don't match for ",
entry.filename,
": ",
content
);
return false;
case 403:
console.error(
"Server reported forbidden. Assuming expired credential and trying again in 3s..."
);
return new Promise<boolean>((resolve, reject) =>
window.setTimeout(() => {
RequestValidation(entry, uploadSlotId, sha1sum, attempt)
.then(resolve)
.catch(reject);
}, 3000)
);
default:
console.log("Unexpected error: ", content);
let msg = content;
try {
const json = JSON.parse(content);
msg = json.detail;
} catch (err) {
console.warn("could not parse error report as json: ", err);
}
throw msg;
}
}
async function UploadAndValidate(
entry: FileEntry,
index: number,
uploadSlotId: string,
chunkSize: number,
updateCb: (updated: FileEntry, index: number) => void
) {
const [uploadResult, sha] = await Promise.all([
ChunkedUploadFromEntry(entry, index, uploadSlotId, chunkSize, updateCb),
GetSHA(entry),
]);
console.log("Upload and local checksum completed: ", sha);
const validationResult = await RequestValidation(entry, uploadSlotId, sha);
if (validationResult) {
console.log("Upload validated successfully");
} else {
console.log("Validation failed");
}
return validationResult;
}
export {
ChunkedUploadFromEntry,
GetSHA,
InitiateUpload,
RequestValidation,
UploadAndValidate,
};