Commit Graph
21 Commits
Author SHA1 Message Date
tamakyi 25aee35030 安全与可靠性: 修复配额并发、投票事务、密码授权与导出完整性 2026-11-05 12:17:05 +08:00
tamakyi 937e688cfa 可靠性: 完善 R2 备份恢复与对象生命周期 2026-11-05 08:41:55 +08:00
tamakyi 3e7719f200 安全: 修复凭据、遥测与账号数据清理风险 2026-11-04 14:38:19 +08:00
tamakyi b9060c982b feat(notify): 新增站级 Webhook 与用户级 Telegram 通知通道
在现有邮件通知之外补两条广播/点对点通道:

- 站级 Webhook([notify] webhook_url):新提问 / 新回答 / 新追问 / 新私聊 /
  新投票回复统一 POST JSON 到通用 Webhook(Discord / Slack / 企业微信机器人等)。
  消息体同时带 content(Discord 直接可用)与结构化 event/title/body/link 字段。
- 用户级 Telegram([notify] telegram_bot_token + users.telegram_chat_id):箱主
  自填 Chat ID,新提问 / 新投票回复点对点推送。

设计约束:通知是「尽力而为」旁路 —— 任一通道发送失败只记日志、不向上抛错,
绝不影响提问/回答的主流程;通道未配置时静默跳过。发送放在 shared.SafeGo 异步
执行,与现有邮件通知同一条路径。

6 处业务触发点接入 notify;私聊消息保留仅邮件(已有 5 分钟节流,避免广播刷屏)。
profile 页新增 Telegram Chat ID 输入框 + 四语言字典。app.sample.ini 加 [notify] 段。

测试覆盖 webhook 的 JSON 结构与未配置时的静默跳过。
2026-10-29 19:27:23 +08:00
tamakyi 4e574ac64a feat(insights): 箱主数据面板——访问量、提问趋势、处理漏斗
新增 /user/insights,让箱主第一次能看到「有多少人来过我的箱、提问处理得怎么样」。

数据来源:
- 新表 box_views:一行 = 一访客×一天×一箱,views 累加,唯一索引
  (user_id, viewed_on, visitor)。刻意不内嵌 dbutil.Model —— 软删除 + 唯一索引
  会让被删行永久占槽,且删过的槽位再也插不进去。
- 访客标识 = HMAC(日盐, IP) 前 32 hex:同日可去重(UV 准)、跨日不可关联(隐私)。
  代价是近 7/30 天 UV = 各日 UV 之和,页面上已写明口径。

写入是异步的:buffered channel 512 + 2s 批量 flush,队列满即丢(统计尽力而为,
不该因为记一笔访问就拖慢页面)。过滤箱主本人、空 UA、爬虫 UA、预取请求。
密钥复用 server.xsrf_key;每日清理 400 天前的明细。

页面(纯 CSS 画图,不引图表库):
- 今日/近 7 天/近 30 天 KPI + 上周对比
- 30 天 PV/UV 双柱趋势、24 小时时段分布
- 提问处理漏斗 + 回答率进度条 + 自动结论

聚合层 internal/db/box_analytics.go 复用 analyticsDialectFor 的时区处理,
与全站 stats 分开(那个不含单箱维度、按自然日缓存)。

测试:box_views 4 项(含「同批同键先内存合并」——PG 的 ON CONFLICT 不允许
同条 INSERT 二次命中同一行)、insights 的 pct/窗口切分/visitorID/爬虫过滤。
2026-10-29 18:25:53 +08:00
tamakyi d5d25c804b fix(data): 删除改成一个事务且真删、配额与内容同事务
四类问题都出在「多次独立写入 + 失败只打日志」这个模式上。

1) 删除投票的级联没有事务(route/user/vote.go)
   选项 / 表单字段 / 提交记录 / 投票本体是 4 次独立写入,前 3 步失败只 Warn。
   中途失败会留下「选项没了但票还在」这种界面无法修复的状态 —— 重试删除时
   投票已不存在,只能报 not exist,脏数据永远留着。
   → db.Votes.DeleteWithCascade:四张表在同一事务里删完。

