Files
tamabox/route/question/page.go
T
tamakyi e3e59e7c69 chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
2026-09-10 10:56:27 +08:00

788 lines
29 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 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/mail"
"github.com/tamakyi/TamaBox/internal/security/censor"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/storage"
templatepkg "github.com/tamakyi/TamaBox/internal/template"
)
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)
// 个人页语言:永远跟随提问箱主人(pageUser)配置的语言,忽略访客 cookie;未配置时回退站点默认语言
if pageUser.Language != "" {
lang := i18n.Resolve(pageUser.Language)
ctx.Data["Lang"] = lang
ctx.Data["LangCode"] = lang
}
// 页面密码检查
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"] = ""
}
}
// 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")
}
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
}
password := ctx.Request().FormValue("password")
if pageUser.CheckPagePassword(password) {
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
ctx.Session.Set(passKey, true)
// 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页
next := ctx.Request().FormValue("next")
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.Contains(next, "://") {
next = "/_/" + domain
}
ctx.Redirect(next)
} else {
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()
}
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)
}
func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) {
// 提问密码检查
if pageUser.AskPassword != "" {
if !pageUser.CheckAskPassword(f.AskPassword) {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errAskPasswordWrong")), f)
ctx.Success("question/list")
return
}
}
// 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。
// 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。
isChatRequest := f.StartChat == "true" && ctx.IsLogged
askState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion)
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
}
// ---------- 私聊处理 ----------
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 := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureChat)
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
}
// 同 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
}
// 内容安全检测
censorResponse, err := censor.Text(ctx.Request().Context(), f.Content)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
ctx.Success("question/list")
return
}
if !censorResponse.Pass {
ctx.SetError(errors.New(censorResponse.ErrorMessage()), f)
ctx.Success("question/list")
return
}
maxMessages := pageUser.ChatMaxMessages
if maxMessages <= 0 {
// 存量兜底(老数据 / 极端配置):回落到系统硬顶 conf.Limits.ChatMaxMessages
maxMessages = conf.Limits.ChatMaxMessages
}
conv, err := db.Conversations.Create(ctx.Request().Context(), db.CreateConversationOptions{
OwnerID: pageUser.ID,
AskerID: ctx.User.ID,
MaxMessages: maxMessages,
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create conversation")
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
_, err = db.Messages.Create(ctx.Request().Context(), db.CreateMessageOptions{
ConversationID: conv.ID,
SenderID: ctx.User.ID,
Content: f.Content,
})
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create first message")
ctx.SetInternalError(f)
ctx.Success("question/list")
return
}
if err := db.Conversations.IncrementMessageCount(ctx.Request().Context(), conv.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to increment message count")
}
// 私聊定量收集:新建会话即计一条(后续往来消息不重复计数)
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), pageUser.ID, db.FeatureChat, 1); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment chat quota")
}
// 请求态数据提前提取,避免 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")
}
}
})
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")
ctx.SetErrorFlash(err.Error())
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if !ok {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
ctx.Redirect("/_/" + pageUser.Domain)
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, err := censor.Text(ctx.Request().Context(), content)
if err != nil {
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行,
// 避免审核服务宕机期间提问内容免审直接公开。
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
ctx.Success("question/list")
return
}
if !censorResponse.Pass {
errorMessage := censorResponse.ErrorMessage()
ctx.SetError(errors.New(errorMessage), 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 err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion, 1); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment question quota")
}
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 {
if errors.Is(err, ErrUploadImageSizeTooLarge) || errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errImageTooLarge"))
ctx.Success("question/list")
return
} else {
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")
}
}
})
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")
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
// 检测原始格式(解码前先校验像素总量,防止解压炸弹)
if err := imagecheck.CheckPixelLimit(bytes.NewReader(originalData)); err != nil {
if errors.Is(err, imagecheck.ErrImageTooLarge) {
return imagecheck.ErrImageTooLarge
}
return errors.Wrap(err, "decode image config")
}
_, format, err := image.Decode(bytes.NewReader(originalData))
if err != nil {
return errors.Wrap(err, "decode image")
}
now := time.Now()
switch format {
case "gif":
// GIF 保持原样上传,不转换(动画 GIF 需要 cwebp 工具支持)
uploadData = originalData
contentType = "image/gif"
uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt)
default:
// JPEG / PNG 等静态图片转换为 WebP
webpData, err := storage.ConvertStaticImageToWebP(bytes.NewReader(originalData))
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),
}); 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"
}
}