Files
aia-website/www/tools/employee-portal-migrate.mjs

91 lines
6.2 KiB
JavaScript

/** Owner-run only. No credential arguments, environment passwords or plaintext output. */
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { decryptPortalStream, encryptPortalStream, validatePortalPayload } from '../src/utils/portal-crypto.mjs';
import { collectResource, extractPayload, resourceReferences, rewriteResources } from './portal-resources.mjs';
const root = fileURLToPath(new URL('../private/employee-portal/', import.meta.url));
async function hiddenPrompt(label) {
if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error('An interactive private terminal is required');
process.stderr.write(label);
process.stdin.setRawMode(true); process.stdin.resume();
return new Promise((resolve, reject) => {
let value = '';
const finish = () => { process.stdin.off('data', onData); process.stdin.setRawMode(false); process.stdin.pause(); process.stderr.write('\n'); };
const onData = (data) => {
for (const char of data.toString('utf8')) {
if (char === '\u0003' || char === '\u0004') { finish(); reject(new Error('Canceled')); return; }
if (char === '\r' || char === '\n') { finish(); resolve(value); return; }
if (char === '\u007f' || char === '\b') value = value.slice(0, -1);
else if (char >= ' ') value += char;
}
};
process.stdin.on('data', onData);
});
}
async function main() {
const [command, source, ...extra] = process.argv.slice(2);
if (!['collect', 'rewrite'].includes(command) || !source || extra.length) throw new Error('Usage: node tools/employee-portal-migrate.mjs collect|rewrite /absolute/private/footer-source.txt');
// No scanning for credentials. The original is read only; never copied into Git.
const payload = validatePortalPayload(extractPayload(await fs.readFile(source, 'utf8')));
await fs.mkdir(root, { recursive: true, mode: 0o700 });
await fs.chmod(root, 0o700);
const manifestPath = path.join(root, 'manifest.json');
let previous = { resources: [] };
try { previous = JSON.parse(await fs.readFile(manifestPath, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') throw new Error('Private manifest could not be read'); }
const resources = new Map(previous.resources.map((r) => [r.sourceUrl, { ...r, references: [] }]));
const nextStreams = [], unlocked = [];
let rewritten = 0;
for (let index = 0; index < payload.streams.length; index++) {
let pass = await hiddenPrompt(`Access code for portal view ${index + 1} (not echoed): `);
const stream = payload.streams[index];
let html = await decryptPortalStream(stream, pass);
if (html === null) throw new Error('Code did not authenticate the requested view; no content was written');
// Reject identical credentials across views to preserve stream separation.
for (let other = 0; other < payload.streams.length; other++) if (other !== index && await decryptPortalStream(payload.streams[other], pass) !== null) throw new Error('View codes must be distinct');
for (const ref of resourceReferences(html, stream.id)) {
const record = resources.get(ref.sourceUrl) ?? { sourceUrl: ref.sourceUrl, originalPath: ref.originalPath, classification: 'UNRESOLVED', references: [], downloadStatus: 'pending', deliveryPath: null, rewriteApproved: false, destinationImplemented: false, destinationVerified: false, destinationAccessControlled: false };
record.references.push(ref); resources.set(ref.sourceUrl, record);
}
if (command === 'rewrite') {
const result = rewriteResources(html, previous.resources.filter((r) => r.references.some((ref) => ref.view === stream.id)));
html = result.html; rewritten += result.count;
const next = await encryptPortalStream(stream.id, html, pass);
if (await decryptPortalStream(next, pass) !== html) throw new Error('Re-encryption verification failed');
for (const other of nextStreams) if (await decryptPortalStream(other, pass) !== null) throw new Error('View separation verification failed');
nextStreams.push(next);
}
unlocked.push(stream.id); html = ''; pass = '';
}
const stamp = new Date().toISOString().replaceAll(':', '-');
if (command === 'collect') {
const approvedOrigins = Array.isArray(previous.approvedOrigins)
? previous.approvedOrigins.filter((origin) => {
try { return new URL(origin).origin === origin && new URL(origin).protocol === 'https:'; }
catch { return false; }
})
: undefined;
for (const [url, record] of resources) {
try { resources.set(url, await collectResource(record, path.join(root, 'hubfs'), approvedOrigins)); }
catch { resources.set(url, { ...record, downloadStatus: 'failed-or-unapproved-redirect', verificationStatus: 'owner-review-required' }); }
}
const manifest = {
version: 1,
updated: stamp,
approvedOrigins: approvedOrigins ?? ['https://www.azinstitute4autism.com'],
views: unlocked,
resources: [...resources.values()],
};
// Retain each previous manifest; no original evidence is overwritten.
try { await fs.copyFile(manifestPath, path.join(root, `manifest-before-${stamp}.json`), fs.constants.COPYFILE_EXCL); } catch (e) { if (e.code !== 'ENOENT') throw e; }
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), { mode: 0o600 });
await fs.writeFile(path.join(root, `ciphertext-${stamp}.json`), JSON.stringify(payload), { mode: 0o600, flag: 'wx' });
} else {
await fs.writeFile(path.join(root, `rewritten-ciphertext-${stamp}.json`), JSON.stringify(validatePortalPayload({ ...payload, streams: nextStreams })), { mode: 0o600, flag: 'wx' });
}
const list = [...resources.values()];
console.log(JSON.stringify({ command, distinctResources: list.length, totalReferences: list.reduce((n, r) => n + r.references.length, 0), collected: list.filter((r) => ['downloaded','verified-existing'].includes(r.downloadStatus)).length, unresolved: list.filter((r) => r.classification === 'UNRESOLVED').length, rewritten }, null, 2));
}
main().catch(() => { console.error('Private migration stopped. Check access codes, source format, private manifest and file permissions locally. No credential or content details are logged.'); process.exitCode = 1; });