ToC
前言
大家好,好久(一会)不见,我是某昨。
最近把博客进行迁移的过程中,个人体感最明显的缺失功能就是密码。对于时不时会逆向一些专有软件的我而言,有些东西是不大适合公开可访问的。Wordpress
的密码功能很好地解决了这个问题,但在静态博客生成工具中我一直没有找到很好的解决方案。
理论上,实现某篇博客文章的密码保护可以有以下两种方式:
- 一种是在运行时有一个简单的后端服务,负责校验密码、返回文章内容
- 一种是在构建时对文章进行对称/非对称加密,然后在前端解密
我选择了第二种,因为第二种的部署方式能够 100%
静态化,架构上也相比前者简单不少。
定义一个密码
首先,我们需要定义一个密码。我选择在 frontmatter
区域增加一个 password
字段:
1import { SITE } from "@config";2import { defineCollection, z } from "astro:content";3
4const blog = defineCollection({5 type: "content",6 schema: ({ image }) =>7 z.object({14 collapsed lines
8 author: z.string().default(SITE.author),9 published_at: z.date(),10 modified_at: z.date().optional().nullable(),11 title: z.string(),12 featured: z.boolean().optional(),13 draft: z.boolean().optional(),14 tags: z.array(z.string()).default(["others"]),15 ogImage: image()16 .refine(img => img.width >= 1200 && img.height >= 630, {17 message: "OpenGraph image must be at least 1200 X 630 pixels!",18 })19 .or(z.string())20 .optional(),21 description: z.string(),22 canonicalURL: z.string().optional(),23 password: z.string().optional(),24 }),25});26
27export const collections = { blog };
这样就可以在 Markdown
的首部定义密码了,就像这样:
1---2author: Yesterday173published_at: 2024-03-31T16:58:25.000+08:004#modified_at:5title: Sample Password-protected page (pass=test)6slug: blog-02-password-example7featured: false8draft: false9tags:10 - blog11description: It's just an example.12password: test13---14
15## ToC
加密与解密
要回答加密和解密的问题,我们首先需要理清楚需要解决的问题:
- 如何获取待加密的文本?
- 如何加密?
- 用户如何输入密码?
- 如何解密?
- 解密后如何展示?
一项项看,首先是加密的文本。在咕老师的帮助下,我们在 Stackoverflow 上发现可以通过 slot.render()
的方式拿到 HTML
文本:
1---2const html = await Astro.slots.render("default");3import { Code } from "astro/components";4---5
6<Fragment set:html={html} />7
8<Code code={html} lang="html" />
使用的时候只要把希望加密的组件放到这个 Wrapper
的里面就可以了:
1---2import Card from "../components/Card.astro";3import StringWrapper from "../components/StringWrapper.astro";4---5
6<StringWrapper>7 <Card title="Test" />8</StringWrapper>
拿到待加密的文本之后,第二个问题是如何加密。我们选择通过 AES-256-CBC
简单加密一下:
1export async function encrypt(data: string, key: string): Promise<string> {2 key = key.padEnd(16, "0");3
4 const dataBuffer = Buffer.from(data);5 const keyBuffer = Buffer.from(key);6
7 const cryptoKey = await crypto.subtle.importKey(8 "raw",9 keyBuffer,10 { name: "AES-CBC", length: 256 },11 false,12 ["encrypt"]13 );14
15 const iv = crypto.getRandomValues(new Uint8Array(16));16 const encryptedData = await crypto.subtle.encrypt(17 { name: "AES-CBC", iv },18 cryptoKey,19 dataBuffer20 );21 const combinedData = new Uint8Array(iv.length + encryptedData.byteLength);22 combinedData.set(iv);23 combinedData.set(new Uint8Array(encryptedData), iv.length);24 return Buffer.from(combinedData).toString("base64");25}
这里基本都是用了 WebCrypto
,只在 Buffer
的部分偷了一下懒x
加密完成之后就是用户界面了,在副驾驶的帮助下搓了个姑且能看的:
13 collapsed lines
1---2import { encrypt } from "@utils/encrypt";3
4export interface Props {5 password: string;6}7
8const html = await Astro.slots.render("default");9const encryptedHtml = await encrypt(html, Astro.props.password);10---11
12<meta name="encrypted" content={encryptedHtml} />13
14<div>15 <input16 id="password"17 class="w-auto rounded border border-skin-fill18border-opacity-40 bg-skin-fill p-2 text-skin-base19placeholder:italic placeholder:text-opacity-7520focus:border-skin-accent focus:outline-none"21 placeholder="Enter password"22 type="text"23 autocomplete="off"24 autofocus25 />26 <button27 id="password-btn"28 class="bg-skin-full rounded-md29 border border-skin-fill border-opacity-50 p-230 text-skin-base31 hover:border-skin-accent"32 >33 Submit34 </button>35</div>
再搓一个配套的解密,但这里就不能用 Buffer
了,需要纯浏览器端可用,最后把代码串起来:
36 collapsed lines
1---2import { encrypt } from "@utils/encrypt";3
4export interface Props {5 password: string;6}7
8const html = await Astro.slots.render("default");9const encryptedHtml = await encrypt(html, Astro.props.password);10---11
12<meta name="encrypted" content={encryptedHtml} />13
14<div>15 <input16 id="password"17 class="w-auto rounded border border-skin-fill18border-opacity-40 bg-skin-fill p-2 text-skin-base19placeholder:italic placeholder:text-opacity-7520focus:border-skin-accent focus:outline-none"21 placeholder="Enter password"22 type="text"23 autocomplete="off"24 autofocus25 />26 <button27 id="password-btn"28 class="bg-skin-full rounded-md29 border border-skin-fill border-opacity-50 p-230 text-skin-base31 hover:border-skin-accent"32 >33 Submit34 </button>35</div>36
37<script is:inline data-astro-rerun>38 async function decrypt(data, key) {39 key = key.padEnd(16, "0");40
41 const decoder = new TextDecoder();42 const dataBuffer = new Uint8Array(43 atob(data)44 .split("")45 .map(c => c.charCodeAt(0))46 );47 const keyBuffer = new TextEncoder().encode(key);48
49 const cryptoKey = await crypto.subtle.importKey(50 "raw",51 keyBuffer,52 { name: "AES-CBC", length: 256 },53 false,54 ["decrypt"]55 );56
57 const iv = dataBuffer.slice(0, 16);58 const encryptedData = dataBuffer.slice(16);59
60 const decryptedData = await crypto.subtle.decrypt(61 { name: "AES-CBC", iv },62 cryptoKey,63 encryptedData64 );65
66 return decoder.decode(decryptedData);67 }68
69 function prepare() {70 const encrypted = document71 .querySelector("meta[name=encrypted]")72 ?.getAttribute("content");73 const input = document.getElementById("password");74 const btn = document.getElementById("password-btn");75 const article = document.querySelector("#article");76
77 btn?.addEventListener("click", async () => {78 const password = input.value;79 try {80 const html = await decrypt(encrypted, password);81 article.innerHTML = html;82 } catch (e) {83 alert("Incorrect password");84 }85 });86 }87
88 prepare();89 document.addEventListener("astro:after-swap", prepare);90</script>
这里值得注意的是 [1]
和 [2]
的部分。由于 Astro
的 View Transition
机制,不能保证脚本的执行。因此需要通过 event
的方式手动在页面变换的时候加上 Listener
。
套壳,启动!
完成了加密组件,接下来就要给我们有密码的文章套上这个加密的壳了。我们先弄个简单的 Wrapper
,方便我们套:
1---2import Encrypt from "./Encrypt.astro";3
4export interface Props {5 password?: string;6}7
8const password = Astro.props.password;9---10
11{12 !password ? (13 <slot />14 ) : (15 <Encrypt password={password}>16 <slot />17 </Encrypt>18 )19}
然后在合适的地方把 PasswordWrapper
塞进去:
78<article id="article" role="article" class="prose mx-auto mt-8 max-w-3xl">79 <PasswordWrapper password={post.data.password}>80 <Content />81 </PasswordWrapper>82</article>
就大功告成啦!
体验一下?
最终的成品在这里。你可以尝试输入密码(test
),解密后的内容渲染应该是可以正常工作的。
关于未来
最主要的有/无问题已经解决,接下来就是体验优化的部分了。简单列了一下可以优化的点:
- 目前的密码没有任何缓存,因此每次刷新都需要重新输入一遍。~~这里可能需要
localStorage
帮一下忙,存一下密码(~~最后用了query
-
此外,密码加密的文章在文章列表里也应该有一个专门的图标用来标识(比如🔒)加了把可爱的锁x - 以及,解密成功能不能有个动画(?
- 最后,有没有可能支持一下文章内的局部加密呢,不过这可能就是很远之后的事情了(笑)
嘛,就是这样x