nobloat/poll — 设计一个极简的投票工具
摘要
nobloat/poll 是一个用纯 Go 编写、无外部依赖的自托管 Doodle 替代工具,打包为单个 3.5 MB 二进制文件,无需数据库或注册即可使用,每个投票以 JSON 文件存储。
<p><a href="https://lobste.rs/s/jhraa2/nobloat_poll_designing_minimal_poll_tool">评论</a></p>
查看缓存全文
缓存时间: 2026/10/12 17:48
# ][
✓ nobloat/poll
来源:[https://nobloat.org/articles/2026-10-11-nobloat-poll.html](https://nobloat.org/articles/2026-10-11-nobloat-poll.html)
2026-10-11
所以我打算为我们的年度"zeitkapsl Punsch"活动发起一个投票。这个活动会邀请那些帮助 [zeitkapsl](https://zeitkapsl.eu/) 成长到今天这个样子的人——早期客户、前开发团队成员等等。好吧……
> "……我打开 Doodle,想为活动创建一个新投票。结果一打开就弹出不停闪烁的升级提示,催我立刻升级 Pro。我一直在找删除已有投票的地方,完全不直观。最后终于搞清楚了,成功创建了 Punsch 活动的投票。但这让我想问:其他人到底是怎么忍的?"
一张手绘投票:「办公室的植物叫什么名字好?」选项包括:go fern()、/dev/leaf、Monstera Monolith 和 Photosynthesis as a Service
## TL;DR
[nobloat/poll](https://github.com/nobloat/poll) 是一个可自托管的单二进制 Doodle 替代方案,用纯 Go 编写,[没有任何额外依赖](https://github.com/nobloat/poll/blob/main/go.mod)。它不需要数据库、任务队列、npm 或 node。无需注册即可使用。每个投票就是一个 JSON 文件,整个程序以一个 3.5 MB 的二进制文件发布,页面加载约 27 KB(未压缩的 6.9 kB JavaScript + 20 kB CSS)。你可以直接在 [poll.nobloat.org](https://poll.nobloat.org/) 上使用它。它没有"Pro 版本"。
## 曾经的 Doodle
我以前一直用 Doodle 来做这事,但显然过去一年那里发生了不少变化,向 *enshi***ation* 的进程(链接:https://www.youtube.com/watch?v=T4Upf_B9RLQ)又推进了一大步:
- 即使作为参与者,你也必须留下邮箱地址。
- "免费账号"现在总共只能创建一个投票,哪怕之前的那个已经关闭了。
- 你无法导出参与者列表。
- 加载投票页面大约需要 3 秒,下载 4.7 MB 的 JavaScript。
- 你被各种弹窗轰炸。
当然还有 [nuudel](https://nuudel.digitalcourage.de/) 和几个其他[可接受的替代方案](https://www.heise.de/tipps-tricks/Doodle-Alternativen-Die-besten-Tools-zur-Terminfindung-4964023.html),但我想要一个能轻松自托管在我那台相当老旧的自制"服务器"(当然是 ThinkPad X260)上的工具。
我的自制"服务器",一台 ThinkPad X260
距离我上次按照 *nobloat 风格*构建东西已经过去一年多了,所以我觉得这是一个绝佳的机会——花几个小时构建一个我个人**乐于使用和维护**的东西。
## 我的需求清单
目标是在周末结束前做好一个工具,让我终于可以发出"Punsch"活动的时间投票。
### 功能需求
- 可选择向参与者发送邮件(如果他们主动留下邮箱地址的话)
- 允许公开创建投票,无需注册/登录的繁琐流程
- 通过浏览器执行的工作量证明(Proof of Work)提供基本的防垃圾保护
- 支持 i18n(https://github.com/nobloat/poll/tree/main/i18n)
- 基本的主题功能(https://github.com/nobloat/poll/tree/main/static/themes),便于定制品牌和适配不同场景(生日派对、晚餐邀请、商务会议等)
### 非功能需求
- 享受整个过程,创建一个我乐于使用的东西
- 单个静态链接的交叉编译二进制文件,基本可以部署在任何有文件系统的地方
- 尽可能少的外部构建时和运行时依赖,确保未来几年低维护成本
- 状态保存在纯文本文件中,方便理解、备份、迁移、修改、批量编辑/创建/删除
- 绝对不要 **node、npm、yarn** 那套东西。我通常用 [Svelte](https://svelte.dev/) 或 [SvelteKit](https://svelte.dev/docs/kit/introduction) 构建 Web 前端,但那需要 npm 和一大堆其他 node 模块。我想要低维护,不想让 [renovate-bot](https://docs.renovatebot.com/) 每隔一天就来骚扰我。
- 简单的配置,在容器内外都能工作
事实证明(https://github.com/nobloat/poll/blob/main/go.mod),**纯 Go 标准库** 就足以构建一个简单的 Doodle 替代品了。
```
cat go.mod
package.json
module github.com/nobloat/poll
go 1.27.0
cat: package.json: No such file or directory
```
## 绕过注册和登录
为了避免让用户注册和登录,每个投票在创建时都会生成一个私密的所有者令牌。包含该令牌的链接就是你管理投票所需的一切。如果所有者选择提供邮箱地址,这个唯一链接会发送给他们。如果没有,他们可以复制粘贴或收藏该链接。
```
Hi Peter, your poll "Punsch Event" is live.
Share this link with participants: http://localhost:8737/p/d7fminnmxnvygaxg
Manage it (see answers, edit, close, delete): http://localhost:8737/p/d7fminnmxnvygaxg/m/TMP6BZXB2SE4TOP3PVZODGRS6U
The manage link is personal. Anyone with it can edit, close or delete the poll, so don't share it.
```
投票回复也是一样的机制。每个回答都会获得自己的令牌,通过该链接可以查看、编辑或删除自己的回答。
```
Hi Peter, thanks for answering "Punsch Event".
Here is your personal link to change or remove it: http://localhost:8737/p/d7fminnmxnvygaxg/a/CGYZDMGL7UJ5WA4RR4ON66HBEN
You'll be notified by email when the final decision is made.
This link is personal, don't share it.
If you didn't answer this poll, ignore this email or use the link to remove the answer.
```
所有邮件都与 Web 界面一样支持多语言。语言按参与者记忆,因此每个人都会收到与其回答时所用语言一致的邮件,即使投票所有者使用的是另一种语言。
## 创建投票或活动
你可以在[这里](https://poll.nobloat.org/new?lang=en)创建新的投票。
投票创建表单
表单要求你输入标题和描述。选项就是一个简单的文本框,每一行代表一个选项。你可以在*单选*和*多选*之间切换。为了在投票者之间提供更好的隐私,你还可以选择是否让参与者看到彼此的回答。
没有单独的"活动"投票类型。如果某个选项看起来像日期,投票就会自动变为活动投票类型(详见[下文](https://nobloat.org/articles/2026-10-11-nobloat-poll.html#calendar-invites))。由于选项只是文本行,你也可以一次性复制粘贴整个选项列表。
## 投票回复
通过个人链接编辑回答
如果投票创建者提供了邮箱地址,收到新的回答时他们会收到一封更新邮件。
```
New answer from Grace: Pizza Friday
# Answers
01 Margherita [2] ■■ Ada Lovelace, Grace Hopper
02 Diavola [2] ■■ Linus, Grace Hopper
03 Quattro Formaggi [2] ■■ Ada Lovelace, Grace Hopper
04 Hawaii [1] ■ Rubber Duck
http://localhost:8737/p/pizza-friday/m/ASEIRUQD7POJBVG6VITTHGCIIK
```
## 日历邀请
我的大多数投票实际上都是在找时间。所以当某个选项看起来像日期时,投票就会将其视为日期。以下格式都能识别:
```
2026-12-12
12.12.2026
12.12.
Sat, 12.12.2026, 18:00–22:00
12.12. um 18:00 Uhr
```
缺少年份意味着该日期下次到来时的年份。没有时间的日期视为全天事件,只有开始时间没有结束时间则默认一小时。如果结束时间早于开始时间,事件会跨过午夜。任何不是有效日期的内容,比如 `31.02.`,则保持为普通文本选项。
当投票关闭且获胜选项是日期时,所有留下邮箱地址的人都会收到结果,附带一个 `invite.ics` 文件:
```
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//nobloat//poll//EN
METHOD:PUBLISH
BEGIN:VEVENT
UID:[email protected]
DTSTAMP:20261205T091500Z
DTSTART:20261212T170000Z
DTEND:20261212T210000Z
SUMMARY:zeitkapsl Punsch
DESCRIPTION:Punsch, Kekse and old stories.
END:VEVENT
END:VCALENDAR
```
一次点击,就进入日历了。整个格式的生成只用了大约 40 行 Go 代码:转义、在 75 字节处折行、然后用 `\r\n` 连接。完全不需要库。
## 键盘快捷键
所有操作都可以通过键盘快捷键完成,完全不需要使用鼠标。每个快捷键旁边都有一个小提示,例如:`1`–`9` 切换选项,`n` 跳转到姓名输入框,`e` 跳转到邮箱输入框,`s` 提交,`Ctrl+Enter` 从任何输入框内提交。在管理页面上,`c` 复制分享链接,`1`–`9` 选择最终选项。
底层实现其实只是 HTML 模板中的一个 `data-key` 属性和 `app.js` 中的一个 `keydown` 监听器。
## 没有验证码的防垃圾保护
由于任何人都可以在 [poll.nobloat.org](https://poll.nobloat.org/) 上创建投票,我需要一些基本的防机器人保护,但不使用验证码或第三方服务。在服务器接受表单之前,浏览器必须先消耗一些 CPU 资源。对于一个人提交一个投票来说,这几乎感觉不到。但对于一个提交数千次的机器人来说,这些开销会累积起来。
**1. 获取挑战。** 在提交时,浏览器获取 `GET /pow`。服务器响应难度和一个挑战值:
```
16
1760180000.K3J5ZQ7XW2M4N6P8R2T4V6X8YA.9f2c...e41a
```
挑战值由一个时间戳、一个随机值和对两者的 HMAC 组成,用一个仅存在于内存中的密钥签名。因为有签名,服务器不需要存储已发出的挑战。它稍后可以自行识别。
**2. 解答。** 浏览器递增一个数字(nonce),直到 `SHA-256(challenge + nonce)` 以 16 个零位开头:
```javascript
const zeroBits = (buf) => {
const b = new Uint8Array(buf);
let n = 0;
for (const x of b) {
if (x === 0) { n += 8; continue; }
return n + Math.clz32(x) - 24;
}
return n;
};
async function solve(challenge, bits) {
const enc = new TextEncoder();
const batch = 64;
for (let n = 0; ; n += batch) {
const hashes = await Promise.all(
Array.from({ length: batch }, (_, i) =>
crypto.subtle.digest('SHA-256', enc.encode(challenge + (n + i)))
)
);
const i = hashes.findIndex((h) => zeroBits(h) >= bits);
if (i >= 0) return String(n + i);
}
}
```
每多一个零位,随机哈希合格的概率就减半。所以 16 位意味着平均需要约 2¹⁶ ≈ 65,000 次尝试。`crypto.subtle.digest` 内置于每个浏览器中,所以不需要任何库。它是异步的,这就是为什么哈希计算以 64 个为一批用 `Promise.all` 处理,而不是逐个等待。
**3. 带证明提交。** 一个全局 `submit` 监听器拦截表单,解答挑战,然后将两个值作为隐藏字段添加后发送出去:
```javascript
async function proof() {
const [bits, pow] = (await (await fetch('/pow')).text()).split(' ');
return { pow, nonce: await solve(pow, +bits) };
}
document.addEventListener('submit', async (e) => {
const form = e.target;
if (form.method !== 'post') return;
if (form.dataset.proved) {
delete form.dataset.proved;
return;
}
e.preventDefault();
// ...
const p = await proof();
for (const k in p)
form.insertAdjacentHTML('beforeend',
`<input type="hidden" name="${k}" value="${p[k]}">`);
form.dataset.proved = 1;
// ...
form.requestSubmit(e.submitter);
});
```
表单本身是纯 HTML 表单。它们对这一切毫不知情。
**4. 验证。** 服务器上的每个 `POST` 都会经过一个中间件检查证明。验证只需一次哈希计算,而客户端需要约 65,000 次:
- HMAC 是否匹配?否则挑战就是伪造的。
- 挑战是否未超过 2 小时?
- `SHA-256(challenge + nonce)` 是否确实以 16 个零位开头?
- 这个挑战是否被使用过?已使用的挑战会保留在内存中直到过期,所以已解答的挑战无法重放。
这无法阻止有决心的攻击者,但足以使大量提交的成本很高,让笨拙的机器人知难而退。
## 跳过数据库
由于显而易见的原因,Postgres 被排除为本项目的运行时依赖。SQLite 对我来说本可以接受,但常用的 Go 驱动需要 `CGO`,这在交叉编译时会引入一堆其他问题。纯 Go 的移植版本如 `modernc.org/sqlite` 确实存在,但它们又是另一个外部依赖。
所以我决定更进一步保持极简,直接将投票存储在简单的 `.json` 文件中。所有修改都通过一个全局锁进行。如果数千人同时投票,这会成为瓶颈——但这不是我需要面对的问题。
投票定义和投票者的回答存储在同一个文件中。文件以生成的投票 ID 命名:`data/polls/.json`
```
cat data/polls/d7fminnmxnvygaxg.json
{
"title": "Punsch Event",
"description": "",
"multiple": true,
"resultsVisible": false,
"options": [
{
"text": "12.10.2026",
"start": "2026-10-12T00:00:00+02:00",
"end": "2026-10-13T00:00:00+02:00",
"allDay": true
},
{
"text": "13.10.2026",
"start": "2026-10-13T00:00:00+02:00",
"end": "2026-10-14T00:00:00+02:00",
"allDay": true
},
{
"text": "15.10.2026",
"start": "2026-10-15T00:00:00+02:00",
"end": "2026-10-16T00:00:00+02:00",
"allDay": true
}
],
"answers": [
{
"token": "CGYZDMGL7UJ5WA4RR4ON66HBEN",
"name": "Peter",
"email": "",
"lang": "en",
"choices": [1],
"created": "2026-10-11T13:00:27.373786142+02:00"
}
],
"closed": false,
"outcome": 0,
"created": "2026-10-11T12:59:34.699813479+02:00",
"owner_name": "Peter",
"owner_email": "",
"owner_lang": "en",
"owner_token": "TMP6BZXB2SE4TOP3PVZODGRS6U",
"theme": "minimal"
}
```
但如果进程崩溃或者在写入投票文件时断电了怎么办?使用普通的 `os.WriteFile` 可能会得到一个被截断的、只写了一半的 `.json` 文件,投票就没了。
为了避免这种情况,[每次写入都是原子操作](https://github.com/nobloat/poll/blob/main/store.go):新内容写入同一目录下的一个临时文件,然后刷新到磁盘,最后重命名覆盖旧文件。同一文件系统内的 `rename` 是原子的,所以投票文件要么是旧版本,要么是新版本。
```go
f, _ := os.CreateTemp(s.dir, ".tmp-*")
f.Write(b)
f.Sync() // 内容已落盘
f.Close()
os.Rename(f.Name(), s.path(p.ID)) // 原子替换
s.syncDir() // rename 操作本身也落盘
```
最后一次对目录的 `fsync` 确保 rename 操作在断电后也能生效。如果在 rename 之前崩溃,只会留下一个过期的 `.tmp-*` 文件,这些文件会在下次启动时被清理。
## 可靠地发送邮件
当投票关闭时,每个留下邮箱地址的参与者都会收到结果,所以邮件绝不能丢失。因此,我们需要某种可靠的邮件发送机制。
Go 自带 `net/smtp` 提供 SMTP 支持。最简单的方法是为每个收件人启动一个 goroutine 就完事了。但 SMTP 可能失败,我们也可能在邮件发送过程中重启应用。
为了避免此类问题,通常会使用异步任务队列,比如 Redis 或面向 Postgres 的 [river queue](https://riverqueue.com/)。当然,这两者都不是这个项目的选项……
相似文章
dots.. 但不被厂商锁定..
作者发布了一个开源、免费、可本地自托管的 'dots' 项目,模仿 Grok bots 的体验,支持用户现有的订阅、任意模型和推理级别,并可在手机上配对使用,反对厂商锁定。
@Ryrenz: 一块白板同时装下思维导图、流程图和自由画 GitHub 上 14.4K stars 画个流程图开专门的工具,做思维导图换另一个,想随手涂几笔又得再开一个。三个软件三套快捷键,文件散在三个地方,一个想法要跨三次工具才能表达完整。 Drawn…
Drawnix 是一款开源白板工具,集成了思维导图、流程图和自由绘画功能,支持 Mermaid 和 Markdown 转换,基于插件架构开发。
@ruyi3699: 刚扒到一个叫Numa的开源工具 Rust写的单文件DNS解析器,体积才8MB,装完能干这几件事: ①拦截38万多条广告,域名走到哪儿清静到哪儿 ②给本地自定义域名自动配上HTTPS证书,浏览器直接亮绿锁 ③自带存储完全不靠云端,数据全留在…
Numa 是一个用 Rust 编写、单文件约 8MB 的开源 DNS 解析器,支持广告拦截、本地自定义域名自动 HTTPS 证书、ODoH/DNSSEC 加密与本地存储,无需云端即可在 macOS、Linux、Windows 上一条命令部署。
@lijigang: 给这个世界的一份答卷:
一个为Claude Code设计的自定义技能集,提供多种生产力工具和功能模块,支持通过CLI安装和Markdown/Org格式输出。
麻雀
Sparrow 是一款自托管的 Webhook 投递产品,通过单个 Go 二进制文件和 PostgreSQL 实现重试、加密签名、健康状态跟踪、载荷转换,以及 Slack、Discord 和 PagerDuty 的集成方案,免费提供,并采用 MIT 许可证。