FastComments.com

為 Val Town 應用程式新增即時評論功能

Val Town 在 Deno 上執行 TypeScript,因此 val 是一個真實的伺服器。這使它非常適合 FastComments:小工具是頁面上的 script 標籤,任何需要機密的功能,如 Secure SSO 或驗證 webhook,都可以在同一個 val 的伺服器端執行。

本指南說明如何將評論小工具加入 HTTP val、在索引頁面顯示評論計數、使用他們已擁有的 Val Town 帳號登入使用者,以及接收評論 webhook。

您不需要帳號即可試用。範例使用 tenantId: "demo",這是一個共享的 sandbox,第二步說明如何切換到您自己的環境。

索引頁面的評論計數 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,因此無論您在 script 標籤之前或之後設定配置都沒關係。

使用 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 must be stable for the same person, or they get a new comment identity on every login.
6 const id = `vt-${user.id}`;
7
8 return new SecureSSOPayloadBuilder(Deno.env.get("FASTCOMMENTS_API_SECRET"), {
9 id,
10 // email is required and must be unique.
11 email: user.email ?? `${id}@users.noreply.val.town`,
12 // username is required and cannot be an email.
13 username: user.username ?? id,
14 displayName: user.username ?? undefined,
15 avatar: user.links.profileImageUrl ?? undefined,
16 }).getPayload();
17}
18

getPayload() 會回傳 { userDataJSONBase64, verificationHash, timestamp }。這三個值就是傳遞到瀏覽器的全部內容。密鑰會對它們簽名,之後即被丟棄,因此頁面中沒有任何東西可以讓讀者偽造其他使用者。

將其傳入小工具

含 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 // ...render the widget with this config
17});
18
19export default oauthMiddleware(app.fetch);
20

oauthMiddleware 會為您新增 GET /auth/loginGET /auth/callbackPOST /auth/logout。請注意,登出是 POST,而小工具會以 GET 方式導向 logoutURL,因此請將 logoutURL 指向您自行實作的、會送出 POST 的小路由。

當訪客已登出時,僅傳入包含 loginURLsso。小工具隨即顯示登入提示,而非匿名留言框。

常見問題

timestamp 為 epoch 毫秒,不能是未來的時間,也不能超過兩天前。請在伺服器端於計算雜湊的同一個請求中產生它。在瀏覽器端產生是典型的失敗情形:產生的值與雜湊時使用的值不同,導致所有留言皆被拒絕。

千萬不要從身分提供者設定 isAdminisModerator。使用 Val Town 帳號登入並不代表該使用者應該擔任站點的管理員或版主。

請參閱 SSO guide 以取得完整欄位清單、群組限制的討論串以及徽章資訊。


接收 Webhook Internal Link

A val 是一個自然的 webhook 接收器:它具有穩定的 URL、能驗證簽名,且內建 SQLite 與 blob 儲存。

FastComments 使用您帳戶的 API 密鑰對 ${timestamp}.${body} 進行簽名,並傳送兩個標頭:

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

此方法攜帶事件:對於新建或更新的評論使用 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 在編輯與刪除時會再次到達,因此沒有穩定的依據可用於去重。


範例值 Internal Link

Four public vals you can remix, each covering one piece of this guide.

Blog with comments (live) 是一個 Markdown 部落格,每篇文章下都有討論串,索引頁顯示大量評論計數。它在您 remix 的瞬間即可運作,且只需一個環境變數即可指向您自己的帳號。

SSO demo (live) 使用訪客的 Val Town 帳號登入,並將該身份傳遞給小部件,因而不需要第二次登入。

Webhook receiver (live) 在每次傳遞時驗證 HMAC 簽名,並將事件儲存於 SQLite。它有一個按鈕可簽署測試 payload 並自行傳送,讓您在設定真實 webhook 前看到驗證成功。

Agent skills (live) 是一個 FastComments 代理技能庫,涵蓋小部件、SSO、REST API、審核以及從 Disqus 遷移。Remix 它後,Val Town 的代理 Townie 會自動從 skills/ 取得這些技能,讓您的代理知道如何設定評論,而不必把文件貼到聊天中。

The same skills install anywhere else with npx skills add fastcomments/skills.