chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty). Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak, then full worktree committed as a rollback baseline.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
// 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 cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
)
|
||||
|
||||
// Ban 封禁用户并连带清理其全部数据。
|
||||
// 用法:TamaBox ban --email [email protected] 或 TamaBox ban --domain someone
|
||||
var Ban = &cli.Command{
|
||||
Name: "ban",
|
||||
Usage: "Ban a user and clean up all their questions, follow-ups and chats",
|
||||
Action: runBan,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "email",
|
||||
Usage: "Email of the user to ban",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "domain",
|
||||
Usage: "Custom domain of the user to ban",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "yes",
|
||||
Usage: "Skip confirmation",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func runBan(ctx *cli.Context) error {
|
||||
if err := conf.Init(); err != nil {
|
||||
return errors.Wrap(err, "load configuration")
|
||||
}
|
||||
|
||||
dbType := conf.Database.Type
|
||||
if dbType == "postgres" {
|
||||
conf.Database.DSN = conf.PostgresDsn()
|
||||
} else {
|
||||
conf.Database.DSN = conf.MySQLDsn()
|
||||
}
|
||||
|
||||
database, err := db.Init(dbType, conf.Database.DSN)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "connect to database")
|
||||
}
|
||||
|
||||
email := ctx.String("email")
|
||||
domain := ctx.String("domain")
|
||||
if email == "" && domain == "" {
|
||||
return errors.New("must provide --email or --domain")
|
||||
}
|
||||
|
||||
// 用 Unscoped 查找,支持封禁已软删除(自助停用)的账号
|
||||
var user db.User
|
||||
query := database.WithContext(ctx.Context).Unscoped()
|
||||
if email != "" {
|
||||
query = query.Where("email = ?", email)
|
||||
} else {
|
||||
query = query.Where("domain = ?", domain)
|
||||
}
|
||||
if err := query.First(&user).Error; err != nil {
|
||||
return errors.Wrap(err, "find user to ban")
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"id": user.ID,
|
||||
"name": user.Name,
|
||||
"email": user.Email,
|
||||
"domain": user.Domain,
|
||||
}).Warn("准备封禁以下账号,其提问/追问/私聊将一并删除,此操作不可恢复!")
|
||||
|
||||
if !ctx.Bool("yes") {
|
||||
logrus.Warn("输入大写 YES 确认执行封禁:")
|
||||
var answer string
|
||||
if _, err := fmtScan(&answer); err != nil {
|
||||
return errors.Wrap(err, "read confirmation")
|
||||
}
|
||||
if answer != "YES" {
|
||||
return errors.New("已取消")
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Users.BanUserAndCleanup(ctx.Context, user.ID); err != nil {
|
||||
return errors.Wrap(err, "ban user and cleanup")
|
||||
}
|
||||
|
||||
logrus.WithField("user_id", user.ID).Info("用户已封禁,关联数据已清理")
|
||||
return nil
|
||||
}
|
||||
|
||||
// fmtScan 从标准输入读取一行,便于交互确认(隔离以便测试)。
|
||||
var fmtScan = func(dst *string) (int, error) {
|
||||
return fmt.Fscan(os.Stdin, dst)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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 cmd
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||
)
|
||||
|
||||
var Censor = &cli.Command{
|
||||
Name: "censor",
|
||||
Usage: "Censor the questions",
|
||||
Action: runCensor,
|
||||
}
|
||||
|
||||
func runCensor(ctx *cli.Context) error {
|
||||
if err := conf.Init(); err != nil {
|
||||
return errors.Wrap(err, "load configuration")
|
||||
}
|
||||
|
||||
if !conf.Security.EnableTextCensor {
|
||||
return errors.New("text censor is disabled")
|
||||
}
|
||||
|
||||
dbType := conf.Database.Type
|
||||
if dbType == "postgres" {
|
||||
conf.Database.DSN = conf.PostgresDsn()
|
||||
} else {
|
||||
conf.Database.DSN = conf.MySQLDsn()
|
||||
}
|
||||
|
||||
database, err := db.Init(dbType, conf.Database.DSN)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "connect to database")
|
||||
}
|
||||
|
||||
// Check all the unprocessed questions.
|
||||
var questions []db.Question
|
||||
if err := database.Raw(`SELECT * FROM questions WHERE content_censor_metadata IS NULL`).Find(&questions).Error; err != nil {
|
||||
return errors.Wrap(err, "query questions")
|
||||
}
|
||||
|
||||
logrus.WithContext(ctx.Context).WithField("count", len(questions)).Info("Found un-censor questions")
|
||||
|
||||
for i, question := range questions {
|
||||
question := question
|
||||
|
||||
content := question.Content
|
||||
answer := question.Answer
|
||||
contentCensorResponse, err := censor.Text(ctx.Context, content)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Context).WithField("question_id", question.ID).WithError(err).Error("Failed to censor content")
|
||||
} else {
|
||||
// We don't want to update the `updated_at` field, so just execute the raw SQL.
|
||||
if err := database.Debug().Exec(`UPDATE questions SET content_censor_metadata = ? WHERE id = ?`, contentCensorResponse.ToJSON(), question.ID).Error; err != nil {
|
||||
logrus.WithContext(ctx.Context).WithField("question_id", question.ID).WithError(err).Error("Failed to update content censor metadata")
|
||||
}
|
||||
}
|
||||
|
||||
if answer != "" && question.AnswerCensorMetadata == nil {
|
||||
answerCensorResponse, err := censor.Text(ctx.Context, answer)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Context).WithField("question_id", question.ID).WithError(err).Error("Failed to censor answer")
|
||||
} else {
|
||||
// We don't want to update the `updated_at` field, so just execute the raw SQL.
|
||||
if err := database.Exec(`UPDATE questions SET answer_censor_metadata = ? WHERE id = ?`, answerCensorResponse.ToJSON(), question.ID).Error; err != nil {
|
||||
logrus.WithContext(ctx.Context).WithField("question_id", question.ID).WithError(err).Error("Failed to update answer censor metadata")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if i%1000 == 0 {
|
||||
logrus.WithContext(ctx.Context).WithField("count", i).Trace("Processed questions")
|
||||
}
|
||||
}
|
||||
|
||||
logrus.WithContext(ctx.Context).WithField("count", len(questions)).Info("Processed all questions")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/xid"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
)
|
||||
|
||||
var Uid = &cli.Command{
|
||||
Name: "uid",
|
||||
Usage: "Create uid for user",
|
||||
Action: runUid,
|
||||
}
|
||||
|
||||
func runUid(ctx *cli.Context) error {
|
||||
if err := conf.Init(); err != nil {
|
||||
return errors.Wrap(err, "load configuration")
|
||||
}
|
||||
|
||||
dbType := conf.Database.Type
|
||||
if dbType == "postgres" {
|
||||
conf.Database.DSN = conf.PostgresDsn()
|
||||
} else {
|
||||
conf.Database.DSN = conf.MySQLDsn()
|
||||
}
|
||||
|
||||
database, err := db.Init(dbType, conf.Database.DSN)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "connect to database")
|
||||
}
|
||||
|
||||
var users []*db.User
|
||||
if err := database.WithContext(ctx.Context).Unscoped().Find(&users).Error; err != nil {
|
||||
return errors.Wrap(err, "query users")
|
||||
}
|
||||
|
||||
for idx, user := range users {
|
||||
user := user
|
||||
if user.UID != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if idx%1000 == 0 {
|
||||
fmt.Printf("Processing user %d/%d\n", idx, len(users))
|
||||
}
|
||||
|
||||
uid := xid.NewWithTime(user.CreatedAt).String()
|
||||
if err := database.WithContext(ctx.Context).Unscoped().
|
||||
Model(&db.User{}).
|
||||
Omit("updated_at").
|
||||
Where("id = ?", user.ID).UpdateColumn("uid", uid).Error; err != nil {
|
||||
return errors.Wrap(err, "update uid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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 cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/uptrace/opentelemetry-go-extra/otellogrus"
|
||||
"github.com/uptrace/uptrace-go/uptrace"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/route"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
"github.com/tamakyi/TamaBox/internal/tracing"
|
||||
)
|
||||
|
||||
var Web = &cli.Command{
|
||||
Name: "web",
|
||||
Usage: "Start web server",
|
||||
Action: runWeb,
|
||||
}
|
||||
|
||||
func runWeb(ctx *cli.Context) error {
|
||||
if err := conf.Init(); err != nil {
|
||||
return errors.Wrap(err, "load configuration")
|
||||
}
|
||||
|
||||
// 解析可信代理网段(用于客户端 IP 解析,避免维护 Cloudflare 庞大 CIDR 清单)。
|
||||
if err := shared.InitTrustedProxies(); err != nil {
|
||||
return errors.Wrap(err, "init trusted proxies")
|
||||
}
|
||||
|
||||
if conf.App.UptraceDSN != "" {
|
||||
uptrace.ConfigureOpentelemetry(
|
||||
uptrace.WithDSN(conf.App.UptraceDSN),
|
||||
uptrace.WithServiceName("tamabox"),
|
||||
uptrace.WithServiceVersion(conf.BuildCommit),
|
||||
)
|
||||
logrus.WithContext(ctx.Context).Debug("Tracing enabled.")
|
||||
}
|
||||
|
||||
logrus.AddHook(otellogrus.NewHook(otellogrus.WithLevels(
|
||||
logrus.PanicLevel,
|
||||
logrus.FatalLevel,
|
||||
logrus.ErrorLevel,
|
||||
logrus.WarnLevel,
|
||||
)))
|
||||
|
||||
dbType := conf.Database.Type
|
||||
|
||||
var dsn string
|
||||
switch dbType {
|
||||
case "mysql", "":
|
||||
dsn = conf.MySQLDsn()
|
||||
case "postgres":
|
||||
dsn = conf.PostgresDsn()
|
||||
default:
|
||||
return errors.Errorf("unknown database type: %q", dbType)
|
||||
}
|
||||
conf.Database.DSN = dsn
|
||||
|
||||
_, err := db.Init(dbType, dsn)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "connect to database")
|
||||
}
|
||||
|
||||
logrus.WithContext(ctx.Context).WithField("external_url", conf.App.ExternalURL).Info("Starting web server")
|
||||
|
||||
// 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
|
||||
shared.App.SetLang(conf.App.DefaultLang)
|
||||
|
||||
r := route.New()
|
||||
r.Use(tracing.Middleware("TamaBox"))
|
||||
|
||||
// 最外层挂载 gzip 压缩,并在 http.Server 上配置超时:
|
||||
// - ReadHeaderTimeout 防止慢速客户端占用连接
|
||||
// - ReadTimeout / WriteTimeout 防止读写挂死
|
||||
// - IdleTimeout 复用 keep-alive 连接
|
||||
// listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为
|
||||
// "127.0.0.1:<port>",使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连。
|
||||
addr := conf.Server.Listen
|
||||
if addr == "" {
|
||||
addr = fmt.Sprintf(":%d", conf.Server.Port)
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: route.GzipHandler(r),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
logrus.WithContext(ctx.Context).WithField("port", conf.Server.Port).Info("Listening on")
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return errors.Wrap(err, "start http server")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user