s-blog

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

Demo ↗ GitHub ↗

📛 此项目已于 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_IDPARTYKIT_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:

  1. openssl rand -hex 32 生成新 token
  2. printf '%s' "$NEW" | pnpm party env add ADMIN_TOKEN(⚠️ 不能用 echo,会加 \n)
  3. pnpm party:deploy(⚠️ env add 不会自动 redeploy Worker)
  4. ~/Desktop/tokens 双写一致
  5. 跑回归确认新 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,只读)

原文链接:https://www.ssssmy.com/works/greatfish