restrict v6
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "[email protected]", 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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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": "投票",
|
||||
|
||||
|
||||
@@ -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": "投票",
|
||||
|
||||
|
||||
@@ -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": "投票",
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user