2) 删除其实是软删除,个人数据没真删掉
   模型都嵌了 dbutil.Model(含 DeletedAt),原来所有 Delete 都是软删:
   提交记录里参与者填写的文本、voter_ip、voter_token,以及提问里的 FromIP,
   只是「界面上看不见」,库里永久留着。用户点删除的预期不是这样。
   软删还会占住 link_token 唯一索引,删过的链接永远不能复用。
   → 删除投票与删除提问统一 Unscoped 真删;提问的追问/图片关联/配额回退
     原来也是三个独立写入(失败只 Warn),一并收进事务。

3) 配额计数会永久偏低
   「创建内容」与「quota_count+1」是两次写入,后者失败只 Warn:
   箱主看到的「已收集 7/10」偏小,达到上限后因为计数偏小实际还能继续收,
   等于定量收集被静默绕过;重置计数也校准不回来(重置是把计数清零,更脱节)。
   → 抽出 incrementQuotaTx(tx, userID, feature, delta) 作为唯一实现,
     提问 Questions.Create、私聊 CreateWithFirstMessage、投票提交
     VoteSubmissions.Create(新增 QuotaUserID)都在自己的事务里完成 +1。
   注意口径:投票的定量收集是「累计收到的票数」(提交成功时 +1),
   不是「创建了几个投票」—— 所以删除投票不回退这个计数。

4) 改写追问回答每次都发通知邮件
   箱主改措辞、补一句、修错别字都会重新保存,每次都发的话提问者会连着收到
   好几封一模一样的「您的提问有回复」。主回答那边早有 question.Answer == ""
   的判定,追问这里补齐:UpdateAnswerByID 在事务内读旧值返回 wasFirst。

顺带:
- isQuestioner 的 token 比较从 == 改为 subtle.ConstantTimeCompare,
  与 Pager 中间件里的校验同口径(== 按字节短路,可用响应时间逐字节试探)。
- CSV 导出的表头(字段名)与选项内容列补 sanitizeCSVCell。原来只有数据列走它,
  而选项名同样可能是 =HYPERLINK(...) 形式;判断也改成看 trim 后的首字符。
- 写路径的功能开关改 fail-closed:新增 db.FeatureStateOfStrict,读配置失败时
  拒绝而不是回退默认配置 —— 提问/投票的默认值是「开启」,fail-open 等于让一次
  数据库抖动绕过箱主设的关闭与定量上限。读展示仍用宽松版。

验证:全量单测(带 DB)与 go build ./... 通过;verify_round3 6/6(私聊
message_count 与实际消息数一致)、verify_p1 4/4、verify_round2 19/19、
verify_page_next 6/6、verify_chat_owner 3/3。
2026-10-29 17:18:28 +08:00
tamakyi 0041cc51f8 perf(vote): 结果聚合改 stale-while-revalidate,消除持续投票时的 O(N²)
多选 / 评分 / 排序 / 表单的统计必须在 Go 层逐条 json.Unmarshal 全部提交记录
(ListByVoteID 传 nil 不设 LIMIT),而 Submit 每来一票就 InvalidateResultsCache
删掉缓存 —— 于是第 k 票触发一次 O(k) 重算,持续有人投票时稳态是 O(N²)。
一万票的投票在投票期间每次刷新都要扫一万行 JSON。

现在:
- LoadResults 用 GetWithExpiry 读到「已过期但仍在」的旧值,先返回给调用方,
  再在后台按投票去重重算(sync.Map 占位 + shared.SafeGo,60s 超时)
- 后台只拷贝 ID/Type/AllowCustom 三个字段,不把 handler 正在用的 *db.Vote 带进 goroutine
- Submit 从 InvalidateResultsCache(删缓存)改为 ExpireResultsCache(标记过期保留旧值),
  否则每次提交都会让下一次读退化成同步全量重算
- 选项变更 / 删除投票仍走 InvalidateResultsCache(结构性变化,旧值已无意义)
- TTLCache 新增 GetWithExpiry / Expire:Get 遇到过期项直接删除并返回 false,
  「过期」与「不存在」分不开,正是无法做 SWR 的原因

实测(2 万条多选提交):冷启动 0.211s → 过期首读 0.023s(快 9.2x),
后台刷新后 0.009s。缓存单测 3 项覆盖 GetWithExpiry 不删条目 / Expire 保留值。

顺带把 7 处裸类型断言改成带 ok(其中 results.go 的是新代码)。
2026-10-29 16:28:23 +08:00
tamakyi c971d99bdb fix(vote): 站方处置覆盖投票链路
提问链路上 ActiveLevel 有 7 处调用,投票链路 0 处。于是 L4(整页停用)处置某人后:
/_/domain 已经 302 到 /restricted,但 /vote/{token} 照常渲染、照常收票;被处置者
还能发新投票;探索页的「热门/全部投票」也不做处置过滤 —— 同一张页面里
「箱主消失了、他的投票还在曝光」。这是运营口径不一致,不是越权。

