333 lines
12 KiB
Go
333 lines
12 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 (
|
||
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
|
||
|
||
// 🚨 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
|
||
}
|
||
|
||
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))
|
||
}
|