☰
VuePress vdoing 保密文章:页面过滤、路由守卫与正文加密
2026/11/2 8:18:28 网站建设 项目流程

从一个真实场景说起。去年帮一个团队搭内部文档站,选的是 vuepress-theme-vdoing,三十多篇运维手册和接口约定写得挺顺,唯独有几篇涉及账号体系和内网拓扑的文档不该对外。当时图省事,给那几篇配了个没人猜得到的 permalink,导航里也没挂链接,心想这样就查不到了。结果上线第二天,有人从站内搜索里搜出了其中一篇的标题和摘要,侧边栏也自动把它们列了出来——因为主题的结构化侧边栏是按 categories 自动生成的。这件事让我彻底想明白:在 vuepress-theme-vdoing 这类静态站点上做“保密文章”,靠藏路径几乎没有意义,必须落到 Vue 层,从页面数据、路由和渲染结果三个环节分别做处理。下面这套做法,就是我在那次返工之后沉淀下来的,核心是围绕 vue 配置和 enhanceApp.js 展开,配合一个构建期的正文加密脚本。读者对象是已经用 vdoing 跑起一个文档站、会写点 Vue 2、想给部分文章加一道门槛的人。全文给的是可以直接抄的方案,包括每一步为什么这么做、以及我在调试阶段踩过的坑。

1. 先厘清一件事:静态站点上的“保密”到底防的是什么

1.1 没有后端,就没有真正的权限

把话说在前面,免得后面白忙。VuePress 构建出来的是纯静态资源,扔在任意一个静态托管上就能访问,中间没有任何进程能替你做鉴权。这意味着所有“保密”手段最终都要在浏览器里执行,而浏览器里的代码和密钥,理论上都能被拿到。所以这类方案的本质是提高门槛、拦住顺手翻看的人,而不是构建一个安全的权限系统。

这个定位决定了后面的技术选型。如果那几篇文档的敏感程度到了“泄露会造成实质损失”的级别,正确做法是把它们从静态站里拿出去,放到有真实鉴权的系统里,比如带登录态的内部文档平台。而如果只是“不想让外部人随随便便看到”“不想在公众号里被转发出去”,那么前端这道门是完全够用的。

我在实际项目里的判断标准很朴素:假设这套前端防护被人用五分钟破解了,后果能不能接受。能接受,就继续做;不能接受,就别在静态站上花时间,直接换承载方式。这个判断前置之后,后面所有取舍都会清晰很多。

1.2 一次泄露复盘:三处自动生成的东西把文章交了出去

回到开头那个事故。当时我以为“没有链接 = 访问不到”,但重新翻构建产物时,发现至少三个地方把我卖了:

第一处是主题的结构化侧边栏。vdoing 的sidebar: 'structuring'模式会根据每篇文章 frontmatter 里的categories自动归类,只要有分类,它就会被塞进侧边栏。我当时给这几篇打了内部文档的分类名,于是它们堂而皇之地出现在所有访客的左侧目录里。

第二处是首页的“最近更新”区块。vdoing 的首页会从全站页面池里按date字段排序,取最近更新的若干篇展示。保密文章的date是新的,所以排在了最前面。

第三处最隐蔽——搜索索引。不管用的是主题内置搜索还是另外装的全文搜索插件,原理都一样:构建期把每个页面的标题、标题层级、正文片段抽出来,生成一个 JSON 索引文件放进dist。这个文件是静态的,任何人可以直接下载。也就是说,即使页面打不开,标题和摘要已经在索引里泄露了。

把这三处列出来之后你会发现,它们有一个共同源头:全站的页面元数据集合。在 VuePress 1.x 里,这个东西叫siteData.pages,通过this.$site.pages在任何组件里都能拿到。所有聚合类功能——首页最近更新、分类页、标签页、归档页、侧边栏自动生成——全是从这一个数组里派生出来的。所以只要能控制住这个数组,就能一次性掐掉前两处暴露。第三处属于构建期产物,得单独处理。

1.3 三道防线,按成本从低到高排

我把整套方案拆成三层,每层解决不同的问题,你可以按需要只做前两层:

