Files
tamabox/route/question/ask_history.go
T
tamakyi 693b1102c2 fix: 封堵 ask-status domain 枚举、私聊写路径 fail-open、压缩兜底重复提交
三处小修 + 两个格式清理,均来自全盘复查:

1. ask-status domain 枚举:ErrUserNotExists 分支原返回空数组、正常分支返回带元素
   数组,凭空/非空可枚举 domain 是否存在(含被 L1 隐藏或设了页面密码的半公开箱)。
   改为箱主不存在时也回显 ok:false,四种失败响应完全同形。

2. 私聊 SendMessage 写路径用 FeatureStateOf(fail-open 变体):读配置失败会回退默认值,
   与「写路径必须 FeatureStateOfStrict(失败即拒)」的约定不符。此前靠 chat 默认关闭
   的巧合侥幸安全,现统一改 Strict。

3. 图片压缩 submit 兜底可重复提交:capture 监听器里 tbBeginSubmit 从 Promise 回调提前
   到 preventDefault 同步栈,物理阻止压缩窗口内双击重复提交。

顺带:box_views.go / message_image.go / insights 两个文件补末尾换行与 struct 字段对齐
(gofmt 干净)。
2026-10-29 18:52:38 +08:00

139 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 {
// 路由只注册了 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()
}
// 收集合法 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})
}