Files

425 lines
18 KiB
JavaScript
Raw Permalink Normal View History

/* bau-pm-pdf-tools - the hand-off between bau-pm and BentoPDF's tools.
*
* Loaded by every page of this image, the PDF.js viewers' pages inside the
* tools included, as a classic script in <head>, so it runs before BentoPDF's
* own modules. It does five things:
*
* 1. A link to this image's source on every page, because the AGPL asks the
* source of a modified version served over a network to be offered
* prominently, and BentoPDF's Simple Mode hides its own footer.
* 2. The hand-in. bau-pm's entry leaves the hand-off token in a one-minute
* cookie; it moves into sessionStorage (this tab only, gone with it), the
* cookie is deleted at once, and the file is fetched from
* /bau-pm/handoff/file and given to the tool's file input, the way a user
* picking it would. Never in a URL: a token in a redirect's Location
* header lands in every access log on the way.
* 3. The hand-back. Every BentoPDF tool saves its result the same way: a blob
* URL on an <a download>, click(), revoke. While a hand-off is active and
* may be saved, that click is caught - in the top page and in the viewer's
* frame - and offered back to bau-pm instead of the downloads folder. The
* blob is read inside click(), because the next line revokes its URL.
* 4. For the signing tool, BentoPDF's "flatten" switched on and locked, so a
* signature becomes part of the page and cannot be moved later.
* 5. No signature kept on the device. PDF.js's signature editor saves up to
* five signatures in localStorage, ticked by default, for this origin and
* not for a bau-pm user, so the next person on a shared tablet would find
* the last one's signature one click away, a Redmine logout included
* (bau-pm PLAN.md §2.77, Gitea #1). Every window deletes what is stored,
* refuses the store's writes, and in the viewer the "save" box is held off
* and hidden. The dialog's image tab is hidden too, so a scanned
* signature never enters the tool: signing means drawing or typing it.
*
* Nothing here talks to anything but this origin. The words of the hand-off
* come from bau-pm in the user's language; only the source link has its own.
*/
(function () {
'use strict';
var KEY = 'bau-pm-handoff';
var COOKIE = '__Host-bau_pm_handoff';
var API = '/bau-pm/handoff';
var SOURCE_URL = '__BAU_PM_SOURCE_URL__';
var SIGNATURES = 'pdfjs.signature';
var isTop = window.top === window;
// --- shared state --------------------------------------------------------
function readState() {
try { return JSON.parse(window.sessionStorage.getItem(KEY) || 'null'); } catch (e) { return null; }
}
function writeState(state) {
try {
if (state) { window.sessionStorage.setItem(KEY, JSON.stringify(state)); } else { window.sessionStorage.removeItem(KEY); }
} catch (e) { /* storage blocked: the hand-off lasts this page only */ }
}
function ready(fn) {
if (document.readyState !== 'loading') { fn(); } else { document.addEventListener('DOMContentLoaded', fn); }
}
function fill(text, values) {
return String(text || '').replace(/%\{(\w+)\}/g, function (m, key) {
return values && values[key] != null ? values[key] : m;
});
}
// --- 3. the hand-back, installed first and in every window ---------------
var nativeClick = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function () {
var api = null;
try { api = window.top.bauPmHandoff; } catch (e) { api = null; }
if (api && api.catches() && this.hasAttribute('download') && /^blob:/.test(this.href)) {
// Started synchronously: fetch() resolves the blob URL now, before the
// caller's revokeObjectURL on the next line.
var name = this.download;
var blob = window.fetch(this.href).then(function (r) { return r.blob(); });
api.offer(blob, name);
return undefined;
}
return nativeClick.apply(this, arguments);
};
forgetSignatures();
ready(signatureDialog);
if (!isTop) {
signFrame();
return;
}
// --- 1. the source link --------------------------------------------------
var LANG = (window.location.pathname.split('/')[1] || 'en').slice(0, 2);
var SOURCE_LABEL = { de: 'Quelltext (AGPL-3.0)', fr: 'Code source (AGPL-3.0)', it: 'Codice sorgente (AGPL-3.0)' }[LANG] ||
'Source code (AGPL-3.0)';
var CSS = [
'.bau-pm-source{position:fixed;left:8px;bottom:8px;z-index:2147483000;font:12px/1.4 system-ui,sans-serif;',
'color:#d1d5db;background:rgba(17,24,39,.85);padding:2px 8px;border-radius:4px;text-decoration:none}',
'.bau-pm-source:hover,.bau-pm-source:focus{color:#fff;text-decoration:underline}',
'.bau-pm-bar{position:sticky;top:0;z-index:2147483001;display:flex;gap:12px;align-items:center;',
'padding:10px 14px;background:#111827;color:#f9fafb;border-bottom:3px solid #F28E00;font:15px/1.4 system-ui,sans-serif}',
'.bau-pm-bar span{flex:1;min-width:0;overflow-wrap:anywhere}',
'.bau-pm-bar button,.bau-pm-sheet button,.bau-pm-sheet a{font:inherit;min-height:44px;padding:8px 16px;border-radius:6px;cursor:pointer}',
'.bau-pm-bar button{background:transparent;color:#f9fafb;border:1px solid #6b7280}',
'.bau-pm-scrim{position:fixed;inset:0;z-index:2147483002;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center}',
'.bau-pm-sheet{width:100%;max-width:560px;background:#111827;color:#f9fafb;border-radius:12px 12px 0 0;',
'padding:20px;display:flex;flex-direction:column;gap:12px;font:16px/1.4 system-ui,sans-serif;box-sizing:border-box}',
'.bau-pm-sheet p{margin:0;overflow-wrap:anywhere}',
'.bau-pm-sheet .bau-pm-primary{background:#F28E00;color:#1a1a1a;border:0;font-weight:600}',
'.bau-pm-sheet .bau-pm-secondary{background:transparent;color:#f9fafb;border:1px solid #6b7280;text-align:center;text-decoration:none}',
'.bau-pm-sheet .bau-pm-error{color:#fca5a5}'
].join('');
ready(function () {
var style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
var link = document.createElement('a');
link.className = 'bau-pm-source';
link.href = SOURCE_URL;
link.rel = 'noopener';
link.target = '_blank';
link.textContent = SOURCE_LABEL;
document.body.appendChild(link);
});
// --- 2. the hand-in ------------------------------------------------------
var match = new RegExp('(?:^|;\\s*)' + COOKIE + '=([^;]+)').exec(document.cookie);
if (match) {
writeState({ token: decodeURIComponent(match[1].replace(/\+/g, ' ')) });
document.cookie = COOKIE + '=; Max-Age=0; Path=/; Secure; SameSite=Strict';
}
var state = readState();
var meta = null;
var receipt = null;
var file = null;
var settle = null;
// Resolves with the meta once it is here, or null when there is none. A
// save clicked before that - BentoPDF reloads the page once when its service
// worker first takes control, and the glue fetches the meta again - waits
// for it instead of slipping through as a plain download.
var metaReady = new Promise(function (resolve) { settle = resolve; });
window.bauPmHandoff = {
catches: function () { return meta ? !!meta.writable : !!(state && state.token); },
offer: function (blob, name) {
metaReady.then(function (ready) {
// Not to be saved back: an ordinary download, unless the hand-off
// offers none either (the Vorausmass), when the result goes nowhere.
if (ready && ready.writable) { offer(blob, name); } else if (!ready || ready.download) { save(blob, name); }
});
}
};
// A plain download, for a result that is not to be offered back.
function save(blobPromise, name) {
blobPromise.then(function (blob) {
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
nativeClick.call(a);
window.setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
});
}
if (!state || !state.token) { settle(null); return; }
function call(path, options) {
options = options || {};
options.headers = options.headers || {};
options.headers.Authorization = 'Bearer ' + state.token;
options.credentials = 'same-origin';
options.cache = 'no-store';
return window.fetch(API + path, options);
}
function end() {
writeState(null);
state = null;
meta = null;
settle(null);
var bar = document.querySelector('.bau-pm-bar');
if (bar) { bar.remove(); }
}
call('/meta').then(function (response) {
if (!response.ok) { throw new Error(String(response.status)); }
return response.json();
}).then(function (data) {
meta = data;
settle(data);
return call('/file');
}).then(function (response) {
if (!response.ok) { throw new Error(String(response.status)); }
receipt = response.headers.get('X-Bau-Pm-Receipt');
return response.blob();
}).then(function (blob) {
file = new File([blob], meta.filename, { type: 'application/pdf' });
ready(function () { banner(); feed(); lockFlatten(); });
}).catch(function () {
// An expired or foreign token, or a file that is gone: nothing to hand
// over, and nothing to say that the gate's own page would not say better.
end();
});
function banner() {
var bar = document.createElement('div');
bar.className = 'bau-pm-bar';
bar.setAttribute('role', 'status');
var text = document.createElement('span');
var values = { filename: meta.filename, title: meta.title };
text.textContent = fill(meta.strings.banner, values) +
(document.querySelector('input[type=file]') ? '' : ' - ' + meta.strings.pick);
var close = document.createElement('button');
close.type = 'button';
close.textContent = meta.strings.dismiss;
close.addEventListener('click', end);
bar.appendChild(text);
bar.appendChild(close);
document.body.insertBefore(bar, document.body.firstChild);
}
// The tool's own input, once per page and only while it is empty: picking
// another file by hand always wins.
//
// After the page's load event, and confirmed: BentoPDF attaches a tool's
// change listener from a module it imports after load, so a change
// dispatched too early reaches nobody - which is what happened once its
// service worker served the page from cache and this file's own fetches
// won the race. So the feed watches for the tool's reaction (new nodes in
// the page that are not ours) and dispatches again if there is none, a few
// times at most, with time enough between tries that a slow tool is not
// handed the file twice.
function feed() {
var tries = 0;
function ours(node) {
var el = node.nodeType === 1 ? node : node.parentElement;
return !!(el && el.closest && el.closest('.bau-pm-bar, .bau-pm-source, .bau-pm-scrim'));
}
function attempt() {
var input = document.querySelector('input[type=file]');
if (!input || !/pdf/i.test(input.accept || 'pdf')) {
tries += 1;
if (tries < 60) { window.setTimeout(attempt, 250); }
return;
}
if (input.files && input.files.length && input.dataset.bauPmFed !== '1') { return; }
var transfer = new DataTransfer();
transfer.items.add(file);
input.files = transfer.files;
input.dataset.bauPmFed = '1';
var reacted = false;
var observer = new MutationObserver(function (records) {
if (records.some(function (r) { return !ours(r.target); })) { reacted = true; observer.disconnect(); }
});
observer.observe(document.body, { childList: true, subtree: true });
input.dispatchEvent(new Event('change', { bubbles: true }));
window.setTimeout(function () {
observer.disconnect();
tries += 1;
if (!reacted && tries < 5) { attempt(); }
}, 2000);
}
if (document.readyState === 'complete') { window.setTimeout(attempt, 300); } else {
window.addEventListener('load', function () { window.setTimeout(attempt, 300); });
}
}
// --- 4. the signing tool -------------------------------------------------
function lockFlatten() {
if (meta.tool !== 'sign-pdf') { return; }
var lock = function () {
var toggle = document.getElementById('flatten-signature-toggle');
if (!toggle || toggle.dataset.bauPmLocked) { return; }
toggle.checked = true;
toggle.dispatchEvent(new Event('change', { bubbles: true }));
toggle.disabled = true;
toggle.dataset.bauPmLocked = '1';
var row = toggle.closest('label') || toggle.parentElement;
if (row) { row.style.display = 'none'; }
};
lock();
new MutationObserver(lock).observe(document.body, { childList: true, subtree: true });
}
// Inside the viewer's frame of the signing tool: open on the last page,
// where the sheet's signature lines are.
function signFrame() {
var path = null;
try { path = window.top.bauPmHandoff && readState() ? window.top.location.pathname : null; } catch (e) { path = null; }
if (!path || !/\/sign-pdf(\.html)?$/.test(path)) { return; }
var tries = 0;
(function attempt() {
var app = window.PDFViewerApplication;
if (app && app.pdfDocument && app.pagesCount) {
app.page = app.pagesCount;
return;
}
tries += 1;
if (tries < 80) { window.setTimeout(attempt, 250); }
})();
}
// --- 5. no signature kept on the device ---------------------------------
// Runs before PDF.js's modules in every window, so the viewer reads an empty
// store, and a device that saved signatures before this glue loses them on
// its first visit. The write is refused rather than undone afterwards: a
// signature that is never stored cannot be read by the next page either.
function forgetSignatures() {
try {
window.localStorage.removeItem(SIGNATURES);
var nativeSetItem = Storage.prototype.setItem;
Storage.prototype.setItem = function (key) {
if (String(key) === SIGNATURES) { return undefined; }
return nativeSetItem.apply(this, arguments);
};
} catch (e) { /* storage blocked: then nothing can be kept either */ }
}
// The dialog sets the box to "save" each time it opens; a property of the
// element's own shadows the prototype's, so it reads unticked whatever is
// assigned to it. Only PDF.js's viewer has these elements.
function signatureDialog() {
var save = document.getElementById('addSignatureSaveCheckbox');
if (!save) { return; }
Object.defineProperty(save, 'checked', {
configurable: true,
get: function () { return false; },
set: function () {}
});
hide(document.getElementById('addSignatureSaveContainer'));
hide(document.getElementById('addSignatureImageButton'));
var picker = document.getElementById('addSignatureFilePicker');
if (picker) { picker.disabled = true; }
}
function hide(element) {
if (element) { element.style.setProperty('display', 'none', 'important'); }
}
// --- 3. the hand-back's sheet --------------------------------------------
function button(label, kind) {
var b = document.createElement('button');
b.type = 'button';
b.className = kind;
b.textContent = label;
return b;
}
function offer(blobPromise, name) {
var values = { title: meta.title, filename: name };
var scrim = document.createElement('div');
scrim.className = 'bau-pm-scrim';
var sheet = document.createElement('div');
sheet.className = 'bau-pm-sheet';
sheet.setAttribute('role', 'dialog');
sheet.setAttribute('aria-modal', 'true');
var message = document.createElement('p');
message.textContent = name;
var save = button(fill(meta.strings.save, values), 'bau-pm-primary');
sheet.appendChild(message);
sheet.appendChild(save);
if (meta.download) {
var download = button(meta.strings.download, 'bau-pm-secondary');
download.addEventListener('click', function () {
save(blobPromise, name);
scrim.remove();
});
sheet.appendChild(download);
}
var close = button(meta.strings.dismiss, 'bau-pm-secondary');
close.addEventListener('click', function () { scrim.remove(); });
sheet.appendChild(close);
save.addEventListener('click', function () {
save.disabled = true;
save.textContent = meta.strings.saving;
blobPromise.then(function (blob) {
return call('/file', {
method: 'POST',
body: blob,
headers: { 'Content-Type': 'application/pdf', 'X-Bau-Pm-Receipt': receipt || '' }
});
}).then(function (response) {
return response.json().then(function (body) { return { ok: response.ok, body: body }; });
}).then(function (result) {
if (!result.ok) { throw new Error(result.body.error || ''); }
if (result.body.next_url) {
writeState(null);
window.location.assign(result.body.next_url);
return;
}
message.textContent = result.body.notice || meta.strings.saved;
save.remove();
if (result.body.back_url) {
var back = document.createElement('a');
back.className = 'bau-pm-primary';
back.href = result.body.back_url;
back.textContent = fill(meta.strings.back, values);
back.style.textAlign = 'center';
back.style.textDecoration = 'none';
sheet.insertBefore(back, sheet.children[1] || null);
}
}).catch(function (error) {
message.className = 'bau-pm-error';
message.textContent = fill(meta.strings.failed, { error: error.message });
save.disabled = false;
save.textContent = meta.strings.retry;
});
});
scrim.appendChild(sheet);
document.body.appendChild(scrim);
save.focus();
}
})();