// 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 } // 🚨 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 } // L2 收窄公开:审核通过后再把该题置为私密,然后写入回答。 // 顺序不能提前到审核之前 —— 审核失败(违规或审核源不可用,fail-closed)会直接返回, // 那时题目已经被改成私密,等于箱主一次失败的编辑让原有公开内容凭空消失。 // 放在这里仍然满足「任何时刻都不出现公开 + 已作答」:此刻它还是一条未作答的私密题。 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 } } 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)) }