From 1111372dc965b8ea14b1748f33e653d2502c11c7 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 20 Jun 2026 13:59:26 +0800 Subject: [PATCH] jiami --- .gitignore | 2 + js/page-unlock.js | 78 +++++++++++ pages/wzy.html | 37 ----- tools/protect-pages.mjs | 295 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 375 insertions(+), 37 deletions(-) create mode 100644 .gitignore create mode 100644 js/page-unlock.js delete mode 100644 pages/wzy.html create mode 100644 tools/protect-pages.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da8d672 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.protected-backups/ +page-passwords*.json diff --git a/js/page-unlock.js b/js/page-unlock.js new file mode 100644 index 0000000..76e33e6 --- /dev/null +++ b/js/page-unlock.js @@ -0,0 +1,78 @@ +(function () { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const form = document.getElementById('unlockForm'); + const passwordInput = document.getElementById('letterPassword'); + const statusEl = document.getElementById('unlockStatus'); + const payloadEl = document.getElementById('encryptedPayload'); + + function fromBase64(value) { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + + async function deriveKey(password, salt, iterations) { + const baseKey = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveKey'] + ); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations, + hash: 'SHA-256' + }, + baseKey, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'] + ); + } + + async function unlock(password) { + const payload = JSON.parse(payloadEl.textContent); + const salt = fromBase64(payload.salt); + const iv = fromBase64(payload.iv); + const ciphertext = fromBase64(payload.ciphertext); + const key = await deriveKey(password, salt, payload.iterations); + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ciphertext + ); + + return decoder.decode(plaintext); + } + + form.addEventListener('submit', async event => { + event.preventDefault(); + const password = passwordInput.value; + if (!password) { + statusEl.textContent = '请输入密码。'; + return; + } + + statusEl.textContent = '正在解锁...'; + form.querySelector('button').disabled = true; + + try { + const html = await unlock(password); + document.open(); + document.write(html); + document.close(); + } catch (error) { + statusEl.textContent = '密码不正确,或页面密文已损坏。'; + form.querySelector('button').disabled = false; + passwordInput.select(); + } + }); +}()); diff --git a/pages/wzy.html b/pages/wzy.html deleted file mode 100644 index eee0bdd..0000000 --- a/pages/wzy.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - 多彩场景信函样例 - - - - - - -
- -

亲爱的王子玉:

- -
-

好久不见!最近过得怎么样?

-

转眼间夏天就要到了,还记得那时候我们一起在操场上挥洒汗水、在教室里畅谈未来的日子吗?那段时光,真的就像昨天刚发生一样。

-

这次致信,是想告诉你一个好消息。下周六我要举办一个久违的聚会,希望你一定要来!哪怕只是聊聊天,喝杯茶,也是极好的。时光易逝,友情常驻。愿你每天都充满活力,事事顺心!

-
- -
-

此致

-

敬礼!

-
- - - -
- - - - diff --git a/tools/protect-pages.mjs b/tools/protect-pages.mjs new file mode 100644 index 0000000..62eb4d5 --- /dev/null +++ b/tools/protect-pages.mjs @@ -0,0 +1,295 @@ +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; +});