页面密码(P0,2026-11-05 复核发现) - 旧实现 `ver, _ := Session.Get(key).(uint); if ver != user.PagePasswordVer` 把「session 里没有授权记录」(断言失败得 0)与「新增列默认值 0」判成相等, 升级瞬间所有存量页面密码静默失效:匿名访客不带 cookie 即可读箱主页。 - 收口为 pagePasswordAuthorized:缺 key 一律视为未授权, key 名由 pagePassKey 统一生成,Pager / ListAPI / AskStatusAPI 三处共用。 - 新增迁移 v6 回填 page_password_ver / ask_password_ver(仅仍为 0 且已设密码的行, 幂等),让模型语义与「改过一次密码」一致,并作废升级前的布尔授权。 - AskStatusAPI 的 L4 查询补日志(保持与 Pager 一致的 fail-open 口径)。 导出(P1) - JSON 导出的投票提交不再输出 voter_ip。库里照常保留, 防刷、取证与站方 CLI 处置不受影响,只是不进箱主的自助导出文件。 测试 - route/question/page_password_test.go:8 个用例钉住零值/类型不符/轮换失效等边界。 - internal/db/page_password_migration_test.go:回填只碰该碰的行且幂等。 验证:WSL2 Debian + PostgreSQL 全量测试通过;隔离实例实测 ver=0 匿名访问返回密码页、API 401,输对密码后放行、改密后旧会话立即失效, 删除迁移账本后重启可正确回填。
162 lines
6.6 KiB
Go
162 lines
6.6 KiB
Go
// Copyright 2026 tamakyi. All rights reserved.
|
||
// Use of this source code is governed by a MIT-style
|
||
// license that can be found in the LICENSE file.
|
||
|
||
package question
|
||
|
||
import (
|
||
"crypto/subtle"
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/pkg/errors"
|
||
"github.com/sirupsen/logrus"
|
||
|
||
"github.com/tamakyi/TamaBox/internal/context"
|
||
"github.com/tamakyi/TamaBox/internal/db"
|
||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||
templatepkg "github.com/tamakyi/TamaBox/internal/template"
|
||
)
|
||
|
||
// 一次最多查询多少条。浏览器里存的是「这台设备上提过的全部问题」,
|
||
// 正常用户不会超过几条;设上限是为了让这个公开接口不能被当成批量探测器用。
|
||
const askStatusMaxItems = 20
|
||
|
||
type askStatusItem struct {
|
||
ID uint `json:"id"`
|
||
Token string `json:"token"`
|
||
}
|
||
|
||
type askStatusRequest struct {
|
||
Domain string `json:"domain"`
|
||
Items []askStatusItem `json:"items"`
|
||
}
|
||
|
||
type askStatusResult struct {
|
||
ID uint `json:"id"`
|
||
OK bool `json:"ok"` // token 校验通过(false 表示记录已失效,前端应删除)
|
||
Answered bool `json:"answered"` // 已有回答(追问不计)
|
||
Excerpt string `json:"excerpt"` // 正文摘要,让用户一眼认出是哪一条
|
||
Created int64 `json:"created"` // 提问时间(unix 秒),前端按本地时区显示
|
||
}
|
||
|
||
// askExcerptRuneLimit 摘要长度(按 rune 计,不按字节 —— 按字节会在多字节字符中间切断)。
|
||
const askExcerptRuneLimit = 60
|
||
|
||
// askExcerpt 取正文前若干字符作为摘要。
|
||
//
|
||
// 先转纯文本再截断:正文是 markdown,直接截原文会在列表里露出
|
||
// `` 这种噪音,一条带图的提问几乎只剩链接。
|
||
func askExcerpt(content string) string {
|
||
r := []rune(strings.TrimSpace(templatepkg.PlainText(content)))
|
||
if len(r) <= askExcerptRuneLimit {
|
||
return string(r)
|
||
}
|
||
return string(r[:askExcerptRuneLimit]) + "…"
|
||
}
|
||
|
||
// AskStatusAPI 批量查询「这些提问里哪些已经被回答」。
|
||
//
|
||
// 存在的理由:不填邮箱的提问者,唯一的追踪手段是提问成功页上那条链接。
|
||
// 关掉页面就等于永久失联 —— 箱主认真答了,提问者永远不知道。
|
||
// 前端把 {domain, id, token} 存在 localStorage,再回到箱主页时问一次这个接口,
|
||
// 就能提示「你有 2 条提问已被回答」。不引入账号,也不在服务端建立访客追踪。
|
||
//
|
||
// 安全性:必须同时给出正确的 token 才返回 ok=true,token 是 12 位随机串,
|
||
// 且比较走常数时间(与 Pager 中间件同口径),所以这个接口不能用来枚举
|
||
// 「某箱主有多少提问」「某个 id 是否存在」—— 错误的组合一律 ok=false,
|
||
// 与「提问不存在」「不属于该箱主」「token 不对」三种情况无法区分。
|
||
func AskStatusAPI(ctx context.Context) error {
|
||
// 路由只注册了 POST(token 不能进 URL),这段是防御:万一将来有人把
|
||
// 路由改成 f.Any / f.Combo,GET 也不该走到解析 body 那一步。
|
||
if ctx.Request().Method != http.MethodPost {
|
||
return ctx.JSONError(40500, http.StatusText(http.StatusMethodNotAllowed))
|
||
}
|
||
|
||
var req askStatusRequest
|
||
if err := json.NewDecoder(ctx.Request().Request.Body).Decode(&req); err != nil {
|
||
return ctx.JSONError(40000, i18n.T(ctx.Lang(), "internal.errBadRequest"))
|
||
}
|
||
if req.Domain == "" || len(req.Items) == 0 {
|
||
return ctx.JSONError(40000, i18n.T(ctx.Lang(), "internal.errBadRequest"))
|
||
}
|
||
if len(req.Items) > askStatusMaxItems {
|
||
req.Items = req.Items[:askStatusMaxItems]
|
||
}
|
||
|
||
owner, err := db.Users.GetByDomain(ctx.Request().Context(), req.Domain)
|
||
if err != nil {
|
||
if errors.Is(err, db.ErrUserNotExists) {
|
||
// 箱主不存在时,也按请求的 items 原样回显 ok=false,与「箱主存在但
|
||
// 校验失败」保持完全同形。否则「空数组 vs 带元素数组」会泄露 domain
|
||
// 是否存在(含被 L1 从探索页隐藏、或设了页面密码的半公开箱)。
|
||
out := make([]askStatusResult, 0, len(req.Items))
|
||
for _, it := range req.Items {
|
||
out = append(out, askStatusResult{ID: it.ID})
|
||
}
|
||
return ctx.JSON(map[string]interface{}{"items": out})
|
||
}
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get box owner for ask status")
|
||
return ctx.ServerError()
|
||
}
|
||
|
||
// SEC-06:与 ListAPI(route/question/page.go)对齐,补上 L4 和页面密码检查。
|
||
// 否则持有正确问题 token 的请求者可以绕过页面访问边界,取得回答状态、
|
||
// 创建时间和前 60 字摘要。
|
||
// 两者都返回「用户不存在」以隐藏真实原因(不暴露处置状态与密码设置)。
|
||
emptyResult := func() error {
|
||
out := make([]askStatusResult, 0, len(req.Items))
|
||
for _, it := range req.Items {
|
||
out = append(out, askStatusResult{ID: it.ID})
|
||
}
|
||
return ctx.JSON(map[string]interface{}{"items": out})
|
||
}
|
||
// 处置查询失败时不拦(fail-open),与 Pager / ListAPI 的既定口径一致:
|
||
// 这一层不是内容外传通道,DB 异常时再 fail-closed 只会让所有提问箱一起白屏。
|
||
// 与它们唯一的差别是这里补了日志,不再静默吞掉错误。
|
||
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), owner.ID); err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction for ask status")
|
||
} else if level == db.RestrictionL4 {
|
||
return emptyResult()
|
||
}
|
||
if !pagePasswordAuthorized(ctx, owner) {
|
||
return emptyResult()
|
||
}
|
||
|
||
// 收集合法 ID 并一次查回(IN 查询,避免逐条 N+1)。
|
||
ids := make([]uint, 0, len(req.Items))
|
||
for _, it := range req.Items {
|
||
if it.ID != 0 {
|
||
ids = append(ids, it.ID)
|
||
}
|
||
}
|
||
questions, err := db.Questions.ListByIDsAndUser(ctx.Request().Context(), ids, owner.ID)
|
||
if err != nil {
|
||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to list questions for ask status")
|
||
return ctx.ServerError()
|
||
}
|
||
|
||
byID := make(map[uint]*db.Question, len(questions))
|
||
for _, q := range questions {
|
||
byID[q.ID] = q
|
||
}
|
||
|
||
out := make([]askStatusResult, 0, len(req.Items))
|
||
for _, it := range req.Items {
|
||
res := askStatusResult{ID: it.ID}
|
||
if q, found := byID[it.ID]; found && q.Token != "" && it.Token != "" &&
|
||
subtle.ConstantTimeCompare([]byte(q.Token), []byte(it.Token)) == 1 {
|
||
res.OK = true
|
||
res.Answered = q.Answer != ""
|
||
// token 已校验通过,返回摘要不额外泄露任何东西 —— 提问者本来就能用
|
||
// 同一个 token 打开全文。
|
||
res.Excerpt = askExcerpt(q.Content)
|
||
res.Created = q.CreatedAt.Unix()
|
||
}
|
||
out = append(out, res)
|
||
}
|
||
|
||
return ctx.JSON(map[string]interface{}{"items": out})
|
||
}
|