Files
tamakyi 7ffab24025 安全: 修复页面密码零值绕过,导出不再泄露投票者 IP
页面密码(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,输对密码后放行、改密后旧会话立即失效,
删除迁移账本后重启可正确回填。
2026-11-05 16:31:54 +08:00

952 lines
38 KiB
Go
Raw Permalink 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 2022 E99p1ant. 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 (
"bytes"
stdctx "context"
"crypto/md5"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"mime/multipart"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/flamego/recaptcha"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/wuhan005/govalid"
"github.com/tamakyi/TamaBox/internal/captcha"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
"github.com/tamakyi/TamaBox/internal/form"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/imagecheck"
"github.com/tamakyi/TamaBox/internal/insights"
"github.com/tamakyi/TamaBox/internal/mail"
"github.com/tamakyi/TamaBox/internal/notify"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/storage"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
"github.com/tamakyi/TamaBox/internal/theme"
)
func Pager(ctx context.Context) {
domain := ctx.Param("domain")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
if errors.Is(err, db.ErrUserNotExists) {
ctx.Redirect("/")
return
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get user by domain")
ctx.SetInternalError()
}
ctx.Success("question/page")
return
}
ctx.Map(pageUser)
// 站方处置 L4:整页停止对外访问(数据保留)。
// 查询失败不拦(fail-open):DB 异常时整站都不可用,这里再 fail-closed 只会让所有
// 提问箱一起白屏;与分享卡不同,这一层不是内容外传通道,宁可放行也不要制造大面积故障。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
} else if level == db.RestrictionL4 {
ctx.Redirect("/restricted")
return
}
// 个人页语言:永远跟随提问箱主人(pageUser)配置的语言,忽略访客 cookie;未配置时回退站点默认语言
if pageUser.Language != "" {
lang := i18n.Resolve(pageUser.Language)
ctx.Data["Lang"] = lang
ctx.Data["LangCode"] = lang
}
// 主题只作用于箱主相关页面(提问箱主页 / 单题页 / 密码页 / 分享卡路由),
// 与背景图、颜色同属箱主个性化配置;/user 后台由 Contexter 按登录用户自己的配置注入。
// 空串 = 不加载主题 css(站点默认就是基础外观时省掉一次请求)。
ctx.Data["ThemeSlug"] = theme.Resolve(pageUser.Theme)
// 页面密码检查:session 中保存授权时的 PagePasswordVer 版本号,
// 与当前版本号比对,修改密码后旧授权立即失效(SEC-06)。
if !pagePasswordAuthorized(ctx, pageUser) {
// 记录原始请求地址(含 query,如带 token 的单题链接),验证通过后跳回此处而非默认首页
ctx.Data["Next"] = ctx.Request().URL.RequestURI()
ctx.Data["PageUser"] = pageUser
ctx.Success("question/password")
return
}
// 分页加载公开已回答的问题(首次加载 20 条,置顶优先)
// 多取 1 条探测是否仍有下一页,供模板决定「加载更多」按钮的初始可见状态,
// 避免内容不足一页时按钮仍然显示、点击后才消失。
const pageQuestionsPageSize = 20
pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{
Cursor: &dbutil.Cursor{
PageSize: pageQuestionsPageSize + 1, // 每页数量 + 1 条探测行
},
FilterAnswered: true, // 只显示已回答的问题
PrivateStatus: 1, // 1 = 仅公开(is_private = false)
PinFirst: true, // 置顶的问题排在最前
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get questions by page id")
ctx.SetInternalError()
ctx.Success("question/page")
return
}
pageQuestionHasMore := false
if len(pageQuestions) > pageQuestionsPageSize {
pageQuestions = pageQuestions[:pageQuestionsPageSize]
pageQuestionHasMore = true
}
answeredCount, err := db.Questions.Count(ctx.Request().Context(), pageUser.ID, db.GetQuestionsCountOptions{
FilterAnswered: true,
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to count questions")
ctx.SetInternalError()
ctx.Success("question/page")
return
}
// 页面标题语言与页面语言保持一致:Data["Lang"] 已由 Pager 上方逻辑或 Contexter 解析好
// (提问箱主人语言 > 站点默认语言 shared.App > i18n.Default;个人页忽略访客 cookie)。
titleLang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
titleLang = l
}
ctx.SetTitle(fmt.Sprintf("%s - %s", pageUser.Name, i18n.T(titleLang, "site.title")))
ctx.Data["IsUserPage"] = true
ctx.Data["IsOwnPage"] = ctx.IsLogged && ctx.User.ID == pageUser.ID
ctx.Data["PageUser"] = pageUser
ctx.Data["PageQuestions"] = pageQuestions
ctx.Data["PageQuestionHasMore"] = pageQuestionHasMore
ctx.Data["AnsweredCount"] = answeredCount
ctx.Data["ChatMaxMessages"] = pageUser.ChatMaxMessages
// 功能开关(开关 + 定时 + 定量收集)统一由 feature_controls 驱动:
// - 提问:关闭后隐藏提问框并提示原因;
// - 私聊:关闭后隐藏/禁用私聊入口;
// - 投票:关闭后投票 Tab 保留显示但置灰禁用,hover 提示原因(不再隐藏入口)。
askState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion)
ctx.Data["CanAsk"] = askState.Open && (ctx.IsLogged || pageUser.HarassmentSetting != db.HarassmentSettingTypeRegisterOnly)
ctx.Data["AskState"] = askState
// 二维码下的引导文案随「仅允许注册用户提问」设置联动,避免勾选后仍显示"谁都可以匿名提问"。
ctx.Data["RegisterOnlyAsk"] = pageUser.HarassmentSetting == db.HarassmentSettingTypeRegisterOnly
if !askState.Open {
ctx.Data["AskClosedMessage"] = i18n.FeatureClosedMessage(titleLang, db.FeatureQuestion, askState.Reason)
}
// 关闭状态下展示更多信息:上次关闭时间 / 下次开放时间(含循环定时)。
ctx.Data["AskNextOpenAtStr"] = formatFeatureTime(askState.NextOpenAt)
ctx.Data["AskLastCloseAtStr"] = formatFeatureTime(askState.LastCloseAt)
chatState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureChat)
ctx.Data["AllowChat"] = chatState.Open
ctx.Data["ChatState"] = chatState
voteState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureVote)
ctx.Data["VoteEnabled"] = voteState.Open
ctx.Data["VoteState"] = voteState
if !voteState.Open {
// 投票 Tab 置灰禁用时的原因提示(手动关闭 / 未到开放时间 / 已结束 / 已达上限)。
ctx.Data["VoteClosedMessage"] = i18n.FeatureClosedMessage(titleLang, db.FeatureVote, voteState.Reason)
}
// 个人主页初始 Tab:默认提问;支持 ?tab=votes 直达投票面板(可分享链接)。
// hash(#votes)优先级更高,由前端 JS 在渲染后覆盖。
// 投票功能关闭时投票 Tab 置灰不可点,直达链接 ?tab=votes 回落到提问 Tab,
// 否则会渲染出一个所有面板都隐藏的空页面。
activeTab := ctx.Query("tab")
if activeTab != "votes" || !voteState.Open {
activeTab = "questions"
}
ctx.Data["ActiveProfileTab"] = activeTab
// 匿名投票板块:该箱主"进行中"的公开投票(个人主页投票 Tab 展示,最多 20 条)。
// 箱主关闭投票功能时不展示任何投票(也不再浪费一次查询)。
pageVotes := []*db.Vote{}
if voteState.Open {
pageVotes, err = db.Votes.ListPublishedPublicByUserID(ctx.Request().Context(), pageUser.ID, 20)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to list page votes")
pageVotes = []*db.Vote{}
}
}
ctx.Data["PageVotes"] = pageVotes
// 设置下一页游标(如果有数据)。置顶优先场景下必须使用复合键集游标,
// 否则置顶项与非置顶项 id 交错会造成"加载更多"漏掉介于其间的非置顶项。
if len(pageQuestions) > 0 {
last := pageQuestions[len(pageQuestions)-1]
ctx.Data["PageQuestionCursor"] = db.EncodePinCursor(last.IsPinned, last.CreatedAt, last.ID)
} else {
ctx.Data["PageQuestionCursor"] = ""
}
// 访问统计:只有走到这里才是「真的把箱主页渲染出去了」——
// 上面所有错误分支、密码页分支都已提前 return。
//
// 箱主自己访问自己的箱不计入:否则箱主每刷新一次就把自己的 UV/PV 拉高一次,
// 面板上的数字就没有任何参考价值。爬虫与预取由 RecordView 内部过滤。
if !ctx.IsLogged || ctx.User.ID != pageUser.ID {
insights.RecordView(pageUser.ID, shared.ResolveClientIP(ctx.Request().Request), ctx.Request().Request)
}
}
// conversationWithFirstMessage 用于在列表展示私聊时附带第一条消息
type conversationWithFirstMessage struct {
Conversation *db.Conversation
FirstMessage string
}
// formatFeatureTime 格式化功能开放/关闭时间供模板展示;nil 返回空串。
func formatFeatureTime(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("2006-01-02 15:04")
}
// pagePasswordGuard 页面访问密码的爆破防护(P1-2)。
//
// 现实参数:该入口限流 10 次/分钟/IP,4 位纯数字(10^4)约 17 小时即可穷举完,
// 而页面密码正是产品对外宣传的访问控制。改成「10 分钟内错 5 次 → 锁 10 分钟」后,
// 同样的穷举被拉到数周量级,且锁定期间根本不比对密码。
// 锁定期满会自动清零(不会一次误触就永久锁死),语义见 shared.AttemptLimiter。
var pagePasswordGuard = shared.NewAttemptLimiter(5, 10*time.Minute, 10*time.Minute)
func PasswordPage(ctx context.Context) {
domain := ctx.Param("domain")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
ctx.Data["PageUser"] = pageUser
// 直接访问密码页(无"上一页")时,验证后回首页
ctx.Data["Next"] = "/_/" + domain
// 密码页同样遵循提问箱主人配置的语言
if pageUser.Language != "" {
lang := i18n.Resolve(pageUser.Language)
ctx.Data["Lang"] = lang
ctx.Data["LangCode"] = lang
}
ctx.Success("question/password")
}
// pagePasswordAuthorized 判断当前访客是否已通过该箱主的页面密码校验。
//
// ⚠️ 这里是页面密码的唯一判定入口,HTML 页面与列表 API 都必须走它,不要各写一份。
//
// 关键点:session 里没有对应键时必须判为**未授权**。
// 曾用 `sessionVer, _ := ctx.Session.Get(key).(uint); if sessionVer != user.PagePasswordVer`
// 的写法:断言失败同样得到零值 0,而 PagePasswordVer 对存量用户也是 0(新增列默认值),
// 于是 0 != 0 为假 —— 升级瞬间所有既有页面密码静默失效,匿名访客无需密码即可看箱主页。
// 因此必须先用 ok 断言「确实存在授权记录」,再比较版本号。
func pagePasswordAuthorized(ctx context.Context, owner *db.User) bool {
if owner.PagePassword == "" {
return true
}
// 箱主本人不受页面密码限制。
if ctx.IsLogged && ctx.User.ID == owner.ID {
return true
}
ver, ok := ctx.Session.Get(pagePassKey(owner.ID)).(uint)
return ok && ver == owner.PagePasswordVer
}
// pagePassKey 是页面密码授权在 session 里的键名,读写两侧共用,避免格式漂移。
func pagePassKey(userID uint) string {
return fmt.Sprintf("page_pass_%d", userID)
}
func VerifyPagePassword(ctx context.Context) {
domain := ctx.Param("domain")
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
// 爆破防护(P1-2):同一「箱主 + 来源 IP」在窗口内错够次数就锁一段时间。
// 键里带 pageUser.ID,避免攻击者用自己箱子的失败次数把别人的密码入口锁死。
// 锁定期间直接拒绝、不比对密码,免得每次失败都白算一遍哈希。
guardKey := fmt.Sprintf("page_pwd:%d:%s", pageUser.ID, shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)))
if blocked, remain := pagePasswordGuard.Blocked(guardKey); blocked {
minutes := int(remain/time.Minute) + 1
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errPasswordLocked", minutes))
ctx.Redirect("/_/" + domain + "/password")
return
}
password := ctx.Request().FormValue("password")
if pageUser.CheckPagePassword(password) {
pagePasswordGuard.Reset(guardKey)
// 保存当前密码版本号到 session,修改密码后旧授权自动失效(SEC-06)
ctx.Session.Set(pagePassKey(pageUser.ID), pageUser.PagePasswordVer)
// 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页。
// 必须走 shared.SafeRedirectTarget:这里原先自己判「以 / 开头、非 //、不含 ://」,
// 但没拒反斜杠 —— `next=/\evil.example` 会被浏览器当成 https://evil.example/。
next := shared.SafeRedirectTarget(ctx.Request().FormValue("next"))
if next == "" {
next = "/_/" + domain
}
ctx.Redirect(next)
} else {
pagePasswordGuard.Fail(guardKey)
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errWrongPassword"))
ctx.Redirect("/_/" + domain + "/password")
}
}
func List(ctx context.Context) {
ctx.Success("question/list")
}
func ListAPI(ctx context.Context) error {
domain := ctx.Param("domain")
pageSize := ctx.QueryInt("page_size")
cursorValue := ctx.Query("cursor")
// 规范化每页大小,并多取 1 条用于探测是否仍有下一页(has_more),
// 使前端在加载完最后一页后能立即隐藏「加载更多」按钮,
// 而不是依赖下一次点击返回空列表来收尾(避免最后一页重复展示)。
if pageSize < 1 {
pageSize = dbutil.DefaultPageSize
}
if pageSize > dbutil.MaxDefaultPageSize-1 {
pageSize = dbutil.MaxDefaultPageSize - 1
}
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
if errors.Is(err, db.ErrUserNotExists) {
return ctx.JSONError(40400, i18n.T(ctx.Lang(), "auth.errUserNotFound"))
}
return ctx.ServerError()
}
// ⚠️ 这个接口不经过 question.Pager 中间件,必须自己补两道检查:
// 1) L4 整页停用:否则「停用」只挡 HTML 页面,API 仍能整页拉取内容;
// 2) 页面访问密码:Pager 用 session 标记校验,API 不校验就等于密码形同虚设。
// 两者都返回「用户不存在」以隐藏真实原因(不暴露处置状态与密码设置)。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID); err == nil && level == db.RestrictionL4 {
return ctx.JSONError(40400, i18n.T(ctx.Lang(), "auth.errUserNotFound"))
}
if !pagePasswordAuthorized(ctx, pageUser) {
return ctx.JSONError(40100, i18n.T(ctx.Lang(), "password.desc"))
}
pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{
Cursor: &dbutil.Cursor{
Value: cursorValue,
PageSize: pageSize + 1,
},
FilterAnswered: true,
PrivateStatus: 1, // 只返回公开问题
PinFirst: true,
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get questions by page id")
return ctx.ServerError()
}
// 实际取回条数超过 pageSize 说明还有下一页;裁掉多取的探测行。
hasMore := false
if len(pageQuestions) > pageSize {
pageQuestions = pageQuestions[:pageSize]
hasMore = true
}
// 列表 API 返回纯文本内容(剥离 Markdown 符号/排版,与首屏展示一致)
for _, q := range pageQuestions {
q.Content = templatepkg.PlainText(q.Content)
}
// 响应直接附带服务端编码好的下一页复合键集游标(与 EncodePinCursor 一致)。
// 不在前端用 Date.getTime()*1e6 自行拼游标:JS 双精度浮点无法精确表示纳秒
// 级 UnixNano(毫秒以下精度丢失),时间戳相同的提问会在翻页边界被跳过。
resp := struct {
Data []*db.Question `json:"data"`
NextCursor string `json:"next_cursor"`
HasMore bool `json:"has_more"`
}{Data: pageQuestions, HasMore: hasMore}
if len(pageQuestions) > 0 {
last := pageQuestions[len(pageQuestions)-1]
resp.NextCursor = db.EncodePinCursor(last.IsPinned, last.CreatedAt, last.ID)
}
return ctx.JSON(resp)
}
// askPasswordDenied 校验提问密码,失败时写入表单错误并返回 true(调用方直接 return)。
//
// ⚠️ 必须排在验证码校验之后调用。旧实现把它放在 New() 的第一行,于是「猜密码」的请求
// 完全不消耗验证码:实测错误密码 + 完全无效的验证码,返回的是「提问密码错误」,
// 验证码在这个入口等于不存在 —— 30 次/分钟 × 无成本,4 位数字约 6 小时可破。
// 挪到验证码之后,每次猜密码都要先解一道题(验证码接口自身还有 20/分钟/IP 的限额)。
func askPasswordDenied(ctx context.Context, pageUser *db.User, f form.NewQuestion) bool {
if pageUser.AskPassword == "" {
return false
}
if pageUser.CheckAskPassword(f.AskPassword) {
return false
}
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errAskPasswordWrong")), f)
ctx.Success("question/list")
return true
}
func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) {
// 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。
// 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。
isChatRequest := f.StartChat == "true" && ctx.IsLogged
// 用严格版:读配置失败时拒绝而不是回退默认「开启」,
// 否则箱主设的关闭 / 已达上限只要赶上一次数据库抖动就被绕过。
askState, err := db.FeatureStateOfStrict(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion)
if err != nil {
// 同请求渲染:这里用户刚写完正文,不能因为一次读配置失败就把内容丢掉。
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if !askState.Open && !isChatRequest {
lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
lang = l
}
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureQuestion, askState.Reason))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if !ctx.IsLogged && pageUser.HarassmentSetting == db.HarassmentSettingTypeRegisterOnly {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errLoginRequired"))
ctx.Redirect(fmt.Sprintf("/login?to=%s", ctx.Request().Request.RequestURI))
return
}
// 内容审核严格度:提问 / 私聊 / 投票三条链路共用同一份档位(users.moderation_strictness),
// 取值解释与裁决逻辑见 internal/security/censor/policy.go。
// 在此解析一次,供下方私聊分支与普通提问分支复用,保证同一请求的判定口径一致。
strictness := moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness)
// ---------- 私聊处理 ----------
if f.StartChat == "true" && ctx.IsLogged {
if ctx.User.ID == pageUser.ID {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errSelfChat"))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 私聊功能开关 / 定时 / 定量(严格版,理由同上)
chatState, err := db.FeatureStateOfStrict(ctx.Request().Context(), pageUser.ID, db.FeatureChat)
if err != nil {
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if !chatState.Open {
lang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
lang = l
}
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureChat, chatState.Reason))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 检查是否被对方拉黑
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to check block status")
}
if blocked {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errBlockedChat"))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 检查每日消息限制
if pageUser.DailyMessageLimit > 0 {
todayCount, err := db.Users.CountTodayMessagesFromUser(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
if err != nil {
logrus.WithError(err).Error("Failed to count today messages")
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if todayCount >= int64(pageUser.DailyMessageLimit) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errChatDailyLimit", pageUser.DailyMessageLimit))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
}
if ctx.HasError() {
ctx.Success("question/list")
return
}
// 私聊首条消息长度上限:与对话内后续消息一致(conf.Limits.ChatMessageLen,
// [limits] 配置段)。此分支与提问共用同一 content 字段,长度不能按提问上限约束,
// 必须在入口按聊天语义单独校验。
if utf8.RuneCountInString(f.Content) > conf.Limits.ChatMessageLen {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errTooLong", conf.Limits.ChatMessageLen))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 箱主全局屏蔽词(提问/私聊/投票共用一份词表):首条消息同样拦截,
// 防止通过发起私聊绕过提问入口的屏蔽词校验(发送者是对方,非箱主本人)。
if _, hit := shared.BlockWordHit(pageUser.BlockWords, f.Content); hit {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockWords"))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 私聊同样需要验证码校验(防止机器人批量发起私聊刷骚扰信息)
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 提问密码:验证码通过之后才比对(见 askPasswordDenied 注释)。
// 私聊与提问共用同一个提问密码,这里不能漏,否则发起私聊可绕过它。
if askPasswordDenied(ctx, pageUser, f) {
return
}
// 同 IP + 相同内容重复拦截(防刷屏)
if !shared.DuplicateGuard.Allow(shared.DuplicateKey(shared.ResolveClientIP(ctx.Request().Request), f.Content)) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errDuplicateSubmit"))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行
// (审核源不可用时 moderation.Check 内部仍为 fail-closed)。
if _, err := moderation.Check(ctx.Request().Context(), strictness, ctx.Lang(), f.Content); err != nil {
ctx.SetError(err, f)
ctx.Success("question/list")
return
}
maxMessages := pageUser.ChatMaxMessages
if maxMessages <= 0 {
// 存量兜底(老数据 / 极端配置):回落到系统硬顶 conf.Limits.ChatMaxMessages
maxMessages = conf.Limits.ChatMaxMessages
}
// 建会话 + 首条消息,一个事务写完。
// 原来三步分开写:第二步失败时会话已经落库,
// 留下一条 message_count=0 、没有任何消息的「空会话」
// (箱主的私聊列表里能看到、点进去什么都没有),
// 而且 BeforeCreate 已经占掉一个 6 字符 token(唯一索引),孤儿会话会永久占用它。
conv, _, err := db.Conversations.CreateWithFirstMessage(
ctx.Request().Context(),
db.CreateConversationOptions{
OwnerID: pageUser.ID,
AskerID: ctx.User.ID,
MaxMessages: maxMessages,
},
ctx.User.ID,
f.Content,
)
if err != nil {
if errors.Is(err, db.ErrDailyMessageLimit) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errChatDailyLimit", pageUser.DailyMessageLimit))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create conversation with first message")
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
// 请求态数据提前提取,避免 goroutine 在请求结束后访问 ctx.User
askerName := ctx.User.Name
askerEmail := ctx.User.Email
shared.SafeGo(func() {
if pageUser.Notify == db.NotifyTypeEmail {
if err := mail.SendNewConversationMail(pageUser.Email, askerName, pageUser.Domain, conv.Token, f.Content); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new conversation mail to owner")
}
if err := mail.SendNewConversationMail(askerEmail, askerName, pageUser.Domain, conv.Token, f.Content); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new conversation mail to asker")
}
}
notify.Send(stdctx.Background(), "new_conversation",
"新的私聊", f.Content,
fmt.Sprintf("%s/_/%s/chat/%s", conf.App.ExternalURL, pageUser.Domain, conv.Token))
})
chatURL := fmt.Sprintf("/_/%s/chat/%s", pageUser.Domain, conv.Token)
chatAbsURL := fmt.Sprintf("%s%s", strings.TrimRight(conf.App.ExternalURL, "/"), chatURL)
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.chatSent", chatAbsURL))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// ---------- 普通提问 ----------
// 提问内容长度上限(conf.Limits.QuestionContentLen,[limits] 配置段),
// 与前端提问框 maxlength 同源。旧实现依赖 govalid maxlen 标签(写死 2000,
// 无法随配置调整),现移到此处以 rune 计数校验。
if utf8.RuneCountInString(f.Content) > conf.Limits.QuestionContentLen {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.question.contentTooLong", conf.Limits.QuestionContentLen)), f)
ctx.Success("question/list")
return
}
var receiveReplyEmail string
if f.ReceiveReplyViaEmail != "" {
if errs, ok := govalid.Check(struct {
Email string `valid:"required;email" label:"邮箱地址" msg:"form.email.required"`
}{
Email: f.ReceiveReplyEmail,
}); !ok {
ctx.SetError(errs[0], f)
ctx.Success("question/list")
return
}
receiveReplyEmail = f.ReceiveReplyEmail
}
if ctx.HasError() {
ctx.Success("question/list")
return
}
// Check captcha code (unified: recaptcha, turnstile, or math).
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
// 不把 err.Error() 直接给用户:里面可能带上游返回的原始信息。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "captcha.error")), f)
ctx.Success("question/list")
return
}
if !ok {
// 验证码失败是这里最常见的失败(token 过期、网络抖动)。
// 原来走 Flash + Redirect 会把用户已经写好的正文整段丢掉;
// 这里与其他失败分支一致,用 SetError + Success 回填表单。
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "captcha.error")), f)
ctx.Success("question/list")
return
}
// 提问密码:验证码通过之后才比对(见 askPasswordDenied 注释)。
if askPasswordDenied(ctx, pageUser, f) {
return
}
content := f.Content
isPrivate := f.IsPrivate != ""
// 统一走 shared.ResolveClientIP:仅 trust_proxy=true 时信任代理头,防止伪造 IP 混入审计记录
fromIP := shared.ResolveClientIP(ctx.Request().Request)
// 同 IP + 相同内容重复拦截(防刷屏)
if !shared.DuplicateGuard.Allow(shared.DuplicateKey(fromIP, content)) {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errDuplicateSubmit")), f)
ctx.Success("question/list")
return
}
// 箱主全局屏蔽词(逗号分隔,提问/私聊/投票共用一份词表):命中即拒绝。
if _, hit := shared.BlockWordHit(pageUser.BlockWords, content); hit {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errBlockWords")), f)
ctx.Success("question/list")
return
}
// 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行。
// 返回的 censorResponse 供下方落 content_censor_metadata(审计留痕)。
censorResponse, err := moderation.Check(ctx.Request().Context(), strictness, ctx.Lang(), content)
if err != nil {
ctx.SetError(err, f)
ctx.Success("question/list")
return
}
// 统一走 shared.ResolveClientIP(已在重复拦截处解析,此处复用)
var askerUserID uint
if ctx.IsLogged {
askerUserID = ctx.User.ID
}
question, err := db.Questions.Create(ctx.Request().Context(), db.CreateQuestionOptions{
FromIP: fromIP,
UserID: pageUser.ID,
Content: content,
ReceiveReplyEmail: receiveReplyEmail,
AskerUserID: askerUserID,
IsPrivate: isPrivate,
})
if err != nil {
if errors.Is(err, db.ErrFeatureQuotaReached) {
ctx.SetErrorFlash(i18n.FeatureClosedMessage(ctx.Lang(), db.FeatureQuestion, db.FeatureReasonQuotaReached))
ctx.Redirect("/_/" + pageUser.Domain)
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create new question")
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if len(f.Images) > 0 {
image := f.Images[0]
if err := uploadImage(ctx, uploadImageOptions{
Type: db.UploadImageQuestionTypeAsk,
Image: image,
QuestionID: question.ID,
UploaderUserID: askerUserID,
}); err != nil {
// 问题在上面已经落库(db.Questions.Create),图片这一步失败必须回滚。
// 不回滚的后果不是「少一张图」:箱主会收到一条无图提问,而提问者只看到
// 「图片过大 / 上传失败」,以为没发出去;再想重发又会被 DuplicateGuard
// 按「同 IP + 同内容 5 分钟内重复」拦住 —— 既没发成功也重发不了。
// DeleteByID 会连带清理追问与图片关联,并回退前面那次提问配额(+1)。
if delErr := db.Questions.DeleteByID(ctx.Request().Context(), question.ID); delErr != nil {
logrus.WithContext(ctx.Request().Context()).WithError(delErr).Errorf(
"图片上传失败后回滚提问 %d 失败,已留下一条无图提问", question.ID)
}
if errors.Is(err, ErrUploadImageSizeTooLarge) || errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errImageTooLarge")), f)
ctx.Success("question/list")
return
}
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload image")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errUploadImageFailed")), f)
ctx.Success("question/list")
return
}
}
if err := db.Questions.UpdateCensor(ctx.Request().Context(), question.ID, db.UpdateQuestionCensorOptions{
ContentCensorMetadata: censorResponse.ToJSON(),
}); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update question censor result")
}
shared.SafeGo(func() {
if pageUser.Notify == db.NotifyTypeEmail {
if err := mail.SendNewQuestionMail(pageUser.Email, pageUser.Domain, question.ID, question.Content, question.Token); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new question mail to user")
}
if err := mail.SendNewQuestionMailToUser(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, question.Token); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new question mail to questioner")
}
}
// 站级 Webhook:新提问是运营最关心的事件之一,推给配置的广播通道。
notify.Send(stdctx.Background(), "new_question",
"新的提问", question.Content,
fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, pageUser.Domain, question.ID))
// 用户级 Telegram:箱主自填 chat_id 时点对点推送。
if pageUser.TelegramChatID != "" {
notify.SendToTelegram(stdctx.Background(), pageUser.TelegramChatID,
fmt.Sprintf("📬 新的提问\n\n%s\n\n%s/_/%s/%d",
question.Content, conf.App.ExternalURL, pageUser.Domain, question.ID))
}
})
questionPrivateURL := fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)
questionPrivateAbsURL := fmt.Sprintf("%s%s", strings.TrimRight(conf.App.ExternalURL, "/"), questionPrivateURL)
// 提问成功页:强提醒保存私密链接/token(尤其针对未留邮箱的匿名提问者)
ctx.Data["Question"] = question
ctx.Data["PrivateURL"] = questionPrivateURL
ctx.Data["PrivateAbsURL"] = questionPrivateAbsURL
ctx.Data["HasReceiveEmail"] = receiveReplyEmail != ""
titleLang := i18n.Default
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
titleLang = l
}
ctx.SetTitle(i18n.T(titleLang, "success.pageTitle", i18n.T(titleLang, "site.title")))
ctx.Success("question/success")
}
type uploadImageOptions struct {
Type db.UploadImageQuestionType
Image *multipart.FileHeader
QuestionID uint
UploaderUserID uint
IsDeletingPrevious bool
}
var ErrUploadImageSizeTooLarge = errors.New("图片文件大小不能大于 5Mb")
// fitsWithinSide 判断图片宽高是否都已在 maxSide 的边长限制内。
func fitsWithinSide(cfg image.Config, maxSide int) bool {
return cfg.Width > 0 && cfg.Height > 0 && cfg.Width <= maxSide && cfg.Height <= maxSide
}
func uploadImage(ctx context.Context, opts uploadImageOptions) error {
imgFile := opts.Image
fileName := imgFile.Filename
fileExt := filepath.Ext(fileName)
fileSize := imgFile.Size
if fileSize > 1024*1024*5 {
return ErrUploadImageSizeTooLarge
}
uploadImageFile, err := imgFile.Open()
if err != nil {
return errors.Wrap(err, "open image")
}
defer func() { _ = uploadImageFile.Close() }()
// 读取原始文件数据并计算 MD5
hasher := md5.New()
originalData, err := io.ReadAll(io.TeeReader(uploadImageFile, hasher))
if err != nil {
return errors.Wrap(err, "read image data")
}
fileMd5 := fmt.Sprintf("%x", hasher.Sum(nil))
// 根据格式决定是否转换为 WebP
var uploadData []byte
var contentType string
var uploadKey string
// 检测原始格式(只解析文件头,不解像素):既拿到 format 也拿到宽高,
// 供像素总量校验与 WebP 透传判断共用。此前此处 image.Decode 完整解码一遍
// 只为取 format,转换时还要再解一遍,峰值内存翻倍。
cfg, format, err := image.DecodeConfig(bytes.NewReader(originalData))
if err != nil {
return errors.Wrap(err, "decode image config")
}
if cfg.Width > 0 && cfg.Height > 0 &&
int64(cfg.Width)*int64(cfg.Height) > imagecheck.MaxImagePixels {
return imagecheck.ErrImageTooLarge
}
now := time.Now()
switch format {
case "gif":
// 与 storage.UploadPictureToS3 走同一实现,避免同一张 GIF 在两个入口
// 得到不同格式/体积(此前此处完全跳过转换)。
webpData, err := storage.ConvertGifToAnimatedWebP(originalData)
if err != nil || len(webpData) >= len(originalData) {
if err != nil {
logrus.WithError(err).Warn("animated WebP conversion failed, falling back to original GIF")
}
uploadData = originalData
contentType = "image/gif"
uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt)
} else {
uploadData = webpData
contentType = "image/webp"
uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), ".webp")
}
default:
// JPEG / PNG 等静态图片转换为 WebP。
// 已是 WebP 且尺寸合规 -> 原样上传:再编码会白丢一代有损画质。
if format == "webp" &&
fitsWithinSide(cfg, storage.GeneralEncodeSpec.MaxSide) {
uploadData = originalData
contentType = "image/webp"
uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt)
break
}
webpData, err := storage.ConvertStaticImageToWebP(originalData, storage.GeneralEncodeSpec)
if err != nil || len(webpData) >= len(originalData) {
// 转换失败,或转换后并不比原图小(小图标/线框图/已优化 JPEG 上有损 WebP
// 可能反而更大)—— 一律沿用原图。
if err != nil {
logrus.WithError(err).Warn("WebP conversion failed, falling back to original format")
}
uploadData = originalData
contentType = contentTypeFromFormat(format)
uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt)
} else {
uploadData = webpData
contentType = "image/webp"
// WebP 无后缀名,与 UploadPictureToS3 风格一致
uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), ".webp")
}
}
client, err := storage.GetS3Client()
if err != nil {
return errors.Wrap(err, "get S3 client")
}
if err := storage.PutObject(ctx.Request().Context(), client, &s3.PutObjectInput{
Bucket: aws.String(conf.Upload.ImageBucket),
Key: aws.String(uploadKey),
Body: bytes.NewReader(uploadData),
ContentLength: aws.Int64(int64(len(uploadData))),
ContentType: aws.String(contentType),
// key 含纳秒时间戳且内容不可变,与 storage.uploadPictureToS3 保持一致的 immutable 强缓存。
CacheControl: aws.String("public, max-age=31536000, immutable"),
}); err != nil {
return errors.Wrap(err, "put object")
}
_, err = db.UploadImages.Create(ctx.Request().Context(), db.CreateUploadImageOptions{
Type: opts.Type,
QuestionID: opts.QuestionID,
UploaderUserID: opts.UploaderUserID,
Name: fileName,
FileSize: int64(len(uploadData)),
Md5: fileMd5,
Key: uploadKey,
IsDeletingPrevious: opts.IsDeletingPrevious,
})
if err != nil {
if enqueueErr := db.R2DeleteTasks.Enqueue(ctx.Request().Context(), conf.Upload.ImageBucket, []string{uploadKey}); enqueueErr != nil {
return errors.Wrapf(enqueueErr, "compensate upload image after metadata failure: %v", err)
}
return errors.Wrap(err, "create upload image")
}
return nil
}
// contentTypeFromFormat 根据图片格式返回 MIME 类型
func contentTypeFromFormat(format string) string {
switch format {
case "jpeg":
return "image/jpeg"
case "png":
return "image/png"
case "gif":
return "image/gif"
default:
return "application/octet-stream"
}
}