restrict v6
This commit is contained in:
@@ -225,3 +225,52 @@ qid=90:发提问成功(L3 允许收信 ✔);`POST /answer` **没有重
|
|||||||
已把 3 处(PublishAnswer L3 / SetPublic L2 / AnswerFollowUp L2)改成 `ctx.SetError(...)`,
|
已把 3 处(PublishAnswer L3 / SetPublic L2 / AnswerFollowUp L2)改成 `ctx.SetError(...)`,
|
||||||
并顺手修了 `follow_up.go` 里同类的 moderation 失败分支(既有缺陷)。编译 EXIT=0。
|
并顺手修了 `follow_up.go` 里同类的 moderation 失败分支(既有缺陷)。编译 EXIT=0。
|
||||||
**该修复需再部署一次**;部署后当前仍是 L3,可直接验提示是否出现。
|
**该修复需再部署一次**;部署后当前仍是 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`:回答下方 `⚑ 举报这条内容`,`<details>` 折叠 + 固定理由下拉,
|
||||||
|
仅 `{{ 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)。
|
||||||
|
|||||||
@@ -98,6 +98,8 @@
|
|||||||
2026-10-18 实测:L3 拒绝发布时箱主完全看不到提示,改 `SetError` 后正常显示。
|
2026-10-18 实测:L3 拒绝发布时箱主完全看不到提示,改 `SetError` 后正常显示。
|
||||||
- 项目里仍有若干 `SetErrorFlash + Success("question/item")` 的既有组合(`follow_up.go` 的 errNotFound、
|
- 项目里仍有若干 `SetErrorFlash + Success("question/item")` 的既有组合(`follow_up.go` 的 errNotFound、
|
||||||
`question.go` 的上传图片过大等),同属该缺陷,遇到时顺手改成 `SetError`。
|
`question.go` 的上传图片过大等),同属该缺陷,遇到时顺手改成 `SetError`。
|
||||||
|
- **TODO(用户 2026-10-18 明确要求后续处理)**:全量排查 `SetErrorFlash + Success(...)` 组合并统一改为
|
||||||
|
`SetError`,做一轮专项清理。
|
||||||
|
|
||||||
## 表单校验
|
## 表单校验
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
|||||||
VoteSubmissions = NewVoteSubmissionsStore(db)
|
VoteSubmissions = NewVoteSubmissionsStore(db)
|
||||||
FeatureControls = NewFeatureControlsStore(db)
|
FeatureControls = NewFeatureControlsStore(db)
|
||||||
Restrictions = NewRestrictionsStore(db)
|
Restrictions = NewRestrictionsStore(db)
|
||||||
|
Reports = NewReportsStore(db)
|
||||||
|
|
||||||
if err := db.Use(otelgorm.NewPlugin(
|
if err := db.Use(otelgorm.NewPlugin(
|
||||||
otelgorm.WithDBName(conf.Database.Name),
|
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 {
|
type AnswerFollowUpQuestion struct {
|
||||||
Answer string `form:"answer" valid:"required" label:"追问回答" msg:"form.followup.answer"`
|
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.title": "This question box is temporarily unavailable",
|
||||||
"restricted.desc": "This question box has been suspended for violating community guidelines.",
|
"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
|
// Anonymous voting
|
||||||
"nav.votes": "Polls",
|
"nav.votes": "Polls",
|
||||||
|
|
||||||
|
|||||||
@@ -429,6 +429,21 @@ var jaDict = map[string]string{
|
|||||||
"restricted.title": "この質問箱は一時的に利用できません",
|
"restricted.title": "この質問箱は一時的に利用できません",
|
||||||
"restricted.desc": "この質問箱はコミュニティガイドライン違反のため、公開を停止しています。",
|
"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": "投票",
|
"nav.votes": "投票",
|
||||||
|
|
||||||
|
|||||||
@@ -429,6 +429,21 @@ var zhCNDict = map[string]string{
|
|||||||
"restricted.title": "提问箱暂时不可访问",
|
"restricted.title": "提问箱暂时不可访问",
|
||||||
"restricted.desc": "该提问箱因违反社区规范,已暂停对外访问。",
|
"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": "投票",
|
"nav.votes": "投票",
|
||||||
|
|
||||||
|
|||||||
@@ -429,6 +429,21 @@ var zhTWDict = map[string]string{
|
|||||||
"restricted.title": "提問箱暫時無法存取",
|
"restricted.title": "提問箱暫時無法存取",
|
||||||
"restricted.desc": "該提問箱因違反社群規範,已暫停對外存取。",
|
"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": "投票",
|
"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)
|
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 {
|
func sendTemplateMail(email, title string, templateFS embed.FS, templatePath string, params map[string]string) error {
|
||||||
var content bytes.Buffer
|
var content bytes.Buffer
|
||||||
t, err := template.ParseFS(templateFS, templatePath)
|
t, err := template.ParseFS(templateFS, templatePath)
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ func New() *flamego.Flame {
|
|||||||
f.Post("/read", question.SetRead)
|
f.Post("/read", question.SetRead)
|
||||||
f.Post("/unread", question.SetUnread)
|
f.Post("/unread", question.SetUnread)
|
||||||
f.Post("/answer", reqUserSignIn, form.Bind(form.PublishAnswerQuestion{}), question.PublishAnswer)
|
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", form.Bind(form.PublishFollowUpQuestion{}), question.PublishFollowUp)
|
||||||
f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp)
|
f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp)
|
||||||
}, question.Questioner)
|
}, question.Questioner)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+21
-10
@@ -39,6 +39,9 @@ type receivedItem struct {
|
|||||||
QuestionID uint // 仅对普通提问有效,用于置顶操作
|
QuestionID uint // 仅对普通提问有效,用于置顶操作
|
||||||
IsPinned bool // 仅对普通提问有效
|
IsPinned bool // 仅对普通提问有效
|
||||||
PinLink string // 仅对普通提问有效:置顶/取消置顶操作链接
|
PinLink string // 仅对普通提问有效:置顶/取消置顶操作链接
|
||||||
|
// ReportCount 仅对普通提问有效:待处理举报数。
|
||||||
|
// 只给数量、不给举报人 IP —— 箱主若能看到 IP,就有能力反查举报者,举报意愿会直接归零。
|
||||||
|
ReportCount int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuestionList 用户后台提问列表(支持筛选与全文搜索)
|
// 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 中匹配,但类型/状态筛选需在此统一应用,
|
||||||
// 使"搜索 + 筛选"可组合(完善筛选逻辑)。非搜索场景下此处与 SQL 过滤结果一致。
|
// 使"搜索 + 筛选"可组合(完善筛选逻辑)。非搜索场景下此处与 SQL 过滤结果一致。
|
||||||
for _, q := range questions {
|
for _, q := range questions {
|
||||||
@@ -191,16 +201,17 @@ func QuestionList(ctx context.Context) {
|
|||||||
itemType = "question_private"
|
itemType = "question_private"
|
||||||
}
|
}
|
||||||
receivedItems = append(receivedItems, receivedItem{
|
receivedItems = append(receivedItems, receivedItem{
|
||||||
Type: itemType,
|
Type: itemType,
|
||||||
CreatedAt: q.CreatedAt,
|
CreatedAt: q.CreatedAt,
|
||||||
Content: q.Content,
|
Content: q.Content,
|
||||||
Link: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "?" + adminLinkQueryStr,
|
Link: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "?" + adminLinkQueryStr,
|
||||||
IsAnswered: q.Answer != "",
|
IsAnswered: q.Answer != "",
|
||||||
IsRead: isRead,
|
IsRead: isRead,
|
||||||
IsPrivate: q.IsPrivate,
|
IsPrivate: q.IsPrivate,
|
||||||
QuestionID: q.ID,
|
QuestionID: q.ID,
|
||||||
IsPinned: q.IsPinned,
|
IsPinned: q.IsPinned,
|
||||||
PinLink: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "/pin",
|
PinLink: "/_/" + ctx.User.Domain + "/" + fmt.Sprint(q.ID) + "/pin",
|
||||||
|
ReportCount: reportCounts[q.ID],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta name="format-detection" content="email=no"/>
|
||||||
|
<meta name="format-detection" content="date=no"/>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0;" bgcolor="#FFFFFF">
|
||||||
|
<table width="100%" height="100%" style="min-width: 348px;" border="0" cellspacing="0" cellpadding="0" lang="zh-CN">
|
||||||
|
<tbody>
|
||||||
|
<tr height="32" style="height: 32px;">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr align="center">
|
||||||
|
<td>
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0"
|
||||||
|
style="padding-bottom: 20px;max-width: 516px;min-width: 220px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td width="8" style="width: 8px;"></td>
|
||||||
|
<td>
|
||||||
|
<div style="border-style: solid; border-width: thin; border-color:#dadce0; border-radius: 8px; padding: 40px 20px;"
|
||||||
|
align="center">
|
||||||
|
<div style="font-family: 'Google Sans',Roboto,RobotoDraft,Helvetica,Arial,sans-serif;border-bottom: thin solid #dadce0; color: rgba(0,0,0,0.87); line-height: 32px; padding-bottom: 24px;text-align: center; word-break: break-word;">
|
||||||
|
<div style="font-size: 24px;">
|
||||||
|
某箱主收到集中举报
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 13px; color: rgba(0,0,0,0.54); margin-top: 6px;">
|
||||||
|
{{.time}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 14px; color: rgba(0,0,0,0.87); line-height: 22px; padding-top: 20px; text-align: left;">
|
||||||
|
<div style="padding: 12px 14px; border-radius: 6px; background-color: #fdecea; border: 1px solid #f5c6cb; margin-bottom: 14px;">
|
||||||
|
<div><b>箱主:</b>{{.name}}(@{{.domain}})</div>
|
||||||
|
<div style="margin-top: 4px;"><b>近 24 小时:</b>{{.count24}} 条待处理举报</div>
|
||||||
|
<div style="margin-top: 4px;"><b>近 7 天:</b>{{.count7d}} 条待处理举报</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 13px; color: rgba(0,0,0,0.54);">查看证据:</div>
|
||||||
|
<div style="font-family: monospace; font-size: 12px; color: #333; background-color: #f7f7f7; padding: 10px 12px; border-radius: 6px; margin-top: 6px; word-break: break-all;">
|
||||||
|
TamaBox reports --domain {{.domain}}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 13px; color: rgba(0,0,0,0.54); margin-top: 12px;">如需处置:</div>
|
||||||
|
<div style="font-family: monospace; font-size: 12px; color: #333; background-color: #f7f7f7; padding: 10px 12px; border-radius: 6px; margin-top: 6px; word-break: break-all;">
|
||||||
|
TamaBox restrict --domain {{.domain}} --level L1 --days 7 --reason "组织挂人"
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: rgba(0,0,0,0.54); margin-top: 12px;">
|
||||||
|
这是一封阈值告警,不是自动处置:举报数达到阈值只代表「需要人看一眼」。
|
||||||
|
请结合 reports 输出判断是偶发摩擦还是组织化挂人,并对互殴情形做双向取证。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: left;">
|
||||||
|
<div style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif;color: rgba(0,0,0,0.54);font-size: 11px; line-height: 18px; padding-top: 12px; text-align: center;">
|
||||||
|
<div>
|
||||||
|
此邮件由系统自动发送,请勿直接回复。<br/>
|
||||||
|
{{ .year }} 狼的提问箱
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td width="8" style="width: 8px;"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="32" style="height: 32px;">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -85,6 +85,25 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<p class="uk-text-small uk-text-right uk-text-muted">{{t .LangCode "qitem.answeredBy" .PageUser.Name (Date .Question.UpdatedAt "Y-m-d H:i:s")}}</p>
|
<p class="uk-text-small uk-text-right uk-text-muted">{{t .LangCode "qitem.answeredBy" .PageUser.Name (Date .Question.UpdatedAt "Y-m-d H:i:s")}}</p>
|
||||||
|
{{ if not .CanDelete }}
|
||||||
|
<!-- 举报入口:仅对非箱主显示;默认折叠,避免在每条回答下方都堆一个显眼按钮。
|
||||||
|
理由为固定枚举(不接受自由文本),重复举报服务端幂等处理。 -->
|
||||||
|
<div class="uk-margin-small-top">
|
||||||
|
<details>
|
||||||
|
<summary class="uk-text-small uk-text-muted" style="cursor: pointer;">⚑ {{t .LangCode "report.entry"}}</summary>
|
||||||
|
<form method="post" action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/report" class="uk-margin-small-top uk-flex uk-flex-middle uk-flex-wrap" style="gap: 8px;">
|
||||||
|
{{ .CSRFTokenHTML }}
|
||||||
|
<select name="reason" class="uk-select uk-form-small" style="max-width: 200px;">
|
||||||
|
<option value="harassment">{{t .LangCode "report.reason.harassment"}}</option>
|
||||||
|
<option value="doxxing">{{t .LangCode "report.reason.doxxing"}}</option>
|
||||||
|
<option value="divisive">{{t .LangCode "report.reason.divisive"}}</option>
|
||||||
|
<option value="other">{{t .LangCode "report.reason.other"}}</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="uk-button uk-button-default uk-button-small">{{t .LangCode "report.submit"}}</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
<!-- 分享此提问(左)与 更新回答(右)一左一右分开;箱主未登录(无更新回答按钮)时分享按钮居中;编辑区在下方由 tbToggleAnswerEditor 控制 -->
|
<!-- 分享此提问(左)与 更新回答(右)一左一右分开;箱主未登录(无更新回答按钮)时分享按钮居中;编辑区在下方由 tbToggleAnswerEditor 控制 -->
|
||||||
<div class="uk-flex uk-flex-middle uk-flex-wrap uk-margin-small-top {{ if not .CanDelete }}uk-flex-center{{ end }}" style="gap: 8px;">
|
<div class="uk-flex uk-flex-middle uk-flex-wrap uk-margin-small-top {{ if not .CanDelete }}uk-flex-center{{ end }}" style="gap: 8px;">
|
||||||
{{ if and (not .Question.IsPrivate) (ne .Question.Answer "") }}
|
{{ if and (not .Question.IsPrivate) (ne .Question.Answer "") }}
|
||||||
|
|||||||
@@ -259,6 +259,9 @@
|
|||||||
{{ if not .IsAnswered }}
|
{{ if not .IsAnswered }}
|
||||||
<span class="uk-label qlist-pill" title="{{t $.LangCode "qlist.unanswered"}}"><span class="tb-short">{{t $.LangCode "qlist.unansweredShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.unanswered"}}</span></span>
|
<span class="uk-label qlist-pill" title="{{t $.LangCode "qlist.unanswered"}}"><span class="tb-short">{{t $.LangCode "qlist.unansweredShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.unanswered"}}</span></span>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ if gt .ReportCount 0 }}
|
||||||
|
<span class="uk-label uk-label-danger qlist-pill" title="{{t $.LangCode "qlist.reported" .ReportCount}}"><span class="tb-short">{{t $.LangCode "qlist.reportedShort" .ReportCount}}</span><span class="tb-text">{{t $.LangCode "qlist.reported" .ReportCount}}</span></span>
|
||||||
|
{{ end }}
|
||||||
{{ if .IsRead }}
|
{{ if .IsRead }}
|
||||||
<span class="uk-label uk-label-success qlist-pill" title="{{t $.LangCode "qlist.read"}}"><span class="tb-short">{{t $.LangCode "qlist.readShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.read"}}</span></span>
|
<span class="uk-label uk-label-success qlist-pill" title="{{t $.LangCode "qlist.read"}}"><span class="tb-short">{{t $.LangCode "qlist.readShort"}}</span><span class="tb-text">{{t $.LangCode "qlist.read"}}</span></span>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
|
|||||||
Reference in New Issue
Block a user