BentoPDF for bau-pm, without third-party hosts (bau-pm BAU-119)
BentoPDF v2.8.8 built from source at its pinned commit with BentoPDF's own air-gapped build options, every module it would load from a CDN vendored and checked against vendor.lock, a Content-Security-Policy without a single host, digital-sign-pdf and timestamp-pdf disabled because both need a third party, and the hand-off glue on every page: the AGPL source link, the hand-in of a file from bau-pm through a one-minute cookie into sessionStorage, confirmed by the tool's reaction, the hand-back of a result from every window, and the signing tool's flatten switch locked on. The zero-host gate drives all 117 enabled tool pages in Chromium and fails on any request to another origin, any CSP violation, a CSP that names a host, and a disabled id that names no page. Green on this image (3 min 52 s on a private network), red on the stock upstream image (115 findings) and on a misspelled id. CI runs it on every push, the release workflow before it pushes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
47138ddfbe
19 files changed
+1733
No files matched your search
+375
@@ -0,0 +1,375 @@
|
||||
/* 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 four 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.
|
||||
*
|
||||
* 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 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);
|
||||
};
|
||||
|
||||
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); }
|
||||
})();
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user