transbeam/static/js/upload.js

396 lines
12 KiB
JavaScript

const FILE_CHUNK_SIZE = 16384;
const MAX_FILES = 256;
const SAMPLE_WINDOW = 100;
const STALL_THRESHOLD = 1000;
const MAX_WS_BUFFER = 1048576;
const WS_BUFFER_DELAY = 10;
let files = [];
let socket;
let fileIndex = 0;
let byteIndex = 0;
let bytesSent = 0;
let totalBytes = 0;
let timestamps = [];
let maxSize = null;
let uploadPassword;
let messageBox;
let fileInput;
let fileList;
let uploadButton;
let lifetimeInput;
let collectionNameInput;
let downloadCode;
let progressPercentage;
let progressSize;
let progressRate;
let progressEta;
let progressBar;
let animationID = null;
window.addEventListener('beforeunload', (event) => {
if (socket && socket.readyState !== 3) {
event.returnValue = "Upload is still in progress!";
}
});
document.addEventListener('DOMContentLoaded', () => {
document.body.className = 'landing';
messageBox = document.getElementById('message');
fileInput = document.getElementById('file_input');
fileList = document.getElementById('file_list');
uploadButton = document.getElementById('upload_button');
lifetimeInput = document.getElementById('lifetime');
collectionNameInput = document.getElementById('collection_name');
downloadCode = document.getElementById('download_code');
progressPercentage = document.getElementById('progress_percentage');
progressSize = document.getElementById('progress_size');
progressRate = document.getElementById('progress_rate');
progressEta = document.getElementById('progress_eta');
progressBar = document.getElementById('progress_bar');
fetch('/upload/limits.json')
.then((res) => res.json())
.then((limits) => {
if (limits.open === false) {
document.body.className = 'uploads_closed landing';
return;
}
maxSize = limits.max_size;
updateMaxLifetime(limits.max_lifetime);
});
const uploadPasswordInput = document.getElementById('upload_password');
const uploadPasswordForm = document.getElementById('upload_password_form');
uploadPasswordForm.addEventListener('submit', (e) => {
e.preventDefault();
uploadPassword = uploadPasswordInput.value;
fetch('/upload/check_password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: uploadPassword }),
}).then((res) => {
if (res.ok) {
updateFiles();
} else {
messageBox.textContent = (res.status === 403) ? 'Incorrect password' : 'An error occurred';
uploadPasswordInput.value = '';
document.body.className = 'error landing';
}
});
});
fileInput.addEventListener('input', () => {
for (const file of fileInput.files) { addFile(file); }
updateFiles();
fileInput.value = '';
});
uploadButton.addEventListener('click', beginUpload);
const downloadCodeContainer = document.getElementById('download_code_container');
downloadCodeContainer.addEventListener('click', () => {
const downloadUrl = new URL(`download?code=${downloadCode.textContent}`, window.location);
navigator.clipboard.writeText(downloadUrl.href);
downloadCodeContainer.className = 'copied';
});
downloadCodeContainer.addEventListener('mouseleave', () => {
downloadCodeContainer.className = '';
});
});
function updateFiles() {
const fileInputMessage = document.getElementById('file_input_message');
totalBytes = files.reduce((acc, file) => acc + file.size, 0);
fileInput.disabled = (files.length >= MAX_FILES || (maxSize !== null && totalBytes >= maxSize));
let extraClasses = '';
if (maxSize !== null && totalBytes > maxSize) {
uploadButton.disabled = true;
displayError(`The maximum size for uploads is ${displaySize(maxSize)}`);
extraClasses = ' error';
} else {
uploadButton.disabled = false;
}
if (files.length === 0) {
fileInputMessage.textContent = 'Select files to upload...';
document.body.className = 'no_files selecting' + extraClasses;
} else {
if (files.length === 1) {
extraClasses += " one_file";
}
fileInputMessage.textContent = 'Select more files to upload...';
uploadButton.textContent = `Upload ${files.length} file${files.length > 1 ? 's' : ''} (${displaySize(totalBytes)})`;
document.body.className = 'selecting' + extraClasses;
}
}
function addFile(newFile) {
if (files.length >= MAX_FILES) { return; }
if (files.some((oldFile) => newFile.name === oldFile.name)) { return; }
files.push(newFile);
addListEntry(newFile);
}
function addListEntry(file) {
const listEntry = document.createElement('tr');
const deleteButtonCell = document.createElement('td');
deleteButtonCell.className = 'delete_button';
deleteButtonCell.addEventListener('click', () => {
removeFile(file.name);
listEntry.remove();
updateFiles();
});
const sizeCell = document.createElement('td');
sizeCell.className = 'file_size';
sizeCell.textContent = displaySize(file.size);
const nameCell = document.createElement('td');
nameCell.className = 'file_name';
nameCell.textContent = file.name;
listEntry.appendChild(deleteButtonCell);
listEntry.appendChild(sizeCell);
listEntry.appendChild(nameCell);
fileList.appendChild(listEntry);
}
function removeFile(name) {
files = files.filter((file) => file.name !== name);
}
function beginUpload() {
if (files.length === 0 || files.length > MAX_FILES) { return; }
if (socket && socket.readyState !== 3) { return; }
fileIndex = 0;
byteIndex = 0;
bytesSent = 0;
timestamps = [];
let websocketUrl = new URL('upload', window.location);
websocketUrl.protocol = (window.location.protocol === 'http:') ? 'ws:' : 'wss:';
socket = new WebSocket(websocketUrl);
socket.addEventListener('open', sendManifest);
socket.addEventListener('message', handleMessage);
socket.addEventListener('close', handleClose);
}
function sendManifest() {
const lifetime = parseInt(lifetimeInput.value);
const collection_name = collectionNameInput.value || null;
const fileMetadata = files.map((file) => ({
name: file.name,
size: file.size,
modtime: file.lastModified,
}));
socket.send(JSON.stringify({
files: fileMetadata,
lifetime,
collection_name,
password: uploadPassword,
}));
}
function handleMessage(msg) {
if (bytesSent > 0) {
console.warn('Received unexpected message from server during upload', msg.data);
return;
}
let reply;
try {
reply = JSON.parse(msg.data);
} catch (error) {
socket.close();
displayError('Received an invalid response from the server');
console.error(error);
return;
}
if (reply.type === 'ready') {
downloadCode.textContent = reply.code;
updateProgress();
document.body.className = 'uploading';
sendData();
return;
}
// we're going to display a more useful error message
socket.removeEventListener('close', handleClose);
socket.close();
if (reply.type === 'too_big') {
maxSize = reply.max_size;
updateFiles();
} else if (reply.type === 'too_long') {
updateMaxLifetime(reply.max_lifetime);
displayError(`The maximum retention time for uploads is ${reply.max_lifetime} days`);
} else if (reply.type === 'incorrect_password') {
messageBox.textContent = ('Incorrect password');
document.body.className = 'error landing';
} else if (reply.type === 'error') {
displayError(reply.details);
}
}
function updateMaxLifetime(lifetime) {
let options = Array.from(lifetimeInput.options);
options.reverse();
for (const option of options) {
if (option.value > lifetime) {
option.disabled = true;
} else {
option.selected = true;
break;
}
}
}
function sendData() {
if (socket.readyState !== 1) {
return;
}
while (true) {
if (fileIndex >= files.length) {
finishSending();
return;
}
if (socket.bufferedAmount >= MAX_WS_BUFFER) {
setTimeout(sendData, WS_BUFFER_DELAY);
return;
}
const currentFile = files[fileIndex];
if (byteIndex < currentFile.size) {
const endpoint = Math.min(byteIndex+FILE_CHUNK_SIZE, currentFile.size);
const data = currentFile.slice(byteIndex, endpoint);
socket.send(data);
byteIndex = endpoint;
bytesSent += data.size;
// It's ok if the monotonically increasing fields like
// percentage are updating super quickly, but it's awkward for
// rate and ETA
const now = Date.now() / 1000;
if (timestamps.length === 0 || now - timestamps.at(-1)[0] > 1) {
timestamps.push([now, bytesSent]);
if (timestamps.length > SAMPLE_WINDOW) { timestamps.shift(); }
}
if (animationID === null) {
animationID = requestAnimationFrame(updateProgress);
}
} else {
fileIndex += 1;
byteIndex = 0;
}
}
}
function updateProgress() {
animationID = null;
let percentage;
if (totalBytes === 0) {
percentage = "0%";
} else {
percentage = `${(bytesSent*100/totalBytes).toFixed(1)}%`;
}
progressPercentage.textContent = percentage;
progressSize.textContent = `${displaySize(bytesSent)}/${displaySize(totalBytes)}`;
if (timestamps.length >= 2) {
const start = timestamps.at(0);
const end = timestamps.at(-1);
const rate = (end[1] - start[1])/(end[0] - start[0]);
progressRate.textContent = `${displaySize(rate)}/s`;
if (rate > STALL_THRESHOLD) {
// Use the value from timestamps rather than bytesSent to avoid awkward UI thrashing
const remaining = (totalBytes - end[1]) / rate;
progressEta.textContent = `${displayTime(remaining)} remaining`;
} else {
progressEta.textContent = "stalled";
}
} else {
progressRate.textContent = "???B/s";
progressEta.textContent = "??? remaining";
}
progressBar.style.backgroundSize = percentage;
const fileEntries = Array.from(fileList.children);
for (entry of fileEntries.slice(0, fileIndex)) {
entry.style.backgroundSize = "100%";
}
if (fileIndex < files.length) {
const currentFile = files[fileIndex];
if (currentFile.size > 0) {
fileEntries[fileIndex].style.backgroundSize = `${(byteIndex*100/currentFile.size)}%`;
}
}
}
function handleClose(e) {
console.log('Websocket closed', e);
if (fileIndex >= files.length) {
displayCompletion();
} else {
let error;
if (e.code === 1011) {
if (e.reason) {
error = `Server error: ${e.reason}`;
} else {
error = "A server error has occurred."
}
} else if (e.reason) {
error = e.reason;
}
displayError(error);
}
}
function finishSending() {
if (socket.bufferedAmount > 0) {
setTimeout(finishSending, 1000);
return;
}
socket.close();
displayCompletion();
}
function displayCompletion() {
messageBox.textContent = 'Upload complete!';
document.body.className = 'completed';
removePerFileProgressBars(); // completed file backgrounds are handled in CSS
}
function displayError(error) {
messageBox.textContent = error || 'An error has occurred.';
document.body.className = 'selecting error';
removePerFileProgressBars();
}
function removePerFileProgressBars() {
const fileEntries = Array.from(fileList.children);
for (entry of fileEntries) {
entry.style.backgroundSize = "0%";
}
}