91 lines
6.2 KiB
JavaScript
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; });
|