// Progressive enhancement for the upload form. With JavaScript off the plain // multipart POST still works; everything here is additive. (function () { 'use strict'; var base = document.body.dataset.base || '/'; // Copy buttons appear on the server-rendered result page too, which has no // upload form, so they are wired before anything form-specific bails out. // They ship hidden so that without this script there is no dead button. function wireCopy(root) { var buttons = root.querySelectorAll('button.copy[data-copy]'); Array.prototype.forEach.call(buttons, function (button) { var target = document.getElementById(button.dataset.copy); if (!target) return; button.hidden = false; button.addEventListener('click', function () { target.select(); var done = function () { var was = button.textContent; button.textContent = 'Copied'; setTimeout(function () { button.textContent = was; }, 1500); }; if (navigator.clipboard) navigator.clipboard.writeText(target.value).then(done, function () {}); else { document.execCommand('copy'); done(); } }); }); } wireCopy(document); // Destructive buttons ask first. The attribute carries the question, so the // markup decides what is worth confirming and this stays generic. Array.prototype.forEach.call(document.querySelectorAll('[data-confirm]'), function (button) { button.addEventListener('click', function (e) { if (!window.confirm(button.dataset.confirm)) e.preventDefault(); }); }); var form = document.getElementById('upload'); if (!form) return; var fileInput = document.getElementById('file'); var tokenInput = document.getElementById('token'); var drop = document.getElementById('drop'); var dropHint = document.getElementById('drop-hint'); var progress = document.getElementById('progress'); var barFill = document.getElementById('bar-fill'); var progressText = document.getElementById('progress-text'); var submit = document.getElementById('submit'); var errorBox = document.getElementById('error'); // Limits as rendered for an anonymous caller; refreshed when a token is typed. var limits = { max_size: null, allow_vanity: false }; function formatSize(n) { if (n === null || n === undefined) return 'unlimited'; var units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'], i = 0; while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } return (i === 0 ? n : n.toFixed(2).replace(/\.00$/, '')) + ' ' + units[i]; } function showError(msg) { errorBox.textContent = msg; errorBox.hidden = !msg; } // --- credentials -------------------------------------------------------- // A token is remembered in an HttpOnly cookie the server sets, not here: // this script cannot read it back, so an injected script cannot steal it // either. The page is told who it is by the server when it renders, and the // limits panel is refreshed from /api/limits, which reads the same cookie. function refreshLimits() { var token = tokenInput ? tokenInput.value.trim() : ''; var xhr = new XMLHttpRequest(); xhr.open('GET', base + 'api/limits'); xhr.setRequestHeader('Accept', 'application/json'); // Sent only when the field holds something; otherwise the cookie answers. if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token); xhr.onload = function () { if (xhr.status !== 200) { if (xhr.status === 401 && token) showError('That token is not recognised.'); return; } showError(''); var l; try { l = JSON.parse(xhr.responseText); } catch (e) { return; } limits = l; set('limit-size', l.max_size === null ? 'unlimited' : formatSize(l.max_size)); set('limit-expiry', l.max_expiry || 'never'); set('limit-default', l.default_expiry || 'never'); set('limit-vanity', l.allow_vanity ? 'allowed' : 'requires a token'); var vanity = document.getElementById('vanity'); if (vanity) vanity.disabled = !l.allow_vanity; var expiry = document.getElementById('expiry'); if (expiry) expiry.placeholder = l.default_expiry || 'never'; }; xhr.send(); } function set(id, text) { var el = document.getElementById(id); if (el) el.textContent = text; } if (tokenInput) { var debounce; tokenInput.addEventListener('input', function () { clearTimeout(debounce); debounce = setTimeout(refreshLimits, 400); }); } refreshLimits(); // --- drag and drop ------------------------------------------------------ function describeSelection() { var f = fileInput.files[0]; dropHint.textContent = f ? f.name + ' — ' + formatSize(f.size) : 'Choose a file, or drop one here.'; } ['dragenter', 'dragover'].forEach(function (name) { drop.addEventListener(name, function (e) { e.preventDefault(); drop.classList.add('over'); }); }); ['dragleave', 'drop'].forEach(function (name) { drop.addEventListener(name, function () { drop.classList.remove('over'); }); }); drop.addEventListener('drop', function (e) { e.preventDefault(); if (!e.dataTransfer.files.length) return; // DataTransfer is the only way to put a dropped file into a file input. var dt = new DataTransfer(); dt.items.add(e.dataTransfer.files[0]); fileInput.files = dt.files; describeSelection(); }); fileInput.addEventListener('change', describeSelection); // --- upload ------------------------------------------------------------- form.addEventListener('submit', function (e) { var file = fileInput.files[0]; if (!file) return; // let the browser's own validation speak if (limits.max_size && file.size > limits.max_size) { e.preventDefault(); showError('That file is ' + formatSize(file.size) + '; the limit is ' + formatSize(limits.max_size) + '.'); return; } e.preventDefault(); showError(''); submit.disabled = true; progress.hidden = false; // FormData follows DOM order, so the token, expiry and vanity fields all // precede the file part — which is exactly what the server requires. var data = new FormData(form); var started = Date.now(); var xhr = new XMLHttpRequest(); xhr.open('POST', form.action); xhr.setRequestHeader('Accept', 'application/json'); xhr.upload.onprogress = function (ev) { if (!ev.lengthComputable) return; var pct = ev.loaded / ev.total; barFill.style.width = (pct * 100).toFixed(1) + '%'; var elapsed = (Date.now() - started) / 1000; var rate = elapsed > 0 ? ev.loaded / elapsed : 0; var eta = rate > 0 ? (ev.total - ev.loaded) / rate : 0; progressText.textContent = (pct * 100).toFixed(0) + '% — ' + formatSize(ev.loaded) + ' of ' + formatSize(ev.total) + ' — ' + formatSize(rate) + '/s' + (eta > 1 ? ', ' + formatTime(eta) + ' left' : ''); }; xhr.onload = function () { submit.disabled = false; var body; try { body = JSON.parse(xhr.responseText); } catch (err) { body = null; } if (xhr.status >= 200 && xhr.status < 300 && body) { showResult(body); } else { progress.hidden = true; showError(body && body.error ? body.error : 'Upload failed (' + xhr.status + ').'); } }; xhr.onerror = function () { submit.disabled = false; progress.hidden = true; showError('Upload failed: the connection was lost.'); }; xhr.send(data); }); function formatTime(s) { s = Math.round(s); if (s < 60) return s + 's'; var m = Math.floor(s / 60); if (m < 60) return m + 'm ' + (s % 60) + 's'; return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'; } // Mirrors web/templates/result.html, so the JavaScript and no-JavaScript // paths show the same thing. function showResult(r) { var card = el('section', 'card result'); card.appendChild(el('h1', null, 'Uploaded')); card.appendChild(el('p', 'filename', r.filename)); var links = el('div', 'links'); links.appendChild(linkField( 'Share link', 'a page showing size, expiry and a download button', 'link-info', r.info_url)); links.appendChild(linkField( 'Direct download', 'the file itself, for curl, wget or a browser', 'link-file', r.url)); card.appendChild(links); var dl = document.createElement('dl'); addRow(dl, 'Size', formatSize(r.size)); addRow(dl, 'Expires', r.expires || 'never'); addRow(dl, 'SHA-256', r.sha256, 'mono wrap'); card.appendChild(dl); var warn = el('div', 'warn'); warn.appendChild(el('h2', null, 'Delete token')); warn.appendChild(el('p', null, 'Shown once. Keep it alongside the share link: pasting it under ' + '"Remove this file" on that page deletes the file before it expires.')); warn.appendChild(el('p', 'mono wrap', r.delete_token)); card.appendChild(warn); var actions = el('p', 'actions'); var again = document.createElement('a'); again.className = 'button'; again.href = base; again.textContent = 'Upload another file'; actions.appendChild(again); card.appendChild(actions); form.parentNode.insertBefore(card, form); form.hidden = true; wireCopy(card); card.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function el(tag, cls, text) { var node = document.createElement(tag); if (cls) node.className = cls; if (text !== undefined && text !== null) node.textContent = text; return node; } // One labelled, read-only URL with a copy button beside it. function linkField(label, hint, id, value) { var field = el('div', 'field'); var span = document.createElement('span'); span.appendChild(document.createTextNode(label + ' ')); span.appendChild(el('em', null, hint)); field.appendChild(span); var row = el('div', 'copyrow'); var input = document.createElement('input'); input.type = 'text'; input.id = id; input.readOnly = true; input.value = value; row.appendChild(input); var button = el('button', 'copy', 'Copy'); button.type = 'button'; button.hidden = true; button.dataset.copy = id; row.appendChild(button); field.appendChild(row); return field; } function addRow(dl, label, value, cls) { var dt = document.createElement('dt'); dt.textContent = label; var dd = document.createElement('dd'); dd.textContent = value; if (cls) dd.className = cls; dl.appendChild(dt); dl.appendChild(dd); } })();