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;
+});