PDF.js's signature dialog saved every signature in localStorage under pdfjs.signature, "save" ticked on every open, for this origin rather than for a bau-pm user. Measured on v0.1.0: a typed and an uploaded signature were offered to a fresh page on the same browser, the way the next person on a shared tablet would get it, a Redmine logout included. Another browser profile saw nothing; the server never had anything. The glue's fifth duty, in every window before PDF.js's modules: delete the store (a device that saved signatures loses them on its first visit), refuse writes to that key, hold the save box unticked through a property on the element and hide it, and hide the dialog's Bild tab and its file picker, so a scanned signature never enters the dialog. test/signatures.mjs is the second gate, in CI and the release workflow: a browser seeded with a real saved entry (captured from v0.1.0's own store) is offered none, the dialog shows neither the box nor the tab, a typed signature is placed and not stored, a fresh page is offered nothing. Fed v0.1.0: 9 findings, exit 1. Against this image: 0, and the zero-host gate drove 117 tool pages with nothing leaving the origin. A signed PDF still exports with the signature on the page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
426 lines
18 KiB
JavaScript
426 lines
18 KiB
JavaScript
/* 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();
|
|
}
|
|
})();
|