FastComments.com

为 Val Town 应用添加实时评论

Val Town 在 Deno 上运行 TypeScript,因此 val 是一个真实的服务器。这使它非常适合 FastComments:小部件是页面上的 script 标签,任何需要密钥的功能,如 Secure SSO 或验证 webhook,都可以在同一个 val 的服务器端运行。

本指南涵盖了将评论小部件添加到 HTTP val、在索引页面显示评论计数、使用用户已有的 Val Town 账户登录以及接收评论 webhook。

您无需账户即可尝试。示例使用 tenantId: "demo",这是一个共享的沙盒环境,第 2 步将介绍如何切换到您自己的。

索引页面上的评论计数 Internal Link

在索引页面上,不要为每一行渲染一个 comment-count 小部件。这会对每篇文章产生一次请求。使用批量计数,它只需要对整页发起一次请求。

为每一行标记其线程使用的 urlId,然后一次性加载批量小部件:

索引页面的批量评论计数
Copy CopyRun External Link
1
2<ul>
3 {posts.map((post) => (
4 <li>
5 <a href={post.slug}>{post.title}</a>{" "}
6 <span class="fast-comments-count" data-fast-comments-url-id={post.slug}></span>
7 </li>
8 ))}
9</ul>
10
11<script
12 dangerouslySetInnerHTML={{
13 __html: `window.FastCommentsBulkCountConfig = ${
14 JSON.stringify({ tenantId: TENANT_ID })
15 };`,
16 }}
17/>
18<script src="https://cdn.fastcomments.com/js/embed-widget-comment-count-bulk.min.js"></script>
19

脚本会查找页面上所有 .fast-comments-count 元素并填充其计数。

data-fast-comments-url-id 必须与帖子评论小部件使用的 urlId 相匹配。如果小部件使用 slug,则标记也使用 slug。若不匹配,则会在已有评论的线程上显示为零。

脚本会轮询 window.FastCommentsBulkCountConfig,因此在脚本标签之前或之后设置配置都没有关系。

使用 std/oauth 实现安全单点登录 Internal Link

如果你的 val 已经知道访客的身份,Secure SSO 会将该身份传递给评论组件,这样访客就无需再次登录。无需构建任何端点,运行时也无需调用任何东西:你只需在服务端计算三个值,并将它们传入组件配置即可。

Val Town 通过 std/oauth 提供零配置登录,因此访客可以使用他们已有的 Val Town 账户登录。你可以将其替换为你应用所使用的任何登录方式;FastComments 这部分不会改变。

在服务端构建 payload

API 密钥用于对 payload 进行签名,绝不能出现在浏览器代码中。从 npm 安装 SDK,它可以直接在 Val Town 的 Deno 运行时上使用:

sso.ts
Copy CopyRun External Link
1
2import { SecureSSOPayloadBuilder } from "npm:fastcomments-sdk/server";
3
4export function buildSSOPayload(user) {
5 // id 对于同一个人必须保持稳定,否则他们每次登录都会获得一个新的评论身份。
6 const id = `vt-${user.id}`;
7
8 return new SecureSSOPayloadBuilder(Deno.env.get("FASTCOMMENTS_API_SECRET"), {
9 id,
10 // email 是必填项,且必须唯一。
11 email: user.email ?? `${id}@users.noreply.val.town`,
12 // username 是必填项,且不能是邮箱地址。
13 username: user.username ?? id,
14 displayName: user.username ?? undefined,
15 avatar: user.links.profileImageUrl ?? undefined,
16 }).getPayload();
17}
18

getPayload() 返回 { userDataJSONBase64, verificationHash, timestamp }。这三个值就是传递给浏览器的全部内容。密钥对它们进行签名后即被丢弃,因此页面中的任何内容都无法让阅读者伪造其他用户。

将其传递给组件

Widget config with SSO
Copy CopyRun External Link
1
2import { getOAuthUserData, oauthMiddleware } from "https://esm.town/v/std/oauth/middleware.ts";
3
4app.get("/", async (c) => {
5 const session = await getOAuthUserData(c.req.raw);
6 const user = session?.user;
7
8 const config = {
9 tenantId: TENANT_ID,
10 urlId: "my-thread",
11 ...(user
12 ? { sso: { ...buildSSOPayload(user), logoutURL: "/logout" } }
13 : { sso: { loginURL: "/auth/login" } }),
14 };
15
16 // ...使用此配置渲染组件
17});
18
19export default oauthMiddleware(app.fetch);
20

