From bd81398a0637314d5a268ce504bbbf6621835355 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sun, 18 Oct 2026 22:18:21 +0800 Subject: [PATCH] restrict v6 --- .workbuddy/memory/2026-10-18.md | 49 ++++++++++++ .workbuddy/memory/MEMORY.md | 2 + internal/db/db.go | 1 + internal/db/report.go | 123 ++++++++++++++++++++++++++++++ internal/db/report_test.go | 87 +++++++++++++++++++++ internal/form/question.go | 6 ++ internal/i18n/dict_en.go | 15 ++++ internal/i18n/dict_ja.go | 15 ++++ internal/i18n/dict_zh_cn.go | 15 ++++ internal/i18n/dict_zh_tw.go | 15 ++++ internal/mail/mail.go | 16 ++++ internal/route/route.go | 3 + route/question/abuse_notify.go | 91 ++++++++++++++++++++++ route/question/report.go | 68 +++++++++++++++++ route/user/question.go | 31 +++++--- templates/mail/abuse-alert.html | 72 +++++++++++++++++ templates/question/item.html | 19 +++++ templates/user/question-list.html | 3 + 18 files changed, 621 insertions(+), 10 deletions(-) create mode 100644 internal/db/report.go create mode 100644 internal/db/report_test.go create mode 100644 route/question/abuse_notify.go create mode 100644 route/question/report.go create mode 100644 templates/mail/abuse-alert.html diff --git a/.workbuddy/memory/2026-10-18.md b/.workbuddy/memory/2026-10-18.md index 4a49047..4173c96 100644 --- a/.workbuddy/memory/2026-10-18.md +++ b/.workbuddy/memory/2026-10-18.md @@ -225,3 +225,52 @@ qid=90:发提问成功(L3 允许收信 ✔);`POST /answer` **没有重 已把 3 处(PublishAnswer L3 / SetPublic L2 / AnswerFollowUp L2)改成 `ctx.SetError(...)`, 并顺手修了 `follow_up.go` 里同类的 moderation 失败分支(既有缺陷)。编译 EXIT=0。 **该修复需再部署一次**;部署后当前仍是 L3,可直接验提示是否出现。 + +### 重新部署后 L3 提示验证:通过 + +qid=90 再答一次:`ALERT: 当前处于平台处置期,已暂停发布回答` 已渲染;仍无重定向、回答表单仍在 +(未写入)。L3 全链路闭环。待办:用户要求后续做「`SetErrorFlash + Success` 全量清理」专项。 + +剩余未验:L2 的「已保存为私密」提示(需把 admin6 切到 L2 后自测),以及举报采集链(未开始)。 + +### L2 提示验证:通过(qid=90) + +``` +post url: /_/admin6/90 (正常 302 回题目页) +ALERT: 已保存为私密:当前处于平台处置期,回答不会公开 +匿名访问 90 -> 首页;公开页 ids: [73] +``` +L1/L2/L3/L4 至此全部有真实环境实测证据: +- L1:页面 200 + 分享卡 302(qid=73) +- L2:回答成功 + 转私密 + 匿名不可见 + 专用提示 +- L3:回答被拒(无重定向、表单仍在、未写入)+ 拒绝提示 +- L4:整页 302 → /restricted 200 + 四语言文案 + +测试数据:88(用户手测)、89(L2 自检)、90(L3/L2 自检,现已答且私密),均需在测试站清理。 +admin6 当前处于 L2(到期次日),必要时 `unrestrict` 解除。 + +## 举报采集链(已落地,编译 + 单测 + 模板检查通过,未部署验证) + +新增/改动: +- `internal/db/report.go`(新):`ReportsStore` —— `Create`(唯一索引冲突静默为幂等成功)、 + `QuestionIDsByUser`(箱主列表标标,排除已删除提问)、`CountByUserSince`(告警阈值)。 + 理由枚举 `harassment/doxxing/divisive/other` + `IsValidReportReason`。 +- `internal/form/question.go`:`ReportQuestion{Reason}`(`valid:"required;maxlen:32"`)。 +- `route/question/report.go`(新):`Report` handler。**不要求登录**(目击者是匿名访客); + 理由白名单;同 IP 同题幂等且提示与首次一致(避免变成内容存在性探测接口);只写库不改可见性。 +- `route/question/abuse_notify.go`(新):阈值告警 24h≥8 或 7d≥20,24h 冷却(进程内 map + mutex), + 复用 `conf.Security.SecurityAlertEmail`,`shared.SafeGo` 异步发送。**只提醒不自动处置**。 +- `internal/mail/mail.go` + `templates/mail/abuse-alert.html`:`SendAbuseAlertMail`。 + 邮件只给计数与下一步命令,**不贴举报内容原文**(邮件会被转发归档,等于又复制一份挂人内容)。 +- `internal/route/route.go`:`POST /_/{domain}/{questionID}/report`,`rateLimitMiddleware(10, 1*time.Minute)`。 +- `templates/question/item.html`:回答下方 `⚑ 举报这条内容`,`
` 折叠 + 固定理由下拉, + 仅 `{{ if not .CanDelete }}`(非箱主)可见。 +- `templates/user/question-list.html` + `route/user/question.go`:箱主列表加「被举报 N 次」红色 badge, + **只给数量不给举报人 IP**。 +- i18n 4 语言共 11 个新键(report.* / qlist.reported*)。 + +验证:`go build ./...` EXIT=0;`TestReports` PASS(首次写入 / 同 IP 幂等 / 异 IP 计数 / +时间窗口 / 驳回后不计入);`TestDictionaryComplete` PASS;`go run ./dev/checktpl` → all templates syntax OK +(项目自带模板语法检查工具,改模板后必跑)。 + +未做:箱主列表的「只看被举报」筛选(目前只有 badge);举报驳回/成立的处置动作(目前 status 只有 pending)。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index b86c784..87d9209 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -98,6 +98,8 @@ 2026-10-18 实测:L3 拒绝发布时箱主完全看不到提示,改 `SetError` 后正常显示。 - 项目里仍有若干 `SetErrorFlash + Success("question/item")` 的既有组合(`follow_up.go` 的 errNotFound、 `question.go` 的上传图片过大等),同属该缺陷,遇到时顺手改成 `SetError`。 +- **TODO(用户 2026-10-18 明确要求后续处理)**:全量排查 `SetErrorFlash + Success(...)` 组合并统一改为 + `SetError`,做一轮专项清理。 ## 表单校验 diff --git a/internal/db/db.go b/internal/db/db.go index eecdfca..f7b95cb 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -116,6 +116,7 @@ func Init(typ, dsn string) (*gorm.DB, error) { VoteSubmissions = NewVoteSubmissionsStore(db) FeatureControls = NewFeatureControlsStore(db) Restrictions = NewRestrictionsStore(db) + Reports = NewReportsStore(db) if err := db.Use(otelgorm.NewPlugin( otelgorm.WithDBName(conf.Database.Name), diff --git a/internal/db/report.go b/internal/db/report.go new file mode 100644 index 0000000..18d13cb --- /dev/null +++ b/internal/db/report.go @@ -0,0 +1,123 @@ +// Copyright 2026 tamakyi. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "context" + "time" + + "github.com/pkg/errors" + "gorm.io/gorm" +) + +// ReportsStore 举报记录的读写。 +// +// 举报只是「信号」,处置仍然由箱主(在 /user 面板删/设私密)或运维(CLI)完成 —— +// 本项目没有管理后台,所以这里不提供「审核队列」之类的方法,只做写入与计数。 +type ReportsStore interface { + // Create 写入一条举报。返回 created=false 表示该 IP 已举报过同一内容(唯一索引冲突), + // 属于幂等成功:调用方不应据此提示用户「已举报过」,否则会成为内容存在性的探测接口。 + Create(ctx context.Context, opts CreateReportOptions) (bool, error) + // QuestionIDsByUser 返回某箱主名下「被举报的提问 ID -> 待处理举报数」。 + // 箱主列表用它打标;已删除的提问不计入。 + QuestionIDsByUser(ctx context.Context, userID uint) (map[uint]int64, error) + // CountByUserSince 统计某箱主自 since 起收到的待处理举报总数(邮件告警的触发依据)。 + CountByUserSince(ctx context.Context, userID uint, since time.Time) (int64, error) +} + +// Reports 举报记录全局 store,由 db.Init 装配。 +var Reports ReportsStore + +var _ ReportsStore = (*reports)(nil) + +// 举报理由白名单。做成固定枚举而不是自由文本:自由填写等于又开了一个可以写辱骂内容的输入框, +// 而举报本身并不需要精确描述。 +const ( + ReportReasonHarassment = "harassment" // 人身攻击 / 辱骂 + ReportReasonDoxxing = "doxxing" // 曝光他人账号或作品 + ReportReasonDivisive = "divisive" // 挑起群体对立 + ReportReasonOther = "other" // 其他 +) + +// IsValidReportReason 校验举报理由。 +func IsValidReportReason(reason string) bool { + switch reason { + case ReportReasonHarassment, ReportReasonDoxxing, ReportReasonDivisive, ReportReasonOther: + return true + default: + return false + } +} + +// CreateReportOptions 写入举报的入参。 +type CreateReportOptions struct { + QuestionID uint + ReporterIP string + Reason string +} + +func NewReportsStore(db *gorm.DB) ReportsStore { + return &reports{db: db} +} + +type reports struct { + db *gorm.DB +} + +func (s *reports) Create(ctx context.Context, opts CreateReportOptions) (bool, error) { + report := &ContentReport{ + QuestionID: opts.QuestionID, + ReporterIP: opts.ReporterIP, + Reason: opts.Reason, + Status: ReportStatusPending, + } + if err := s.db.WithContext(ctx).Create(report).Error; err != nil { + // (question_id, reporter_ip) 唯一索引:同一个人对同一条内容只能报一次。 + // 静默视为成功,避免通过「已举报过」的提示反查内容是否存在。 + if isDuplicateKeyError(err) { + return false, nil + } + return false, errors.Wrap(err, "create content report") + } + return true, nil +} + +func (s *reports) QuestionIDsByUser(ctx context.Context, userID uint) (map[uint]int64, error) { + type row struct { + QuestionID uint + Count int64 + } + var rows []*row + if err := s.db.WithContext(ctx). + Table("content_reports AS r"). + Select("r.question_id AS question_id, COUNT(r.id) AS count"). + Joins("JOIN questions q ON q.id = r.question_id"). + Where("q.user_id = ? AND q.deleted_at IS NULL", userID). + Where("r.status = ?", ReportStatusPending). + Group("r.question_id"). + Scan(&rows).Error; err != nil { + return nil, errors.Wrap(err, "count reports by user") + } + + counts := make(map[uint]int64, len(rows)) + for _, r := range rows { + counts[r.QuestionID] = r.Count + } + return counts, nil +} + +func (s *reports) CountByUserSince(ctx context.Context, userID uint, since time.Time) (int64, error) { + var count int64 + if err := s.db.WithContext(ctx). + Table("content_reports AS r"). + Joins("JOIN questions q ON q.id = r.question_id"). + Where("q.user_id = ? AND q.deleted_at IS NULL", userID). + Where("r.status = ?", ReportStatusPending). + Where("r.created_at >= ?", since). + Count(&count).Error; err != nil { + return 0, errors.Wrap(err, "count reports since") + } + return count, nil +} diff --git a/internal/db/report_test.go b/internal/db/report_test.go new file mode 100644 index 0000000..4ef7e00 --- /dev/null +++ b/internal/db/report_test.go @@ -0,0 +1,87 @@ +// Copyright 2026 tamakyi. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestReports 覆盖举报写入的幂等性与两种计数口径。 +// +// 幂等必须由唯一索引 + isDuplicateKeyError 兜住:如果改成「先查后插」, +// 并发下仍会撞索引报 500;而如果直接把冲突当错误返回,举报按钮会变成 +// 「这条内容是否存在」的探测接口(第二次举报报错 = 内容存在)。 +func TestReports(t *testing.T) { + database, cleanup := newTestDB(t) + ctx := context.Background() + + t.Cleanup(func() { + if err := cleanup("users", "questions", "content_reports"); err != nil { + t.Fatal(err) + } + }) + + usersStore := NewUsersStore(database) + questionsStore := NewQuestionsStore(database) + reportsStore := NewReportsStore(database) + + require.NoError(t, usersStore.Create(ctx, CreateUserOptions{ + Name: "Box", Password: "super_secret", Email: "rp@example.com", Domain: "rpbox", + })) + owner, err := usersStore.GetByDomain(ctx, "rpbox") + require.NoError(t, err) + + question, err := questionsStore.Create(ctx, CreateQuestionOptions{ + FromIP: "1.1.1.1", UserID: owner.ID, Content: "被举报的内容", + }) + require.NoError(t, err) + require.NoError(t, questionsStore.AnswerByID(ctx, question.ID, "回答")) + + // 首次举报写入成功。 + created, err := reportsStore.Create(ctx, CreateReportOptions{ + QuestionID: question.ID, ReporterIP: "2.2.2.2", Reason: ReportReasonHarassment, + }) + require.NoError(t, err) + require.True(t, created) + + // 同 IP 再报一次:幂等成功(不报错、不重复计数)。 + created, err = reportsStore.Create(ctx, CreateReportOptions{ + QuestionID: question.ID, ReporterIP: "2.2.2.2", Reason: ReportReasonDoxxing, + }) + require.NoError(t, err) + require.False(t, created, "duplicate (question_id, reporter_ip) must be idempotent") + + // 另一个 IP 正常计入。 + created, err = reportsStore.Create(ctx, CreateReportOptions{ + QuestionID: question.ID, ReporterIP: "3.3.3.3", Reason: ReportReasonDivisive, + }) + require.NoError(t, err) + require.True(t, created) + + counts, err := reportsStore.QuestionIDsByUser(ctx, owner.ID) + require.NoError(t, err) + require.Equal(t, map[uint]int64{question.ID: 2}, counts) + + count24, err := reportsStore.CountByUserSince(ctx, owner.ID, time.Now().Add(-24*time.Hour)) + require.NoError(t, err) + require.EqualValues(t, 2, count24) + + // 窗口之外(未来时间点起算)应为 0,保证告警不会用错时间范围。 + countFuture, err := reportsStore.CountByUserSince(ctx, owner.ID, time.Now().Add(time.Hour)) + require.NoError(t, err) + require.EqualValues(t, 0, countFuture) + + // 已驳回的不计入箱主侧标记与告警。 + require.NoError(t, database.Model(&ContentReport{}). + Where("question_id = ?", question.ID). + Update("status", ReportStatusRejected).Error) + counts, err = reportsStore.QuestionIDsByUser(ctx, owner.ID) + require.NoError(t, err) + require.Empty(t, counts) +} diff --git a/internal/form/question.go b/internal/form/question.go index d64ef24..96fb531 100644 --- a/internal/form/question.go +++ b/internal/form/question.go @@ -41,3 +41,9 @@ type PublishFollowUpQuestion struct { type AnswerFollowUpQuestion struct { Answer string `form:"answer" valid:"required" label:"追问回答" msg:"form.followup.answer"` } + +// ReportQuestion 举报单条内容。理由只接受固定枚举(见 db.IsValidReportReason), +// 不做自由文本 —— 否则举报框本身就成了新的辱骂输入口。 +type ReportQuestion struct { + Reason string `form:"reason" valid:"required;maxlen:32" label:"举报理由" msg:"report.errInvalidReason"` +} diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index 0a041a2..c02c323 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -429,6 +429,21 @@ var enDict = map[string]string{ "restricted.title": "This question box is temporarily unavailable", "restricted.desc": "This question box has been suspended for violating community guidelines.", + // 举报(单题页,公开访客可用) + "report.entry": "Report this content", + "report.submit": "Submit", + "report.reason.harassment": "Harassment or abuse", + "report.reason.doxxing": "Exposing someone's account or work", + "report.reason.divisive": "Inciting group conflict", + "report.reason.other": "Other", + "report.flash.received": "Report received, thank you", + "report.errInvalidReason": "Please choose a reason", + "report.errFailed": "Failed to submit report, please try again later", + + // 箱主侧举报标记(只给数量,不给举报人 IP) + "qlist.reported": "%d reports", + "qlist.reportedShort": "!%d", + // Anonymous voting "nav.votes": "Polls", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index 990d34c..cd4d199 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -429,6 +429,21 @@ var jaDict = map[string]string{ "restricted.title": "この質問箱は一時的に利用できません", "restricted.desc": "この質問箱はコミュニティガイドライン違反のため、公開を停止しています。", + // 举报(单题页,公开访客可用) + "report.entry": "この内容を報告する", + "report.submit": "送信", + "report.reason.harassment": "誹謗中傷・暴言", + "report.reason.doxxing": "他人のアカウントや作品の晒し", + "report.reason.divisive": "集団対立の扇動", + "report.reason.other": "その他", + "report.flash.received": "報告を受け付けました。ありがとうございます", + "report.errInvalidReason": "報告理由を選択してください", + "report.errFailed": "報告の送信に失敗しました。しばらくしてからお試しください", + + // 箱主侧举报标记(只给数量,不给举报人 IP) + "qlist.reported": "%d 件の報告", + "qlist.reportedShort": "報%d", + // 匿名投票 "nav.votes": "投票", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index df7271c..799c0f0 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -429,6 +429,21 @@ var zhCNDict = map[string]string{ "restricted.title": "提问箱暂时不可访问", "restricted.desc": "该提问箱因违反社区规范,已暂停对外访问。", + // 举报(单题页,公开访客可用) + "report.entry": "举报这条内容", + "report.submit": "提交", + "report.reason.harassment": "人身攻击 / 辱骂", + "report.reason.doxxing": "曝光他人账号或作品", + "report.reason.divisive": "挑起群体对立", + "report.reason.other": "其他", + "report.flash.received": "已收到举报,感谢反馈", + "report.errInvalidReason": "请选择一个举报理由", + "report.errFailed": "举报提交失败,请稍后再试", + + // 箱主侧举报标记(只给数量,不给举报人 IP) + "qlist.reported": "被举报 %d 次", + "qlist.reportedShort": "报%d", + // 匿名投票 "nav.votes": "投票", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index 94eb07c..54ae546 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -429,6 +429,21 @@ var zhTWDict = map[string]string{ "restricted.title": "提問箱暫時無法存取", "restricted.desc": "該提問箱因違反社群規範,已暫停對外存取。", + // 举报(单题页,公开访客可用) + "report.entry": "檢舉這則內容", + "report.submit": "提交", + "report.reason.harassment": "人身攻擊 / 辱罵", + "report.reason.doxxing": "曝光他人帳號或作品", + "report.reason.divisive": "挑起群體對立", + "report.reason.other": "其他", + "report.flash.received": "已收到檢舉,感謝回饋", + "report.errInvalidReason": "請選擇一個檢舉理由", + "report.errFailed": "檢舉提交失敗,請稍後再試", + + // 箱主侧举报标记(只给数量,不给举报人 IP) + "qlist.reported": "被檢舉 %d 次", + "qlist.reportedShort": "檢%d", + // 匿名投票 "nav.votes": "投票", diff --git a/internal/mail/mail.go b/internal/mail/mail.go index c130b30..ff3d4df 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -113,6 +113,22 @@ func SendSecurityAlertMail(ip, provider string, proxied bool, samplePaths []stri return sendTemplateMail(conf.Security.SecurityAlertEmail, "【狼的提问箱】检测到疑似批量扫描,已自动封禁 IP", templates.FS, "mail/security-alert.html", params) } +// SendAbuseAlertMail 发送「某箱主收到集中举报」的阈值告警。 +// +// 与 SendSecurityAlertMail 共用 conf.Security.SecurityAlertEmail 收件人,不新增配置项。 +// 邮件只给计数与下一步命令,不贴举报内容原文:邮件可能被转发/归档,贴原文等于把挂人内容 +// 又复制了一份出去;要看内容请用 reports --domain。 +func SendAbuseAlertMail(domain, name string, count24, count7d int64) error { + params := map[string]string{ + "domain": domain, + "name": name, + "count24": fmt.Sprintf("%d", count24), + "count7d": fmt.Sprintf("%d", count7d), + "time": time.Now().Format("2006-01-02 15:04:05"), + } + return sendTemplateMail(conf.Security.SecurityAlertEmail, "【狼的提问箱】某箱主收到集中举报", templates.FS, "mail/abuse-alert.html", params) +} + func sendTemplateMail(email, title string, templateFS embed.FS, templatePath string, params map[string]string) error { var content bytes.Buffer t, err := template.ParseFS(templateFS, templatePath) diff --git a/internal/route/route.go b/internal/route/route.go index 4e82eb2..e7bdedd 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -269,6 +269,9 @@ func New() *flamego.Flame { f.Post("/read", question.SetRead) f.Post("/unread", question.SetUnread) f.Post("/answer", reqUserSignIn, form.Bind(form.PublishAnswerQuestion{}), question.PublishAnswer) + // 举报入口:公开、不要求登录(主要目击者是匿名访客),限流防刷。 + // 举报只写入 content_reports,不改变任何可见性;处置由箱主或 CLI 完成。 + f.Post("/report", form.Bind(form.ReportQuestion{}), rateLimitMiddleware(10, 1*time.Minute), question.Report) f.Post("/follow-up", form.Bind(form.PublishFollowUpQuestion{}), question.PublishFollowUp) f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp) }, question.Questioner) diff --git a/route/question/abuse_notify.go b/route/question/abuse_notify.go new file mode 100644 index 0000000..3838df9 --- /dev/null +++ b/route/question/abuse_notify.go @@ -0,0 +1,91 @@ +// Copyright 2026 tamakyi. 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" + "sync" + "time" + + "github.com/sirupsen/logrus" + + "github.com/tamakyi/TamaBox/internal/conf" + "github.com/tamakyi/TamaBox/internal/db" + "github.com/tamakyi/TamaBox/internal/mail" + "github.com/tamakyi/TamaBox/internal/shared" +) + +// 举报阈值:达到任一条件就发一封汇总邮件给运维。 +// +// 为什么是 8 / 20 而不是更低:一次粉圈摩擦里,围观者顺手举报同一内容很容易攒到三五条, +// 阈值太低会天天误报,最后这个告警会被无视;而组织化挂人往往短时间内几十条。 +const ( + abuseAlertThreshold24h = 8 + abuseAlertThreshold7d = 20 + abuseAlertCooldown = 24 * time.Hour +) + +// abuseAlertCooldowns 每个箱主上次发送阈值告警的时间。 +// +// 用进程内 map 而不是加表:告警是「一次性提醒」,重启后丢失最坏结果是多发一封, +// 而为此新增一张表和一套清理逻辑不划算。并发下用互斥锁保证同一箱主不会连发。 +var ( + abuseAlertMu sync.Mutex + abuseAlertCooldowns = make(map[uint]time.Time) +) + +func allowAbuseAlert(userID uint) bool { + abuseAlertMu.Lock() + defer abuseAlertMu.Unlock() + + if last, ok := abuseAlertCooldowns[userID]; ok && time.Since(last) < abuseAlertCooldown { + return false + } + abuseAlertCooldowns[userID] = time.Now() + return true +} + +// notifyAbuseIfNeeded 在举报写入后判断是否达到告警阈值,异步发送汇总邮件。 +// +// 只发「该看一眼」的信号,不做任何自动处置:举报数达标不等于内容违规, +// 尤其这类互殴场景,自动处置会直接变成站队。 +func notifyAbuseIfNeeded(pageUser *db.User) { + if conf.Security.SecurityAlertEmail == "" { + return + } + + shared.SafeGo(func() { + ctx := stdctx.Background() + + count24, err := db.Reports.CountByUserSince(ctx, pageUser.ID, time.Now().Add(-24*time.Hour)) + if err != nil { + logrus.WithError(err).Error("Failed to count reports within 24h") + return + } + count7d, err := db.Reports.CountByUserSince(ctx, pageUser.ID, time.Now().AddDate(0, 0, -7)) + if err != nil { + logrus.WithError(err).Error("Failed to count reports within 7d") + return + } + + if count24 < abuseAlertThreshold24h && count7d < abuseAlertThreshold7d { + return + } + if !allowAbuseAlert(pageUser.ID) { + return + } + + if err := mail.SendAbuseAlertMail(pageUser.Domain, pageUser.Name, count24, count7d); err != nil { + logrus.WithError(err).Error("Failed to send abuse alert mail") + return + } + logrus.WithFields(logrus.Fields{ + "user_id": pageUser.ID, + "domain": pageUser.Domain, + "count24": count24, + "count7d": count7d, + }).Warn("Abuse alert mail sent") + }) +} diff --git a/route/question/report.go b/route/question/report.go new file mode 100644 index 0000000..ccb940a --- /dev/null +++ b/route/question/report.go @@ -0,0 +1,68 @@ +// Copyright 2026 tamakyi. 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 ( + "fmt" + + "github.com/sirupsen/logrus" + + "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/shared" +) + +// Report 接收访客对单条已公开回答的举报。 +// +// 设计取舍(都是有意为之,改动前请先想清楚): +// - **不要求登录**:这类内容的主要目击者是匿名访客,要求登录等于把举报率降到零; +// - 理由只接受固定枚举,不做自由文本 —— 否则举报框本身成了新的辱骂输入口; +// - 同 IP 同内容重复举报**幂等成功**,且提示与首次完全一致, +// 否则「已举报过」会变成「这条内容是否存在」的探测接口; +// - 举报只是信号:处置仍由箱主(后台删除/设私密)或运维(TamaBox restrict)完成, +// 本 handler 不改变任何内容可见性。 +func Report(ctx context.Context, pageUser *db.User, question *db.Question, f form.ReportQuestion) { + back := fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID) + + if ctx.HasError() || !db.IsValidReportReason(f.Reason) { + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "report.errInvalidReason")) + ctx.Redirect(back) + return + } + + // 兜底:只有公开且已回答的内容可被举报(Questioner 中间件已挡住私密/未回答)。 + if question.Answer == "" || question.IsPrivate { + ctx.Redirect(back) + return + } + + ip := shared.ResolveClientIP(ctx.Request().Request) + created, err := db.Reports.Create(ctx.Request().Context(), db.CreateReportOptions{ + QuestionID: question.ID, + ReporterIP: ip, + Reason: f.Reason, + }) + if err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create content report") + ctx.SetErrorFlash(i18n.T(ctx.Lang(), "report.errFailed")) + ctx.Redirect(back) + return + } + + if created { + logrus.WithContext(ctx.Request().Context()).WithFields(logrus.Fields{ + "question_id": question.ID, + "user_id": pageUser.ID, + "reason": f.Reason, + }).Warn("Content reported") + notifyAbuseIfNeeded(pageUser) + } + + // 首次与重复使用同一句提示(见上方设计取舍)。 + ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "report.flash.received")) + ctx.Redirect(back) +} diff --git a/route/user/question.go b/route/user/question.go index 0fcebf0..2f993ef 100644 --- a/route/user/question.go +++ b/route/user/question.go @@ -39,6 +39,9 @@ type receivedItem struct { QuestionID uint // 仅对普通提问有效,用于置顶操作 IsPinned bool // 仅对普通提问有效 PinLink string // 仅对普通提问有效:置顶/取消置顶操作链接 + // ReportCount 仅对普通提问有效:待处理举报数。 + // 只给数量、不给举报人 IP —— 箱主若能看到 IP,就有能力反查举报者,举报意愿会直接归零。 + ReportCount int64 } // QuestionList 用户后台提问列表(支持筛选与全文搜索) @@ -163,6 +166,13 @@ func QuestionList(ctx context.Context) { } } + // 待处理举报数:箱主侧只展示数量。查询失败不阻断列表,仅少一个标记。 + reportCounts, rErr := db.Reports.QuestionIDsByUser(ctx.Request().Context(), ctx.User.ID) + if rErr != nil { + logrus.WithContext(ctx.Request().Context()).WithError(rErr).Error("Failed to count content reports") + reportCounts = map[uint]int64{} + } + // 搜索场景下关键词已在 SQL 中匹配,但类型/状态筛选需在此统一应用, // 使"搜索 + 筛选"可组合(完善筛选逻辑)。非搜索场景下此处与 SQL 过滤结果一致。 for _, q := range questions { @@ -191,16 +201,17 @@ func QuestionList(ctx context.Context) { itemType = "question_private" } receivedItems = append(receivedItems, receivedItem{ - Type: itemType, - CreatedAt: q.CreatedAt, - Content: q.Content, - Link: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "?" + adminLinkQueryStr, - IsAnswered: q.Answer != "", - IsRead: isRead, - IsPrivate: q.IsPrivate, - QuestionID: q.ID, - IsPinned: q.IsPinned, - PinLink: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "/pin", + Type: itemType, + CreatedAt: q.CreatedAt, + Content: q.Content, + Link: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "?" + adminLinkQueryStr, + IsAnswered: q.Answer != "", + IsRead: isRead, + IsPrivate: q.IsPrivate, + QuestionID: q.ID, + IsPinned: q.IsPinned, + PinLink: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "/pin", + ReportCount: reportCounts[q.ID], }) } diff --git a/templates/mail/abuse-alert.html b/templates/mail/abuse-alert.html new file mode 100644 index 0000000..aeebf9c --- /dev/null +++ b/templates/mail/abuse-alert.html @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
+
+
+ 某箱主收到集中举报 +
+
+ {{.time}} +
+
+
+
+
箱主:{{.name}}(@{{.domain}})
+
近 24 小时:{{.count24}} 条待处理举报
+
近 7 天:{{.count7d}} 条待处理举报
+
+
查看证据:
+
+ TamaBox reports --domain {{.domain}} +
+
如需处置:
+
+ TamaBox restrict --domain {{.domain}} --level L1 --days 7 --reason "组织挂人" +
+
+ 这是一封阈值告警,不是自动处置:举报数达到阈值只代表「需要人看一眼」。 + 请结合 reports 输出判断是偶发摩擦还是组织化挂人,并对互殴情形做双向取证。 +
+
+
+
+
+
+ 此邮件由系统自动发送,请勿直接回复。
+ {{ .year }} 狼的提问箱 +
+
+
+
+
+ + diff --git a/templates/question/item.html b/templates/question/item.html index 54d5591..8693a00 100644 --- a/templates/question/item.html +++ b/templates/question/item.html @@ -85,6 +85,25 @@ {{ end }}

{{t .LangCode "qitem.answeredBy" .PageUser.Name (Date .Question.UpdatedAt "Y-m-d H:i:s")}}

+ {{ if not .CanDelete }} + +
+
+ ⚑ {{t .LangCode "report.entry"}} +
+ {{ .CSRFTokenHTML }} + + +
+
+
+ {{ end }}
{{ if and (not .Question.IsPrivate) (ne .Question.Answer "") }} diff --git a/templates/user/question-list.html b/templates/user/question-list.html index 3c8c53a..1f13c36 100644 --- a/templates/user/question-list.html +++ b/templates/user/question-list.html @@ -259,6 +259,9 @@ {{ if not .IsAnswered }} {{t $.LangCode "qlist.unansweredShort"}}{{t $.LangCode "qlist.unanswered"}} {{ end }} + {{ if gt .ReportCount 0 }} + {{t $.LangCode "qlist.reportedShort" .ReportCount}}{{t $.LangCode "qlist.reported" .ReportCount}} + {{ end }} {{ if .IsRead }} {{t $.LangCode "qlist.readShort"}}{{t $.LangCode "qlist.read"}} {{ else }}