↓ Skip to main content

I Built a Free, Self-Hosted Anonymous Q&A System with a Serverless Architecture!

·3867 words·8 mins·
Table of Contents
Oops! This article is currently only available in Traditional Chinese. English translation is coming soon!

為什麼要做這個?
#

相信各位在 Instagram 或其他社群上,常常會看到那種「匿名問答箱」(例如 NGL.link 或 Marshmallow-qa 等等) :丟一條連結給粉絲,大家匿名發送問題或想說的話,你再挑幾則貼到限時動態上回答。

用起來很方便,沒錯,我自己也用過。但資料都在第三方手上,他們怎麼存、怎麼用、會不會外洩,你完全無法掌控。

於是我就想:這種東西為什麼不能自己做?

而且我要的是——

  • 完全免費(不要為了解鎖什麼特殊留言功能而付月費)
  • 不用自己架伺服器(我不是伺服器管理員的,雖然管理員聽起來很酷)
  • 資料 100% 自己掌控
  • 能一鍵把留言變成好看的圖卡,直接貼 IG 限動
  • (且包含市面上常見的匿名問答系統有的功能)

這就是這個專案的起點:一個我自己做的、開源、可自主架設的匿名問答系統。

我這個自製的開源專案的 GitHub 頁面: AiixiBear/aqa_system

前台公開留言頁面 手機版頁面
前端預覽
手機版前端預覽
後台留言管理面板 留言來源與裝置資訊 密碼保護
後台留言面板
後台資訊
密碼保護後台

特色一覽
#

  • 完全無須架設自己的伺服器,而且免費——靠 Cloudflare Worker 與 Google Sheet + Apps Script 就能跑起來,並且不需要輸入信用卡資訊即可直接使用
  • Cloudflare Turnstile 驗證防機器人
  • 收集 IP、User-Agent,並透過 ipinfo.io API 解析 IP 所在地(國家 / ASN)
  • 內建篩選後台,可直接查看留言、一鍵生成留言圖卡,貼上 Instagram 限時動態超方便
  • 每一則留言都有專屬辨識碼(例如 AQA-9487),粉絲可以在限動上認出自己的留言
  • URL 加上 ?tag= 就能標記留言來源,方便分流
  • 資料自己掌控,並且本專案使用 MIT License 開源,你各位想怎麼改就怎麼改

系統怎麼運作?
#

整個架構是 Serverless(無伺服器) 的,可以拆成兩條路徑:

  1. 公開留言路徑:使用者 → 前台網頁 → Cloudflare Worker(驗證 Turnstile)→ Google Apps Script(查 IP、發流水號)→ Google Sheets
  2. 後台管理路徑:管理者 → 後台網頁(輸入管理密碼)→ Cloudflare Worker 代理驗證 (帶入 X-Admin-Password 標頭) → 後台 Google Apps Script → Google Sheets → 回傳 JSON → 渲染卡片 (自動計算動態字級) → 前端匯出 PNG 圖卡

有人留言時:

  1. 使用者在前台輸入內容、通過 Turnstile 人機驗證
  2. Cloudflare Worker 檢查欄位、驗證 Turnstile Token
  3. 驗證通過後轉送到 Google Apps Script
  4. Apps Script 去 ipinfo.io 查 IP 國家與 ASN,用 Lock Service 配發唯一流水號(AQA-0001 這種),最後寫入 Google Sheets
  5. 前台顯示這則留言的專屬代碼,粉絲可以複製保存

管理者則是打開後台,用關鍵字、IP、tag、代碼篩選留言,點一下就能匯出成 PNG 圖卡,貼上 IG 限動。

後台的留言卡片支援一鍵下載圖片,圖卡裡不會包含下載按鈕,看起來就是乾乾淨淨的一張留言圖。

後台留言面板

後台可以顯示留言的來源 IP / 裝置資料

架構圖
#

%% Generated by https://gitdiagram.com/aiixibear/aqa_system
flowchart TD

