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%"; } }