GreatFish —— 公共匿名便利贴瓜区
基于 PartyKit + Yjs + Cloudflare 边缘的实时多人协同便利贴画布。零门槛公共空间,按主题分频道,所有人实时同步。一晚上从想法到生产 + 通过 AI 红队 (Shannon) 审查 + 修复 8 个 P0/P1 漏洞 + 加 10 个新功能。
React 18 Vite TypeScript Yjs y-partykit y-protocols PartyKit Cloudflare Workers Durable Objects Cloudflare Pages mint-filter GitHub Actions
📛 此项目已于 2026-05-31 下线
后端 PartyKit、CF Pages 前端、自定义域
greatfish.ssssmy.com全部销毁。所有 Yjs 实时数据已不可恢复。GitHub 仓库归档为只读保留:https://github.com/ssssmy/greatfish
作品页保留作为历史 portfolio 记录 —— 完整的从灵感、原型、上线、安全审查、V2 扩展到主动关停的全过程,也是一个 builder 实验的样本。
项目概览
GreatFish 是一张公共匿名便利贴画布:打开 URL 不需要注册、不需要登录、不需要创建文档,直接在画布上贴便利贴,所有人实时看见。
灵感来自最近网络上流行的「在共享 Excel 表格上聊天」的摸鱼方式 —— 表格背后的灵魂不是 Excel,而是「看起来在干活 + 实际在聊天」这个反差爽点。GreatFish 把这个行为做成了一个原生的网站。
核心数据:
- 前端 bundle:~90 KB gzip(无画布库,自建可拖拽便利贴 ~200 行 TS)
- 后端代码:~500 行 TypeScript(自实现 Yjs sync 协议 + 服务端 sandbox 校验)
- 部署架构:Cloudflare Pages(前端)+ PartyKit on Cloudflare Workers + Durable Objects(后端)
- 运维成本:MVP 规模下 0 元起(全部在 Cloudflare 免费层内)
- 生命周期:2026-05-28 上线 → 2026-05-31 下线,共在线约 3 天
🐠 在线:https://greatfish.ssssmy.com(已下线)
📦 代码:https://github.com/ssssmy/greatfish(archived,只读)
技术栈
前端
| 依赖 | 版本 | 用途 |
|---|---|---|
| React | 18.3 | UI 层 |
| Vite | 5.4 | 构建 + dev server |
| TypeScript | 5.6 | 类型系统 |
| react-router-dom | 6.x | SPA 路由 |
| Yjs | 13.6 | CRDT 共享数据结构 |
| y-partykit | 0.0.33 | Yjs ↔ PartyKit 客户端 Provider |
| nanoid | 5.x | 短 ID 生成 |
| mint-filter | 4.x | 中文敏感词过滤(客户端 + 服务端共享) |
bundle 280KB 总 / 88KB gzip,核心是 Yjs + React 本身,没有任何画布库(Excalidraw / tldraw 都 fork 不动并且太重)。便利贴画布是手写的 <div> 绝对定位 + Pointer Events drag handler。
后端
| 依赖 | 版本 | 用途 |
|---|---|---|
| PartyKit | 0.0.114 | Cloudflare Workers + Durable Objects 框架 |
| yjs | 13.6 | 在 DO 里维护 Y.Doc |
| y-protocols | 1.0.7 | sync + awareness 协议二进制编解码 |
| lib0 | 0.2.x | 协议必需的二进制 codec |
| mint-filter | 4.x | 服务端二次审核 |
不用 y-partykit 的 onConnect 透传,而是自实现 Yjs sync 协议 — 这样能在 sandbox doc 里对每条 update 做 ownership/范围/词库校验后才决定 apply + broadcast。
基础设施
| 服务 | 用途 |
|---|---|
| Cloudflare Pages | 前端静态 CDN + 自定义域 + 自动 TLS |
| Cloudflare Workers | PartyKit 后端跑的运行时 |
| Cloudflare Durable Objects | 每个频道一个 DO 实例,内置持久化 + 多区域副本 |
| Cloudflare DNS(橙云代理) | greatfish.ssssmy.com + DDoS 防御 |
| GitHub Actions | CI(type check + build)+ CD(双自动部署 workflow) |
架构与技术细节
数据模型
每个频道(channel)= 一个独立的 Yjs Y.Doc,doc 里有一个共享 Map:
type StickyNote = {
id: string;
x: number; y: number;
text: string;
color: string;
authorId: string;
authorName: string;
ts: number;
// v2 customization
w?: number; h?: number;
fontSize?: number;
shape?: "sticky" | "rect" | "circle";
// v3 social
z?: number;
parentId?: string;
reactions?: Record<string, string[]>; // emoji -> identityIds
};
stickies = doc.getMap<StickyNote>("stickies")
所有 mutation 通过 Y.Map 的 set/delete 进行,Yjs CRDT 保证多端无冲突合并。
连接协议
wss://host/parties/main/<channel>?identity=<base64({id,name,color})>&admin=<token?>
- 客户端
YPartyKitProvider自动用此 URL 连接 - 服务端
onConnect解析 query string,identity 是 mandatory,缺失 →close(4001) - admin token(可选)如提供,服务端比对
env.ADMIN_TOKEN,错则close(4003) - 连接 state 存
{ identity, isAdmin, writes: number[] }
服务端 Yjs sync 协议(自实现)
服务端不用 y-partykit/onConnect 的透传 wrap,而是直接吃裸 WebSocket 消息:
client → server:
[SYNC_MSG(0), syncStep1(0), stateVector] ← 客户端首次同步请求
[SYNC_MSG(0), syncStep2(1), update] ← 初始状态推送
[SYNC_MSG(0), update(2), update] ← 增量更新
[AWARENESS_MSG(1), awarenessUpdate] ← 光标 / presence
server → client:
syncStep2 / update / awareness 反向广播
对每条 incoming update 做 sandbox 校验:
const shadow = new Y.Doc()
Y.applyUpdate(shadow, Y.encodeStateAsUpdate(this.doc))
Y.applyUpdate(shadow, update)
for each sticky changed between current.doc and shadow:
- shape check (StickyNote interface 校验)
- 字段范围(x/y/w/h/fontSize/color/text/shape/z 全部有 cap)
- 颜色白名单 `^#[0-9a-f]{3,8}$`(防 CSS url() 注入)
- mint-filter 文本审核
- ownership: authorId === connection.identity.id 或 admin
- 反应: 只能加/减自己的 identity id
- 不允许写 stickies 之外的 Y.share key
- 写速率: 15 mutations / min / connection
if any violation:
- 整批 update 丢弃,不进 doc 也不广播
- 反推 syncStep1 让客户端回到 canonical 状态
反应权限模型(特殊放行)
ownership 规则会拒绝非作者的修改,但反应是例外 —— 任何人都应该能给任何贴子点反应。
放行规则:
- 非 reactions 字段变动 → 走 ownership 检查
- reactions 字段变动 → 走特殊规则:
- 每个 emoji 的 identity id array 变化必须只是当前连接 identity id 的加或减
- 不允许加别人的 id(防伪造 + 1 灌水)
- 不允许删别人的 id(防恶意取消)
- 两套规则独立检查,都过才 apply
实时光标 (awareness)
客户端在 canvas pointermove 时:
provider.awareness.setLocalStateField("cursor", { x: worldX, y: worldY })
provider.awareness.setLocalStateField("identity", { name, color })
服务端只透传 awareness 消息(不持久化),其他连接监听 awareness.on("change") 拿别人的状态,渲染浮动光标。
视口与坐标系
便利贴存的是 world 坐标(channel 坐标系)。客户端有个 viewport: { panX, panY, zoom },所有渲染走 CSS transform:
transform: translate(${panX}px, ${panY}px) scale(${zoom});
鼠标交互需要把 screen 坐标转 world:
worldX = (screenX - canvasRect.left - panX) / zoom
drag 增量同样除以 zoom 才能正确移动便利贴。
部署方案
三个 Cloudflare 服务并行
┌────────────────────────────────────────────────────────┐
│ greatfish.ssssmy.com (CF Pages + 自定义域) │
│ ├─ 前端 SPA (~90KB gzip) │
│ └─ TLS 自动 + 全球 300+ 边缘节点 │
└────────────────────────────────────────────────────────┘
│ WSS
▼
┌────────────────────────────────────────────────────────┐
│ greatfish-sync.ssssmy.partykit.dev (CF Workers + DO) │
│ ├─ 一个 Worker 路由 wss 升级到对应频道的 DO │
│ ├─ 每个频道(slug)= 一个独立 Durable Object 实例 │
│ └─ DO 自带跨区域副本 + 11 个 9 耐久性持久化 │
└────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ GitHub Actions (CI/CD) │
│ ├─ ci.yml type check + build (push/PR) │
│ ├─ deploy-web.yml CF Pages 自动部署 (src/** 改动) │
│ └─ deploy-party.yml PartyKit 自动部署 (party/** 改动) │
└────────────────────────────────────────────────────────┘
后端部署命令
HTTPS_PROXY=http://127.0.0.1:7897 pnpm party:deploy
scripts/proxy-bootstrap.mjs 通过 undici.setGlobalDispatcher 强制 Node fetch 走代理(Node 默认不读 HTTPS_PROXY 环境变量,踩过坑)。
部署后,需要设置 admin token:
printf '%s' "$NEW_TOKEN" | pnpm party env add ADMIN_TOKEN
pnpm party:deploy # ⚠️ 必须 follow,env add 不会自动 redeploy Worker
前端部署命令
echo "VITE_PARTY_HOST=greatfish-sync.ssssmy.partykit.dev" > .env.production
pnpm build
pnpm exec wrangler pages deploy ./dist --project-name=greatfish-web --branch=main
自定义域
前端绑定 greatfish.ssssmy.com:
# 1. CF Pages 项目里注册域名
curl -X POST -H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"greatfish.ssssmy.com"}' \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/pages/projects/greatfish-web/domains"
# 2. 加 CNAME (橙云代理)
curl -X POST -H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"CNAME","name":"greatfish","content":"greatfish-web.pages.dev","proxied":true,"ttl":1}' \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records"
后端自定义域(greatfish-sync.ssssmy.com)需要升级 CF Workers Paid plan($5/月,Durable Objects 在免费层只支持 SQLite-backed,PartyKit 0.0.114 还没切),目前 deferred,保留 *.partykit.dev 默认域,功能完全相同。
GitHub Actions CI/CD
3 个 workflow:
ci.yml:每次 push/PR 跑tsc -b --noEmit+pnpm build,~30 秒deploy-web.yml:src/**/index.html/vite.config.ts等改动触发,wrangler-action 部署deploy-party.yml:party/**/partykit.json改动触发,npx partykit deploy
需要 4 个 secret:CLOUDFLARE_API_TOKEN(Pages: Edit)、CLOUDFLARE_ACCOUNT_ID、PARTYKIT_LOGIN(=用户名)、PARTYKIT_TOKEN(JWT)。
性能优化
前端
- 无画布库:不用 Excalidraw(~1MB)/ tldraw(non-MIT)。便利贴是绝对定位
<div>,拖拽是手写的 pointer event handler ~30 行 - CRDT 替代 RPC:Yjs 自动处理多端冲突,服务端不需要实现复杂的 OT
- bundle splitting:Vite 默认按 import 分 chunk,React vendor 缓存友好
- bundle 大小:总 280KB / gzip 88KB,首屏 < 1s
- Pan/Zoom 用 CSS transform:GPU 合成层加速,无重排
- awareness 节流:cursor 更新走 Yjs awareness,内部 debounce 节流
后端
- Durable Object Hibernation API:y-partykit 内部用,空闲 WebSocket 连接挂起不吃 CPU
- 持久化 debounce:
doc.on('update')触发 1 秒延迟批量写 storage,避免高频 IO - sandbox 校验早返回:违例字段一旦检测出,整批 update 立刻丢弃,不浪费后续 apply 周期
- broadcast 排除 sender:用 PartyKit
room.broadcast(msg, [senderId])而不是手动 for-loop
容量预估
| 维度 | 单频道舒适上限 | 触发什么 |
|---|---|---|
| 并发观察者 | ~1,000 | 超过开始延迟 |
| 并发编辑者 | ~100-200 | 单 DO CPU bound |
| 单频道累计便利贴 | ~10,000 | Yjs doc 同步变慢 |
| 全站 DAU | ~500 | CF Workers 免费层 10 万请求/天 |
超出免费层后 $0.50/百万请求 计费,500 DAU 之内每月 $0。
安全方案
渗透测试
2026-05-28 接受了 Shannon(一个 AI pentester)的全面渗透测试,发现 9 个漏洞(4 critical + 4 high + 1 medium)。第二天全部修完,8 项 witness-based 回归 持续可重放。
| ID | 等级 | 描述 | 修复 |
|---|---|---|---|
| AUTH-VULN-01 | CRITICAL | admin token 被 Vite 内联进公开 JS bundle | 删除 VITE_ADMIN_TOKEN,token 只存 PartyKit env;7 个老 deployment 全删 + CF cache purge |
| AUTH-VULN-02 | CRITICAL | localStorage 设任意值都能进 admin | 服务端 token 校验,客户端只做 UX 缓存 |
| AUTH-VULN-03 | CRITICAL | WS 端点完全无认证,匿名 Yjs 客户端可任意读写 | onConnect 解析 identity query param,缺失 close(4001) |
| AUTHZ-VULN-01 | CRITICAL | 任意用户便利贴可被匿名修改/删除 | 服务端每条 update 做 sandbox ownership 校验 |
| AUTHZ-VULN-03 | HIGH | localStorage identity 可任意伪造 | ownership 检查改用连接绑定的 identity,不再相信 sticky.authorId |
| AUTHZ-VULN-05 | HIGH | client 限流(useRef 计数)直连 WS 绕过 | 服务端 sliding window 15 ops/min/connection |
| AUTHZ-VULN-06 | MEDIUM | mint-filter 只在 client onChange 跑 | 服务端二次拦截 |
| XSS-VULN-01/02 | HIGH | sticky.color 渲染为 CSS background,CSS url() 注入产生跨域信标 | 颜色白名单 ^#[0-9a-f]{3,8}$,前后两侧校验 |
| AUTH-VULN-05 | HIGH | admin token 无服务端限流 | IP 层 20 conn/min/IP/room 限,token 64 hex 实际不可暴力 |
三层防御
┌─────────────────────────┐
Attacker WS connect → │ L1: IP rate limit │
│ 20 conn/min/IP/room │
└────────┬────────────────┘
│ allow
┌────────▼────────────────┐
│ L2: identity & admin │
│ - identity required │
│ - admin token verify │
└────────┬────────────────┘
│ admin or normal
┌────────▼────────────────┐
│ L3: per-update sandbox │
│ - shape / range check │
│ - color whitelist │
│ - mint-filter text │
│ - ownership / authorId │
│ - reactions delta only │
│ - 15 mutations/min/conn│
└─────────────────────────┘
内容审核
- 客户端:mint-filter 实时拦,显示 toast 提示词被替换
- 服务端:同字典二次校验,违例整批 reject
- 后台:
/admin路由(服务端 token 验证),按时间倒序列出全部频道便利贴,一键删除
自动化回归
scripts/security-regression.mjs —— 8 项 witness-based 攻击重放:
✅ no-identity rejected
✅ spoof-author not broadcast
✅ css-url() color not broadcast (XSS)
✅ delete-other not propagated
✅ sensitive-word not broadcast
✅ server-side write rate (15/30 cap)
✅ bad-admin-token rejected
✅ legit write still works
每次改服务端代码后,本地 + production 都可重放,确保任何 refactor 都没破坏防御。
Token 轮换流程
文档化在 ~/greatfish-runbook.md § 5.4:
openssl rand -hex 32生成新 tokenprintf '%s' "$NEW" | pnpm party env add ADMIN_TOKEN(⚠️ 不能用echo,会加\n)pnpm party:deploy(⚠️ env add 不会自动 redeploy Worker)~/Desktop/tokens双写一致- 跑回归确认新 token 接受 + 老 token 拒绝
V2 功能集
2026-05-29 凌晨一次性上线 10 个新功能:
| # | 功能 | 实现 |
|---|---|---|
| 1 | 多人光标 / 在线人数 | Yjs awareness,服务端零代码改动 |
| 2 | 角拖拽 resize | 4 个 corner pointer handler,坐标 / zoom |
| 3 | z-index 置顶置底 | schema 加 z 字段,inline zIndex 渲染 |
| 4 | Emoji picker | 6 反应 + 18 文本表情,native button panel |
| 5 | Permalink (#hash) | location.hash 上挂 sticky id,自动 scroll + flash |
| 6 | 画布 pan/zoom | viewport state + CSS transform |
| 7 | 反应 (Reactions) | schema 加 reactions Record,服务端特殊放行 |
| 8 | 回复线程 (parentId) | schema 加 parentId,SVG line 渲染父子关系 |
| 9 | 搜索 / 过滤 | useMemo filter snapshot |
| 10 | Feed 列表视图 + 排序 | 新 view,按 ts 或 reactionCount 排 |
10 项全部通过 8 项原回归 + 2 项新 reactions 权限测试,无新增 critical 漏洞。
项目时间线
| 日期 | 事件 |
|---|---|
| 2026-05-28 晚 | 0 → 1 上线,接 office-hours 后开始,4 小时内 production live |
| 2026-05-28 夜 | 接 Shannon 渗透测试,发现 9 个漏洞 |
| 2026-05-29 凌晨 | 全部 P0/P1 修完,服务端真鉴权 + sandbox 校验 + 颜色白名单 + 服务端 mint-filter 上线 |
| 2026-05-29 凌晨同夜 | V2 一次性上线 10 个新功能,8 项回归全过 |
| 2026-05-30 | 稳定运行 |
| 2026-05-31 | L3 完全销毁,所有云资源清零,GitHub 仓库 archived |
已知限制 / 不打算做(回顾)
- 图片附件:NSFW 模型 + 存储 + 带宽四个洞同时开,V2 候选,目前不做
- Markdown / 富文本:刚把 CSS 注入封死,XSS 风险大,不做
- 私密频道 / 密码:直接打死「公共」这个核心定位
- DM / 私信:广场不是私聊
- AI 自动回复:公共瓜区的乐趣是真人真八卦,AI 来插话破坏氛围
- 强制登录 / 实名:杀掉「匿名摸鱼」灵魂
- 付费 / 商业化:这就是个 builder 项目,验证 0 → 1 速度,不打算运营
License
MIT。fork 改商业化都可以。所有代码 + commit history 公开,踩过的坑全部写在 commit message 里。
🐠 https://greatfish.ssssmy.com(已下线)
📦 https://github.com/ssssmy/greatfish(archived,只读)