Files
uncensored-send/web/static/app.js
T

286 lines
10 KiB
JavaScript

// 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);
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 if you want to remove 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);
}
})();