473 lines
17 KiB
JavaScript
473 lines
17 KiB
JavaScript
(function (app) {
|
|
'use strict';
|
|
|
|
const util = app.util = app.util || {};
|
|
|
|
util.hasCrypto = function hasCrypto() {
|
|
return !!(window.crypto && window.crypto.subtle && typeof window.crypto.subtle.digest === 'function');
|
|
};
|
|
|
|
util.canonicalStringify = function canonicalStringify(input) {
|
|
if (input === null) {
|
|
return 'null';
|
|
}
|
|
const type = typeof input;
|
|
if (type === 'number' || type === 'boolean' || type === 'string') {
|
|
return JSON.stringify(input);
|
|
}
|
|
if (Array.isArray(input)) {
|
|
return '[' + input.map(util.canonicalStringify).join(',') + ']';
|
|
}
|
|
const keys = Object.keys(input).sort();
|
|
return '{' + keys.map(function (key) {
|
|
return JSON.stringify(key) + ':' + util.canonicalStringify(input[key]);
|
|
}).join(',') + '}';
|
|
};
|
|
|
|
util.hashString = function hashString(str) {
|
|
return zddc.crypto.sha256String(str);
|
|
};
|
|
|
|
util.arrayBufferToHex = function arrayBufferToHex(buffer) {
|
|
return zddc.crypto.bytesToHex(buffer);
|
|
};
|
|
|
|
util.base64ToArrayBuffer = function base64ToArrayBuffer(base64Value) {
|
|
if (!base64Value) {
|
|
return new ArrayBuffer(0);
|
|
}
|
|
const binaryString = atob(base64Value);
|
|
const length = binaryString.length;
|
|
const bytes = new Uint8Array(length);
|
|
for (let index = 0; index < length; index += 1) {
|
|
bytes[index] = binaryString.charCodeAt(index);
|
|
}
|
|
return bytes.buffer;
|
|
};
|
|
|
|
util.arrayBufferToBase64 = function arrayBufferToBase64(buffer) {
|
|
const bytes = new Uint8Array(buffer);
|
|
let output = '';
|
|
for (let index = 0; index < bytes.length; index += 1) {
|
|
output += String.fromCharCode(bytes[index]);
|
|
}
|
|
return btoa(output);
|
|
};
|
|
|
|
// Revision comparison delegates to shared zddc library
|
|
util.compareRevisionPriority = function compareRevisionPriority(aRevision, bRevision) {
|
|
return zddc.compareRevisions(aRevision, bRevision);
|
|
};
|
|
|
|
util.compareFilesByTrackingRevision = function compareFilesByTrackingRevision(a, b) {
|
|
const trackingA = (a && a.trackingNumber ? String(a.trackingNumber) : '').toLowerCase();
|
|
const trackingB = (b && b.trackingNumber ? String(b.trackingNumber) : '').toLowerCase();
|
|
if (trackingA < trackingB) {
|
|
return -1;
|
|
}
|
|
if (trackingA > trackingB) {
|
|
return 1;
|
|
}
|
|
|
|
const revisionCompare = util.compareRevisionPriority(a && a.revision, b && b.revision);
|
|
if (revisionCompare !== 0) {
|
|
return revisionCompare;
|
|
}
|
|
|
|
const extA = (a && a.extension ? String(a.extension) : '').toLowerCase();
|
|
const extB = (b && b.extension ? String(b.extension) : '').toLowerCase();
|
|
if (extA < extB) {
|
|
return -1;
|
|
}
|
|
if (extA > extB) {
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
};
|
|
|
|
util.canonicalizePublicJwk = function canonicalizePublicJwk(pk) {
|
|
if (!pk) {
|
|
return { kty: 'EC', crv: 'P-256', x: '', y: '' };
|
|
}
|
|
return {
|
|
kty: pk.kty || 'EC',
|
|
crv: pk.crv || 'P-256',
|
|
x: pk.x || '',
|
|
y: pk.y || ''
|
|
};
|
|
};
|
|
|
|
util.publicKeyFingerprint = async function publicKeyFingerprint(pk) {
|
|
try {
|
|
if (!pk) {
|
|
return '';
|
|
}
|
|
if (!util.hasCrypto()) {
|
|
return null;
|
|
}
|
|
const canonical = util.canonicalizePublicJwk(pk);
|
|
const canonicalStr = util.canonicalStringify(canonical);
|
|
const hash = await util.hashString(canonicalStr);
|
|
return (hash || '').slice(0, 12);
|
|
} catch (err) {
|
|
console.error('[transmittal] publicKeyFingerprint error', err);
|
|
return '';
|
|
}
|
|
};
|
|
|
|
util.hashFile = function hashFile(file, onProgress) {
|
|
return zddc.crypto.sha256File(file, onProgress);
|
|
};
|
|
|
|
util.escapeHtml = function escapeHtml(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
};
|
|
|
|
util.escapeHtmlAttribute = function escapeHtmlAttribute(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"');
|
|
};
|
|
|
|
util.downloadBlob = function downloadBlob(filename, contents, mime) {
|
|
var blob = (contents instanceof Blob)
|
|
? contents
|
|
: new Blob([contents], { type: mime || 'application/octet-stream' });
|
|
var anchor = document.createElement('a');
|
|
anchor.href = URL.createObjectURL(blob);
|
|
anchor.download = filename;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
setTimeout(function () {
|
|
URL.revokeObjectURL(anchor.href);
|
|
anchor.remove();
|
|
}, 0);
|
|
};
|
|
|
|
util.createEmptyData = function createEmptyData(date) {
|
|
return {
|
|
envelope: {
|
|
version: 1,
|
|
digestAlgorithm: app.constants.digestAlgorithm,
|
|
digest: '',
|
|
digestedAt: '',
|
|
signatureAlgorithm: app.constants.signatureAlgorithm,
|
|
signatures: []
|
|
},
|
|
payload: {
|
|
version: 1,
|
|
type: 'Transmittal',
|
|
title: '',
|
|
client: '',
|
|
project: '',
|
|
projectNumber: '',
|
|
date: date || '',
|
|
trackingNumber: '',
|
|
from: '',
|
|
to: '',
|
|
purpose: '',
|
|
responseDue: '',
|
|
subject: '',
|
|
remarks: '',
|
|
files: []
|
|
},
|
|
presentation: {
|
|
leftLogo: '',
|
|
rightLogo: '',
|
|
theme: 'default',
|
|
customCss: ''
|
|
}
|
|
};
|
|
};
|
|
|
|
util.fetchTrustedTime = function fetchTrustedTime() {
|
|
return new Date().toISOString();
|
|
};
|
|
|
|
util.formatISOWithTZ = function formatISOWithTZ(isoStr) {
|
|
if (!isoStr) { return 'Unknown'; }
|
|
var d = new Date(isoStr);
|
|
if (isNaN(d.getTime())) { return isoStr; }
|
|
try {
|
|
return d.toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
timeZoneName: 'short'
|
|
});
|
|
} catch (_) {
|
|
return d.toLocaleString();
|
|
}
|
|
};
|
|
|
|
util.signEnvelope = async function signEnvelope(envelope, jwk) {
|
|
if (!jwk || jwk.kty !== 'EC') {
|
|
throw new Error('A valid EC private key (JWK) is required to sign.');
|
|
}
|
|
var envelopeToSign = {
|
|
version: envelope.version || 1,
|
|
digestAlgorithm: envelope.digestAlgorithm || app.constants.digestAlgorithm,
|
|
digest: envelope.digest,
|
|
digestedAt: envelope.digestedAt,
|
|
signatureAlgorithm: envelope.signatureAlgorithm || app.constants.signatureAlgorithm
|
|
};
|
|
var envelopeStr = util.canonicalStringify(envelopeToSign);
|
|
var key = await window.crypto.subtle.importKey(
|
|
'jwk',
|
|
jwk,
|
|
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
false,
|
|
['sign']
|
|
);
|
|
var raw = await window.crypto.subtle.sign(
|
|
{ name: 'ECDSA', hash: { name: 'SHA-256' } },
|
|
key,
|
|
new TextEncoder().encode(envelopeStr)
|
|
);
|
|
return {
|
|
signature: util.arrayBufferToBase64(raw),
|
|
signedAt: util.fetchTrustedTime()
|
|
};
|
|
};
|
|
|
|
|
|
util.formatShortHash = function formatShortHash(hex) {
|
|
const normalized = (hex || '').trim();
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
if (normalized.length <= 20) {
|
|
return normalized;
|
|
}
|
|
return normalized.slice(0, 12) + '\u2026' + normalized.slice(-8);
|
|
};
|
|
|
|
util.formatShortFileHash = function formatShortFileHash(hex) {
|
|
const normalized = (hex || '').trim();
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
if (normalized.length <= 12) {
|
|
return normalized;
|
|
}
|
|
return normalized.slice(0, 6) + '\u2026' + normalized.slice(-5);
|
|
};
|
|
|
|
util.formatFileSize = function formatFileSize(bytes) {
|
|
const num = Number(bytes) || 0;
|
|
if (num === 0) {
|
|
return '0 B';
|
|
}
|
|
if (num < 1024) {
|
|
return num + ' B';
|
|
}
|
|
if (num < 1024 * 1024) {
|
|
return (num / 1024).toFixed(1) + ' KB';
|
|
}
|
|
if (num < 1024 * 1024 * 1024) {
|
|
return (num / (1024 * 1024)).toFixed(1) + ' MB';
|
|
}
|
|
return (num / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
};
|
|
|
|
util.fileToImage = function fileToImage(file) {
|
|
return new Promise(function (resolve, reject) {
|
|
const url = URL.createObjectURL(file);
|
|
const img = new Image();
|
|
img.onload = function () {
|
|
URL.revokeObjectURL(url);
|
|
resolve(img);
|
|
};
|
|
img.onerror = function (err) {
|
|
URL.revokeObjectURL(url);
|
|
reject(err);
|
|
};
|
|
img.src = url;
|
|
});
|
|
};
|
|
|
|
util.imageFileToPngDataUrl = async function imageFileToPngDataUrl(file, maxWidth, maxHeight) {
|
|
const img = await util.fileToImage(file);
|
|
const scale = Math.min(1, maxWidth / img.width, maxHeight / img.height);
|
|
const w = Math.round(img.width * scale);
|
|
const h = Math.round(img.height * scale);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0, w, h);
|
|
return canvas.toDataURL('image/png');
|
|
};
|
|
|
|
// ── ZDDC Signing Key utilities ─────────────────────────
|
|
var KEY_FORMAT = 'zddc-signing-key-v1';
|
|
var KDF_ITERATIONS = 100000;
|
|
|
|
function deriveWrappingKey(password, salt) {
|
|
var enc = new TextEncoder();
|
|
return window.crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
|
|
.then(function (baseKey) {
|
|
return window.crypto.subtle.deriveKey(
|
|
{ name: 'PBKDF2', salt: salt, iterations: KDF_ITERATIONS, hash: 'SHA-256' },
|
|
baseKey,
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false,
|
|
['encrypt', 'decrypt']
|
|
);
|
|
});
|
|
}
|
|
|
|
util.encryptPrivateKey = async function encryptPrivateKey(jwk, password, publicFingerprint) {
|
|
var plaintext = new TextEncoder().encode(JSON.stringify(jwk));
|
|
var salt = window.crypto.getRandomValues(new Uint8Array(16));
|
|
var iv = window.crypto.getRandomValues(new Uint8Array(12));
|
|
var wrappingKey = await deriveWrappingKey(password, salt);
|
|
var ciphertext = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, wrappingKey, plaintext);
|
|
return {
|
|
format: KEY_FORMAT,
|
|
publicFingerprint: publicFingerprint || '',
|
|
encrypted: true,
|
|
kdf: 'PBKDF2-SHA256',
|
|
iterations: KDF_ITERATIONS,
|
|
salt: util.arrayBufferToBase64(salt),
|
|
iv: util.arrayBufferToBase64(iv),
|
|
ciphertext: util.arrayBufferToBase64(ciphertext)
|
|
};
|
|
};
|
|
|
|
util.decryptPrivateKey = async function decryptPrivateKey(keyData, password) {
|
|
var salt = util.base64ToArrayBuffer(keyData.salt);
|
|
var iv = util.base64ToArrayBuffer(keyData.iv);
|
|
var ciphertext = util.base64ToArrayBuffer(keyData.ciphertext);
|
|
var wrappingKey = await deriveWrappingKey(password, salt);
|
|
var plaintext = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, wrappingKey, ciphertext);
|
|
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
};
|
|
|
|
util.wrapKeyFile = function wrapKeyFile(jwk, publicFingerprint) {
|
|
return {
|
|
format: KEY_FORMAT,
|
|
publicFingerprint: publicFingerprint || '',
|
|
encrypted: false,
|
|
key: jwk
|
|
};
|
|
};
|
|
|
|
util.loadKeyFile = async function loadKeyFile(text, promptForPassword) {
|
|
var data = JSON.parse(text);
|
|
// ZDDC key format
|
|
if (data.format === KEY_FORMAT) {
|
|
if (data.encrypted) {
|
|
if (typeof promptForPassword !== 'function') {
|
|
throw new Error('Password required but no prompt available.');
|
|
}
|
|
var password = await promptForPassword(data.publicFingerprint);
|
|
if (!password && password !== '') { return null; } // user cancelled
|
|
return util.decryptPrivateKey(data, password);
|
|
}
|
|
return data.key;
|
|
}
|
|
throw new Error('Unrecognized key file format. Expected a .zddc-key file.');
|
|
};
|
|
|
|
util.cloneDocumentHtml = function cloneDocumentHtml() {
|
|
// Temporarily remove inline scripts from the LIVE DOM so that
|
|
// outerHTML produces a clean string with zero script bodies.
|
|
// We never create a clone via innerHTML because the HTML parser
|
|
// would corrupt the JSON block if base64 data contains a close-script tag.
|
|
var scripts = document.querySelectorAll('script:not([src])');
|
|
var saved = [];
|
|
for (var i = 0; i < scripts.length; i++) {
|
|
var el = scripts[i];
|
|
saved.push({
|
|
el: el,
|
|
parent: el.parentNode,
|
|
next: el.nextSibling,
|
|
body: el.textContent || '',
|
|
attrs: ''
|
|
});
|
|
for (var a = 0; a < el.attributes.length; a++) {
|
|
var attr = el.attributes[a];
|
|
saved[i].attrs += ' ' + attr.name + '="' +
|
|
attr.value.replace(/&/g, '&').replace(/"/g, '"') + '"';
|
|
}
|
|
el.parentNode.removeChild(el);
|
|
}
|
|
|
|
var html = document.documentElement.outerHTML;
|
|
|
|
// Restore every script element to the live DOM immediately
|
|
for (var r = 0; r < saved.length; r++) {
|
|
saved[r].parent.insertBefore(saved[r].el, saved[r].next);
|
|
}
|
|
|
|
// Build each script tag as a safe string and insert before </body>
|
|
var scriptStrings = '';
|
|
for (var j = 0; j < saved.length; j++) {
|
|
var s = saved[j];
|
|
var safeBody = s.body;
|
|
var scriptType = (s.el.getAttribute('type') || '').toLowerCase();
|
|
if (scriptType === 'application/json') {
|
|
// Re-serialize from parsed data so we control the output.
|
|
// \u003c is valid JSON; JSON.parse converts it back to <
|
|
var jsonData = app.json.parse();
|
|
safeBody = JSON.stringify(jsonData, null, 2).replace(/</g, '\\u003c');
|
|
}
|
|
scriptStrings += '\n<script' + s.attrs + '>' + safeBody +
|
|
'</' + 'script>';
|
|
}
|
|
|
|
// Insert scripts before closing </body>
|
|
var bodyClose = html.lastIndexOf('</' + 'body>');
|
|
if (bodyClose !== -1) {
|
|
html = html.substring(0, bodyClose) + scriptStrings + '\n' +
|
|
html.substring(bodyClose);
|
|
} else {
|
|
html += scriptStrings;
|
|
}
|
|
|
|
return '<!DOCTYPE html>\n' + html;
|
|
};
|
|
|
|
/**
|
|
* Fetch the current page's own source HTML and replace only the
|
|
* transmittal-data JSON block with the supplied data object.
|
|
*
|
|
* This is the preferred save mechanism for drafts because it produces
|
|
* an exact copy of the source file with only the data changed, rather
|
|
* than a DOM snapshot that may contain stale or mutated content.
|
|
*
|
|
* @param {object} jsonData - The data object to embed as JSON.
|
|
* @returns {Promise<string>} The patched HTML string.
|
|
* @throws {Error} If the fetch fails or the JSON block is not found.
|
|
*/
|
|
util.fetchAndPatchHtml = async function fetchAndPatchHtml(jsonData) {
|
|
var response = await fetch(location.href, { cache: 'no-cache' });
|
|
if (!response.ok) {
|
|
throw new Error('fetch failed with status ' + response.status);
|
|
}
|
|
var html = await response.text();
|
|
// \u003c is valid JSON; JSON.parse converts it back to <
|
|
var jsonStr = JSON.stringify(jsonData, null, 2).replace(/</g, '\\u003c');
|
|
// Replace the transmittal-data script body. The non-greedy [\s\S]*? stops
|
|
// at the first close-script tag, which is safe because < is escaped above.
|
|
var patched = html.replace(
|
|
new RegExp(
|
|
'(<script\\b[^>]*\\bid\\s*=\\s*["\']transmittal-data["\'][^>]*>)[\\s\\S]*?(<\\/' + 'script>)',
|
|
'i'
|
|
),
|
|
'$1\n' + jsonStr + '\n$2'
|
|
);
|
|
if (patched === html) {
|
|
throw new Error('transmittal-data script block not found in fetched HTML');
|
|
}
|
|
return patched;
|
|
};
|
|
})(window.transmittalApp);
|