Files
tamabox/route/question/question.go
T
2026-10-18 21:14:59 +08:00

371 lines
14 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 (
stdctx "context"
"fmt"
"net/url"
"unicode/utf8"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"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"
)
func Questioner(ctx context.Context, pageUser *db.User) {
// 从 URL query 读取 token;追问等 POST 请求 token 在表单隐藏域中,需回退读取表单字段。
// FormValue 同时覆盖 query 与 body(含 multipart),对 GET 请求行为与 Query 一致。
token := ctx.Query("t")
if token == "" {
token = ctx.Request().FormValue("token")
}
questionID := uint(ctx.ParamInt("questionID"))
question, err := db.Questions.GetByID(ctx.Request().Context(), questionID)
if err != nil {
if !errors.Is(err, db.ErrQuestionNotExist) {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get question by ID")
}
ctx.Redirect("/")
return
}
ctx.Data["Question"] = question
askUploadImages, err := db.UploadImages.GetByTypeQuestionID(ctx.Request().Context(), db.UploadImageQuestionTypeAsk, questionID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get ask upload images")
}
ctx.Data["AskUploadImages"] = askUploadImages
answerUploadImages, err := db.UploadImages.GetByTypeQuestionID(ctx.Request().Context(), db.UploadImageQuestionTypeAnswer, questionID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get answer upload images")
}
ctx.Data["AnswerUploadImages"] = answerUploadImages
// Check the question is belongs to the correct page user.
// If the question has not been answered, we should check the question is belongs to the correct page user.
// The questioner can use the token to view the question.
// 注意:token 为空的未回答/私密问题同样禁止匿名访问(不能因 token 为空而放行)。
if question.UserID != pageUser.ID ||
((question.Answer == "" || question.IsPrivate) &&
(!ctx.IsLogged || ctx.User.ID != question.UserID) &&
!(question.Token != "" && question.Token == token)) {
ctx.Redirect("/")
return
}
// The page's owner or the question's token can have the permission to delete the question.
// Inject the permission into the context.
canDelete := ctx.IsLogged && ctx.User.ID == pageUser.ID
ctx.Map(canDelete)
ctx.Data["CanDelete"] = canDelete
// ---------- 追问 ----------
followUps, err := db.FollowUps.GetByQuestionID(ctx.Request().Context(), questionID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get follow ups")
followUps = []*db.FollowUp{}
}
ctx.Data["FollowUps"] = followUps
// 提问者身份:token 匹配(匿名)或登录账号匹配(登录用户)
isQuestioner := isQuestioner(ctx, question, token)
ctx.Data["IsQuestioner"] = isQuestioner
// 是否可追问:已回答 + 提问者 + 追问次数未达上限
followUpRemaining := 0
if isQuestioner && question.Answer != "" {
count, err := db.FollowUps.CountByQuestionID(ctx.Request().Context(), questionID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to count follow ups")
} else {
followUpRemaining = db.MaxFollowUpCount - int(count)
}
}
// 是否可追问:已回答 + 提问者 + 追问次数未达上限 + 上一条追问已被主人回复
// (主人尚未回复上一条追问时,提问者不应继续发起新的追问)
// 等待提示同样仅对提问者展示——主人/旁观者看到的应是回复框或正常内容。
waitingFollowUpReply := false
if isQuestioner && len(followUps) > 0 && followUps[len(followUps)-1].Answer == "" {
waitingFollowUpReply = true
}
canFollowUp := isQuestioner && question.Answer != "" && followUpRemaining > 0 && !waitingFollowUpReply
ctx.Data["CanFollowUp"] = canFollowUp
ctx.Data["WaitingFollowUpReply"] = waitingFollowUpReply
ctx.Data["FollowUpRemaining"] = followUpRemaining
ctx.Data["FollowUpLimit"] = db.MaxFollowUpCount
// 供追问表单隐藏域回传(匿名提问者必须携带 token)
ctx.Data["QuestionToken"] = token
// 返回来源:admin=从管理后台(/user/questions)进入;空=从提问箱主页(/_/domain)或直接访问,
// 默认返回提问箱主页。详情页底部"返回xxx的提问箱"按钮据此决定跳转目标。
backFrom := ctx.Query("from")
ctx.Data["BackFrom"] = backFrom
// 从后台返回时,把进入时携带的筛选参数(sort/status/type/read/q)原样回传,
// 使"返回"跳回带筛选条件的后台列表,避免丢失已选的筛选/搜索条件。
if backFrom == "admin" {
backQuery := url.Values{}
for _, key := range []string{"sort", "status", "type", "read", "q"} {
if v := ctx.Query(key); v != "" {
backQuery.Set(key, v)
}
}
backTo := "/user/questions"
if qs := backQuery.Encode(); qs != "" {
backTo += "?" + qs
}
ctx.Data["BackTo"] = backTo
}
ctx.Map(question)
}
func Item(ctx context.Context) {
ctx.Success("question/item")
}
func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question, f form.PublishAnswerQuestion) {
if ctx.HasError() {
ctx.Success("question/item")
return
}
// 回答内容长度上限(conf.Limits.AnswerContentLen,[limits] 配置段),
// 与前端回答框 maxlength 同源(发布与编辑共用,rune 计数)。
if utf8.RuneCountInString(f.Answer) > conf.Limits.AnswerContentLen {
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.answer.contentTooLong", conf.Limits.AnswerContentLen)), f)
ctx.Success("question/item")
return
}
if ctx.User.ID != pageUser.ID {
ctx.Redirect("/")
return
}
// 纵深防御:确认该提问归属当前页面箱主(Questioner 中间件已保证,这里二次校验防中间件改动引入越权)。
if question.UserID != pageUser.ID {
ctx.Redirect("/")
return
}
answer := f.Answer
// 站方处置:L3 起冻结发布,L2 收窄公开(回答强制转私密)。
// 处置记录只能由运维经 CLI 写入,箱主无法自行解除 —— 与 feature_controls 的自主开关严格区分。
restrictionLevel, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
if db.RestrictionAtLeast(restrictionLevel, db.RestrictionL3) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errRestrictedPublish"))
ctx.Success("question/item")
return
}
// L2:先把该题置为私密再写入回答,任何时刻都不会出现「公开 + 已作答」的中间态。
if db.RestrictionAtLeast(restrictionLevel, db.RestrictionL2) {
if err := db.Questions.SetPrivate(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to force question private")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
}
// 🚨 Content security check.
// 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed,
// 拒绝发布而非免审放行)。返回的 censorResponse 用于下方落审计字段。
censorResponse, err := moderation.Check(ctx.Request().Context(), moderation.StrictnessFor(pageUser.ModerationStrictness), ctx.Lang(), answer)
if err != nil {
ctx.SetError(err, f)
ctx.Success("question/item")
return
}
if len(f.Images) > 0 {
image := f.Images[0]
if err := uploadImage(ctx, uploadImageOptions{
Type: db.UploadImageQuestionTypeAnswer,
Image: image,
QuestionID: question.ID,
UploaderUserID: ctx.User.ID,
IsDeletingPrevious: true,
}); err != nil {
if errors.Is(err, ErrUploadImageSizeTooLarge) || errors.Is(err, imagecheck.ErrImageTooLarge) {
ctx.SetErrorFlash(err.Error())
ctx.Success("question/item")
return
} else {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload image")
}
}
}
if err := db.Questions.AnswerByID(ctx.Request().Context(), question.ID, f.Answer); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to answer question")
ctx.SetInternalError(f)
ctx.Success("question/item")
return
}
// Update censor result.
if err := db.Questions.UpdateCensor(ctx.Request().Context(), question.ID, db.UpdateQuestionCensorOptions{
AnswerCensorMetadata: censorResponse.ToJSON(),
}); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update answer censor result")
}
shared.SafeGo(func() {
if question.ReceiveReplyEmail != "" && question.Answer == "" { // We only send the email when the question has not been answered.
// Send notification to questioner.
if err := mail.SendNewAnswerMail(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, f.Answer, question.Token); err != nil {
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send receive reply mail to questioner")
}
}
})
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.answered"))
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
}
func Delete(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if err := db.Questions.DeleteByID(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete question")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.Redirect("/_/" + pageUser.Domain)
}
func SetPrivate(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if err := db.Questions.SetPrivate(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question private")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
}
func SetPublic(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 站方处置 L2 起禁止「设为公开」:L2 的语义就是收窄公开,放开等于自行解除处置。
restrictionLevel, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
if db.RestrictionAtLeast(restrictionLevel, db.RestrictionL2) {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errRestrictedPublic"))
ctx.Success("question/item")
return
}
if err := db.Questions.SetPublic(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question public")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
}
// TogglePin 置顶/取消置顶(仅提问箱主人,操作入口在我的提问管理页)。
func TogglePin(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if err := db.Questions.SetPinned(ctx.Request().Context(), question.ID, !question.IsPinned); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to toggle question pinned")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.operated"))
ctx.Redirect("/user/questions")
}
// SetRead 手动标记提问为已读,跳转回管理列表并定位到该条。
func SetRead(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
if err := db.Questions.SetRead(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question read")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.markedRead"))
ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID))
}
// SetUnread 手动标记提问为未读。已回答的提问锁定为已读,不可改回未读。
// 跳转与 SetRead 一致,回到管理列表并定位到该条,保证"已读/未读"操作路径统一。
func SetUnread(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
if !canDelete {
ctx.Redirect("/_/" + pageUser.Domain)
return
}
// 已回答的问题锁定为已读:上层直接拦截,避免误导用户。
if question.Answer != "" {
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errAnsweredLocked"))
ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID))
return
}
if err := db.Questions.SetUnread(ctx.Request().Context(), question.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question unread")
ctx.SetInternalError()
ctx.Success("question/item")
return
}
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.markedUnread"))
ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID))
}