subgraph group_public["Public submission"]
  node_public_page["Submission page
[index.html]"] end subgraph group_ingest["Verification and routing"] node_submit_worker["Submission Worker
[index.js]"] node_turnstile{{"Cloudflare Turnstile"}} end subgraph group_data["Storage and enrichment"] node_write_gas["Write Apps Script
[main.gs]"] node_sheet[("Google Sheets")] node_ipinfo{{"IPinfo lookup"}} end subgraph group_admin["Admin review and export"] node_admin_page["Admin dashboard
[index.html]"] node_admin_worker["Admin proxy Worker
[index.js]"] node_read_gas["Read Apps Script
[main.gs]"] node_card_export["Card rendering and PNG export
[index.html]"] end node_visitor(("Visitor")) node_admin_user(("Administrator")) node_visitor -->|"submits message"| node_public_page node_public_page -->|"sends submission"| node_submit_worker node_submit_worker -->|"verifies token"| node_turnstile node_submit_worker -->|"forwards verified message"| node_write_gas node_write_gas -->|"looks up source"| node_ipinfo node_write_gas -->|"stores numbered message"| node_sheet node_submit_worker -->|"returns assigned code"| node_public_page node_admin_user -->|"manages messages"| node_admin_page node_admin_page -->|"requests messages"| node_admin_worker node_admin_worker -->|"proxies retrieval"| node_read_gas node_read_gas -->|"reads messages"| node_sheet node_admin_page -->|"filters and renders"| node_card_export click node_public_page "https://github.com/aiixibear/aqa_system/blob/main/frontend/index.html" click node_submit_worker "https://github.com/aiixibear/aqa_system/blob/main/worker/index.js" click node_write_gas "https://github.com/aiixibear/aqa_system/blob/main/gas/main.gs" click node_admin_page "https://github.com/aiixibear/aqa_system/blob/main/admin_panel/index.html" click node_admin_worker "https://github.com/aiixibear/aqa_system/blob/main/admin_panel_worker/index.js" click node_read_gas "https://github.com/aiixibear/aqa_system/blob/main/admin_panel_gas/main.gs" click node_card_export "https://github.com/aiixibear/aqa_system/blob/main/admin_panel/index.html" classDef toneNeutral fill:#f8fafc,stroke:#334155,stroke-width:1.5px,color:#0f172a classDef toneBlue fill:#dbeafe,stroke:#2563eb,stroke-width:1.5px,color:#172554 classDef toneAmber fill:#fef3c7,stroke:#d97706,stroke-width:1.5px,color:#78350f classDef toneMint fill:#dcfce7,stroke:#16a34a,stroke-width:1.5px,color:#14532d classDef toneRose fill:#ffe4e6,stroke:#e11d48,stroke-width:1.5px,color:#881337 classDef toneIndigo fill:#e0e7ff,stroke:#4f46e5,stroke-width:1.5px,color:#312e81 classDef toneTeal fill:#ccfbf1,stroke:#0f766e,stroke-width:1.5px,color:#134e4a class node_public_page toneBlue class node_submit_worker,node_turnstile toneAmber class node_write_gas,node_sheet,node_ipinfo toneMint class node_admin_page,node_admin_worker,node_read_gas,node_card_export toneRose class node_visitor,node_admin_user toneIndigo

怎麼讓別人來留言?
#

很簡單,跟一般匿名問答箱一樣:把前台網頁的連結傳給粉絲或朋友就行了。

你也可以在網址後面加上 ?tag= 參數,例如:

https://example.com/?tag=ig-stories

這個 tag 會一起被記錄到留言資料裡,而且只有你(資料庫持有者)看得到,適合用來辨識留言是從哪個管道來的。

送出留言後,系統會配發一組獨一無二的流水號(像是 AQA-9487)。這組號碼會:

  • 一起存進 Google Sheets
  • 顯示在後台的留言卡片上

這樣粉絲在你的 IG 限動上看到圖卡時,一眼就能認出「啊!這是我留的那則欸!」。

架設前的準備
#

這個系統全部都使用免費服務,而且全都不需要輸入信用卡資訊即可直接使用:

  • Google 帳號:建立 Google Sheets 與部署 Google Apps Script
  • Cloudflare 帳號:部署 Worker 與建立 Turnstile Widget
  • IPinfo Lite API Token:查詢 IP 的國家 / 地區與 ASN(ipinfo.io/lite 註冊)
  • 靜態網頁空間:GitHub Pages、Cloudflare Pages 或任何能託管 HTML 的服務都可以

架設教學:公開留言系統
#

1. Google Sheets
#

  1. 到 Google Sheets 建立一個空白試算表
  2. 在第一列貼上表頭:
接收時間 (台北時間)	留言	來源IP	Tags	來源國家/地區	AS_Name	User-Agent	唯一辨識碼
  1. 記下試算表 URL 與 試算表名稱

Google Sheets 示範

2. 寫入用 Google Apps Script
#

  1. 打開 Google Apps Script 建立新專案
  2. 程式碼貼入專案裡的 gas/main.gs
  3. 到 ipinfo.io/lite 註冊,取得免費 API Token
  4. 在專案的 設定 → 指令碼屬性 填入:
屬性 值 說明
IPINFO_TOKEN 你的 ipinfo.io API Token 剛剛申請的
SHEET_NAME 試算表的名稱 剛剛記下的
SHEET_URL 試算表 URL 剛剛記下的
WRITE_KEY 自訂的隨機密鑰 一串夠長的亂數。Worker 與本專案共用,避免有人繞過 Worker 直灌資料
  1. 部署成網頁應用程式,記下部署後的 URL

GAS 設定示範

GAS 部署示範

關於 WRITE_KEY:這是寫入端的門禁。沒有這把鑰匙,就算知道 Apps Script 的網址也無法直接寫入留言。請自己生成一串夠長的亂數字串(例如 32 碼以上),並記好,稍後要填進 Worker。

3. Cloudflare Worker
#

  1. 註冊 Cloudflare,前往 運算 → Workers 和 Pages
  2. 建立應用程式,先部署 Hello World,記下 Worker 網址
  3. 按下編輯代碼,把專案裡的 worker/index.js 整份貼上取代
  4. 部署
  5. 去註冊一個 Cloudflare Turnstile 小工具,記下 Site Key 與 Secret Key
  6. 回到 Worker 的 設定 → 環境變數 (Runtime variables and secrets) 填入:
變數名稱 值
TURNSTILE_SECRET_KEY 你的 Turnstile Secret Key
APPS_SCRIPT_URL 剛剛寫入用 Apps Script 的 URL
APPS_SCRIPT_WRITE_KEY 剛剛在寫入用 GAS 設定的 WRITE_KEY,必須完全一致
  1. 再部署一次

Worker 示範

Worker 程式碼示範

4. 前台網頁
#

前台只有一個 HTML 檔:frontend/index.html,丟到任何靜態網頁空間都行。

打開檔案,替換這幾個變數:

名稱 值 說明
YOUR_ICON_URL_HERE 你的 Icon 預設大小 64x64
YOUR_WORKER_URL_HERE 你的 Worker URL
YOUR_TURNSTILE_SITE_KEY_HERE 你的 Turnstile Site Key

到這裡,公開留言系統與資料庫就完成了。

架設教學:留言查看面板(後台)
#

1. 後台 Google Apps Script
#

  1. 再開一個 Google Apps Script 專案
  2. 程式碼貼入 admin_panel_gas/main.gs
  3. 指令碼屬性填入:
屬性 值 說明
IPINFO_TOKEN 你的 ipinfo.io API Token 可選。若試算表的 IP 地理資料被清掉,可手動執行 repairOldData 補回來
SHEET_NAME 留言資料庫的試算表名稱
SHEET_URL 留言資料庫的試算表 URL
TIMEZONE 圖卡顯示的時區 不填預設 Asia/Taipei
READ_KEY 自訂的隨機密鑰 後台讀取留言時必須帶上,避免有人拿到後台 GAS 網址就 dump 全部留言與 IP
  1. 部署成網頁應用程式(權限設為「所有人」),記下部署後的 URL。

2. 後台 Cloudflare Worker 代理
#

為了不讓後台 GAS 網址直接裸奔在前端,我們用 Worker 來做一層安全代理:

  1. 在 Cloudflare 建立一個新的 Worker(例如命名為 aqa-admin-proxy)
  2. 貼入 admin_panel_worker/index.js 程式碼(包含 CORS 與 X-Admin-Password 驗證邏輯)
  3. 到 Worker 的 設定 → 環境變數 (Runtime variables) 設定:
變數名稱 值 說明
APPS_SCRIPT_URL 後台 GAS 部署 URL 剛剛步驟 1 記下的網址
ADMIN_PASSWORD 你的後台管理密碼 自訂一組後台登入密碼
  1. 儲存並部署,記下 Worker 的 URL。

3. 後台留言查看與卡片圖片產生網頁
#

檔案在 admin_panel/index.html,丟到靜態網頁空間即可。

打開檔案,替換以下變數:

變數名稱 值 說明
WORKER_URL 後台 Worker 的 URL 剛剛步驟 2 部署的 Worker 網址
YOUR_CARD_IMAGE_URL_HERE 留言圖卡的背景圖 預設為 bg.jpg,1:1 圖片效果最好

後台密碼登入功能說明:打開後台網頁時會跳出輸入密碼的提示,密碼就是你自己在 Worker 變數 ADMIN_PASSWORD 中設定的密碼,密碼驗證成功後會儲存在 sessionStorage 中。當你關閉瀏覽器分頁時密碼會自動銷毀,下次打開就必須再輸入一次密碼,雖然稍微有點不方便但資安 TOP!

安全性我做了哪些事?
#

這種「自己架設的匿名留言」系統最怕兩件事:有人亂灌垃圾訊息,以及後台資料外洩等等。這個專案目前有這些防護:

  • Cloudflare Turnstile:公開留言前必須通過人機驗證
  • WRITE_KEY:寫入端 GAS 只接受帶有正確金鑰的請求,繞過 Worker 直接打 Apps Script 也寫不進去
  • READ_KEY:後台 GAS 讀取留言也要金鑰,保護 IP、User-Agent 等資料
  • Lock Service:配發流水號時使用 Google Apps Script 的互斥鎖,多人同時送出也不會撞號
  • 前端字數限制與冷卻時間:基本的濫用緩衝
  • Cloudflare Worker 後台安全代理:將真正的後台 GAS API 隱藏在 Worker 後方,外部使用者無法直接存取試算表或 dump 敏感資料
  • SessionStorage 臨時憑證管理:後台密碼驗證通過後僅暫存於記憶體/分頁 Session 中,當分頁或瀏覽器關閉時自動清空,避免將管理者密碼明文暴露於 localStorage 引發 XSS 竊取風險
  • 動態防爆框與排版保護:前端渲染時透過字數長度動態調整字體大小與 line-height 行高,防止惡意超長字串破壞卡片視覺佈局

當然,這仍然不是「企業級防護」。如果你要拿去做更大型的公開服務,建議再自己加 rate limit、WAF 之類的機制。但以個人使用、粉絲留言這種場景來說,已經比系統直接裸奔好非常多了。

常見問題與故障排除 (FAQ)
#

Q1. 自己測試時送出留言時顯示錯誤或「驗證失敗」,該怎麼辦?
#

請依序檢查以下幾點:

  1. WRITE_KEY 是否一致:請確認 Worker 的環境變數 APPS_SCRIPT_WRITE_KEY 與寫入用 GAS 的指令碼屬性 WRITE_KEY 是否完全相同(包含大小寫與英數拼寫)。
  2. Turnstile 金鑰是否配對:檢查前台 HTML 的 Site Key 與 Worker 環境變數的 TURNSTILE_SECRET_KEY 是否來自同一組 Cloudflare Turnstile 設定。
  3. GAS 部署權限:確認寫入用 GAS 部署時,「誰有權存取」是否有設定為 「所有人 (Anyone)」。

Q2. 打開後台面板抓不到留言資料?
#

  1. READ_KEY 是否一致:請確認後台 HTML 裡的 YOUR_READ_KEY_HERE 與後台 GAS 指令碼屬性的 READ_KEY 完全一致。
  2. 網址是否填錯:請確認後台 HTML 填入的是後台 GAS 的部署 URL,而不是寫入用 GAS 的 URL。

Q3. 免費配額真的夠用嗎?會不會突然要付費?
#

對個人與一般社群粉絲互動來說完全夠用!

  • Cloudflare Worker:免費版每日提供 100,000 次請求。
  • Google Apps Script:一般 Google 帳號每日提供 20,000 次 URL Fetch 呼叫與足夠的試算表寫入額度。

詳情可參考 本專案使用的相關平台定價配額文件

並且這些免費服務完全不需要輸入信用卡資訊試用,因此不會自動產生任何費用。即使超過額度,服務也只是暫時停止回應。

結語
#

我的這個專案把「匿名問答箱系統」從第三方手裡拿回來,全部建在免費、常見、可長期使用的服務上:

  • Cloudflare Worker
  • Google Sheets / Google Apps Script
  • Cloudflare Turnstile
  • 免費靜態網頁託管

不需要伺服器、不需要資料庫、不需要月費,資料自己存、圖卡自己生、想改程式就直接改(因為本專案採用 MIT 授權)。

還是要提醒一下:上面雖然有提到,Cloudflare Worker 有每天免費 10 萬次請求、Google Apps Script 有相關上限等等,不過對個人使用真的完完全全綽綽有餘。

如果你也想要一個自己的匿名留言箱,歡迎直接拿去用、去改、去亂搞。

有問題或想法,也歡迎到 GitHub 留 issue。

本專案使用的相關平台定價配額文件
#

Google Apps Script 服務設有的每日配額 文件

Cloudflare Workers 方案定價 文件

Related



Comments

Feel free to share your thoughts or questions about this article! Click the button below to load the Giscus comment board. Sign in with GitHub to post a comment.