import { createCipheriv, pbkdf2Sync, randomBytes } from 'node:crypto'; import { mkdir, readdir, readFile, stat, writeFile, copyFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; const ITERATIONS = 310000; const KEY_LENGTH = 32; const ROOT = process.cwd(); const DEFAULT_PASSWORD_FILE = 'page-passwords.json'; function parseArgs(argv) { const args = { dir: 'pages', password: process.env.LETTER_PASSWORD || '', passwordFile: DEFAULT_PASSWORD_FILE, backup: true }; for (let i = 2; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--dir') { args.dir = argv[++i]; } else if (arg === '--password') { args.password = argv[++i]; } else if (arg === '--password-file') { args.passwordFile = argv[++i]; } else if (arg === '--no-password-file') { args.passwordFile = ''; } else if (arg === '--no-backup') { args.backup = false; } else if (arg === '--help' || arg === '-h') { args.help = true; } else { throw new Error(`Unknown argument: ${arg}`); } } return args; } async function listHtmlFiles(dir) { const entries = await readdir(dir, { withFileTypes: true }); const files = await Promise.all(entries.map(async entry => { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) return listHtmlFiles(fullPath); if (entry.isFile() && entry.name.endsWith('.html')) return [fullPath]; return []; })); return files.flat(); } function normalizeRelativePath(value) { return value.replaceAll('\\', '/').replace(/^\.\//, ''); } async function loadPasswordMap(passwordFile) { if (!passwordFile) return new Map(); const fullPath = path.resolve(ROOT, passwordFile); try { const raw = (await readFile(fullPath, 'utf8')).replace(/^\uFEFF/, ''); const parsed = JSON.parse(raw); if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { throw new Error('password file must be a JSON object.'); } return new Map( Object.entries(parsed).map(([filePath, password]) => { if (typeof password !== 'string' || !password) { throw new Error(`password for "${filePath}" must be a non-empty string.`); } return [normalizeRelativePath(filePath), password]; }) ); } catch (error) { if (error.code === 'ENOENT') return new Map(); throw new Error(`Failed to read ${passwordFile}: ${error.message}`); } } function passwordForFile(filePath, sourceDir, passwordMap, fallbackPassword) { const relativeToRoot = normalizeRelativePath(path.relative(ROOT, filePath)); const relativeToSource = normalizeRelativePath(path.relative(sourceDir, filePath)); return ( passwordMap.get(relativeToRoot) || passwordMap.get(relativeToSource) || passwordMap.get('*') || fallbackPassword || '' ); } function toBase64(buffer) { return Buffer.from(buffer).toString('base64'); } function relativeScriptPath(filePath) { return path .relative(path.dirname(filePath), path.join(ROOT, 'js', 'page-unlock.js')) .replaceAll(path.sep, '/'); } function encryptedShell({ filePath, payload }) { const unlockScript = relativeScriptPath(filePath); const payloadJson = JSON.stringify(payload); return ` 信函已加密

信函已加密

请输入密码后在本机解锁查看内容。

`; } async function protectFile(filePath, sourceDir, password, shouldBackup) { const source = await readFile(filePath, 'utf8'); if (source.includes('id="encryptedPayload"')) { return { filePath, skipped: true }; } const salt = randomBytes(16); const iv = randomBytes(12); const key = pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, 'sha256'); const cipher = createCipheriv('aes-256-gcm', key, iv); const encrypted = Buffer.concat([cipher.update(source, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); const ciphertext = Buffer.concat([encrypted, tag]); const payload = { v: 1, alg: 'AES-256-GCM', kdf: 'PBKDF2-SHA256', iterations: ITERATIONS, salt: toBase64(salt), iv: toBase64(iv), ciphertext: toBase64(ciphertext) }; if (shouldBackup) { const relative = path.relative(sourceDir, filePath); const backupPath = path.join(ROOT, '.protected-backups', relative); await mkdir(path.dirname(backupPath), { recursive: true }); await copyFile(filePath, backupPath); } await writeFile(filePath, encryptedShell({ filePath, payload }), 'utf8'); return { filePath, skipped: false }; } async function main() { const args = parseArgs(process.argv); if (args.help) { console.log('Usage: node tools/protect-pages.mjs [--password-file page-passwords.json] [--password "fallback"] [--dir pages] [--no-backup]'); console.log('Password file example: { "pages/fmx.html": "中文密码也可以", "pages/202601/2026-fmx.html": "另一把钥匙" }'); console.log('PowerShell hidden prompt: $p = Read-Host "Letter password" -AsSecureString'); console.log('Set LETTER_PASSWORD only if you want one fallback password for pages missing from the password file.'); return; } const sourceDir = path.resolve(ROOT, args.dir); const sourceStats = await stat(sourceDir); if (!sourceStats.isDirectory()) { throw new Error(`${args.dir} is not a directory.`); } const files = await listHtmlFiles(sourceDir); const passwordMap = await loadPasswordMap(args.passwordFile); const missingPasswords = files .map(filePath => ({ filePath, password: passwordForFile(filePath, sourceDir, passwordMap, args.password) })) .filter(entry => !entry.password); if (missingPasswords.length) { const missingList = missingPasswords .map(entry => `- ${normalizeRelativePath(path.relative(ROOT, entry.filePath))}`) .join('\n'); throw new Error(`Missing passwords for:\n${missingList}\nAdd them to ${args.passwordFile || 'a password file'} or provide --password as a fallback.`); } const results = []; for (const filePath of files) { const password = passwordForFile(filePath, sourceDir, passwordMap, args.password); results.push(await protectFile(filePath, sourceDir, password, args.backup)); } const protectedCount = results.filter(result => !result.skipped).length; const skippedCount = results.length - protectedCount; console.log(`Protected ${protectedCount} HTML file(s). Skipped ${skippedCount} already-protected file(s).`); if (args.backup) { console.log('Plaintext backups were written to .protected-backups/. Do not deploy that directory.'); } } main().catch(error => { console.error(error.message); process.exitCode = 1; });