Repository Wiki
LyraVoid/Mizuki

加密内容机制与安全边界

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

Loading diagram...

架构分两条清晰的链路:

  1. 构建期(Node.js 侧):content.config.ts 定义 encrypted 布尔标志 → Encryptor.astro 接收 password/slug/hint 三个 Props,先用 Astro.slots.render("default") 把被包裹的正文渲染为 HTML 字符串,再调用 crypto-utils.ts 的 encryptContent() 完成加密。密码在此链路中使用后即丢弃,不会进入客户端产物。
  2. 运行时(浏览器侧):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,无第三方依赖。

共享常量

ts
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_ITERATIONS100000PBKDF2 迭代次数,同时决定构建期与浏览器端的暴力破解成本
SALT_LENGTH16派生 salt 的字节长度
IV_LENGTH12AES-GCM 标准 IV 长度
AUTH_TAG_LENGTH16GCM 认证标签长度
KEY_LENGTH32256 位 AES 密钥
VERIFY_PREFIX"MIZUKI-VERIFY:"协议 v2 的明文前缀,用于解密后校验

确定性派生:deriveBytes()

ts
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 密文格式

ts
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

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

ts
/* Page encryption fields */ encrypted: z.boolean().optional().default(false),

Source: content.config.ts

encrypted 默认 false。README 指出可选字段覆盖"browser-side encryption"等能力,且加密文章会被排除出 RSS 与 Atom 输出——这是聚合侧安全边界的实现点。

核心流程:浏览器端解密(PasswordProtection.astro)

组件结构与 i18n

astro
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 弹窗解耦:

js
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:loadingboolean切换解密中的加载态
password:clear-error—清除错误提示

这一设计的意图是:把密码学逻辑放在 is:inline 脚本(必须同步注入常量与密文、不参与打包),把表单交互留在 Svelte 组件(可用框架状态管理),两者仅靠事件耦合。

常量镜像

js
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():与构建端互逆

js
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 重建

js
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,保证单个脚本失败不阻塞整体解锁。

随后:

js
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 清除(见失败模式)。

端到端时序

Loading diagram...

时序图揭示了三个设计决策:① 错误路径与成功路径对称地清理/写入 sessionStorage,避免脏密码残留;② finally 中统一复位加载态,两条路径收敛;③ 所有密码学与 DOM 操作集中在一个内联脚本里,Svelte 组件只负责输入与反馈。

配置选项

Encryptor.astro Props

Prop类型默认值说明
passwordstring | number必填加密/解密密码;数值会被 String() 归一化
slugstring必填参与确定性 salt/IV 派生的上下文,每页应唯一
hintstring""密码提示,透传给 PasswordModal 展示

PasswordProtection.astro Props

Prop类型默认值说明
encryptedContentstring必填base64 密文(由 encryptContent 产出)
hintstring""密码提示

Frontmatter

字段类型默认值说明
encryptedboolean(optional)false标记该页面为加密内容,参与 RSS/Atom 排除与列表脱敏

加密常量(CRYPTO_CONSTANTS)

常量值说明
PBKDF2_ITERATIONS100000密钥派生迭代次数
SALT_LENGTH16 字节确定性 salt 长度
IV_LENGTH12 字节AES-GCM IV 长度
AUTH_TAG_LENGTH16 字节GCM 认证标签长度
KEY_LENGTH32 字节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 分支:

js
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

这意味着该机制的真实保证是:

  1. 静态产物中不含明文正文(加密发生在 Astro.slots.render() 之后);
  2. 密码不出现在客户端产物:密文、hint 由 define:vars 注入内联脚本,密码仅存在于构建环境;
  3. 明文不经过网络:解密在访客浏览器内完成;
  4. 聚合侧排除:加密文章不进入 RSS/Atom feed;
  5. 列表侧脱敏: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:

Sources

(3 files)
src/components/features/auth