// 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) // 页面密码检查 if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) { passKey := fmt.Sprintf("page_pass_%d", pageUser.ID) if authorized, _ := ctx.Session.Get(passKey).(bool); !authorized { // 记录原始请求地址(含 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") } 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) passKey := fmt.Sprintf("page_pass_%d", pageUser.ID) ctx.Session.Set(passKey, true) // 验证成功后跳回原始页面(如带 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 pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) { passKey := fmt.Sprintf("page_pass_%d", pageUser.ID) if authorized, _ := ctx.Session.Get(passKey).(bool); !authorized { 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") } 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 { 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 { 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 := client.PutObject(ctx.Request().Context(), &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 { 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" } }