加密内容机制与安全边界
Mizuki 为静态页面提供了一套构建期加密、浏览器端解密的内容保护机制:页面正文在 Astro 构建阶段用 AES-256-GCM 加密为密文,随静态 HTML 下发;访客在浏览器中输入密码,通过 WebCrypto 在本地完成 PBKDF2 密钥派生与 AES-GCM 解密,正文全程不经过服务器。本文完整剖析这条加密管线的数据流、密码学协议(v2)、前后端常量同步约束,并明确其安全边界——它是"浏览器端加密",不是服务端访问控制。
Purpose and Scope
本页覆盖以下内容,作为"加密内容"这一能力的端到端参考:
- 内容侧入口:frontmatter 中的
encrypted标志(src/content.config.ts); - 构建期加密组件
Encryptor.astro与其 Props 契约; - 密码学核心
src/utils/crypto-utils.ts:CRYPTO_CONSTANTS、deriveBytes()、encryptContent()以及协议 v2 的密文格式; - 浏览器端运行时
PasswordProtection.astro:内联解密脚本、decryptContent()、attemptUnlock()、password:*CustomEvent 通信协议、sessionStorage密码缓存与encrypted-hidden显隐控制; - 列表/聚合侧的脱敏(
ENCRYPTED_POST_HOME_CONTENT、getPostHomeContent)与 RSS/Atom 排除; - 安全边界、失败模式、性能与扩展点,以及保障该机制的回归测试。
以下相关主题有意留给兄弟页面,不在本页展开:
- 加密页面之外的文章渲染管线(Markdown/MDX、frontmatter 完整 schema)——见内容创作相关页面;
- 密码弹窗 UI 组件
PasswordModal.svelte的视图实现细节(本页只覆盖它与内联脚本之间已验证的事件协议一端); - 站点样式体系(回归测试仅验证其与加密包装器的解耦关系)。
Overview
该机制解决的核心问题是:纯静态站点无法在服务端做鉴权,但仍希望某些页面(日记、相册等)不向普通访客明文展示。Mizuki 的方案是把"访问控制"前移到构建期——正文在构建时即被加密成密文,静态产物中不存在明文;解密所需的密码只出现在构建机器上,从不随产物下发。
关键概念:
| 概念 | 含义 |
|---|---|
| 构建期加密 | Encryptor.astro 把默认插槽渲染出的 HTML 用 encryptContent() 加密,产物中只有 base64 密文 |
| 协议 v2 | 明文前拼 MIZUKI-VERIFY: 前缀后再加密,客户端解密后做快速协议级校验 |
| 确定性派生 | salt 与 IV 不用随机数,而是 HMAC-SHA256(password, context) 确定性导出,保证构建可重现 |
| 浏览器端解密 | WebCrypto PBKDF2(100,000 次)派生密钥 + AES-GCM 解密,全部发生在访客设备 |
| 事件协议 | 内联脚本与 Svelte 弹窗通过 password:error / password:loading / password:clear-error CustomEvent 通信 |
| 安全边界 | README 明确声明:加密文章会排除出 RSS/Atom,但浏览器端加密不是服务端访问控制 |
Architecture
架构分两条清晰的链路:
- 构建期(Node.js 侧):
content.config.ts定义encrypted布尔标志 →Encryptor.astro接收password/slug/hint三个 Props,先用Astro.slots.render("default")把被包裹的正文渲染为 HTML 字符串,再调用crypto-utils.ts的encryptContent()完成加密。密码在此链路中使用后即丢弃,不会进入客户端产物。 - 运行时(浏览器侧):
PasswordProtection.astro只拿到密文字符串与提示语。它渲染一个 Svelte 密码弹窗(PasswordModal.svelte,client:load)、一个隐藏的#decrypted-content容器,以及一段通过define:vars注入密文的is:inline脚本。脚本用 WebCrypto 解密,成功后把明文写入容器并重建<script>节点。
两条链路之间唯一的契约是密文格式与加密常量——crypto-utils.ts 源码注释明确要求:客户端内联脚本中的常量必须与服务端保持同步(见下文"扩展点")。
核心实现:构建期加密(crypto-utils.ts)
crypto-utils.ts 是整条管线的密码学核心,仅依赖 Node 内置 node:crypto,无第三方依赖。
共享常量
1import { createCipheriv, createHmac, pbkdf2Sync } from "node:crypto";
2
3// 共享加密常量 — 客户端 PasswordProtection.astro 中的内联脚本必须保持同步
4export const CRYPTO_CONSTANTS = {
5 PBKDF2_ITERATIONS: 100000,
6 SALT_LENGTH: 16,
7 IV_LENGTH: 12,
8 AUTH_TAG_LENGTH: 16,
9 KEY_LENGTH: 32,
10 VERIFY_PREFIX: "MIZUKI-VERIFY:", // 验证前缀:正确解密后内容以此开头
11} as const;Source: crypto-utils.ts
| 常量 | 值 | 作用 |
|---|---|---|
PBKDF2_ITERATIONS | 100000 | PBKDF2 迭代次数,同时决定构建期与浏览器端的暴力破解成本 |
SALT_LENGTH | 16 | 派生 salt 的字节长度 |
IV_LENGTH | 12 | AES-GCM 标准 IV 长度 |
AUTH_TAG_LENGTH | 16 | GCM 认证标签长度 |
KEY_LENGTH | 32 | 256 位 AES 密钥 |
VERIFY_PREFIX | "MIZUKI-VERIFY:" | 协议 v2 的明文前缀,用于解密后校验 |
确定性派生:deriveBytes()
1/**
2 * 使用 HMAC-SHA256 派生确定性字节
3 */
4function deriveBytes(key: string, context: string, length: number): Buffer {
5 return createHmac("sha256", key).update(context).digest().subarray(0, length);
6}Source: crypto-utils.ts
设计意图:salt 与 IV 采用确定性派生(以 password 为 HMAC key、以 salt:${slug} / iv:${slug} 为 context),而不是每次随机生成。好处是构建可重现——同一 (html, password, slug) 组合总是得到同一密文,利于缓存与增量构建;代价是放弃了"同一密码在多页面使用不同 IV"的随机性收益,这一点由每页独立的 slug context 加以缓解。
encryptContent() 与协议 v2 密文格式
1export function encryptContent(
2 html: string,
3 password: string,
4 slug: string,
5): string {
6 const {
7 PBKDF2_ITERATIONS,
8 SALT_LENGTH,
9 IV_LENGTH,
10 KEY_LENGTH,
11 VERIFY_PREFIX,
12 } = CRYPTO_CONSTANTS;
13
14 const plaintext = VERIFY_PREFIX + html;
15
16 const salt = deriveBytes(password, `salt:${slug}`, SALT_LENGTH);
17 const iv = deriveBytes(password, `iv:${slug}`, IV_LENGTH);
18 const key = pbkdf2Sync(
19 password,
20 salt,
21 PBKDF2_ITERATIONS,
22 KEY_LENGTH,
23 "sha256",
24 );
25
26 const cipher = createCipheriv("aes-256-gcm", key, iv);
27 const encrypted = Buffer.concat([
28 cipher.update(plaintext, "utf8"),
29 cipher.final(),
30 ]);
31 const authTag = cipher.getAuthTag();
32
33 return Buffer.concat([salt, iv, authTag, encrypted]).toString("base64");
34}Source: crypto-utils.ts
执行顺序:拼 MIZUKI-VERIFY: 前缀 → HMAC 派生 salt/IV → PBKDF2-SHA256(100k 次)派生 256 位密钥 → AES-256-GCM 加密 → 拼接并 base64。
最终密文布局(客户端按固定偏移切分):
base64( salt[16] || iv[12] || authTag[16] || ciphertext )
协议 v2 的前缀设计意图(源自源码注释):AES-GCM 本身通过 authTag 提供认证——错误密码会导致认证失败并抛异常。但 GCM 的认证失败发生较晚;VERIFY_PREFIX 让客户端在解密成功后立即以 startsWith 做一次轻量校验,从而在"密码错误"与"数据损坏"之间提供一条明确的、可预期的失败路径(客户端两个分支都会走 catch,但语义上后者多了一层显式信号)。此外,WebCrypto 的 AES-GCM 解密要求 ciphertext || authTag 连续存放,因此客户端会把尾部 authTag 移到 ciphertext 之后(见下文 combined 的构造)。
构建期入口:Encryptor.astro
1---
2import "@/styles/encrypted-content.css";
3
4import { encryptContent } from "@utils/crypto-utils";
5
6import PasswordProtection from "./PasswordProtection.astro";
7
8export interface Props {
9 password: string | number;
10 slug: string;
11 hint?: string;
12}
13
14const { password, slug, hint = "" } = Astro.props;
15
16const htmlContent = await Astro.slots.render("default");
17
18const encryptedContent = encryptContent(htmlContent, String(password), slug);
19---
20
21<PasswordProtection encryptedContent={encryptedContent} hint={hint} />Source: Encryptor.astro
要点:
password接受string | number,调用String(password)归一化——frontmatter 中数值型密码同样可用;Astro.slots.render("default")把被包裹的 Markdown/组件树先渲染成完整 HTML 再加密,意味着加密的是最终产物,而非源文本;Encryptor.astro本身只做"渲染 + 加密 + 委托",UI 完全下放给PasswordProtection.astro;tests/layout-regressions.test.mjs专门断言各全局样式表不得import "@/styles/encrypted-content.css"(即不得反向依赖加密包装器),保证加密能力是可选的、不污染站点样式体系。
内容侧开关:frontmatter
/* Page encryption fields */
encrypted: z.boolean().optional().default(false),Source: content.config.ts
encrypted 默认 false。README 指出可选字段覆盖"browser-side encryption"等能力,且加密文章会被排除出 RSS 与 Atom 输出——这是聚合侧安全边界的实现点。
核心流程:浏览器端解密(PasswordProtection.astro)
组件结构与 i18n
1---
2import I18nKey from "@i18n/i18nKey";
3import { i18n } from "@i18n/translation";
4
5import PasswordModal from "./PasswordModal.svelte";
6
7export interface Props {
8 encryptedContent: string;
9 hint?: string;
10}
11
12const { encryptedContent, hint = "" } = Astro.props;
13
14const i18nUnlocking = i18n(I18nKey.passwordUnlocking);
15const i18nIncorrect = i18n(I18nKey.passwordIncorrect);
16const i18nDecryptionError = i18n(I18nKey.decryptionError);
17const i18nUnlock = i18n(I18nKey.passwordUnlock);
18const i18nPasswordRequired = i18n(I18nKey.passwordRequired);
19const i18nPasswordDecryptRetry = i18n(I18nKey.passwordDecryptRetry);
20---
21
22<div id="password-protection">
23 <PasswordModal client:load hint={hint} />
24</div>
25
26<div
27 id="decrypted-content"
28 class="decrypted-content w-full"
29 style="display: none;"
30>
31</div>Source: PasswordProtection.astro
初始 DOM 状态:密码层 #password-protection 可见,内容容器 #decrypted-content 通过内联 style="display:none" 隐藏。内容容器带有 0.3s 淡入动画,并对解密后注入的 .table-wrapper / table 施加横向滚动与全宽约束,.encrypted-hidden 类被全局规则强制隐藏。
事件通信协议
内联脚本通过 document 上的 CustomEvent 与 Svelte 弹窗解耦:
1function showError(message) {
2 const event = new CustomEvent("password:error", { detail: message });
3 document.dispatchEvent(event);
4}
5
6function setLoading(loading) {
7 const event = new CustomEvent("password:loading", { detail: loading });
8 document.dispatchEvent(event);
9}
10
11function clearError() {
12 const event = new CustomEvent("password:clear-error", {});
13 document.dispatchEvent(event);
14}Source: PasswordProtection.astro
| 事件 | detail | 语义 |
|---|---|---|
password:error | 错误文案 | 向弹窗展示失败原因(如"密码必填") |
password:loading | boolean | 切换解密中的加载态 |
password:clear-error | — | 清除错误提示 |
这一设计的意图是:把密码学逻辑放在 is:inline 脚本(必须同步注入常量与密文、不参与打包),把表单交互留在 Svelte 组件(可用框架状态管理),两者仅靠事件耦合。
常量镜像
1var PBKDF2_ITERATIONS = 100000;
2var SALT_LENGTH = 16;
3var IV_LENGTH = 12;
4var AUTH_TAG_LENGTH = 16;
5var VERIFY_PREFIX = "MIZUKI-VERIFY:";Source: PasswordProtection.astro
这是 CRYPTO_CONSTANTS 在客户端的手工镜像。因为脚本声明为 is:inline,无法 import 常量模块,所以靠人工同步——这正是 crypto-utils.ts 顶部注释强调"必须保持同步"的原因,也是该机制最重要的演进约束。
decryptContent():与构建端互逆
1async function decryptContent(encData, password) {
2 var raw = base64ToUint8Array(encData);
3 var salt = raw.slice(0, SALT_LENGTH);
4 var iv = raw.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
5 var authTag = raw.slice(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
6 var ciphertext = raw.slice(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
7
8 var combined = new Uint8Array(ciphertext.length + AUTH_TAG_LENGTH);
9 combined.set(ciphertext, 0);
10 combined.set(authTag, ciphertext.length);
11
12 var enc = new TextEncoder();
13 var keyMaterial = await crypto.subtle.importKey(
14 "raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]
15 );
16 var aesKey = await crypto.subtle.deriveKey(
17 { name: "PBKDF2", salt: salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
18 keyMaterial, { name: "AES-GCM", length: 256 }, false, ["decrypt"]
19 );
20 var decrypted = await crypto.subtle.decrypt(
21 { name: "AES-GCM", iv: iv }, aesKey, combined
22 );
23 var decoded = new TextDecoder().decode(decrypted);
24 if (!decoded.startsWith(VERIFY_PREFIX)) {
25 throw new Error("Verification prefix mismatch");
26 }
27 return decoded.substring(VERIFY_PREFIX.length);
28}Source: PasswordProtection.astro
关键点:
- 密文布局切分严格按
16 / 12 / 16固定偏移读取salt / iv / authTag / ciphertext,与构建端Buffer.concat顺序一一对应; combined重排:Node 的getAuthTag()把 tag 放在密文之后单独返回,而 WebCrypto 的 AES-GCM 要求输入为ciphertext || authTag,因此客户端把尾部 authTag 拼接到 ciphertext 之后——这是两端格式差异的唯一结构性转换;importKey/deriveKey的extractable=false,密钥不可导出;- PBKDF2 参数(100k 次、SHA-256、同一 salt)与构建端逐位一致,否则派生出不同密钥导致 GCM 认证失败;
- 最后校验
VERIFY_PREFIX,不匹配则抛Verification prefix mismatch,返回时剥掉前缀恢复原始 HTML。
attemptUnlock():解密成功后的 DOM 重建
1var realContent = await decryptContent(encryptedContent, inputPassword);
2var contentDiv = document.getElementById("decrypted-content");
3var protectionDiv = document.getElementById("password-protection");
4
5if (contentDiv) {
6 contentDiv.innerHTML = realContent;
7
8 var scripts = contentDiv.querySelectorAll("script");
9 var scriptPromises = Array.from(scripts).map(function(script) {
10 return new Promise(function(resolve) {
11 var newScript = document.createElement("script");
12 if (script.type) {
13 newScript.type = script.type;
14 }
15 newScript.textContent = script.textContent;
16 newScript.onload = resolve;
17 newScript.onerror = resolve;
18 if (script.parentNode) {
19 script.parentNode.replaceChild(newScript, script);
20 }
21 if (!newScript.src) {
22 resolve(undefined);
23 }
24 });
25 });
26 await Promise.all(scriptPromises);
27
28 if (protectionDiv && protectionDiv.parentNode) {
29 protectionDiv.remove();
30 }
31 contentDiv.style.display = "block";Source: PasswordProtection.astro
脚本重建是必经步骤:innerHTML 赋值插入的 <script> 不会被执行,因此逐个创建新 <script> 节点(保留 type 与内联代码)并用 replaceChild 原地替换,等待 Promise.all 全部 settle 后再移除密码层并显示内容。onerror 也 resolve,保证单个脚本失败不阻塞整体解锁。
随后:
1 var shareComponent = document.getElementById("share-component");
2 var licenseComponent = document.getElementById("license-component");
3 if (shareComponent) {
4 shareComponent.classList.remove("encrypted-hidden");
5 }
6 if (licenseComponent) {
7 licenseComponent.classList.remove("encrypted-hidden");
8 }
9
10 sessionStorage.setItem(
11 "page-password-" + window.location.pathname,
12 inputPassword,
13 );
14
15 triggerPostDecryptUpdates();Source: PasswordProtection.astro
- 通过移除
encrypted-hidden类,把页面上"分享 / 许可"等外围组件一并解锁——这些组件在构建期被标记隐藏,等待解密完成后才显现; - 明文密码被存入
sessionStorage(键为page-password-+ 当前路径),用于同一会话内的页面复访免重输;失败路径会调用sessionStorage.removeItem清除(见失败模式)。
端到端时序
时序图揭示了三个设计决策:① 错误路径与成功路径对称地清理/写入 sessionStorage,避免脏密码残留;② finally 中统一复位加载态,两条路径收敛;③ 所有密码学与 DOM 操作集中在一个内联脚本里,Svelte 组件只负责输入与反馈。
配置选项
Encryptor.astro Props
| Prop | 类型 | 默认值 | 说明 |
|---|---|---|---|
password | string | number | 必填 | 加密/解密密码;数值会被 String() 归一化 |
slug | string | 必填 | 参与确定性 salt/IV 派生的上下文,每页应唯一 |
hint | string | "" | 密码提示,透传给 PasswordModal 展示 |
PasswordProtection.astro Props
| Prop | 类型 | 默认值 | 说明 |
|---|---|---|---|
encryptedContent | string | 必填 | base64 密文(由 encryptContent 产出) |
hint | string | "" | 密码提示 |
Frontmatter
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
encrypted | boolean(optional) | false | 标记该页面为加密内容,参与 RSS/Atom 排除与列表脱敏 |
加密常量(CRYPTO_CONSTANTS)
| 常量 | 值 | 说明 |
|---|---|---|
PBKDF2_ITERATIONS | 100000 | 密钥派生迭代次数 |
SALT_LENGTH | 16 字节 | 确定性 salt 长度 |
IV_LENGTH | 12 字节 | AES-GCM IV 长度 |
AUTH_TAG_LENGTH | 16 字节 | GCM 认证标签长度 |
KEY_LENGTH | 32 字节 | AES-256 密钥长度 |
VERIFY_PREFIX | "MIZUKI-VERIFY:" | 协议 v2 校验前缀 |
API Reference
encryptContent(html: string, password: string, slug: string): string
构建期加密入口,位于 src/utils/crypto-utils.ts。
参数:
html(string):待加密的完整 HTML 字符串(通常来自Astro.slots.render("default"))password(string):明文密码slug(string):页面标识,作为 HMAC 派生上下文(salt:${slug}、iv:${slug})
返回: base64(salt[16] + iv[12] + authTag[16] + ciphertext),其中 ciphertext = AES-256-GCM("MIZUKI-VERIFY:" + html)
抛出: 继承 Node crypto 常规异常(如非法参数导致 createCipheriv 抛错)。加密本身不校验密码强度。
deriveBytes(key: string, context: string, length: number): Buffer
模块私有函数(未导出)。
参数:
key:HMAC-SHA256 的 key(此处即密码)context:HMAC 消息(salt:${slug}或iv:${slug})length:截取前 N 字节
返回: 32 字节摘要的前 length 字节。
decryptContent(encData, password): Promise<string>(客户端内联)
参数:
encData:base64 密文字符串password:用户输入密码
返回: 剥离 VERIFY_PREFIX 后的明文 HTML。
抛出:
- GCM 认证失败(密码错误 / 数据被篡改)→ WebCrypto 抛出
OperationError "Verification prefix mismatch":解密成功但前缀不符(协议异常路径)
attemptUnlock(inputPassword): Promise<void>(客户端内联)
参数: inputPassword:用户输入;空值时通过 password:error 提示必填并直接返回。
行为: 解密 → 注入 #decrypted-content → 重建脚本 → 移除密码层 → 解锁外围组件 → 写入 sessionStorage;任一步失败进入 catch。
失败模式、边界情况与并发
失败路径
内联脚本的 catch 分支:
1} catch (error) {
2 console.error(i18nDecryptionError, error);
3 showError(i18nIncorrect);
4 sessionStorage.removeItem("page-password-" + window.location.pathname);
5} finally {Source: PasswordProtection.astro
- 密码错误:GCM authTag 校验失败 → 异常 →
password:error展示"密码不正确",同时清除该路径的 sessionStorage 缓存,防止错误密码在下一次复访时被误用; - 数据损坏/被篡改:同样走 GCM 认证失败;若极端情况下解密成功但无
VERIFY_PREFIX,则显式抛Verification prefix mismatch; - 密码为空:不进入解密,直接以
password:error提示必填; - 解密成功但脚本加载失败:
newScript.onerror也resolve,单个脚本失败不阻塞解锁流程(静默降级)。
安全边界(重要)
README 明确声明:
Encrypted posts are excluded from RSS and Atom, but browser-side encryption is not server-side access control.
Source: README.en.md
这意味着该机制的真实保证是:
- 静态产物中不含明文正文(加密发生在
Astro.slots.render()之后); - 密码不出现在客户端产物:密文、hint 由
define:vars注入内联脚本,密码仅存在于构建环境; - 明文不经过网络:解密在访客浏览器内完成;
- 聚合侧排除:加密文章不进入 RSS/Atom feed;
- 列表侧脱敏:
tests/post-card-content.test.ts验证getPostHomeContent等列表/描述函数对加密文章统一返回ENCRYPTED_POST_HOME_CONTENT占位文本,卡片与描述不泄露正文片段。
它不能阻止:持有密文且离线暴力破解密码的攻击者(PBKDF2 100k 次迭代显著抬高单次尝试成本,但密码强度仍是决定因素);也无法对源码仓库本身保密(若明文 Markdown 提交到仓库,源码可见性与本机制无关)。
并发与一致性
- 构建可重现:salt/IV 确定性派生,同输入恒得同密文,支持缓存与幂等重建;
- 每页独立上下文:派生使用
slug作为 HMAC 上下文,即使多页共用一个密码,salt/IV/密钥也互不相同,单页密文泄露不影响其他页面; - 运行时单线程:浏览器侧
attemptUnlock为 async 串行流程,Promise.all聚合脚本重建;无共享可变状态,重复提交只是重复走同一异步流程; - 会话级缓存:sessionStorage 以路径为键隔离,同一会话同一路径免重输;关闭标签页即失效,密码不落 localStorage,不跨会话持久化。
性能与运维
- 构建期成本:每篇加密文章一次 PBKDF2(100k 次 SHA-256)+ 一次 AES-GCM 加密。在 Node 端毫秒级,对整体构建时间影响可忽略。
- 浏览器端成本:每次解锁执行一次 PBKDF2 100k 次迭代(WebCrypto 原生实现,通常几十至一两百毫秒),随后 GCM 解密近乎瞬时。设计上把昂贵步骤放在点击"解锁"之后,而非页面加载时,避免拖慢首屏。
- 样式解耦约束:
tests/layout-regressions.test.mjs断言每个全局样式表都不得包含import "@/styles/encrypted-content.css",即站点样式不得反向依赖可选的加密包装器。运维上新增全局样式时需保持该方向性约束。 - 常量同步是最大的运维风险:调整任一端常量(迭代次数、长度、前缀)而不同步另一端,会导致所有旧密文永久无法解密(GCM 认证失败)。变更必须成对进行,且旧内容需重新构建。
扩展点
- 协议版本演进:
VERIFY_PREFIX即协议版本载体——源码注释将其称为"协议 v2"。引入 v3 时可在前缀中编码版本号,客户端据此分派解密逻辑,实现新旧密文共存。 - 常量单一来源化:消除
is:inline脚本中手工镜像常量的办法,是构建期把CRYPTO_CONSTANTS通过define:vars注入(如同encryptedContent的注入方式),从结构上消灭两端漂移的可能。 - 密码缓存策略:sessionStorage 可替换为"缓存派生密钥而非明文密码"或加入 TTL,进一步降低明文密码在内存中的驻留窗口。
- 外围组件解锁:
encrypted-hidden机制天然可扩展——任何需要在"解密后才显示"的页面元素,只需在构建期加上该类,即可由内联脚本统一解锁(当前已覆盖share-component、license-component)。 - 列表脱敏复用:
ENCRYPTED_POST_HOME_CONTENT/getPostHomeContent模式可推广到搜索索引、站点地图等任何会输出正文摘要的聚合出口。
测试
tests/post-card-content.test.ts:核心数据脱敏保障。多组断言验证加密文章在首页卡片content与description两条路径上都返回ENCRYPTED_POST_HOME_CONTENT,而非真实正文——这是"列表不泄露"边界的回归防线。tests/layout-regressions.test.mjs:读取Encryptor.astro源文本,断言各可选样式表不依赖加密包装器(!encryptorSource.includes(...)),保护样式体系与可选加密能力的解耦。
Sources:
Related Links
- crypto-utils.ts — 构建期加密核心
- Encryptor.astro — 构建期包装器
- PasswordProtection.astro — 浏览器端解密运行时
- content.config.ts —
encryptedfrontmatter 定义 - post-card-content.test.ts — 列表脱敏回归测试
- layout-regressions.test.mjs — 样式解耦回归测试
- docs/CONTENT_AUTHORING.md — 内容创作指南(含加密限制与发布清单)