diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c8434a..b8d47ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ - 支持在构建期预置默认服务器地址:打包机放一个 `client/.env.local`(已被 gitignore)填 `VITE_DEFAULT_SERVER_URL`,打出的安装包首次启动即指向该地址,玩家不必手填。公开仓库与 CI 不含该文件,产物保持 `http://127.0.0.1:8787`。该值只在生产构建生效,`vite dev` 与单元测试始终使用本地地址。 - 设置窗口的服务器地址下方显示「当前默认」值,便于确认安装包预置的是哪个地址。 +- 每用户图片存储配额(默认 64MB)与未引用图片 TTL 清理(默认 14 天);超限返回 `quota_exceeded`。 +- 每日数据库备份可额外推到 S3 兼容对象存储(AWS / 腾讯云 COS / 阿里云 OSS / MinIO);未配置时行为不变。 +- 好友关系加上无向唯一索引,A 申请 B 与 B 申请 A 不能插成两行;反向并发会自动变成互加。 ## v0.8.0 — 2026-09-09 diff --git a/README.md b/README.md index ebe1c21..dd7ad1c 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,7 @@ cd client && npm install && npm run tauri dev npm test && npm run lint && npm run typecheck ``` -测试基线:服务端 91 例(PGlite 内存库 + 真实 WebSocket 集成),客户端 44 例(含屏幕共享连接生命周期、自动档位、码率分摊与中继限速、默认服务器地址解析的单元测试)。仓库里另有 13 个双账号浏览器回归脚本(`dev/e2e-*.mjs`),覆盖私聊、编辑、表情、通知跳转、邀请链接、屏幕共享等流程;其中 `e2e-screen-share-media.mjs` 用合成采集流跑真实的 P2P 媒体链路(断言画面推进与音频 RMS),不需要人工点选择器。 +测试基线:服务端 105 例(PGlite 内存库 + 真实 WebSocket 集成,含图片配额/TTL 与 S3 兼容备份上传),客户端 45 例(含屏幕共享连接生命周期、自动档位、码率分摊与中继限速、默认服务器地址解析的单元测试)。仓库里另有 13 个双账号浏览器回归脚本(`dev/e2e-*.mjs`),覆盖私聊、编辑、表情、通知跳转、邀请链接、屏幕共享等流程;其中 `e2e-screen-share-media.mjs` 用合成采集流跑真实的 P2P 媒体链路(断言画面推进与音频 RMS),不需要人工点选择器。 数据库结构由 16 个纯 SQL 迁移文件定义(`server/migrations/`),启动时按序自动应用。开发用的 PGlite 和生产 PostgreSQL 执行的是同一份 SQL。 @@ -430,7 +430,7 @@ GameTalk/ - [x] 邀请链接(有效期 / 次数 / 深链加入) - [x] 房间内 P2P 屏幕共享 + 独立观看窗 + 自建 TURN 兜底 - [x] 通知点击跳转到对应会话 -- [ ] 图片对象存储冷备(S3 / COS) +- [x] 图片对象存储冷备(S3 / COS / OSS / MinIO,可选) - [ ] macOS / Linux 的游戏模式适配 ## 参与贡献 diff --git a/docker/.env.example b/docker/.env.example index 1666c65..317a3c7 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -19,3 +19,16 @@ GAMETALK_HOST=chat.example.com # TURN_URL 可多个,逗号分隔(建议同时给 UDP 与 TCP) # TURN_SECRET=change-me-openssl-rand-hex-16 # TURN_URL=turn:你的服务器IP:3478,turn:你的服务器IP:3478?transport=tcp + +# 可选:把每日数据库 dump 再推一份到 S3 兼容对象存储(图片字节在 dump 里)。 +# 四项都填了 backup-db.sh 才会上传;不填则只保留服务器本地 14 天。 +# BACKUP_S3_ENDPOINT=https://cos.ap-guangzhou.myqcloud.com +# BACKUP_S3_BUCKET=your-bucket +# BACKUP_S3_ACCESS_KEY= +# BACKUP_S3_SECRET_KEY= +# BACKUP_S3_REGION=ap-guangzhou +# BACKUP_S3_PREFIX=gametalk/ +# BACKUP_S3_PATH_STYLE=true +# 可选:每用户图片上限(字节,默认 64MB)与未引用图片保留天数(默认 14) +# MEDIA_QUOTA_BYTES=67108864 +# MEDIA_UNUSED_TTL_DAYS=14 diff --git a/docker/backup-db.sh b/docker/backup-db.sh index 5b4b480..84d8033 100755 --- a/docker/backup-db.sh +++ b/docker/backup-db.sh @@ -55,3 +55,43 @@ find "$BACKUP_DIR" -name 'configs-*.tar.gz' -mtime +"$KEEP_DAYS" -delete size=$(du -h "$dump" | cut -f1) log "✅ 完成:$dump ($size, TOC $toc_lines 行) + $cfg;保留 ${KEEP_DAYS} 天" + +# 5) 可选:推到 S3 兼容对象存储(AWS / COS / OSS / MinIO)。未配置四项凭据则跳过。 +# 失败不回滚本地 dump——对象存储是额外的冷备,不能因为远端故障丢掉本机备份。 +if [ -f "$REPO_ROOT/docker/.env" ]; then + set -a + # shellcheck disable=SC1091 + . "$REPO_ROOT/docker/.env" + set +a +fi + +upload_s3() { + local file="$1" + if [ -z "${BACKUP_S3_ENDPOINT:-}" ] || [ -z "${BACKUP_S3_BUCKET:-}" ] \ + || [ -z "${BACKUP_S3_ACCESS_KEY:-}" ] || [ -z "${BACKUP_S3_SECRET_KEY:-}" ]; then + return 0 + fi + local server="${SERVER_CONTAINER:-docker-server-1}" + local remote="/tmp/gt-s3-$(basename "$file")" + if ! docker cp "$file" "$server:$remote" 2>/dev/null; then + log "⚠ 对象存储:无法把文件拷进 $server,跳过 $(basename "$file")" + return 0 + fi + if docker exec \ + -e BACKUP_S3_ENDPOINT -e BACKUP_S3_BUCKET \ + -e BACKUP_S3_ACCESS_KEY -e BACKUP_S3_SECRET_KEY \ + -e BACKUP_S3_REGION -e BACKUP_S3_PREFIX -e BACKUP_S3_PATH_STYLE \ + "$server" node dist/cli/push-backup.js "$remote" "$(basename "$file")"; then + log "✅ 已上传到对象存储:$(basename "$file")" + else + log "⚠ 对象存储上传失败(本地 dump 仍保留):$(basename "$file")" + fi + docker exec "$server" rm -f "$remote" >/dev/null 2>&1 || true +} + +if [ -n "${BACKUP_S3_BUCKET:-}" ]; then + upload_s3 "$dump" + upload_s3 "$cfg" +else + log "对象存储未配置(BACKUP_S3_*),仅保留本地 dump" +fi diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 71e45c7..3f9f4ec 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,6 +14,8 @@ services: # 默认 *(桌面客户端不受浏览器同源限制,够用);如需收紧在 .env 里设置 CORS_ORIGIN CORS_ORIGIN: ${CORS_ORIGIN:-*} LOG_LEVEL: info + MEDIA_QUOTA_BYTES: ${MEDIA_QUOTA_BYTES:-67108864} + MEDIA_UNUSED_TTL_DAYS: ${MEDIA_UNUSED_TTL_DAYS:-14} depends_on: postgres: condition: service_healthy diff --git a/docs/architecture.md b/docs/architecture.md index 437cdd1..870ebfa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,7 +53,7 @@ gametalk/ │ │ ├── routes/ # REST 路由(health / auth / rooms / invites / friends / dm / stickers / media / turn) │ │ ├── ws/ # WS 网关(认证 + 房间广播 + 花名册/在线状态 + 信令透传 + 限流 + 心跳清理) │ │ ├── db/ # pg/PGlite 抽象 + migration 执行器 -│ │ └── lib/ # jwt / password / image / invite / avatar / validate / envfile +│ │ └── lib/ # jwt / password / image / invite / avatar / validate / envfile / mediaLifecycle / s3 │ ├── migrations/ # 纯 SQL migration(生产与 PGlite 同源) │ └── test/ # vitest 单测 + 集成测试 ├── docker/ # 生产部署 compose、Caddyfile、部署/备份/coturn 脚本 @@ -142,7 +142,7 @@ gametalk/ **表情包**:`POST/GET/DELETE /api/stickers`(个人云表情,媒体归属校验 + 24 上限 + 幂等)、`POST/GET/DELETE /api/rooms/:id/stickers`(房间共享,成员资格校验,删除 = 添加者或房主)。客户端表情面板三页签:表情 / 我的表情包(云同步,本地旧数据自动迁移)/ 群表情(按房间隔离)。 -**好友**:`friendships`(pending/accepted,双向唯一);支持 userId / 用户名 / `#8 位短 ID` 查找;反向申请等价于互加。实时事件(`friend:request/accepted/declined/removed`)经 WS 推送在线方。好友与房间完全分离管理。 +**好友**:`friendships`(pending/accepted)。`(requester_id, addressee_id)` 方向唯一之外,另有无向唯一索引 `(LEAST(requester, addressee), GREATEST(...))`,A→B 与 B→A 不能并存。反向申请等价于互加;反向并发插入撞唯一索引时再读对方那一行并自动接受。支持 userId / 用户名 / `#8 位短 ID` 查找。实时事件(`friend:request/accepted/declined/removed`)经 WS 推送在线方。好友与房间完全分离管理。 **好友私聊(DM)**:仅 accepted 好友可互发(`dm:send` 服务端校验,非好友回 `not_friends`)。独立 `dm_messages` 表(与房间消息分离,无提及/禁言语义),消息含 `from/to/username 快照/kind/media_url/reply_to/recalled`;持久化后向**双方所有连接**广播 `dm:new`(发送者自己也收到,多端一致)。撤回 `dm:recall` 仅发送者本人(无房主概念,他人撤回回 `only_sender`),广播 `dm:recalled`,内容清空。REST:`GET /api/dm/conversations`(DISTINCT ON 聚合每会话最后一条,侧栏预览一次拉齐)、`GET /api/dm/:peerId/messages`(游标分页,非好友 403)。删除好友不删历史(重新加好友后消息仍在,UI 隐藏会话)。客户端:乐观发送 + 按序校正,`activeDmPeerId` 与 `activeRoomId` 互斥表达活跃会话。 @@ -195,6 +195,7 @@ gametalk/ - `014_invite_links`:`invite_links`(16 位长码,可过期 `expires_at` / 可限次数 `max_uses` / `used_count` 计数,随房间级联删除) - `015_forwarded_label`:`messages.forwarded_from_label` / `dm_messages.forwarded_from_label`(转发来源展示快照,纯展示不参与权限判断) - `016_media_urls`:`messages.media_urls` / `dm_messages.media_urls`(多图消息的完整 URL 列表 JSONB,首图仍写 `media_url` 兼容旧客户端) +- `017_media_quota_friends_pair`:`media.owner_id` 索引;`friendships` 无向唯一索引(LEAST/GREATEST),并先按「已接受优先」去重 ## 6. 游戏 Overlay(透明置顶窗口方案) @@ -241,7 +242,7 @@ gametalk/ - 邀请码/邀请链接用 `crypto.randomInt` 生成(CSPRNG),不用 `Math.random`。 - 头像:上传 data URL 类型/大小(≤3MB)/魔数三重校验;分发走 `/api/avatars/:id`(公开端点, id 为不可枚举 UUID),带 5 分钟缓存头。 -- 消息图片:`POST /api/media` 需登录,类型/大小(≤5MB)/魔数三重校验;读取 `/api/media/:id` +- 消息图片:`POST /api/media` 需登录,类型/大小(≤5MB)/魔数三重校验;**每用户默认 64MB 配额**(`MEDIA_QUOTA_BYTES`,超限 413 `quota_exceeded`);未被消息或表情引用的图片默认 14 天后清理(`MEDIA_UNUSED_TTL_DAYS`,0 关闭)。读取 `/api/media/:id` 公开(`` 无法附带认证头),id 为不可枚举 UUID + immutable 缓存;发送时校验媒体归属,群表情入库时同样校验归属。 - 注册并发竞态由用户名唯一索引兜底(冲突返回 409);邀请兑换用「占位入房 + 条件原子自增」防超额,并发下不会 500。 - CORS 可配置:compose 默认 `*`(桌面客户端不受浏览器同源限制),可经 `CORS_ORIGIN` 收紧。 diff --git a/docs/deployment.md b/docs/deployment.md index 0473a27..3d63dae 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -50,6 +50,9 @@ nano .env && bash deploy.sh | `CORS_ORIGIN` | 可选,默认 `*`(桌面客户端不受浏览器同源限制);需收紧时设置,多来源逗号分隔 | | `TURN_SECRET` | 可选,自建 TURN 中继共享密钥(见第 9 节) | | `TURN_URL` | 可选,TURN 地址(逗号分隔,建议同时给 UDP 与 TCP) | +| `MEDIA_QUOTA_BYTES` | 可选,每用户图片存储上限(字节,默认 67108864 = 64MB;`0` 不限制) | +| `MEDIA_UNUSED_TTL_DAYS` | 可选,未被消息/表情引用的图片保留天数(默认 14;`0` 不自动清理) | +| `BACKUP_S3_ENDPOINT` 等 | 可选,每日 dump 额外推到 S3 兼容对象存储(见第 6 节);四项凭据都填了才上传 | ### 数据迁移 @@ -197,6 +200,32 @@ schtasks /Create /F /TN "GameTalk 备份异地拉取" /SC DAILY /ST 09:10 ` - 建议每月做一次恢复演练(下述步骤在 VPS 上用最新 dump 验证行数即可,不必真删数据)。 +### 对象存储冷备(S3 / COS / OSS / MinIO,可选) + +`pg_dump` 已经包含 `media` 表里的图片字节。把 dump 再推一份到对象存储,等于数据库和图片一起有了异地冷备,不再依赖单台 Windows 管理机定时拉取。 + +在 `docker/.env` 填齐这四项(缺任何一项脚本都会跳过上传,只保留服务器本地 14 天): + +``` +BACKUP_S3_ENDPOINT=https://cos.<地域>.myqcloud.com # 或 oss-<地域>.aliyuncs.com / s3.<地域>.amazonaws.com / MinIO 地址 +BACKUP_S3_BUCKET=<桶名> +BACKUP_S3_ACCESS_KEY=<密钥 ID> +BACKUP_S3_SECRET_KEY=<密钥> +BACKUP_S3_REGION=<地域> # 签名用,COS/OSS 填对应地域 +BACKUP_S3_PREFIX=gametalk/ # 可选,对象键前缀 +BACKUP_S3_PATH_STYLE=true # 默认路径风格 /bucket/key,MinIO 与大多数兼容层都用这个 +``` + +`backup-db.sh` 在本地 dump 校验通过后,把文件拷进 server 容器,调用 `node dist/cli/push-backup.js` 做 SigV4 PUT。对象存储失败**不会丢掉本地 dump**(日志里会写警告)。手动补传: + +```bash +docker cp /root/gametalk/backups/gametalk-db-XXXX.dump docker-server-1:/tmp/gt.dump +docker exec -e BACKUP_S3_ENDPOINT -e BACKUP_S3_BUCKET -e BACKUP_S3_ACCESS_KEY -e BACKUP_S3_SECRET_KEY \ + docker-server-1 node dist/cli/push-backup.js /tmp/gt.dump gametalk-db-XXXX.dump +``` + +服务端另有每用户图片配额(默认 64MB,`MEDIA_QUOTA_BYTES`)和未引用图片 TTL(默认 14 天,`MEDIA_UNUSED_TTL_DAYS`),避免 BYTEA 把磁盘吃满;撤回后清空引用的图会在 TTL 后被清掉,表情包引用的图不会。 + ### 恢复步骤 ```bash @@ -208,8 +237,7 @@ docker exec -i docker-postgres-1 pg_restore -U gametalk -d gametalk --clean --if docker compose start server # 3) 重启并验证 /health、登录、历史消息 ``` -> 可选升级:条件允许时将备份再推送一份到对象存储(S3/COS/OSS 免费额度即可), -> 把异地副本从「管理机定时拉取」升级为「服务器定时推送」,进一步降低对单台管理机的依赖。 +> 对象存储冷备的配置与手动补传见上一小节;不填 `BACKUP_S3_*` 时行为与原来完全一样(只留服务器本地 dump + 管理机拉取)。 ## 7. 客户端下载 diff --git a/docs/testing.md b/docs/testing.md index 59700f5..f102c25 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,7 +4,7 @@ | 范围 | 工具 | 内容 | |---|---|---| -| 服务端单测/集成 | vitest | REST 路由、WS 网关(双客户端实时收发、房主删房、幂等 join、花名册/在线状态)、好友(申请/互加/删除 + 实时事件 + 在线广播)、好友私聊(非好友拒绝/双方广播/历史分页/会话聚合/撤回权限/图片与引用校验)、@提及(解析/成员校验/入库/编辑重算)、图片消息(上传校验/归属/绝对 URL/多图)、禁言(仅房主/到期/解除)、WS 加固(限流 / 超大帧断连)、REST 限流(登录 429)、头像端点(data URL → HTTP 端点)、邀请链接(创建/资格/过期 410/耗尽 410/幂等入房不计数/吊销权限/并发兑换)、消息转发(源可见性/目标唯一/代复制 media/来源标注;转发进房复用禁言校验)、屏幕共享信令(非成员拒绝 / 启停广播 / 定向转发与越权拒绝 / 晚加入快照)、migration 幂等、输入校验与错误映射 | +| 服务端单测/集成 | vitest | REST 路由、WS 网关(双客户端实时收发、房主删房、幂等 join、花名册/在线状态)、好友(申请/互加/删除 + 实时事件 + 在线广播 + 无向唯一索引)、好友私聊(非好友拒绝/双方广播/历史分页/会话聚合/撤回权限/图片与引用校验)、@提及(解析/成员校验/入库/编辑重算)、图片消息(上传校验/归属/绝对 URL/多图/每用户配额/未引用 TTL 清理)、禁言(仅房主/到期/解除)、WS 加固(限流 / 超大帧断连)、REST 限流(登录 429)、头像端点(data URL → HTTP 端点)、邀请链接(创建/资格/过期 410/耗尽 410/幂等入房不计数/吊销权限/并发兑换)、消息转发(源可见性/目标唯一/代复制 media/来源标注;转发进房复用禁言校验)、屏幕共享信令(非成员拒绝 / 启停广播 / 定向转发与越权拒绝 / 晚加入快照)、S3 兼容备份上传(SigV4 + 本地 mock PUT)、migration 幂等、输入校验与错误映射 | | 服务端回归 | vitest | `test/regressions.test.ts`:历史分页保留最新一条、空 mediaUrls 不算内容、退房/删房后订阅失效、房主不可退房、目标不在房间用 `target_not_in_room`、私聊撤回清 media_urls、编辑重算提及、改名同步广播、群表情媒体归属、邀请并发兑换、非法 id 不 500、代理信任判定(17 例) | | 客户端逻辑 | vitest | gameMode 管理器(mock Tauri,含换键后旧键注销、启动途中关闭的热键清理)、真实 server 集成测试(双端聊天 / not_in_room / 断线重连 / 花名册离线保留 / 好友私聊收发与历史 / 编辑往返) | | 屏幕共享逻辑 | vitest | `test/screenShare.test.ts`:连接生命周期、重复 request 非破坏性、码率分摊与档位、ICE 断线重启、relay 候选命中限码率(12 例,mock RTCPeerConnection) | @@ -69,8 +69,12 @@ cd client/src-tauri && cargo check | 41 | 退房订阅清理对称性:REST 退房后不再收到该房间广播、花名册不再显示其在线 | ✅(session.test.ts 1 例) | ⬜ | | 42 | 客户端顶号处理:提示 + 清凭据 + 不再自动重连(防两端互相顶号死循环) | ✅(store.regressions.test.ts 1 例) | ⬜ | | 43 | 屏幕共享控制条布局体检(按 `CONTROL_W/H` 渲染真实 DOM,量溢出/换行/按钮压缩) | ✅(dev/probe-share-bar.mjs,失败退出码 1) | — | +| 44 | 每用户图片配额(超限 413 `quota_exceeded`,他人不受影响) | ✅(media.test.ts) | — | +| 45 | 未引用图片 TTL 清理(表情/消息附件保留,撤回后可清) | ✅(media.test.ts) | — | +| 46 | 好友无向唯一索引(SQL 层拒绝 A→B 与 B→A 两行) | ✅(friends.test.ts) | — | +| 47 | 备份对象存储上传(SigV4 签名稳定 + mock 端点 PUT 字节一致) | ✅(s3.test.ts) | — | -当前实测:server **98** 测试(12 文件)+ client **45** 测试(5 文件)全绿;lint 双工作区通过;生产模式冒烟(health/register/login)通过。生产环境(境外云服务器,地址不记录在本仓库)2026-09-11 运行 **0.8.0** 并验证(`/health` 版本、`/api/turn` 匿名 401、WSS 握手、容器 healthy、宝塔与其他站点不受影响),当日测试数据已全量清理(users/rooms/messages/dm_messages/media/invite_links 归零)。 +当前实测:server **105** 测试(14 文件)+ client **45** 测试(5 文件)全绿;lint 双工作区通过;生产模式冒烟(health/register/login)通过。生产环境(境外云服务器,地址不记录在本仓库)2026-09-11 运行 **0.8.0** 并验证(`/health` 版本、`/api/turn` 匿名 401、WSS 握手、容器 healthy、宝塔与其他站点不受影响),当日测试数据已全量清理(users/rooms/messages/dm_messages/media/invite_links 归零)。 ## 3. 真机验收清单 diff --git a/server/.env.example b/server/.env.example index 5f8d6e2..6ff1d14 100644 --- a/server/.env.example +++ b/server/.env.example @@ -38,3 +38,18 @@ RATE_LIMIT_AUTH_MAX=10 # 走中继时的单路码率上限(bps,默认 1200000)。按服务器出口带宽设置: # 4Mbps 出口建议 1200000;30Mbps 出口可设 4000000 # TURN_RELAY_MAX_BPS=1200000 + +# 每用户图片存储上限(字节,默认 64MB)。0 表示不限制 +# MEDIA_QUOTA_BYTES=67108864 +# 未被消息/表情引用的图片保留天数(默认 14)。0 表示不自动清理 +# MEDIA_UNUSED_TTL_DAYS=14 + +# 备份推送到 S3 兼容对象存储(AWS / 腾讯云 COS / 阿里云 OSS / MinIO)。 +# 四项都填了,docker/backup-db.sh 才会在本地 dump 成功后额外上传一份。 +# BACKUP_S3_ENDPOINT=https://cos.ap-guangzhou.myqcloud.com +# BACKUP_S3_BUCKET=your-bucket +# BACKUP_S3_ACCESS_KEY= +# BACKUP_S3_SECRET_KEY= +# BACKUP_S3_REGION=ap-guangzhou +# BACKUP_S3_PREFIX=gametalk/ +# BACKUP_S3_PATH_STYLE=true diff --git a/server/README.md b/server/README.md index c3062d3..f30f210 100644 --- a/server/README.md +++ b/server/README.md @@ -16,7 +16,7 @@ npm run migrate # 手动执行 migrations/*.sql(服务启动时也会自 npm run reset-password -- <用户名> <新密码> # 服务器主人重置用户密码(PGlite 模式需先停服) ``` -测试基线 89 例 / 11 个文件,包含 REST、WS 网关、好友、私聊、表情、邀请、转发、屏幕共享信令、TURN 凭据,以及 `test/regressions.test.ts`(分页、越权、竞态、输入校验等回归用例)。 +测试基线 105 例 / 14 个文件,包含 REST、WS 网关、好友、私聊、表情、邀请、转发、屏幕共享信令、TURN 凭据、图片配额与 TTL、S3 兼容备份上传,以及 `test/regressions.test.ts`(分页、越权、竞态、输入校验等回归用例)。 ## 环境变量 @@ -25,13 +25,13 @@ npm run reset-password -- <用户名> <新密码> # 服务器主人重置用 - `DATABASE_URL`:PostgreSQL 连接串(不设置则用 PGlite) - `JWT_SECRET`:生产模式下禁止默认值,否则启动报错 -可选:`RATE_LIMIT_MAX` / `RATE_LIMIT_AUTH_MAX`(限流阈值)、`TURN_SECRET` / `TURN_URL`(屏幕共享中继)、`CORS_ORIGIN`、`LOG_LEVEL`、`PGLITE_DATA_DIR`。完整清单见 `.env.example`。 +可选:`RATE_LIMIT_MAX` / `RATE_LIMIT_AUTH_MAX`(限流阈值)、`TURN_SECRET` / `TURN_URL`(屏幕共享中继)、`MEDIA_QUOTA_BYTES` / `MEDIA_UNUSED_TTL_DAYS`(图片配额与未引用清理)、`BACKUP_S3_*`(每日 dump 推对象存储)、`CORS_ORIGIN`、`LOG_LEVEL`、`PGLITE_DATA_DIR`。完整清单见 `.env.example`。 ## 结构 - `src/routes/`:REST(health / auth / rooms / invites / friends / dm / stickers / media / turn) - `src/ws/gateway.ts`:WS 网关(JWT 鉴权、内存房间表广播、花名册与在线状态、屏幕共享信令透传、单连接限流、心跳清理) -- `src/lib/`:jwt / password / image / invite / avatar / validate / envfile +- `src/lib/`:jwt / password / image / invite / avatar / validate / envfile / mediaLifecycle / s3 - `src/db/`:pg 与 PGlite 双实现 + migration 执行器(同一接口,生产与测试同源 SQL) - `migrations/`:纯 SQL 迁移,启动时按文件名顺序自动应用,`_migrations` 表记录版本 diff --git a/server/migrations/017_media_quota_friends_pair.sql b/server/migrations/017_media_quota_friends_pair.sql new file mode 100644 index 0000000..6b73404 --- /dev/null +++ b/server/migrations/017_media_quota_friends_pair.sql @@ -0,0 +1,23 @@ +-- 017: 媒体按 owner 检索(配额统计)+ 好友关系无向唯一(A→B 与 B→A 不能并存) +-- +-- 好友表原先 UNIQUE (requester_id, addressee_id) 只约束同一方向。 +-- 反向并发申请(A→B 与 B→A 同时到达)会插入两行,列表/删除只命中其中一行。 +-- 先按「已接受优先、更早优先」去重,再加无向唯一索引。 + +DELETE FROM friendships +WHERE id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id) + ORDER BY CASE WHEN status = 'accepted' THEN 0 ELSE 1 END, created_at ASC, id ASC + ) AS rn + FROM friendships + ) ranked + WHERE rn > 1 +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_friendships_pair + ON friendships (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id)); + +CREATE INDEX IF NOT EXISTS idx_media_owner ON media (owner_id); diff --git a/server/package.json b/server/package.json index 6372826..ff57482 100644 --- a/server/package.json +++ b/server/package.json @@ -16,6 +16,7 @@ "test:watch": "vitest", "migrate": "tsx src/db/migrate.ts", "reset-password": "tsx src/cli/reset-password.ts", + "push-backup": "tsx src/cli/push-backup.ts", "lint": "eslint ." }, "dependencies": { diff --git a/server/src/app.ts b/server/src/app.ts index 376ed09..2837acf 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -104,7 +104,7 @@ export async function buildApp(deps: AppDeps): Promise { registerFriendsRoutes(app, { db, jwt }); registerDmRoutes(app, { db, jwt }); registerStickersRoutes(app, { db, jwt }); - registerMediaRoutes(app, { db, jwt }); + registerMediaRoutes(app, { config, db, jwt }); registerTurnRoutes(app, { config, jwt }); registerWsRoutes(app, { config, db, jwt }); diff --git a/server/src/cli/push-backup.ts b/server/src/cli/push-backup.ts new file mode 100644 index 0000000..9786223 --- /dev/null +++ b/server/src/cli/push-backup.ts @@ -0,0 +1,27 @@ +// 把本地文件 PUT 到 S3 兼容对象存储(AWS / COS / OSS / MinIO) +// 用法:node dist/cli/push-backup.js <本地文件> [对象键文件名] +// 未配置 BACKUP_S3_* 时退出码 2(调用方应先判断再调)。 +import { readFile } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { loadEnvFileIfPresent } from '../lib/envfile.js'; +loadEnvFileIfPresent(); +import { loadS3Config, objectKeyOf, putObject } from '../lib/s3.js'; + +const file = process.argv[2]; +const name = process.argv[3] || (file ? basename(file) : ''); + +if (!file || !name) { + console.error('用法: node dist/cli/push-backup.js <本地文件> [对象键文件名]'); + process.exit(1); +} + +const cfg = loadS3Config(); +if (!cfg) { + console.error('未配置 BACKUP_S3_ENDPOINT / BACKUP_S3_BUCKET / BACKUP_S3_ACCESS_KEY / BACKUP_S3_SECRET_KEY,跳过上传'); + process.exit(2); +} + +const body = await readFile(file); +const key = objectKeyOf(cfg.prefix, name); +const { url, status } = await putObject(cfg, key, body); +console.log(`uploaded ${name} → ${url} (${status}, ${body.length} bytes)`); diff --git a/server/src/config.ts b/server/src/config.ts index f003a52..4a8f255 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -20,6 +20,10 @@ export interface Config { turnUrls: string[]; /** 走中继时的单路码率上限(bps):中继消耗服务器出口,按机器带宽配置 */ turnRelayMaxBps: number; + /** 每用户图片存储上限(字节)。≤0 表示不限制。默认 64MB */ + mediaQuotaBytes: number; + /** 未被消息/表情引用的图片保留天数。≤0 表示不自动清理。默认 14 */ + mediaUnusedTtlDays: number; } export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { @@ -44,5 +48,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { turnUrls: env.TURN_URL ? env.TURN_URL.split(',').map((s) => s.trim()).filter(Boolean) : [], // 默认 1.2Mbps(按 4Mbps 出口的小机器设计);带宽充裕的机器可调高 turnRelayMaxBps: Math.max(300_000, Number(env.TURN_RELAY_MAX_BPS) || 1_200_000), + mediaQuotaBytes: env.MEDIA_QUOTA_BYTES === undefined ? 64 * 1024 * 1024 : Number(env.MEDIA_QUOTA_BYTES) || 0, + mediaUnusedTtlDays: env.MEDIA_UNUSED_TTL_DAYS === undefined ? 14 : Number(env.MEDIA_UNUSED_TTL_DAYS) || 0, }; } diff --git a/server/src/lib/mediaLifecycle.ts b/server/src/lib/mediaLifecycle.ts new file mode 100644 index 0000000..5472259 --- /dev/null +++ b/server/src/lib/mediaLifecycle.ts @@ -0,0 +1,58 @@ +import type { QueryResultRow } from 'pg'; +import type { Db } from '../db/db.js'; + +/** 消息/表情里登记的相对路径前缀,与 GET /api/media/:id 对齐 */ +export const MEDIA_URL_PREFIX = '/api/media/'; + +export function mediaPathOf(id: string): string { + return `${MEDIA_URL_PREFIX}${id}`; +} + +interface SumRow extends QueryResultRow { + used: string | number | null; +} + +interface IdRow extends QueryResultRow { + id: string; +} + +/** 某用户当前占用的图片字节数(含尚未挂到消息上的待发图) */ +export async function mediaUsageOf(db: Db, ownerId: string): Promise { + const res = await db.query( + 'SELECT COALESCE(SUM(octet_length(bytes)), 0) AS used FROM media WHERE owner_id = $1', + [ownerId], + ); + return Number(res.rows[0]?.used ?? 0); +} + +/** + * 清掉「过了 TTL 且没有任何引用」的图片:未发出的待发图、已撤回清空引用的图。 + * 表情包(个人/群)引用的媒体不删。cutoff 之前创建的才参与。 + * 返回删除条数。 + */ +export async function sweepUnusedMedia(db: Db, cutoff: Date): Promise { + const res = await db.query( + `DELETE FROM media m + WHERE m.created_at < $1 + AND NOT EXISTS (SELECT 1 FROM user_stickers s WHERE s.media_id = m.id) + AND NOT EXISTS (SELECT 1 FROM room_stickers s WHERE s.media_id = m.id) + AND NOT EXISTS ( + SELECT 1 FROM messages msg + WHERE msg.media_url = $2 || m.id::text + OR msg.media_urls @> jsonb_build_array($2 || m.id::text) + ) + AND NOT EXISTS ( + SELECT 1 FROM dm_messages msg + WHERE msg.media_url = $2 || m.id::text + OR msg.media_urls @> jsonb_build_array($2 || m.id::text) + ) + RETURNING m.id`, + [cutoff.toISOString(), MEDIA_URL_PREFIX], + ); + return res.rowCount ?? res.rows.length; +} + +export function mediaQuotaExceededMessage(quotaBytes: number): string { + const mb = Math.max(1, Math.round(quotaBytes / (1024 * 1024))); + return `图片存储已达上限(${mb}MB/人),请删掉一些表情或等未使用的图片过期后再试`; +} diff --git a/server/src/lib/s3.ts b/server/src/lib/s3.ts new file mode 100644 index 0000000..6eb5117 --- /dev/null +++ b/server/src/lib/s3.ts @@ -0,0 +1,124 @@ +import { createHash, createHmac } from 'node:crypto'; + +export interface S3Config { + endpoint: string; + region: string; + bucket: string; + accessKey: string; + secretKey: string; + /** 默认 true:路径风格 /bucket/key,兼容 MinIO / COS / OSS 的 S3 API */ + pathStyle: boolean; + /** 对象键前缀,默认 gametalk/ */ + prefix: string; +} + +export function loadS3Config(env: NodeJS.ProcessEnv = process.env): S3Config | null { + const endpoint = (env.BACKUP_S3_ENDPOINT || '').trim(); + const bucket = (env.BACKUP_S3_BUCKET || '').trim(); + const accessKey = (env.BACKUP_S3_ACCESS_KEY || '').trim(); + const secretKey = (env.BACKUP_S3_SECRET_KEY || '').trim(); + if (!endpoint || !bucket || !accessKey || !secretKey) return null; + const pathStyleRaw = (env.BACKUP_S3_PATH_STYLE || 'true').trim().toLowerCase(); + return { + endpoint, + region: (env.BACKUP_S3_REGION || 'us-east-1').trim() || 'us-east-1', + bucket, + accessKey, + secretKey, + pathStyle: pathStyleRaw !== '0' && pathStyleRaw !== 'false' && pathStyleRaw !== 'no', + prefix: (env.BACKUP_S3_PREFIX || 'gametalk/').trim() || 'gametalk/', + }; +} + +function sha256Hex(data: Buffer | string): string { + return createHash('sha256').update(data).digest('hex'); +} + +function hmac(key: Buffer | string, data: string): Buffer { + return createHmac('sha256', key).update(data, 'utf8').digest(); +} + +function amzDateOf(now: Date): { amzDate: string; dateStamp: string } { + const iso = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z'); + return { amzDate: iso, dateStamp: iso.slice(0, 8) }; +} + +function encodePath(segments: string[]): string { + return ( + '/' + + segments + .filter((s) => s.length > 0) + .map((s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)) + .join('/') + ); +} + +export interface SignedPut { + url: string; + headers: Record; + canonicalRequest: string; + stringToSign: string; + signature: string; +} + +export function objectKeyOf(prefix: string, name: string): string { + const p = prefix.endsWith('/') ? prefix : `${prefix}/`; + const n = name.replace(/^\/+/, ''); + return `${p}${n}`; +} + +/** 构造 SigV4 签名的 PUT(不发网络)。now 可注入以便单测固定向量。 */ +export function signPutObject( + cfg: S3Config, + key: string, + body: Buffer, + now: Date = new Date(), + contentType = 'application/octet-stream', +): SignedPut { + const endpoint = new URL(cfg.endpoint.includes('://') ? cfg.endpoint : `https://${cfg.endpoint}`); + const { amzDate, dateStamp } = amzDateOf(now); + const payloadHash = sha256Hex(body); + const host = cfg.pathStyle ? endpoint.host : `${cfg.bucket}.${endpoint.host}`; + const canonicalUri = cfg.pathStyle ? encodePath([cfg.bucket, ...key.split('/')]) : encodePath(key.split('/')); + const canonicalHeaders = `content-type:${contentType}\nhost:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`; + const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date'; + const canonicalRequest = ['PUT', canonicalUri, '', canonicalHeaders, signedHeaders, payloadHash].join('\n'); + const credentialScope = `${dateStamp}/${cfg.region}/s3/aws4_request`; + const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n'); + const kDate = hmac(`AWS4${cfg.secretKey}`, dateStamp); + const kRegion = hmac(kDate, cfg.region); + const kService = hmac(kRegion, 's3'); + const kSigning = hmac(kService, 'aws4_request'); + const signature = createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex'); + const authorization = `AWS4-HMAC-SHA256 Credential=${cfg.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + const url = `${endpoint.protocol}//${host}${canonicalUri}`; + return { + url, + headers: { + Host: host, + 'Content-Type': contentType, + 'Content-Length': String(body.length), + 'x-amz-content-sha256': payloadHash, + 'x-amz-date': amzDate, + Authorization: authorization, + }, + canonicalRequest, + stringToSign, + signature, + }; +} + +export async function putObject( + cfg: S3Config, + key: string, + body: Buffer, + now: Date = new Date(), +): Promise<{ url: string; status: number }> { + const signed = signPutObject(cfg, key, body, now); + const res = await fetch(signed.url, { method: 'PUT', headers: signed.headers, body }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`S3 PUT ${res.status} ${signed.url}: ${text.slice(0, 300)}`); + } + return { url: signed.url, status: res.status }; +} diff --git a/server/src/routes/friends.ts b/server/src/routes/friends.ts index f41ff2e..af453ae 100644 --- a/server/src/routes/friends.ts +++ b/server/src/routes/friends.ts @@ -123,7 +123,29 @@ export function registerFriendsRoutes(app: FastifyInstance, deps: FriendsDeps): [req.userId, target.id], ); if (!inserted.rows[0]) { - // 并发反向申请(A→B 与 B→A 同时到达)由唯一索引兜底,视作申请已存在 + // 无向唯一索引挡住了反向并发插入:再读一行,对方先到的 pending 视作互加 + const again = await db.query( + `SELECT * FROM friendships + WHERE (requester_id = $1 AND addressee_id = $2) OR (requester_id = $2 AND addressee_id = $1)`, + [req.userId, target.id], + ); + const raced = again.rows[0]; + if (raced && raced.requester_id === target.id && raced.status === 'pending') { + await db.query("UPDATE friendships SET status = 'accepted', updated_at = now() WHERE id = $1", [raced.id]); + const me = ( + await db.query('SELECT id, username, avatar_url, bio FROM users WHERE id = $1', [req.userId!]) + ).rows[0]!; + sendToUser(target.id, { + type: 'friend:accepted', + payload: { user: { ...publicUserOf(base, me), online: onlineUserIds().has(me.id) } }, + }); + await reply.send({ request: { id: raced.id, status: 'accepted', user: publicUserOf(base, target) } }); + return; + } + if (raced?.status === 'accepted') { + await reply.code(409).send({ error: { code: 'already_friends', message: '你们已经是好友了' } }); + return; + } await reply.code(409).send({ error: { code: 'request_pending', message: '好友申请已发送,等待对方处理' } }); return; } diff --git a/server/src/routes/media.ts b/server/src/routes/media.ts index 1adacc8..50914a9 100644 --- a/server/src/routes/media.ts +++ b/server/src/routes/media.ts @@ -1,13 +1,21 @@ import type { FastifyInstance } from 'fastify'; import type { QueryResultRow } from 'pg'; +import type { Config } from '../config.js'; import type { Db } from '../db/db.js'; import type { JwtService } from '../lib/jwt.js'; import { MAX_IMAGE_BYTES, validateImageDataUrl } from '../lib/image.js'; import { makeAuthPreHandler } from '../plugins/auth.js'; +import { + mediaPathOf, + mediaQuotaExceededMessage, + mediaUsageOf, + sweepUnusedMedia, +} from '../lib/mediaLifecycle.js'; export interface MediaDeps { db: Db; jwt: JwtService; + config: Config; } interface MediaRow extends QueryResultRow { @@ -18,13 +26,10 @@ interface MediaRow extends QueryResultRow { const MEDIA_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -/** 消息图片存储路径(存入 messages.media_url 的规范形式) */ -export function mediaPathOf(id: string): string { - return `/api/media/${id}`; -} +export { mediaPathOf }; export function registerMediaRoutes(app: FastifyInstance, deps: MediaDeps): void { - const { db } = deps; + const { db, config } = deps; const auth = makeAuthPreHandler(deps.jwt); // 图片上传:dataUrl → media 表(bodyLimit 覆盖 5MB 图的 base64 体积 ≈ 6.7MB) @@ -42,6 +47,21 @@ export function registerMediaRoutes(app: FastifyInstance, deps: MediaDeps): void await reply.code(400).send({ error: { code: result.error ?? 'invalid_media', message: msg } }); return; } + // 先清掉过期未引用图,再按当前占用判断配额(避免「已撤回的图」一直占额度) + if (config.mediaUnusedTtlDays > 0) { + const cutoff = new Date(Date.now() - config.mediaUnusedTtlDays * 24 * 60 * 60 * 1000); + await sweepUnusedMedia(db, cutoff); + } + const incoming = result.bytes!.length; + if (config.mediaQuotaBytes > 0) { + const used = await mediaUsageOf(db, req.userId!); + if (used + incoming > config.mediaQuotaBytes) { + await reply.code(413).send({ + error: { code: 'quota_exceeded', message: mediaQuotaExceededMessage(config.mediaQuotaBytes) }, + }); + return; + } + } const inserted = await db.query<{ id: string }>( 'INSERT INTO media (owner_id, mime, bytes) VALUES ($1, $2, $3) RETURNING id', [req.userId, result.mime, result.bytes], diff --git a/server/test/friends.test.ts b/server/test/friends.test.ts index 844004f..e3d4018 100644 --- a/server/test/friends.test.ts +++ b/server/test/friends.test.ts @@ -239,4 +239,19 @@ describe('friends', () => { expect((await bSeesOffline).payload).toMatchObject({ userId: a.userId, online: false }); wsB.close(); }); + + it('pair unique index rejects inserting A→B and B→A as two rows', async () => { + const a = await registerUser('fr_uniq_a'); + const b = await registerUser('fr_uniq_b'); + const created = await app.inject({ + method: 'POST', + url: '/api/friends/requests', + headers: auth(a.token), + payload: { userId: b.userId }, + }); + expect(created.statusCode).toBe(201); + await expect( + db.query('INSERT INTO friendships (requester_id, addressee_id) VALUES ($1, $2)', [b.userId, a.userId]), + ).rejects.toMatchObject({ code: '23505' }); + }); }); diff --git a/server/test/media.test.ts b/server/test/media.test.ts new file mode 100644 index 0000000..13e2fb6 --- /dev/null +++ b/server/test/media.test.ts @@ -0,0 +1,139 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../src/app.js'; +import { createDb, type Db } from '../src/db/db.js'; +import { runMigrations } from '../src/db/migrate.js'; +import { loadConfig } from '../src/config.js'; +import { createJwtService } from '../src/lib/jwt.js'; +import { mediaPathOf, mediaUsageOf, sweepUnusedMedia } from '../src/lib/mediaLifecycle.js'; + +const PNG = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + +const QUOTA = 8 * 1024; // 8KB:够几张 1×1 PNG,SQL 塞满后再测拒绝 + +let app: FastifyInstance; +let db: Db; + +async function registerUser(username: string): Promise<{ token: string; userId: string }> { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username, password: 'password123' }, + }); + return { token: res.json().token, userId: res.json().user.id }; +} + +function auth(token: string) { + return { authorization: `Bearer ${token}` }; +} + +async function upload(token: string) { + return app.inject({ + method: 'POST', + url: '/api/media', + headers: { ...auth(token), 'content-type': 'application/json' }, + payload: { dataUrl: PNG }, + }); +} + +beforeAll(async () => { + db = createDb(loadConfig({ NODE_ENV: 'test' })); + await runMigrations(db); + const jwt = createJwtService('test-secret', '1h'); + app = await buildApp({ + config: loadConfig({ + NODE_ENV: 'test', + MEDIA_QUOTA_BYTES: String(QUOTA), + MEDIA_UNUSED_TTL_DAYS: '0', + }), + db, + jwt, + }); + await app.listen({ host: '127.0.0.1', port: 0 }); +}); + +afterAll(async () => { + await app.close(); + await db.close(); +}); + +describe('media quota', () => { + it('rejects upload when the user is over quota; other users are unaffected', async () => { + const a = await registerUser('mq_a'); + const b = await registerUser('mq_b'); + + const first = await upload(a.token); + expect(first.statusCode).toBe(201); + expect(await mediaUsageOf(db, a.userId)).toBeGreaterThan(0); + + await db.query('INSERT INTO media (owner_id, mime, bytes) VALUES ($1, $2, $3)', [ + a.userId, + 'image/png', + Buffer.alloc(QUOTA), + ]); + const over = await upload(a.token); + expect(over.statusCode).toBe(413); + expect(over.json().error.code).toBe('quota_exceeded'); + expect(over.json().error.message).toMatch(/上限/); + + const other = await upload(b.token); + expect(other.statusCode).toBe(201); + }); +}); + +describe('unused media TTL sweep', () => { + it('deletes unreferenced old media, keeps stickers and message attachments, then quota frees up', async () => { + const u = await registerUser('mq_ttl'); + + const dangling = await upload(u.token); + expect(dangling.statusCode).toBe(201); + const danglingId = dangling.json().id as string; + + const stickerUp = await upload(u.token); + expect(stickerUp.statusCode).toBe(201); + const stickerMediaId = stickerUp.json().id as string; + const addSticker = await app.inject({ + method: 'POST', + url: '/api/stickers', + headers: auth(u.token), + payload: { mediaId: stickerMediaId }, + }); + expect(addSticker.statusCode).toBe(201); + + const room = ( + await app.inject({ method: 'POST', url: '/api/rooms', headers: auth(u.token), payload: { name: 'TTL房' } }) + ).json().room; + const attached = await upload(u.token); + expect(attached.statusCode).toBe(201); + const attachedId = attached.json().id as string; + await db.query( + `INSERT INTO messages (room_id, user_id, username, text, kind, media_url) + VALUES ($1, $2, $3, '', 'image', $4)`, + [room.id, u.userId, 'mq_ttl', mediaPathOf(attachedId)], + ); + + await db.query('UPDATE media SET created_at = now() - interval \'30 days\' WHERE owner_id = $1', [u.userId]); + const removed = await sweepUnusedMedia(db, new Date()); + expect(removed).toBeGreaterThanOrEqual(1); + + expect((await app.inject({ method: 'GET', url: `/api/media/${danglingId}` })).statusCode).toBe(404); + expect((await app.inject({ method: 'GET', url: `/api/media/${stickerMediaId}` })).statusCode).toBe(200); + expect((await app.inject({ method: 'GET', url: `/api/media/${attachedId}` })).statusCode).toBe(200); + + const recalled = await upload(u.token); + expect(recalled.statusCode).toBe(201); + const recalledId = recalled.json().id as string; + await db.query( + `INSERT INTO messages (room_id, user_id, username, text, kind, media_url, media_urls) + VALUES ($1, $2, $3, '', 'image', $4, $5::jsonb)`, + [room.id, u.userId, 'mq_ttl', mediaPathOf(recalledId), JSON.stringify([mediaPathOf(recalledId)])], + ); + await db.query('UPDATE messages SET media_url = NULL, media_urls = NULL WHERE media_url = $1', [ + mediaPathOf(recalledId), + ]); + await db.query('UPDATE media SET created_at = now() - interval \'30 days\' WHERE id = $1', [recalledId]); + await sweepUnusedMedia(db, new Date()); + expect((await app.inject({ method: 'GET', url: `/api/media/${recalledId}` })).statusCode).toBe(404); + }); +}); diff --git a/server/test/s3.test.ts b/server/test/s3.test.ts new file mode 100644 index 0000000..03dde96 --- /dev/null +++ b/server/test/s3.test.ts @@ -0,0 +1,88 @@ +import { createServer, type IncomingMessage } from 'node:http'; +import { describe, expect, it } from 'vitest'; +import { loadS3Config, objectKeyOf, putObject, signPutObject, type S3Config } from '../src/lib/s3.js'; + +const fixture: S3Config = { + endpoint: 'https://s3.example.test', + region: 'us-east-1', + bucket: 'gametalk-backups', + accessKey: 'AKIAIOSFODNN7EXAMPLE', + secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + pathStyle: true, + prefix: 'gametalk/', +}; + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +describe('S3-compatible backup uploader', () => { + it('loadS3Config requires the four credentials', () => { + expect(loadS3Config({})).toBeNull(); + expect(loadS3Config({ BACKUP_S3_ENDPOINT: 'https://s3.test', BACKUP_S3_BUCKET: 'b' })).toBeNull(); + const cfg = loadS3Config({ + BACKUP_S3_ENDPOINT: 'https://s3.test', + BACKUP_S3_BUCKET: 'b', + BACKUP_S3_ACCESS_KEY: 'a', + BACKUP_S3_SECRET_KEY: 's', + BACKUP_S3_REGION: 'ap-guangzhou', + BACKUP_S3_PREFIX: 'gt/', + }); + expect(cfg).toMatchObject({ + endpoint: 'https://s3.test', + bucket: 'b', + region: 'ap-guangzhou', + prefix: 'gt/', + pathStyle: true, + }); + }); + + it('object keys sit under the configured prefix', () => { + expect(objectKeyOf('gametalk/', 'dump.dump')).toBe('gametalk/dump.dump'); + expect(objectKeyOf('gametalk', 'dump.dump')).toBe('gametalk/dump.dump'); + }); + + it('SigV4 signature is stable for a frozen timestamp', () => { + const now = new Date('2013-05-24T00:00:00.000Z'); + const body = Buffer.from('hello backups'); + const a = signPutObject(fixture, 'gametalk/hello.txt', body, now); + const b = signPutObject(fixture, 'gametalk/hello.txt', body, now); + expect(a.signature).toBe(b.signature); + expect(a.signature).toMatch(/^[0-9a-f]{64}$/); + expect(a.headers.Authorization).toContain('AWS4-HMAC-SHA256 Credential='); + expect(a.headers.Authorization).toContain(a.signature); + expect(a.url).toBe('https://s3.example.test/gametalk-backups/gametalk/hello.txt'); + expect(a.canonicalRequest.startsWith('PUT\n/gametalk-backups/gametalk/hello.txt\n')).toBe(true); + }); + + it('PUT reaches an S3-compatible endpoint with the file bytes', async () => { + const received: { url?: string; auth?: string; body?: Buffer } = {}; + const server = createServer((req, res) => { + void readBody(req).then((body) => { + received.url = req.url; + received.auth = String(req.headers.authorization ?? ''); + received.body = body; + res.statusCode = 200; + res.end(); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as { port: number }; + try { + const cfg: S3Config = { ...fixture, endpoint: `http://127.0.0.1:${port}` }; + const payload = Buffer.from('pg_dump-bytes'); + const result = await putObject(cfg, 'gametalk/x.dump', payload); + expect(result.status).toBe(200); + expect(received.url).toBe('/gametalk-backups/gametalk/x.dump'); + expect(received.auth).toMatch(/^AWS4-HMAC-SHA256 /); + expect(received.body?.equals(payload)).toBe(true); + } finally { + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); + } + }); +});