补齐三处,与提问链路对齐:
- vote.Page      L4 → 302 /restricted(同箱主页;查询失败 fail-open,与 Pager 一致)
- vote.Submit    L4 → 拒绝(页面进不去不代表直接构造 POST 也拦得住)
- VotePublish    L2 起拒绝(L2 已是「收窄公开」,不该再新增公开内容)
- 探索页投票列表改用 Restrictions.ActiveLevels 批量过滤(与 GetShowInHome 同口径,
  一次查询避免 N+1),功能开关过滤逻辑保持不变

新增字典 key vote.err.restrictedPublish(四语言)。

验证(本地实例):
- L4 后 /vote/{token} 302 → /restricted;撤销后恢复 200
- 探索页该投票出现次数 0(处置中)→ 2(撤销后)
- 草稿投票在 L2 下发布后状态停留 draft,撤销后同一请求变为 published
2026-10-29 15:53:49 +08:00
tamakyi 7802bb2078 fix(security): 修复安全审计的 P1/P2/P3 项,含全局复查新发现的两处越权与外跳
P1:
- 外链图片:markdown 渲染层只放行站内与自有 CDN 图片。配置里的
  image_bucket_cdn_host 常写成 host/path,统一归一化后再比对,避免把自家图一起剥掉
- 密码:页面/提问密码最小 6 位;密码比对挪到验证码之后;页面密码失败锁定 5 次/10 分钟
- 客户端 IP:CF-Connecting-IP / EO-Client-IP 只在 TCP 对端可信(回环或 trusted_proxies)
  时采信,公网对端带来的权威头一律忽略

P2/P3:
- 跳转参数收敛到 shared.SafeRedirectTarget:拒反斜杠与控制字符(url.Parse 会把 %0d%0a
  解成 CRLF,伪造 Location),最后再用 path.Clean 验一次前缀
- 注册不再暴露「邮箱已注册 / 已停用」:与全新注册共用同一个成功出口,只在日志留痕
- CSP:默认 enforce,白名单由配置派生,覆盖 umami(运行时注入)、Turnstile 与
  按 sentry_dsn 条件放行的 Sentry CDN
- /user 与 /_ 下发 no-store;补 X-XSS-Protection、Permissions-Policy,并把安全头前移到
  静态资源中间件之前(原先静态文件拿不到 nosniff/HSTS)
- 封禁事实落库 ban_records;429 统一带 Retry-After 与本地化文案
- token 比较改常量时间;探索页页码上限;JSON 响应显式 Content-Type

全局复查补的两处:
- 私聊 SendMessage 的 POST 缺 conv.OwnerID 校验,被拉黑者可换 domain 继续发消息
- 页面密码 next 与登录 to 同源的反斜杠外跳

验收:verify_p1_fixes 4/4、verify_round2 19/19、verify_page_next 6/6、
verify_chat_owner 3/3(含变异对照);单测与 go build ./... 全通过。
2026-10-27 12:37:40 +08:00
tamakyi e9fba8b9ed theme v1 2026-10-19 15:23:11 +08:00
tamakyi 12a37e726f fix v14 2026-10-19 02:25:19 +08:00
tamakyi bd613a6f5a fix v12 2026-10-19 01:11:28 +08:00
tamakyi 7a6417c33d restrict v8 2026-10-18 23:32:38 +08:00
tamakyi 317c96ca17 restrict v7 2026-10-18 23:04:50 +08:00
tamakyi bd81398a06 restrict v6 2026-10-18 22:18:21 +08:00
tamakyi 6bd90779f8 restrict v1 2026-10-18 21:14:59 +08:00
tamakyi 0e0c16f786 fix deactivate 2026-09-15 12:10:42 +08:00
tamakyi adcc42cc8c fix anwser token and database issus 2026-09-15 11:06:09 +08:00
tamakyi e683e798aa fix typo error 2026-09-10 19:27:21 +08:00
tamakyi 8e2325eda5 aliyun sensor server 2026-09-10 18:04:19 +08:00
tamakyi e3e59e7c69 chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
2026-09-10 10:56:27 +08:00