// 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" "crypto/subtle" "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/notify" "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 != "" && subtle.ConstantTimeCompare([]byte(question.Token), []byte(token)) == 1)) { 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 // 箱主导航:下一条未读。只查一条(不做 count),用于决定详情页是否渲染该按钮。 if canDelete { nextUnread, err := db.Questions.NextUnread(ctx.Request().Context(), pageUser.ID, question.ID) if err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get next unread question") } else if nextUnread != nil { ctx.Data["NextUnreadID"] = nextUnread.ID } } // ---------- 追问 ---------- 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)原样回传, // 使"返回"跳回带筛选条件的后台列表,避免丢失已选的筛选/搜索条件。 // 「下一条未读」同样带上这份参数,否则答完一条后"返回"就掉回箱主页了。 // 表单 action 必须在这里拼成「完整 URL」再交给模板:写成 action="...?{{ .NextQuery }}" // 时 html/template 会把这段当成 URL 的查询值编码,= 与 & 变成 %3d / %26, // 服务端再也解析不出来源(实测过)。完整 URL 只会把 & 转成 &,浏览器会还原。 if backQuery := adminBackQuery(ctx); backFrom == "admin" { backTo := "/user/questions" answerAction := fmt.Sprintf("/_/%s/%d/answer", pageUser.Domain, question.ID) nextUnreadAction := fmt.Sprintf("/_/%s/%d/next-unread", pageUser.Domain, question.ID) if backQuery != "" { backTo += "?" + backQuery answerAction += "?" + backQuery nextUnreadAction += "?" + backQuery } ctx.Data["BackTo"] = backTo ctx.Data["AnswerAction"] = answerAction ctx.Data["NextUnreadAction"] = nextUnreadAction } 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) { // 这里是**同请求渲染**(Success 直接出页面,没有重定向),必须用 SetError 写 // Data["Error"];SetErrorFlash 依赖下一次请求读取,用在这里提示会静默丢失 // (线上实测:箱主点提交后页面毫无反应,以为没生效)。 ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errRestrictedPublish")), f) ctx.Success("question/item") return } // 🚨 Content security check. // 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed, // 拒绝发布而非免审放行)。返回的 censorResponse 用于下方落审计字段。 // 已经查过处置级别,直接用,避免 EffectiveStrictness 再打一次同样的库。 censorResponse, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictnessFor(restrictionLevel, pageUser.ModerationStrictness), ctx.Lang(), answer) if err != nil { ctx.SetError(err, f) ctx.Success("question/item") return } // L2 收窄公开:审核通过后写回答,并在同一条 UPDATE 里把题目置为私密。 // 顺序不能提前到审核之前 —— 审核失败(违规或审核源不可用,fail-closed)会直接返回, // 那时题目已经被改成私密,等于箱主一次失败的编辑让原有公开内容凭空消失。 // 也不能拆成「先 SetPrivate 再 AnswerByID」:两条语句之间有「公开 + 已作答」窗口, // 且第二条失败会留下「私密 + 无回答」(内容凭空消失)。见 AnswerByIDPrivate 注释。 forcedPrivate := db.RestrictionAtLeast(restrictionLevel, db.RestrictionL2) 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.SetError(err, f) ctx.Success("question/item") return } else { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload image") } } } var answerErr error if forcedPrivate { answerErr = db.Questions.AnswerByIDPrivate(ctx.Request().Context(), question.ID, f.Answer) } else { answerErr = db.Questions.AnswerByID(ctx.Request().Context(), question.ID, f.Answer) } if answerErr != nil { logrus.WithContext(ctx.Request().Context()).WithError(answerErr).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() { // L2 下题目刚被强制转为私密:提问者没有访问权限,带 token 的链接点开也是 302。 // 这时发「你的提问有回答」只会让人以为内容丢了,因此跳过通知。 if question.ReceiveReplyEmail != "" && question.Answer == "" && !forcedPrivate { // 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") } } // 站级 Webhook:新回答(回答发布时推送)。 if !forcedPrivate { notify.Send(stdctx.Background(), "new_answer", "新的回答", f.Answer, fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, pageUser.Domain, question.ID)) } }) // L2 下必须明确告知「已转为私密」:单题页对箱主本人仍是 200、内容照常显示, // 若继续提示「回答发布成功」,箱主会以为一切照旧(实测就是这样误判的)。 if forcedPrivate { ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.answeredPrivate")) } else { ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.answered")) } // 「回答并下一条」:同一表单的第二个提交按钮带 next=1。答题后本条已锁定为已读, // 直接打开最早的一条未读;队列已空(渲染后被打空的竞态)则退回当前页。 if ctx.Request().FormValue("next") == "1" { nextUnread, err := db.Questions.NextUnread(ctx.Request().Context(), pageUser.ID, question.ID) if err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get next unread question") } else if nextUnread != nil { ctx.Redirect(questionPath(ctx, pageUser.Domain, nextUnread.ID)) return } } ctx.Redirect(questionPath(ctx, 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 } // 举报闭环:内容已被箱主处理掉,该题下的待处理举报不应再计入告警阈值。 // 这一步失败不影响删除结果,只记日志 —— 让一次统计失败回滚用户操作是不可接受的。 if _, err := db.Reports.MarkAcceptedByQuestion(ctx.Request().Context(), question.ID); err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to mark reports accepted after delete") } 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 } // 举报闭环:设为私密等于箱主已响应,该题的待处理举报移出统计池(见 report.go 的说明)。 if _, err := db.Reports.MarkAcceptedByQuestion(ctx.Request().Context(), question.ID); err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to mark reports accepted after set private") } 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) { // 同请求渲染,用 SetError(原因见 PublishAnswer 的 L3 分支)。 ctx.SetError(errors.New(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 } // 条件更新:只有当前状态仍是这次读到的值时才写入。 // 原来直接 SetPinned(!question.IsPinned) 是 read-modify-write,双击时两个请求 // 都读到 false、都写 true,结果停在「置顶」与用户预期的「取消」 // 相反。现在第二次会因状态已变而影响 0 行,即「只切换一次」。 if _, err := db.Questions.SetPinnedIfCurrent( ctx.Request().Context(), question.ID, question.IsPinned, !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)) } // NextUnread 阅后即走:把当前提问标记为已读,然后打开最早的一条未读提问。 // // 必须同时改状态:只跳转而不标记的话,当前这条仍是未读,下一次取「最早未读」 // 又会把它选回来,两条未读之间会来回打转。 func NextUnread(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 } nextUnread, err := db.Questions.NextUnread(ctx.Request().Context(), pageUser.ID, question.ID) if err != nil { logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get next unread question") ctx.SetInternalError() ctx.Success("question/item") return } if nextUnread == nil { ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.noUnread")) ctx.Redirect(questionPath(ctx, pageUser.Domain, question.ID)) return } ctx.Redirect(questionPath(ctx, pageUser.Domain, nextUnread.ID)) } // questionPath 拼单题页地址;从后台进入时带上筛选参数,跳转后「返回」仍回原列表。 func questionPath(ctx context.Context, domain string, questionID uint) string { path := fmt.Sprintf("/_/%s/%d", domain, questionID) if backQuery := adminBackQuery(ctx); backQuery != "" { path += "?" + backQuery } return path } // adminBackQuery 提取后台列表的筛选参数(仅 from=admin 时有效),供单题页的 // 「返回」「下一条未读」沿用。POST 请求从表单 action 的 query 里读取。 // 返回值始终带 from=admin:调用方会把它重新写进表单 action 与跳转地址, // 少一个 from,下一次 POST 就认不出来源了。 func adminBackQuery(ctx context.Context) string { if ctx.Query("from") != "admin" { return "" } // from 必须随表单 action 回到 POST:ctx.Query 只读 URL query, // 少了它就认不出来源,「返回」会掉回箱主页。 backQuery := url.Values{"from": {"admin"}} for _, key := range []string{"sort", "status", "type", "read", "q"} { if v := ctx.Query(key); v != "" { backQuery.Set(key, v) } } return backQuery.Encode() }