383 lines
12 KiB
JavaScript
383 lines
12 KiB
JavaScript
const FILE_CHUNK_SIZE = 16384;
|
|
const MAX_FILES = 256;
|
|
const SAMPLE_WINDOW = 100;
|
|
const STALL_THRESHOLD = 1000;
|
|
|
|
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;
|
|
|
|
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) {
|
|
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);
|
|
}
|
|
} else {
|
|
if (msg.data === 'ack') {
|
|
sendData();
|
|
} else {
|
|
console.error('Received unexpected message from server instead of ack', msg.data);
|
|
displayError();
|
|
socket.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
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 (fileIndex >= files.length) {
|
|
finishSending();
|
|
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(); }
|
|
}
|
|
|
|
updateProgress();
|
|
} else {
|
|
fileIndex += 1;
|
|
byteIndex = 0;
|
|
sendData();
|
|
}
|
|
}
|
|
|
|
function updateProgress() {
|
|
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%";
|
|
}
|
|
}
|