163 lines
6.5 KiB
Go
163 lines
6.5 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"
|
||
"fmt"
|
||
"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 字摘要。
|
||
// 两者都返回「用户不存在」以隐藏真实原因(不暴露处置状态与密码设置)。
|
||
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), owner.ID); err == nil && level == db.RestrictionL4 {
|
||
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})
|
||
}
|
||
if owner.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == owner.ID) {
|
||
passKey := fmt.Sprintf("page_pass_%d", owner.ID)
|
||
sessionVer, _ := ctx.Session.Get(passKey).(uint)
|
||
if sessionVer != owner.PagePasswordVer {
|
||
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})
|
||
}
|
||
}
|
||
|
||
// 收集合法 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})
|
||
}
|