808 lines
30 KiB
Go
808 lines
30 KiB
Go
// 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/moderation"
|
||
"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
|
||
}
|
||
|
||
// 内容审核严格度:提问 / 私聊 / 投票三条链路共用同一份档位(users.moderation_strictness),
|
||
// 取值解释与裁决逻辑见 internal/security/censor/policy.go。
|
||
// 在此解析一次,供下方私聊分支与普通提问分支复用,保证同一请求的判定口径一致。
|
||
strictness := moderation.StrictnessFor(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 := 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
|
||
}
|
||
|
||
// 内容安全检测:统一守卫,按箱主「审核严格度」裁决是否放行
|
||
// (审核源不可用时 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
|
||
}
|
||
|
||
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 供下方落 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 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")
|
||
|
||
// 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"
|
||
}
|
||
}
|