/* 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
, 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 , 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(); } })();