oauthMiddleware 会为你添加 GET /auth/login、GET /auth/callback 和 POST /auth/logout。请注意,登出是一个 POST 请求,而组件通过 GET 导航到 logoutURL,因此请将 logoutURL 指向你自己的一个小路由,由它来提交 POST 请求。

当访客未登录时,传入仅包含 loginURL 的 sso。此时组件会显示登录提示,而不是匿名评论框。

常见问题

timestamp 是以毫秒为单位的 epoch 时间,不能是未来时间,也不能超过两天前。请在计算哈希的同一请求中于服务端生成它。在浏览器中生成它是典型的失败原因:该值与被哈希的值不一致,导致所有评论都被拒绝。

切勿根据身份提供商设置 isAdmin 或 isModerator。使用 Val Town 账户登录并不能说明谁应该管理你的网站。

完整的字段列表、基于群组的评论线程以及徽章,请参阅 SSO 指南。

接收 Webhook Internal Link

val 是天然的 webhook 接收器:它拥有稳定的 URL,可以验证签名,并且内置了 SQLite 和 blob 存储。

FastComments 使用您账户的 API secret 对 ${timestamp}.${body} 进行签名,并发送两个请求头:

Webhook 请求头
Copy CopyRun External Link
1
2X-FastComments-Timestamp: 1789004710 unix seconds, not milliseconds
3X-FastComments-Signature: sha256=<hex>
4

HTTP 方法承载了事件类型:PUT 表示评论被创建或更新,DELETE 表示评论被删除。

验证送达
Copy CopyRun External Link
1
2import { createHmac, timingSafeEqual } from "node:crypto";
3
4async function receive(c) {
5 // 到达的原始字节。不要使用 c.req.json() 再重新序列化。
6 const rawBody = await c.req.raw.text();
7 const timestamp = c.req.raw.headers.get("X-FastComments-Timestamp");
8 const signature = c.req.raw.headers.get("X-FastComments-Signature");
9
10 if (!timestamp || !signature) return new Response("Missing headers", { status: 400 });
11
12 // 拒绝过期的送达,防止被截获的请求在之后被重放。
13 if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) {
14 return new Response("Timestamp outside window", { status: 400 });
15 }
16
17 const expected = "sha256=" + createHmac("sha256", Deno.env.get("FASTCOMMENTS_API_SECRET"))
18 .update(`${timestamp}.${rawBody}`)
19 .digest("hex");
20
21 const a = new TextEncoder().encode(signature);
22 const b = new TextEncoder().encode(expected);
23 if (a.length !== b.length || !timingSafeEqual(a, b)) {
24 return new Response("Signature mismatch", { status: 401 });
25 }
26
27 // ...处理 JSON.parse(rawBody)
28 return Response.json({ received: true });
29}
30
31app.put("/", receive);
32app.delete("/", receive);
33

两个容易踩的坑

验证原始字节。 解析 JSON 再重新序列化会改变键的顺序和空白字符,因此哈希值会不同,导致每次送达都失败且没有明显原因。这是 webhook 接收器“就是无法工作”的常见原因。

以恒定时间进行比较。 对签名使用普通的 === 会泄露有多少字节匹配,这足以让攻击者逐字节伪造签名。

处理事件

快速响应。FastComments 会在收到非 2xx 响应时重试,而持续失败的端点最终会被自动禁用,因此应在响应之后再执行实际工作,而不是内联处理。

确保该工作基于评论 id 是幂等的。重试会使用新的时间戳重新签名,并且同一条评论 id 在编辑和删除时会再次到达,因此没有稳定的字段可用于去重。

示例 Vals Internal Link

四个公开的 val 供你 remix,每个对应本指南的一部分。

带评论的博客(在线演示)是一个 Markdown 博客,每篇文章下都有一个评论线程,索引页上显示批量评论计数。remix 后即可立即使用,只需一个环境变量即可指向你自己的账户。

SSO 演示(在线演示)使用访客的 Val Town 账户登录,并将该身份传递给组件,因此无需二次登录。

Webhook 接收器(在线演示)会验证每次投递的 HMAC 签名,并将事件存储在 SQLite 中。它有一个按钮,可以对测试载荷进行签名并投递给自己,因此你可以在配置真实 webhook 之前看到验证成功的过程。

Agent 技能(在线演示)是一个 FastComments agent 技能库,涵盖组件、SSO、REST API、审核以及从 Disqus 迁移。remix 后,Val Town 的 agent Townie 会自动从 skills/ 中获取这些技能,因此你的 agent 无需你将文档粘贴到聊天中就知道如何接入评论。

同样的技能也可以通过 npx skills add fastcomments/skills 安装到其他任何地方。