Skip to content

How To Blog 02: Astro❤️Password

Updated: at 14:39

ToC

前言

大家好,好久(一会)不见,我是某昨。

最近把博客进行迁移的过程中,个人体感最明显的缺失功能就是密码。对于时不时会逆向一些专有软件的我而言,有些东西是不大适合公开可访问的。Wordpress 的密码功能很好地解决了这个问题,但在静态博客生成工具中我一直没有找到很好的解决方案。

理论上,实现某篇博客文章的密码保护可以有以下两种方式:

我选择了第二种,因为第二种的部署方式能够 100% 静态化,架构上也相比前者简单不少。

定义一个密码

首先,我们需要定义一个密码。我选择在 frontmatter 区域增加一个 password 字段:

content/config.ts
1
import { SITE } from "@config";
2
import { defineCollection, z } from "astro:content";
3
4
const 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
27
export const collections = { blog };

这样就可以在 Markdown 的首部定义密码了,就像这样:

1
---
2
author: Yesterday17
3
published_at: 2024-03-31T16:58:25.000+08:00
4
#modified_at:
5
title: Sample Password-protected page (pass=test)
6
slug: blog-02-password-example
7
featured: false
8
draft: false
9
tags:
10
- blog
11
description: It's just an example.
12
password: test
13
---
14
15
## ToC

加密与解密

要回答加密和解密的问题,我们首先需要理清楚需要解决的问题:

  1. 如何获取待加密的文本?
  2. 如何加密?
  3. 用户如何输入密码?
  4. 如何解密?
  5. 解密后如何展示?

一项项看,首先是加密的文本。在咕老师的帮助下,我们在 Stackoverflow 上发现可以通过 slot.render() 的方式拿到 HTML 文本:

StringWrapper.astro
1
---
2
const html = await Astro.slots.render("default");
3
import { Code } from "astro/components";
4
---
5
6
<Fragment set:html={html} />
7
8
<Code code={html} lang="html" />

使用的时候只要把希望加密的组件放到这个 Wrapper 的里面就可以了:

index.astro
1
---
2
import Card from "../components/Card.astro";
3
import StringWrapper from "../components/StringWrapper.astro";
4
---
5
6
<StringWrapper>
7
<Card title="Test" />
8
</StringWrapper>

拿到待加密的文本之后,第二个问题是如何加密。我们选择通过 AES-256-CBC 简单加密一下:

encrypt.ts
1
export 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
dataBuffer
20
);
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

加密完成之后就是用户界面了,在副驾驶的帮助下搓了个姑且能看的:

Encrypt.astro
13 collapsed lines
1
---
2
import { encrypt } from "@utils/encrypt";
3
4
export interface Props {
5
password: string;
6
}
7
8
const html = await Astro.slots.render("default");
9
const encryptedHtml = await encrypt(html, Astro.props.password);
10
---
11
12
<meta name="encrypted" content={encryptedHtml} />
13
14
<div>
15
<input
16
id="password"
17
class="w-auto rounded border border-skin-fill
18
border-opacity-40 bg-skin-fill p-2 text-skin-base
19
placeholder:italic placeholder:text-opacity-75
20
focus:border-skin-accent focus:outline-none"
21
placeholder="Enter password"
22
type="text"
23
autocomplete="off"
24
autofocus
25
/>
26
<button
27
id="password-btn"
28
class="bg-skin-full rounded-md
29
border border-skin-fill border-opacity-50 p-2
30
text-skin-base
31
hover:border-skin-accent"
32
>
33
Submit
34
</button>
35
</div>

再搓一个配套的解密,但这里就不能用 Buffer 了,需要纯浏览器端可用,最后把代码串起来:

Encrypt.astro
36 collapsed lines
1
---
2
import { encrypt } from "@utils/encrypt";
3
4
export interface Props {
5
password: string;
6
}
7
8
const html = await Astro.slots.render("default");
9
const encryptedHtml = await encrypt(html, Astro.props.password);
10
---
11
12
<meta name="encrypted" content={encryptedHtml} />
13
14
<div>
15
<input
16
id="password"
17
class="w-auto rounded border border-skin-fill
18
border-opacity-40 bg-skin-fill p-2 text-skin-base
19
placeholder:italic placeholder:text-opacity-75
20
focus:border-skin-accent focus:outline-none"
21
placeholder="Enter password"
22
type="text"
23
autocomplete="off"
24
autofocus
25
/>
26
<button
27
id="password-btn"
28
class="bg-skin-full rounded-md
29
border border-skin-fill border-opacity-50 p-2
30
text-skin-base
31
hover:border-skin-accent"
32
>
33
Submit
34
</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
encryptedData
64
);
65
66
return decoder.decode(decryptedData);
67
}
68
69
function prepare() {
70
const encrypted = document
71
.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] 的部分。由于 AstroView Transition 机制,不能保证脚本的执行。因此需要通过 event 的方式手动在页面变换的时候加上 Listener

套壳,启动!

完成了加密组件,接下来就要给我们有密码的文章套上这个加密的壳了。我们先弄个简单的 Wrapper,方便我们套:

PasswordWrapper.astro
1
---
2
import Encrypt from "./Encrypt.astro";
3
4
export interface Props {
5
password?: string;
6
}
7
8
const password = Astro.props.password;
9
---
10
11
{
12
!password ? (
13
<slot />
14
) : (
15
<Encrypt password={password}>
16
<slot />
17
</Encrypt>
18
)
19
}

然后在合适的地方把 PasswordWrapper 塞进去:

PostDetail.astro
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),解密后的内容渲染应该是可以正常工作的。

关于未来

最主要的有/无问题已经解决,接下来就是体验优化的部分了。简单列了一下可以优化的点:

嘛,就是这样x