Files
itflow/js/guest_share.js

417 lines
16 KiB
JavaScript

// A shared link, as the person it was sent to sees it (guest/share.php).
//
// The link's secret is after the # - the browser never sends it to the
// server with the page - or, for a link made before 2.9.0, key= and ek= in
// the query string. Every step posts it to guest/share_ajax.php: what the
// link is, the passphrase or emailed code it needs, then the content once
// Reveal is clicked (a mail filter fetching the link never clicks). The page
// shows one state at a time and fills it in as text; only a document's HTML,
// cleaned on the server, goes in as HTML.
(function () {
const page = document.querySelector('[data-share-page]');
if (!page) {
return;
}
const query = new URLSearchParams(window.location.search);
const id = page.dataset.id;
const key = window.location.hash.replace(/^#/, '') || query.get('key') || '';
const ek = query.get('ek') || '';
const typeIcons = { Credential: 'fa-key', Note: 'fa-user-secret', Document: 'fa-file-alt', File: 'fa-paperclip', Export: 'fa-book', Request: 'fa-paper-plane' };
// These come down as a download rather than showing on the page
const downloads = ['File', 'Export'];
let link = null;
let content = null;
let otpTimer = null;
let barTimer = null;
function $(selector, root) {
return (root || page).querySelector(selector);
}
function $$(selector, root) {
return Array.from((root || page).querySelectorAll(selector));
}
function show(state) {
$$('[data-share-state]').forEach(function (el) {
el.hidden = el.dataset.shareState !== state;
});
// Wider for reading a document
page.classList.toggle('itflow-share-page-wide', state === 'document');
const shown = $('[data-share-state="' + state + '"]');
const first = shown && shown.querySelector('input:not([readonly]):not([type=hidden]), textarea');
if (first && window.matchMedia('(pointer: fine)').matches) {
first.focus();
}
return shown;
}
function post(action, data) {
const body = Object.assign({ action: action, id: id, key: key, ek: ek }, data || {});
return fetch('/guest/share_ajax.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
cache: 'no-store',
body: JSON.stringify(body)
}).then(function (res) {
return res.json().catch(function () {
return { error: 'Something went wrong - try again in a moment.' };
});
}, function () {
return { error: 'Could not reach the server - check your connection and try again.' };
});
}
function gone(reason) {
$('[data-share-gone-reason]').textContent = reason || 'It may have expired, been used up or been turned off.';
stopOtp();
show('gone');
}
function error(state, message) {
const box = $('[data-share-state="' + state + '"] [data-share-error]');
if (box) {
box.textContent = message;
box.hidden = !message;
}
}
function note(root, text) {
const box = $('[data-share-note]', root);
if (box) {
box.textContent = text || '';
box.hidden = !text;
}
}
function busy(button, on) {
if (!button) {
return;
}
if (on) {
button.dataset.label = button.innerHTML;
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>One moment...';
} else if (button.dataset.label) {
button.disabled = false;
button.innerHTML = button.dataset.label;
}
}
function copy(button, text) {
const done = function (message) {
if (typeof flashTooltip === 'function') {
flashTooltip(button, message);
}
};
const write = typeof itflowCopyText === 'function'
? itflowCopyText(text, button)
: navigator.clipboard.writeText(text);
write.then(function () { done('Copied!'); }, function () { done('Copy it by hand'); });
}
// What the link is, and what it needs - nothing secret yet, and nothing used up
function open(data) {
if (data.error) {
gone(data.error);
return;
}
if (data.state === 'gone') {
gone(data.reason);
return;
}
link = data;
if (!data.verified && data.protect === 'passphrase') {
const form = show('passphrase');
$('[data-share-intro]', form).textContent = data.intro;
return;
}
if (!data.verified && data.protect === 'code') {
const form = show('code');
$('[data-share-intro]', form).textContent = data.intro;
$('[data-share-masked-email]', form).textContent = data.masked_email;
return;
}
if (data.type === 'Request') {
const form = show('request');
$('[data-share-title]', form).textContent = data.title;
$('[data-share-intro]', form).textContent = data.intro;
note(form, data.note);
return;
}
const ready = show('ready');
$('[data-share-type-icon]', ready).className = 'fas ' + (typeIcons[data.type] || 'fa-share-alt');
$('[data-share-title]', ready).textContent = data.title;
$('[data-share-intro]', ready).textContent = data.intro;
$('[data-share-limits]', ready).textContent = data.limits;
$('[data-share-reveal-label]', ready).textContent = data.type === 'Export' ? 'Download the PDF' : (data.type === 'File' ? 'Download' : (data.type === 'Document' ? 'Open the document' : 'Reveal'));
$('[data-share-reveal]', ready).querySelector('i').className = 'fas me-2 ' + (downloads.indexOf(data.type) !== -1 ? 'fa-download' : 'fa-eye');
$('[data-share-reveal-hint]', ready).textContent = data.hint || '';
note(ready, data.note);
error('ready', '');
}
// The passphrase
$('[data-share-state="passphrase"]').addEventListener('submit', function (e) {
e.preventDefault();
const form = e.currentTarget;
const button = form.querySelector('button[type=submit]');
busy(button, true);
post('passphrase', { passphrase: form.passphrase.value }).then(function (data) {
busy(button, false);
if (data.state === 'gone') {
gone(data.reason);
} else if (data.error) {
error('passphrase', data.error);
form.passphrase.select();
} else {
form.passphrase.value = '';
open(data);
}
});
});
// The emailed code
function sendCode(button) {
const form = $('[data-share-state="code"]');
busy(button, true);
post('send_code').then(function (data) {
busy(button, false);
if (data.state === 'gone') {
gone(data.reason);
return;
}
error('code', data.error || '');
if (!data.error) {
$('[data-share-code-sent]', form).hidden = false;
$('[data-share-send-code]', form).hidden = true;
$('[data-share-code-entry]', form).hidden = false;
form.code.focus();
}
});
}
$('[data-share-send-code]').addEventListener('click', function (e) {
sendCode(e.currentTarget);
});
$('[data-share-resend]').addEventListener('click', function (e) {
sendCode(e.currentTarget);
});
$('[data-share-state="code"]').addEventListener('submit', function (e) {
e.preventDefault();
const form = e.currentTarget;
const button = form.querySelector('button[type=submit]');
busy(button, true);
post('code', { code: form.code.value }).then(function (data) {
busy(button, false);
if (data.state === 'gone') {
gone(data.reason);
} else if (data.error) {
error('code', data.error);
form.code.select();
} else {
form.code.value = '';
open(data);
}
});
});
// Reveal - this is what uses a view
$('[data-share-reveal]').addEventListener('click', function (e) {
const button = e.currentTarget;
if (downloads.indexOf(link.type) !== -1) {
download(button);
return;
}
busy(button, true);
post('reveal').then(function (data) {
busy(button, false);
if (data.state === 'gone') {
gone(data.reason);
} else if (data.error) {
error('ready', data.error);
} else {
reveal(data);
}
});
});
function reveal(data) {
content = data;
if (data.type === 'Credential') {
const view = show('credential');
$('[data-share-name]', view).textContent = data.name;
$('[data-share-uri-row]', view).hidden = !data.uri;
$('[data-share-uri]', view).value = data.uri || '';
const open = $('[data-share-uri-open]', view);
open.hidden = !/^https?:\/\//i.test(data.uri || '');
open.href = open.hidden ? '#' : data.uri;
$('[data-share-username]', view).value = data.username;
$('[data-share-password]', view).value = data.password;
$('[data-share-otp-row]', view).hidden = !data.has_otp;
note(view, link.note);
$('[data-share-after]', view).textContent = data.after;
if (data.has_otp) {
refreshOtp();
}
} else if (data.type === 'Note') {
const view = show('note');
$('[data-share-name]', view).textContent = data.name;
$('[data-share-text]', view).textContent = data.text;
note(view, link.note);
$('[data-share-after]', view).textContent = data.after;
} else if (data.type === 'Document') {
const view = show('document');
$('[data-share-name]', view).textContent = data.name;
// Cleaned on the server (HTMLPurifier) - the only HTML that goes in as HTML
$('[data-share-document]', view).innerHTML = data.html;
note(view, link.note);
$('[data-share-after]', view).textContent = data.after;
if (typeof prettyContent === 'function') {
prettyContent();
}
}
}
// A file comes down as a download - an error comes back as JSON instead
function download(button) {
busy(button, true);
fetch('/guest/guest_download_file.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
cache: 'no-store',
body: JSON.stringify({ id: id, key: key })
}).then(function (res) {
if ((res.headers.get('Content-Type') || '').indexOf('application/json') === 0) {
return res.json().then(function (data) {
busy(button, false);
if (data.state === 'gone') {
gone(data.reason);
} else {
error('ready', data.error || 'The file could not be downloaded.');
}
});
}
const after = res.headers.get('X-Share-After') || '';
return res.blob().then(function (blob) {
busy(button, false);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = link.file_name || 'download';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 60000);
const view = show('downloaded');
$('[data-share-name]', view).textContent = link.file_name;
$('[data-share-after]', view).textContent = decodeURIComponent(after);
});
}, function () {
busy(button, false);
error('ready', 'Could not reach the server - check your connection and try again.');
});
}
// 2FA codes - asked for each time one runs out, while the page stays open
function stopOtp() {
clearTimeout(otpTimer);
clearInterval(barTimer);
}
function refreshOtp() {
stopOtp();
post('totp').then(function (data) {
const field = $('[data-share-otp]');
const bar = $('[data-share-otp-bar]');
if (!data.code) {
field.value = data.error ? 'Ended' : '------';
bar.style.width = '0%';
return;
}
field.value = data.code.slice(0, 3) + ' ' + data.code.slice(3);
const ends = Date.now() + data.expires_in * 1000;
const tick = function () {
const left = Math.max(0, ends - Date.now());
bar.style.width = (left / 300) + '%';
bar.classList.toggle('bg-warning', left < 7000);
};
tick();
barTimer = setInterval(tick, 250);
otpTimer = setTimeout(refreshOtp, data.expires_in * 1000 + 300);
});
}
page.addEventListener('click', function (e) {
const button = e.target.closest('[data-share-copy]');
if (!button || !content) {
return;
}
const what = button.dataset.shareCopy;
const value = what === 'otp' ? $('[data-share-otp]').value.replace(/\s/g, '') : content[what];
if (value) {
copy(button, value);
}
});
// Done with it - nobody can open it again
$$('[data-share-destroy]').forEach(function (button) {
button.addEventListener('click', function () {
busy(button, true);
post('destroy').then(function (data) {
busy(button, false);
stopOtp();
content = null;
if (data.state === 'destroyed') {
show('destroyed');
} else {
gone(data.reason || data.error);
}
});
});
});
// A request - send the login back
$('[data-share-state="request"]').addEventListener('submit', function (e) {
e.preventDefault();
const form = e.currentTarget;
if (!form.username.value.trim() && !form.password.value) {
error('request', 'Fill in the username, the password, or both.');
return;
}
const button = form.querySelector('button[type=submit]');
busy(button, true);
post('submit', {
uri: form.uri.value,
username: form.username.value,
password: form.password.value,
otp: form.otp.value,
note: form.note.value
}).then(function (data) {
busy(button, false);
if (data.state === 'gone') {
gone(data.reason);
} else if (data.error) {
error('request', data.error);
} else {
form.reset();
show('sent');
}
});
});
if (!id || !key) {
gone('The link is not complete - copy the whole of it from the email, including everything after the #.');
return;
}
post('open').then(open);
})();