This commit is contained in:
tamakyi
2026-10-19 01:58:00 +08:00
parent bd613a6f5a
commit 4592b6ef24
14 changed files with 608 additions and 244 deletions
+1 -1
View File
@@ -176,7 +176,7 @@ func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Questio
followUpID := uint(ctx.ParamInt("followUpID"))
// 内容安全检测:统一守卫,箱主本人回复同样按自己的审核严格度裁决。
if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness), ctx.Lang(), f.Answer); err != nil {
if _, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictnessFor(restrictionLevel, pageUser.ModerationStrictness), ctx.Lang(), f.Answer); err != nil {
// 同请求渲染,用 SetError;原先的 SetErrorFlash 在这里不会显示出来(既有缺陷)。
ctx.SetError(err, f)
ctx.Success("question/item")
+14
View File
@@ -281,6 +281,20 @@ func ListAPI(ctx context.Context) error {
return ctx.ServerError()
}
// ⚠️ 这个接口不经过 question.Pager 中间件,必须自己补两道检查:
// 1) L4 整页停用:否则「停用」只挡 HTML 页面,API 仍能整页拉取内容;
// 2) 页面访问密码:Pager 用 session 标记校验,API 不校验就等于密码形同虚设。
// 两者都返回「用户不存在」以隐藏真实原因(不暴露处置状态与密码设置)。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), pageUser.ID); err == nil && level == db.RestrictionL4 {
return ctx.JSONError(40400, i18n.T(ctx.Lang(), "auth.errUserNotFound"))
}
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 {
return ctx.JSONError(40100, i18n.T(ctx.Lang(), "password.desc"))
}
}
pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{
Cursor: &dbutil.Cursor{
Value: cursorValue,
+16 -14
View File
@@ -185,26 +185,20 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
// 🚨 Content security check.
// 统一守卫:按箱主「审核严格度」裁决是否放行(审核源不可用仍为 fail-closed,
// 拒绝发布而非免审放行)。返回的 censorResponse 用于下方落审计字段。
censorResponse, err := moderation.Check(ctx.Request().Context(), moderation.EffectiveStrictness(ctx.Request().Context(), pageUser.ID, pageUser.ModerationStrictness), ctx.Lang(), answer)
// 已经查过处置级别,直接用,避免 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 收窄公开:审核通过后再把该题置为私密,然后写入回答。
// L2 收窄公开:审核通过后写回答,并在同一条 UPDATE 里把题目置为私密。
// 顺序不能提前到审核之前 —— 审核失败(违规或审核源不可用,fail-closed)会直接返回,
// 那时题目已经被改成私密,等于箱主一次失败的编辑让原有公开内容凭空消失。
// 放在这里仍然满足「任何时刻都不出现公开 + 已作答」:此刻它还是一条未作答的私密题。
// 也不能拆成「先 SetPrivate 再 AnswerByID」:两条语句之间有「公开 + 已作答」窗口,
// 且第二条失败会留下「私密 + 无回答」(内容凭空消失)。见 AnswerByIDPrivate 注释。
forcedPrivate := db.RestrictionAtLeast(restrictionLevel, db.RestrictionL2)
if forcedPrivate {
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]
@@ -226,8 +220,14 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
}
}
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")
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
@@ -241,7 +241,9 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
}
shared.SafeGo(func() {
if question.ReceiveReplyEmail != "" && question.Answer == "" { // We only send the email when the question has not been answered.
// 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")
+14
View File
@@ -294,6 +294,20 @@ func ShareCard(ctx context.Context) {
return
}
// 站方处置:任何级别都不再生成整箱分享图(与单题分享卡一致)。
// 放在缓存之前 —— 拦截必须即时生效,否则处置后 30 分钟内仍能拿到旧图。
// DB 异常时按已处置处理(fail-closed):这张图会带上箱主信息与二维码,是外传载体。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), user.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.Redirect("/")
return
} else if level != "" {
logrus.WithContext(ctx.Request().Context()).WithField("domain", domain).WithField("level", level).
Info("Profile share card blocked by restriction")
ctx.Redirect("/")
return
}
// 命中缓存直接返回,避免重复下载图片与重新绘制
cacheKey := shareCardCacheKey(user)
if cached, ok := shareCardCache.Get(cacheKey); ok {