// 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,直接截原文会在列表里露出 // `![图片](https://...)` 这种噪音,一条带图的提问几乎只剩链接。 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 { 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) { // 不区分「箱主不存在」与「提问不属于他」:一律返回空结果。 return ctx.JSON(map[string]interface{}{"items": []askStatusResult{}}) } logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get box owner for ask status") return ctx.ServerError() } // 收集合法 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}) }