防线解决的问题实现位置大概工作量
第一层:隐身让文章从导航、侧边栏、首页聚合里消失enhanceApp.js过滤页面数组半小时
第二层:上锁直接输 URL 也进不去,要口令enhanceApp.js路由守卫 + 登录页两小时
第三层:密文打包产物、HTML 源码里也翻不出明文构建期脚本 + 客户端解密组件半天

这三层的关系是递进的,不是替代的。第一层不做,第二层就形同虚设——别人虽然进不去页面,但从侧边栏看到标题就知道有这么个东西存在;第二层不做,第三层就变成“加密了个寂寞”,因为密文虽然看不懂,但页面结构、标题、目录还摆在那儿;第三层不做,前两层都能被view-source或者直接翻dist目录绕过去。

顺带提一句,VuePress 1.x 生成的是服务端预渲染过的 HTML,每个.html文件里带着页面的完整内容。这一点很多人没意识到,以为“反正数据是 JS 加载的,源码里看不到”。不是的,你打开dist下任意一个 html,正文就在里面。这也是为什么第三层不能省。

2. vdoing 把一篇 Markdown 加工成了什么,frontmatter 是唯一能穿越到浏览器的口子

2.1 从 md 文件到 siteData.pages 的完整链路

要精准地做拦截,得先知道数据是怎么流的。VuePress 1.x 的构建分两大阶段,第一阶段在 Node 环境里跑,扫描docs目录下所有 md,解析每个文件,产出一个 Page 对象。这个对象上有几个关键属性:

  • key:页面唯一标识,通常是相对路径
  • path:最终的路由路径,由permalink决定,没有就按文件路径推导
  • regularPath:不带 hash 和 query 的路径
  • title:页面标题,取自 frontmatter 或首个一级标题
  • frontmatter:整个 YAML 头部的原样对象,包括你自己加的任何字段
  • headers:页面内所有标题构成的树,用于右侧目录

这些 Page 对象汇总起来,就是siteData.pages。第二阶段,服务端渲染器拿这堆数据渲染出各个页面的 HTML,同时把siteData序列化成一坨 JS 塞进每个页面,浏览器启动后 Vue 接管,this.$site就指向这坨数据。

结论很清楚:frontmatter 里的自定义字段,是唯一能无损地从 md 文件一路传递到浏览器组件里的东西。你想让前端知道“这篇是保密文章”,最直接的办法就是在 frontmatter 里打标记。

--- title: 内网账号体系说明 date: 2024-03-11 secret: true secretGate: false permalink: /internal/account/ ---

secret: true是我自己定的约定字段,VuePress 不认它,但它会原封不动出现在$page.frontmatter和$site.pages[i].frontmatter里。这比在正文里写特殊标记、或者按路径前缀猜,都要可靠得多——后者一旦有人改了目录结构就失效。

2.2 enhanceApp.js 到底在什么时候执行

.vuepress/enhanceApp.js是 VuePress 1.x 提供的客户端增强入口,签名长这样:

export default ({ Vue, // Vue 构造函数,可以在这里做全局注册 options, // 根实例的选项,可以加 mixin router, // 路由实例,可以挂守卫 siteData, // 站点元数据,就是前面说的那一坨 isServer // 当前是否在服务端渲染阶段 }) => { // 你的代码 }

这个函数会被执行两次:一次在构建期的 Node 环境里(isServer为 true),一次在浏览器里(isServer为 false)。很多人第一次写的时候会在这栽跟头,直接localStorage.getItem就报localStorage is not defined,构建直接挂掉。

正确的姿势是把逻辑分成两半:跟 DOM、本地存储、路由守卫相关的,全部放在isServer判断为 false 的分支里;纯粹的数据处理,可以两边都跑,但要注意幂等——因为两次执行操作的是两份不同的数据副本,不要假设状态能延续。

这里还有个小陷阱:如果你在isServer为 true 的时候过滤了siteData.pages,构建期的搜索索引、sitemap 会受影响;如果在浏览器侧过滤,只是影响运行时的展示。两边的效果不一样,需要分别考虑。我的做法是两边都过滤,构建期过滤是为了让索引生成阶段也拿不到保密页面,浏览器侧过滤是为了防止运行时从$site.pages里翻出来。

2.3 在路由守卫里怎么拿到当前页面的 frontmatter

router.beforeEach拿到的是 vue-router 的 Route 对象,上面只有path、query、hash、params这些。VuePress 有没有把页面数据挂到meta上?在我用过的 1.x 版本里,并不保证。所以别指望to.meta.frontmatter,老老实实自己查。

查的方式就是拿to.path去siteData.pages里找,匹配path或者regularPath:

function findPage(siteData, path) { return siteData.pages.find(p => p.path === path || p.regularPath === path ) }

这里有个细节值得说:如果你在 vdoing 配置里开了mode: 'hash',浏览器地址栏会变成/#/internal/account/这种形式,但to.path拿到的仍然是/internal/account/,因为 vue-router 已经把 hash 部分剥掉了。所以按path匹配是安全的,不用为 hash 模式单独写逻辑。这一点我一开始没想通,写了个兼容 hash 的匹配函数,结果反而匹配不到,白折腾半天。

另外一个容易忽略的点:带尾斜杠和不带尾斜杠是两条不同的路径。/internal/account和/internal/account/在siteData.pages里只存了一种。所以匹配的时候最好做一次归一化,把尾部斜杠去掉再比:

const normalize = p => p.replace(/\/+$/, '') || '/'

3. 让保密文章从侧边栏、首页和分类页里彻底消失

3.1 用 frontmatter 打标记,把约定固化下来

先把约定定死,后面所有代码都依赖它。我在项目里用这三个字段:

  • secret: true:标记这篇是保密文章,必须上锁
  • secretGate: true:标记这是登录页本身,不参与聚合,但也不能被守卫拦
  • secretLevel:可选,用来区分不同口令等级,一般项目用不上

为了让团队里其他人写新文档时不容易忘,我在docs/.vuepress/public/下放了个模板文件,加上一段构建前的检查脚本——如果某个 md 的路径里含有internal但没写secret,就在控制台抛警告。这种事不能靠自觉,一定要用工具兜住。

3.2 在 enhanceApp.js 里把页面数组过滤掉

这是整套方案里性价比最高的一步。前面分析过,首页最近更新、分类页、标签页、归档页,全都从$site.pages派生。把这一个数组过滤掉,前面这些聚合功能就全都不认识保密文章了。

export default ({ Vue, siteData, isServer }) => { const isSecret = p => p.frontmatter && p.frontmatter.secret === true const isGate = p => p.frontmatter && p.frontmatter.secretGate === true // 先把保密页面存一份,后面守卫要用 const secretPages = siteData.pages.filter(isSecret) if (!isServer) { Vue.prototype.$secretPages = secretPages } // 再从公共池里剔除 siteData.pages = siteData.pages.filter( p => !isSecret(p) && !isGate(p) ) }

这段代码有两个地方需要解释。

第一,为什么不直接delete,而是重新赋值一个新数组?因为siteData.pages可能被其他模块持有引用,直接改数组内容会让别的引用看到不一致的状态,重新赋值反而更干净。实际测试下来,重新赋值在浏览器侧完全生效,$site.pages立刻就是过滤后的结果。

第二,为什么要单独保存一份secretPages挂到Vue.prototype上?因为过滤之后,守卫就没法从$site.pages里查到保密页面了。这份缓存是守卫的数据源。挂到原型上是为了在任何组件里都能this.$secretPages访问,用起来方便。注意isServer为 true 的时候不能挂,服务端渲染阶段挂原型属于污染全局,会影响到其他请求。

3.3 侧边栏和导航的单独处理

siteData.pages过滤能搞定自动聚合,但搞不定手写的配置。vdoing 的themeConfig.nav和themeConfig.sidebar是你在config.js里手写的对象,这些不经过siteData.pages,得单独清理。

导航栏相对简单,保密文章本来就不该出现在顶部导航里,写的时候别加就行。真正麻烦的是侧边栏。

vdoing 支持两种侧边栏模式:'structuring'自动生成,和手写的对象。如果你用的是自动生成模式,过滤siteData.pages之后侧边栏应该也会跟着变(因为它是从页面数据算出来的)。但我在实测中发现,某些版本的自动侧边栏是在主题组件的created里算的,而这个时候siteData.pages已经被我改过了,所以确实是过滤后的结果。这一点建议你自己验证一遍,方法是构建完打开首页,看左侧目录里还有没有保密文章的标题。

如果你用的是手写侧边栏,那就得手动维护。我的建议是:保密文章干脆不要放进sidebar配置,它的入口只通过一个专门的“内部文档”链接给出,点进去才触发守卫。这样既有导航,又不暴露具体有几篇。

补一个容易漏的地方:vdoing 的文章页底部有“上一篇 / 下一篇”的跳转。这个功能是基于侧边栏顺序算的,所以只要侧边栏里没有保密文章,它就不会串过去。但如果你的侧边栏是手写的,而保密文章恰好被排在中间,那就可能出现从普通文章翻到保密文章的情况。测的时候专门点一遍前后翻页,别只看目录。

3.4 构建期产物:搜索索引和 sitemap

这一块是enhanceApp.js管不到的,必须单独处理。

搜索索引。构建完打开dist目录,找一个文件名里带search的 JS 或 JSON。vdoing 主题的搜索实现版本之间差异不小,有的是构建期生成完整索引,有的是运行时从页面数据算。你只需要验证一件事:在dist里全文搜一下保密文章的标题。搜得到,说明索引泄露了,得处理;搜不到,就没事。

处理办法分两种。如果索引是构建期生成的,看看对应插件有没有exclude之类的配置项,或者干脆不装全文搜索,改用主题自带的简化搜索。如果索引是运行时从siteData.pages算的,那我前面那步过滤就已经解决了,不用额外做什么。

sitemap。VuePress 1.x 的 sitemap 是个独立插件,默认不装。如果你装了,记得看它的配置里有没有排除规则。没有的话,最省事的办法是构建完之后用脚本把保密文章的 URL 从sitemap.xml里删掉,或者干脆不生成 sitemap——内部文档站要 sitemap 干什么?

robots。顺手加一条,虽然对内部站意义不大,但成本为零:

User-agent: * Disallow: /internal/

这个文件放在docs/.vuepress/public/robots.txt,构建时会原样拷贝到dist根目录。

4. 用 enhanceApp.js 的路由守卫做访问闸门

4.1 为什么选路由守卫,而不是在每个页面里写 v-if

实现访问控制有好几种思路,我对比过三种:

第一种是在每个保密页面的 md 里内联一个判断组件,没登录就渲染一个提示。问题是每篇都要写,而且 md 里的 Vue 组件在构建期就会被编译,逻辑分散,维护成本高。

第二种是覆盖 Layout,在主题布局外面套一层判断。vdoing 的 Layout 结构比较复杂,包含导航栏、侧边栏、正文、目录、页脚,你要在合适的位置插一层遮罩,得改主题源码或者做组件覆盖,升级主题时容易冲突。

第三种是路由守卫。它在一个地方拦截所有导航,包括直接输 URL、从外链跳进来、浏览器前进后退,覆盖面最全。而且守卫天然知道“要去哪个页面”,可以精确地只拦保密页面,其他页面零影响。

我选第三种。唯一的缺点是:守卫拦不住“页面已经渲染出来之后再加遮罩”的场景,但那本来就不是守卫的职责,是第三层密文化要解决的。

4.2 完整的 enhanceApp.js 骨架

把前面的过滤逻辑和守卫合并起来,完整的文件大概长这样。我加了不少注释,因为这份文件以后团队里别人也要改,注释比代码本身重要。

// docs/.vuepress/enhanceApp.js import CryptoJS from 'crypto-js' const TOKEN_KEY = 'docs_access_token_v1' function normalizePath(p) { const s = (p || '/').split('?')[0].split('#')[0] return s.replace(/\/+$/, '') || '/' } export default ({ Vue, router, siteData, isServer }) => { const isSecret = p => p.frontmatter && p.frontmatter.secret === true const isGate = p => p.frontmatter && p.frontmatter.secretGate === true // ---- 1. 数据层过滤,两边都跑 ---- const secretPages = siteData.pages.filter(isSecret) siteData.pages = siteData.pages.filter(p => !isSecret(p) && !isGate(p)) // ---- 2. 浏览器侧才做的事 ---- if (isServer) return Vue.prototype.$secretPages = secretPages // 口令的哈希,写在 themeConfig 里 const expected = (siteData.themeConfig.secret && siteData.themeConfig.secret.hash) || '' // ---- 3. 注册守卫 ---- router.beforeEach((to, from, next) => { const target = normalizePath(to.path) // 登录页本身放行,否则会死循环 const gatePath = normalizePath( (siteData.themeConfig.secret && siteData.themeConfig.secret.gatePath) || '/secret-login/' ) if (target === gatePath) return next() // 找目标页面,先从保密缓存里找,找不到就放行 const hit = secretPages.find(p => normalizePath(p.path) === target || normalizePath(p.regularPath) === target ) if (!hit) return next() // 已经有有效令牌就放行 const token = window.localStorage.getItem(TOKEN_KEY) if (token && token === expected) return next() // 否则跳登录页,带上回跳地址 next({ path: gatePath, query: { redirect: to.fullPath } }) }) }

几个地方值得单独说明。

口令为什么存哈希而不是明文。themeConfig.secret.hash里放的是口令的 SHA-256,构建完被打进 JS 里,任何人都能读到。但读到哈希不代表能反推出原文——只要口令本身够长够随机。校验的时候前端把用户输入的明文算一次哈希,跟这个值比。这样至少不会出现“打开源码直接看到密码”的尴尬。计算哈希的命令:

node -e "console.log(require('crypto').createHash('sha256').update('你的口令').digest('hex'))"

把输出填进config.js:

// docs/.vuepress/config.js module.exports = { theme: 'vdoing', themeConfig: { secret: { hash: 'a1b2c3...(上面命令的输出)', gatePath: '/secret-login/' }, // ...其他 vdoing 配置 } }

为什么不用next(false)。next(false)是中止导航,浏览器地址栏会停在原地,用户看到的是一个没反应的页面,体验很差。跳到登录页再回跳,用户能明确知道“这里要口令”,也知道输完之后会去哪。

to.fullPath和to.path的区别。fullPath带 query 和 hash,回跳时用它能保留原始访问意图。比如有人分享了一个带锚点的链接#第二节,回跳之后能直接定位过去。这个细节很小,但做和不做,使用体感差一截。

4.3 登录页与回跳逻辑

登录页是一个普通的 md 文件,放在docs/secret-login.md,frontmatter 里标上secretGate: true,正文里嵌一个组件:

--- title: 访问验证 secretGate: true sidebar: false permalink: /secret-login/ ---
<SecretGate />

组件本身放在docs/.vuepress/components/SecretGate.vue,这是 VuePress 约定的全局组件目录,放进去就能在任意 md 里直接用,不需要注册。

<template> <div class="gate-wrap"> <div class="gate-card"> <h3>该内容需要访问口令</h3> <p class="gate-tip">输入团队内部口令后继续,验证状态会保存在本机浏览器。</p> <input v-model="input" type="password" class="gate-input" placeholder="请输入口令" @keyup.enter="submit" /> <button class="gate-btn" @click="submit">验证</button> <p class="gate-err" v-if="error">{{ error }}</p> </div> </div> </template> <script> import CryptoJS from 'crypto-js' const TOKEN_KEY = 'docs_access_token_v1' export default { data() { return { input: '', error: '' } }, methods: { submit() { const expected = (this.$site.themeConfig.secret || {}).hash if (!expected) { this.error = '站点未配置口令,请联系维护者' return } const got = CryptoJS.SHA256(this.input).toString() if (got !== expected) { this.error = '口令不正确' this.input = '' return } window.localStorage.setItem(TOKEN_KEY, expected) const redirect = this.$route.query.redirect || '/' this.$router.replace(redirect) } } } </script> <style scoped> .gate-wrap { display: flex; justify-content: center; padding: 60px 16px; } .gate-card { width: 100%; max-width: 380px; padding: 28px; border: 1px solid var(--borderColor, #e5e5e5); border-radius: 8px; } .gate-input { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #ddd; border-radius: 4px; margin: 16px 0 12px; } .gate-btn { width: 100%; padding: 10px; border: none; border-radius: 4px; background: #3eaf7c; color: #fff; cursor: pointer; } .gate-err { color: #e74c3c; font-size: 13px; margin-top: 10px; } </style>

有一个细节必须处理:登录成功后用replace而不是push。用push的话,用户按浏览器后退会回到登录页,而这个时候令牌已经存在了,守卫会立刻把他弹回保密页面,形成一个“后退一下就闪一下”的诡异体验。replace直接把登录页从历史栈里换掉,后退时直接回到进入保密页面之前的位置。

4.4 多标签页与刷新掉登录

令牌存在localStorage里,刷新不掉,这是基本要求。但有个场景容易被忽略:用户在标签页 A 验证通过,然后在标签页 B 打开同一个保密页面,B 能正常访问(因为共享 localStorage)。反过来,如果用户清掉了本地存储,A 标签页里已经打开的保密页面不会自动锁上——因为守卫只在导航时触发,页面已经渲染完就不会再检查。

这个洞要不要补,取决于你对“保密”的严格程度。如果要补,思路是监听storage事件,当令牌被删掉时刷新或跳转:

window.addEventListener('storage', (e) => { if (e.key === TOKEN_KEY && !e.newValue) { window.location.reload() } })

加上这段之后,在另一个标签页退出登录,当前页会立刻重新走守卫。代价是多一个监听器,以及用户可能被“莫名其妙”地刷新一下。内部文档站我一般不加,因为用户主动清 localStorage 的概率极低,收益不大。

5. 把正文本身加密:让打包产物里也翻不出明文

5.1 为什么前两层还不够

假设你只做了前两层。现在的情况是:首页看不到,侧边栏看不到,搜不到,直接输 URL 会被弹到登录页。看起来挺严实。但任何人拿到dist目录(比如你把它部署在某个可以列目录的静态托管上),打开dist/internal/account/index.html,右键查看源码,正文还是明明白白在那儿。

原因在 2.1 节说过:VuePress 1.x 是服务端预渲染。渲染发生在构建期,产物里就是最终 HTML。守卫只在 Vue 启动后拦截导航,它拦不住“文件本身已经躺在服务器上”这个事实。

所以第三层是必要的:把正文从 HTML 里拿掉,只留一段密文,等用户通过口令之后在浏览器里解出来再渲染。这样即使整个dist被人打包带走,他看到的也只是 base64 字符串。

再说一次边界:这依然是前端加密,密钥最终要在浏览器里用,有心人抓一下就能拿到。它的作用是防止“随手翻源码就能看”,不是防止“专门破解”。

5.2 构建期脚本:把正文换进 frontmatter

我试过好几种载体,最后选了 frontmatter 存密文、正文放占位组件的方式。原因是它最省事:frontmatter 天然会进入$page.frontmatter,组件里直接读,不用去解析 DOM,也不用处理 markdown 渲染顺序的问题。

脚本大概长这样,放在scripts/seal.js:

// scripts/seal.js const fs = require('fs') const path = require('path') const CryptoJS = require('crypto-js') const ROOT = path.resolve(__dirname, '../docs') const KEY = process.env.SEAL_KEY || 'dev-only-key-change-me' const CACHE = path.resolve(__dirname, '../.seal-cache') function walk(dir, out = []) { for (const name of fs.readdirSync(dir)) { const full = path.join(dir, name) const stat = fs.statSync(full) if (stat.isDirectory()) { if (name === '.vuepress' || name === 'node_modules') continue walk(full, out) } else if (name.endsWith('.md')) { out.push(full) } } return out } function parseFm(raw) { const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/) if (!m) return null return { fmText: m[1], body: raw.slice(m[0].length) } } function seal() { fs.mkdirSync(CACHE, { recursive: true }) const files = walk(ROOT) let count = 0 for (const file of files) { const raw = fs.readFileSync(file, 'utf8') const parsed = parseFm(raw) if (!parsed) continue if (!/^secret:\s*true\s*$/m.test(parsed.fmText)) continue if (/^cipher:/m.test(parsed.fmText)) continue // 已加密,跳过 // 备份原文,方便还原 const rel = path.relative(ROOT, file).replace(/[\\/]/g, '__') fs.writeFileSync(path.join(CACHE, rel), raw, 'utf8') const cipher = CryptoJS.AES.encrypt(parsed.body, KEY).toString() const newFm = parsed.fmText + `\ncipher: '${cipher}'` const out = `---\n${newFm}\n---\n\n<SealedContent />\n` fs.writeFileSync(file, out, 'utf8') count++ } console.log(`[seal] 已处理 ${count} 篇保密文章`) } function unseal() { if (!fs.existsSync(CACHE)) return for (const name of fs.readdirSync(CACHE)) { const file = path.join(ROOT, name.replace(/__/g, path.sep)) fs.copyFileSync(path.join(CACHE, name), file) } console.log('[seal] 已还原源文件') } if (process.argv.includes('--restore')) unseal() else seal()

有几个设计取舍要交代。

为什么就地改写而不是输出到新目录。因为就地改写能保留所有相对路径,图片引用、附件链接全都不用改。代价是会污染源文件,所以配了.seal-cache备份和--restore参数。实际使用时,构建流程是:先git status确认干净,跑seal,跑vuepress build,跑seal --restore。中间任何一步失败,都可以git checkout .回滚。

为什么跳过已有 cipher 字段的。防止重复加密。如果构建失败重跑一次,脚本会看到已经有cipher就跳过,不会把密文再加密一遍。

密钥从哪来。SEAL_KEY环境变量优先,没有就用一个开发用的默认值。生产构建一定要通过 CI 的密钥管理注入,不要写死在脚本里。写死的话,这个密钥会跟着代码进仓库,等于没有。

密文为什么放单引号里。 frontmatter 是 YAML,base64 里可能出现+、/、=,虽然大部分情况不加引号也能解析,但=结尾的字符串在某些 YAML 解析器里会被当成类型标记。加单引号最保险,而且 YAML 单引号内的内容不做转义,原样保留。

5.3 客户端解密组件

组件负责三件事:从 frontmatter 拿密文、用密钥解、把 markdown 渲染成 HTML。

<template> <div class="sealed-content"> <div v-if="loading" class="sealed-tip">正在解密…</div> <div v-else-if="error" class="sealed-tip sealed-err">{{ error }}</div> <div v-else v-html="html"></div> </div> </template> <script> import CryptoJS from 'crypto-js' import { marked } from 'marked' export default { data() { return { html: '', error: '', loading: true } }, mounted() { this.render() }, methods: { render() { try { const cipher = this.$page.frontmatter.cipher if (!cipher) { this.error = '未找到密文,可能未执行加密脚本' return } const key = this.$site.themeConfig.secret.contentKey if (!key) { this.error = '站点未配置解密密钥' return } const plain = CryptoJS.AES.decrypt(cipher, key).toString(CryptoJS.enc.Utf8) if (!plain) { this.error = '解密失败,密钥可能不匹配' return } this.html = marked.parse(plain) } catch (e) { this.error = '解密异常:' + e.message } finally { this.loading = false } } } } </script> <style scoped> .sealed-tip { padding: 24px; text-align: center; color: #888; } .sealed-err { color: #e74c3c; } </style>

这里出现了两个密钥:脚本加密用的SEAL_KEY,和前端解密用的themeConfig.secret.contentKey。它们必须是同一个值。所以实际配置里,config.js的这个字段也是从环境变量读的:

// docs/.vuepress/config.js themeConfig: { secret: { hash: process.env.SECRET_HASH, contentKey: process.env.SEAL_KEY, gatePath: '/secret-login/' } }

有人会问,那这个contentKey不就跟着 JS 打进产物了吗,还不是一样能被拿到?是的,一样能被拿到。但拿到之后他想解密,还得知道哪段密文对应哪篇文章、解出来的 markdown 怎么渲染。门槛从“查看源码”抬高到了“读代码、找密钥、写解密脚本”。对于一个内部文档站来说,这个门槛足够了。

如果你觉得还不够,可以再往前走一步:把contentKey也做成派生值。比如用用户输入的口令 + 一个固定盐做 KDF,得到的密钥再用来解密正文。这样不同口令解出来的内容不同,没口令就完全解不开。但这会让“口令”和“内容”强耦合,改口令要重新加密所有文章,运维成本太高。我一般不建议在文档站上做到这个程度。

还有一个兼容性细节:marked的不同版本导出方式不一样。v4 之前是import marked from 'marked',之后是具名导出import { marked } from 'marked'。我前面写的是新版本的写法,如果你的项目里报marked.parse is not a function,大概率是版本问题,换成默认导入或者锁一下版本。

5.4 代码块和图片在解密后怎么处理

这是我实际踩过的坑。用marked渲染出来的代码块,class 名是language-js这种,而 VuePress 主题的代码高亮样式依赖的是<div class="language-js extra-class">加<pre><code>的结构,还需要 prismjs 的 token class。如果你不做处理,解密出来的代码块就是一片没有颜色的纯文本。

三个处理思路,按成本从低到高:

第一,接受它。内部文档里代码块本来就不多,没高亮也能读,省事。

第二,在解密组件里手动补 class。渲染完之后拿 DOM,给每个pre > code加上主题需要的类名,然后调用Prism.highlightElement。前提是你装了 prismjs,且window.Prism可用。VuePress 默认是带的。

第三,最彻底的办法——不用 marked,改成在构建期用 VuePress 自己的 markdown-it 实例渲染成 HTML 再加密。但那要在插件钩子里拿实例,配置复杂,除非你的文档里代码特别多,否则不值得。

图片相对简单,marked输出的<img src="...">会保留原路径,只要你的图片放在docs/.vuepress/public/下并用绝对路径引用(/images/xxx.png),解密后能正常加载。如果用相对路径,可能会因为路由层级对不上而 404。所以我的建议是:保密文章里的图片一律用绝对路径,前面的加密脚本只处理正文不处理图片,图片本身还是公开的,只是文件名不容易猜而已。

6. 调试台上那几行红字:我遇到过的六类报错

6.1 window is not defined

这是第一次写enhanceApp.js必然撞上的。原因在 2.2 节说过,这个函数在 Node 环境也会执行一次,而window、localStorage、document在 Node 里都不存在。

错误信息通常长这样:

ReferenceError: window is not defined at module.exports (...)

解决方式就是在函数开头判断isServer并提前 return,或者用typeof window !== 'undefined'。我更推荐isServer,因为它是 VuePress 主动给的,语义清晰,不会误判——有些构建环境会 mock 一个假window,用typeof判断反而会走进去。

6.2 守卫死循环:登录页把自己拦住了

现象是浏览器不停地跳转,控制台刷屏vue-router的导航重复警告。原因很直接:守卫拦截了/secret-login/这个路径本身,因为它在secretPages里吗?不是,那是因为你忘了给它设secretGate: true,或者虽然设了,但守卫判断时没把secretGate的页面排除掉。

正确的写法是守卫一开始就先判断目标路径是不是登录页,是就直接next()。这个判断要放在最前面,放在任何页面查找之前。

还有一种更隐蔽的死循环:登录页里用了<SecretGate />组件,而这个组件在mounted里调了this.$router.replace(redirect),如果redirect是空的,它会 replace 到/,乍看没问题,但如果/本身也被标记成了保密页面,那就又弹回登录页,来回拉锯。所以redirect的兜底值千万别用当前页,用/之前先确认/不是保密页。

6.3 hash 模式下路径匹配不上

vdoing 的mode配置有两个取值:'history'和'hash'。用history需要服务端配合 rewrite,用hash则不需要,静态托管可以直接跑。很多人图省事选hash。

选hash之后地址栏是/#/internal/account/,如果你在守卫里用to.fullPath去拼跳转地址,会得到/#/secret-login/?redirect=...,这个 URL 是错的——query 应该拼在 hash 里面才对。但 vue-router 的next({ path, query })会自动处理,你只要别自己手拼字符串。

我踩过的具体错误是:用to.fullPath存回跳地址,然后在登录成功后用window.location.href = redirect跳转。在 hash 模式下,这会把/#/xxx当成普通路径跳,浏览器行为很怪。正确做法是用this.$router.replace(redirect),让 router 自己去解析。这个坑我调了一个多小时才定位到。

6.4 热更新之后状态丢失

开发模式下改了enhanceApp.js,热更新有时候不会重新执行这个文件,导致你改的守卫逻辑没生效,还得手动重启 dev server。这是 VuePress

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询