完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,9 +8,17 @@ DSN = postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable
|
||||
[security]
|
||||
; 留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值
|
||||
JWT_SECRET =
|
||||
; 32 字节随机值的标准 Base64(openssl rand -base64 32)。留空则不能保存邮件/S3 凭据。
|
||||
; 环境变量 SETTINGS_MASTER_KEY 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。
|
||||
SETTINGS_MASTER_KEY =
|
||||
|
||||
[paths]
|
||||
DATA = data
|
||||
|
||||
[app]
|
||||
DEV_MODE = true
|
||||
; 对外正式 origin,无尾斜杠。账号邮件链接与 Next 的 SITE_URL 必须一致。
|
||||
; 本地:http://localhost:3000;生产:https://bbs.example.com。环境变量 SITE_URL 优先。
|
||||
SITE_URL = http://localhost:3000
|
||||
; 额外 CORS origin,逗号分隔。环境变量 CORS_ORIGINS 优先。同源反代通常可留空。
|
||||
CORS_ORIGINS =
|
||||
|
||||
@@ -12,19 +12,41 @@ import (
|
||||
|
||||
// Config 应用全局配置
|
||||
type Config struct {
|
||||
WorkPath string
|
||||
Port int
|
||||
DataDir string
|
||||
JWTSecret string
|
||||
DBDSN string
|
||||
DevMode bool
|
||||
SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用
|
||||
CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量)
|
||||
WorkPath string
|
||||
Port int
|
||||
DataDir string
|
||||
JWTSecret string
|
||||
SettingsMasterKey string // 32 字节标准 Base64;加密邮件/存储凭据。留空则禁止保存凭据
|
||||
DBDSN string
|
||||
DevMode bool
|
||||
SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用
|
||||
CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量)
|
||||
}
|
||||
|
||||
// appIniKey 描述 app.ini 中应由启动补全的键(已有值不改写)。
|
||||
type appIniKey struct {
|
||||
section string
|
||||
key string
|
||||
value string // 缺项时写入的缺省值(空串表示键存在但待填写)
|
||||
comment string // 写在键上方的注释(不含前导分号外的格式由 ini 库处理)
|
||||
}
|
||||
|
||||
// appIniSchema 是本地配置清单;启动时把缺失键补进现有 app.ini,避免升级后站长看不到新字段。
|
||||
var appIniSchema = []appIniKey{
|
||||
{section: "server", key: "HTTP_PORT", value: "3001"},
|
||||
{section: "database", key: "DSN", value: "postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable", comment: "PostgreSQL 连接串(部署时请修改账号密码)"},
|
||||
{section: "security", key: "JWT_SECRET", value: "", comment: "留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值"},
|
||||
{section: "security", key: "SETTINGS_MASTER_KEY", value: "", comment: "32 字节随机值的标准 Base64(openssl rand -base64 32)。留空则不能保存邮件/S3 凭据。\n环境变量 SETTINGS_MASTER_KEY 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。"},
|
||||
{section: "paths", key: "DATA", value: "data"},
|
||||
{section: "app", key: "DEV_MODE", value: "true"},
|
||||
{section: "app", key: "SITE_URL", value: "", comment: "对外正式 origin,无尾斜杠。账号邮件链接与 Next 的 SITE_URL 必须一致。\n本地可填 http://localhost:3000;生产填 https://bbs.example.com。环境变量 SITE_URL 优先。"},
|
||||
{section: "app", key: "CORS_ORIGINS", value: "", comment: "额外 CORS origin,逗号分隔。环境变量 CORS_ORIGINS 优先。同源反代通常可留空。"},
|
||||
}
|
||||
|
||||
// Parse 解析配置:环境变量 > app.ini > 默认值。
|
||||
// 工作目录不跟 os.Getwd() 走:从 cwd 向上查找 backend 根(含 app.ini 或源码标记),
|
||||
// 避免在仓库根 / cmd/jiang13 启动时把上传文件写进另一套 data/。
|
||||
// 若 app.ini 缺少 schema 中的键,会就地补全缺省行(不覆盖已有值)。
|
||||
func Parse() (*Config, error) {
|
||||
workPath, err := resolveWorkPath()
|
||||
if err != nil {
|
||||
@@ -40,35 +62,13 @@ func Parse() (*Config, error) {
|
||||
DevMode: true,
|
||||
}
|
||||
|
||||
// 尝试加载 app.ini
|
||||
iniPath := filepath.Join(workPath, "app.ini")
|
||||
if _, err := os.Stat(iniPath); err == nil {
|
||||
f, err := ini.Load(iniPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载 app.ini 失败: %w", err)
|
||||
}
|
||||
if s := f.Section("server"); s.HasKey("HTTP_PORT") {
|
||||
if v, err := s.Key("HTTP_PORT").Int(); err == nil {
|
||||
cfg.Port = v
|
||||
}
|
||||
}
|
||||
if s := f.Section("database"); s.HasKey("DSN") {
|
||||
cfg.DBDSN = s.Key("DSN").String()
|
||||
}
|
||||
if s := f.Section("security"); s.HasKey("JWT_SECRET") {
|
||||
cfg.JWTSecret = s.Key("JWT_SECRET").String()
|
||||
}
|
||||
if s := f.Section("paths"); s.HasKey("DATA") {
|
||||
dataRel := s.Key("DATA").String()
|
||||
if !filepath.IsAbs(dataRel) {
|
||||
cfg.DataDir = filepath.Join(workPath, dataRel)
|
||||
} else {
|
||||
cfg.DataDir = dataRel
|
||||
}
|
||||
}
|
||||
if s := f.Section("app"); s.HasKey("DEV_MODE") {
|
||||
cfg.DevMode = s.Key("DEV_MODE").MustBool(true)
|
||||
}
|
||||
f, err := loadAndEnsureAppIni(iniPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f != nil {
|
||||
applyIni(cfg, f)
|
||||
}
|
||||
|
||||
// 环境变量覆盖
|
||||
@@ -83,6 +83,9 @@ func Parse() (*Config, error) {
|
||||
if v := os.Getenv("JWT_SECRET"); v != "" {
|
||||
cfg.JWTSecret = v
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("SETTINGS_MASTER_KEY")); v != "" {
|
||||
cfg.SettingsMasterKey = v
|
||||
}
|
||||
if v := os.Getenv("DEV_MODE"); v != "" {
|
||||
cfg.DevMode = strings.EqualFold(v, "true") || v == "1"
|
||||
}
|
||||
@@ -123,6 +126,90 @@ func Parse() (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyIni(cfg *Config, f *ini.File) {
|
||||
if s := f.Section("server"); s.HasKey("HTTP_PORT") {
|
||||
if v, err := s.Key("HTTP_PORT").Int(); err == nil {
|
||||
cfg.Port = v
|
||||
}
|
||||
}
|
||||
if s := f.Section("database"); s.HasKey("DSN") {
|
||||
cfg.DBDSN = s.Key("DSN").String()
|
||||
}
|
||||
if s := f.Section("security"); s.HasKey("JWT_SECRET") {
|
||||
cfg.JWTSecret = s.Key("JWT_SECRET").String()
|
||||
}
|
||||
if s := f.Section("security"); s.HasKey("SETTINGS_MASTER_KEY") {
|
||||
cfg.SettingsMasterKey = strings.TrimSpace(s.Key("SETTINGS_MASTER_KEY").String())
|
||||
}
|
||||
if s := f.Section("paths"); s.HasKey("DATA") {
|
||||
dataRel := s.Key("DATA").String()
|
||||
if !filepath.IsAbs(dataRel) {
|
||||
cfg.DataDir = filepath.Join(cfg.WorkPath, dataRel)
|
||||
} else {
|
||||
cfg.DataDir = dataRel
|
||||
}
|
||||
}
|
||||
if s := f.Section("app"); s.HasKey("DEV_MODE") {
|
||||
cfg.DevMode = s.Key("DEV_MODE").MustBool(true)
|
||||
}
|
||||
if s := f.Section("app"); s.HasKey("SITE_URL") {
|
||||
if v := strings.TrimSpace(s.Key("SITE_URL").String()); v != "" {
|
||||
cfg.SiteURL = strings.TrimRight(v, "/")
|
||||
}
|
||||
}
|
||||
if s := f.Section("app"); s.HasKey("CORS_ORIGINS") {
|
||||
if v := strings.TrimSpace(s.Key("CORS_ORIGINS").String()); v != "" {
|
||||
cfg.CORSOrigins = splitCSVOrigins(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadAndEnsureAppIni 加载 app.ini;不存在则按 schema 新建;已存在则只补缺失键。
|
||||
func loadAndEnsureAppIni(path string) (*ini.File, error) {
|
||||
_, statErr := os.Stat(path)
|
||||
missingFile := os.IsNotExist(statErr)
|
||||
if statErr != nil && !missingFile {
|
||||
return nil, fmt.Errorf("读取 app.ini 失败: %w", statErr)
|
||||
}
|
||||
|
||||
var f *ini.File
|
||||
var err error
|
||||
if missingFile {
|
||||
f = ini.Empty()
|
||||
} else {
|
||||
f, err = ini.Load(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载 app.ini 失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
changed := missingFile
|
||||
for _, spec := range appIniSchema {
|
||||
sec := f.Section(spec.section)
|
||||
if !sec.HasKey(spec.key) {
|
||||
val := spec.value
|
||||
// 开发态缺 SITE_URL 时写入本机前端 origin,生产(DEV_MODE=false)只留空键提示填写
|
||||
if spec.section == "app" && spec.key == "SITE_URL" && val == "" && sec.Key("DEV_MODE").MustBool(true) {
|
||||
val = "http://localhost:3000"
|
||||
}
|
||||
k, kerr := sec.NewKey(spec.key, val)
|
||||
if kerr != nil {
|
||||
return nil, fmt.Errorf("补全 app.ini %s.%s 失败: %w", spec.section, spec.key, kerr)
|
||||
}
|
||||
if spec.comment != "" {
|
||||
k.Comment = spec.comment
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
if err := f.SaveTo(path); err != nil {
|
||||
return nil, fmt.Errorf("写回 app.ini 失败: %w", err)
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// AllowOrigin 供 CORS 中间件判断浏览器 Origin。
|
||||
// 开发态只放行 localhost / 127.0.0.1 的 3000 端口(与 next dev 一致);
|
||||
// 生产态放行 SITE_URL 与 CORS_ORIGINS。同源反代下浏览器不依赖 CORS,此列表作兜底。
|
||||
|
||||
@@ -114,6 +114,33 @@ func TestAllowOriginDevAndProd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSettingsMasterKeyFromIni(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
key := "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
|
||||
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte("[security]\nJWT_SECRET = test-secret-not-for-prod\nSETTINGS_MASTER_KEY = "+key+"\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("JIANG13_WORK_PATH", work)
|
||||
t.Setenv("SETTINGS_MASTER_KEY", "")
|
||||
cfg, err := Parse()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.SettingsMasterKey != key {
|
||||
t.Fatalf("未从 app.ini 读取主密钥: %q", cfg.SettingsMasterKey)
|
||||
}
|
||||
|
||||
override := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
t.Setenv("SETTINGS_MASTER_KEY", override)
|
||||
cfg, err = Parse()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.SettingsMasterKey != override {
|
||||
t.Fatalf("环境变量应覆盖 app.ini: %q", cfg.SettingsMasterKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSiteURLAndDataDir(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
data := t.TempDir()
|
||||
@@ -139,3 +166,94 @@ func TestParseSiteURLAndDataDir(t *testing.T) {
|
||||
}
|
||||
assertPath(t, cfg.DataDir, data)
|
||||
}
|
||||
|
||||
func TestEnsureAppIniBackfillsMissingKeys(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
iniPath := filepath.Join(work, "app.ini")
|
||||
// 旧版精简配置:缺 SITE_URL / CORS_ORIGINS / SETTINGS_MASTER_KEY
|
||||
old := "[server]\nHTTP_PORT = 3001\n\n[database]\nDSN = postgres://u:p@localhost/db\n\n[security]\nJWT_SECRET = keep-me\n\n[paths]\nDATA = data\n\n[app]\nDEV_MODE = true\n"
|
||||
if err := os.WriteFile(iniPath, []byte(old), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, err := loadAndEnsureAppIni(iniPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.Section("app").HasKey("SITE_URL") || !f.Section("app").HasKey("CORS_ORIGINS") {
|
||||
t.Fatal("应补全 SITE_URL / CORS_ORIGINS")
|
||||
}
|
||||
if got := f.Section("app").Key("SITE_URL").String(); got != "http://localhost:3000" {
|
||||
t.Fatalf("开发态缺 SITE_URL 应补本机 origin,got %q", got)
|
||||
}
|
||||
if got := f.Section("security").Key("JWT_SECRET").String(); got != "keep-me" {
|
||||
t.Fatalf("已有值被改写: %q", got)
|
||||
}
|
||||
if !f.Section("security").HasKey("SETTINGS_MASTER_KEY") {
|
||||
t.Fatal("应补全 SETTINGS_MASTER_KEY 空键")
|
||||
}
|
||||
|
||||
// 再生产态缺项:只留空键,不写 localhost
|
||||
prod := t.TempDir()
|
||||
prodIni := filepath.Join(prod, "app.ini")
|
||||
if err := os.WriteFile(prodIni, []byte("[app]\nDEV_MODE = false\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pf, err := loadAndEnsureAppIni(prodIni)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := pf.Section("app").Key("SITE_URL").String(); got != "" {
|
||||
t.Fatalf("生产态缺 SITE_URL 应留空提示填写,got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSiteURLFromIni(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
body := "[security]\nJWT_SECRET = test-secret-not-for-prod\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n"
|
||||
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("JIANG13_WORK_PATH", work)
|
||||
t.Setenv("SITE_URL", "")
|
||||
t.Setenv("CORS_ORIGINS", "")
|
||||
t.Setenv("DEV_MODE", "")
|
||||
t.Setenv("JWT_SECRET", "")
|
||||
|
||||
cfg, err := Parse()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.SiteURL != "https://forum.example.com" {
|
||||
t.Fatalf("未从 app.ini 读取 SITE_URL: %q", cfg.SiteURL)
|
||||
}
|
||||
if len(cfg.CORSOrigins) != 2 || cfg.CORSOrigins[0] != "https://a.example.com" {
|
||||
t.Fatalf("未从 app.ini 读取 CORS_ORIGINS: %#v", cfg.CORSOrigins)
|
||||
}
|
||||
|
||||
t.Setenv("SITE_URL", "https://env.example.com")
|
||||
cfg, err = Parse()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.SiteURL != "https://env.example.com" {
|
||||
t.Fatalf("环境变量应覆盖 app.ini SITE_URL: %q", cfg.SiteURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAppIniCreatesWhenMissing(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
iniPath := filepath.Join(work, "app.ini")
|
||||
f, err := loadAndEnsureAppIni(iniPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(iniPath); err != nil {
|
||||
t.Fatal("应创建 app.ini")
|
||||
}
|
||||
for _, spec := range appIniSchema {
|
||||
if !f.Section(spec.section).HasKey(spec.key) {
|
||||
t.Fatalf("新建文件缺少 %s.%s", spec.section, spec.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/minio/minio-go/v7 v7.0.83
|
||||
github.com/yuin/goldmark v1.7.8
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/image v0.46.0
|
||||
golang.org/x/text v0.42.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gorm.io/driver/postgres v1.5.9
|
||||
gorm.io/gorm v1.25.12
|
||||
@@ -19,12 +22,15 @@ require (
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||
@@ -32,20 +38,22 @@ require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -10,6 +10,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
@@ -18,6 +20,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -26,13 +30,15 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -49,9 +55,12 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
@@ -61,6 +70,10 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA=
|
||||
github.com/minio/minio-go/v7 v7.0.83/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -72,6 +85,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -88,6 +103,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
@@ -99,7 +116,6 @@ golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
|
||||
@@ -85,6 +85,7 @@ func clearAuthCookies(c *gin.Context, secure bool) {
|
||||
|
||||
// RegisterRequest 注册请求
|
||||
type RegisterRequest struct {
|
||||
Code string `json:"code"`
|
||||
Username string `json:"username" binding:"required,min=3,max=32"`
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Password string `json:"password" binding:"required,min=6,max=64"`
|
||||
@@ -92,8 +93,8 @@ type RegisterRequest struct {
|
||||
|
||||
// LoginRequest 登录请求
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Username string `json:"username" binding:"required,max=128"`
|
||||
Password string `json:"password" binding:"required,max=128"`
|
||||
}
|
||||
|
||||
// Register 用户注册:成功后直接签发登录态(注册即登录,免去手动再登一次)
|
||||
@@ -110,6 +111,17 @@ func (h *Handlers) Register(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
security, err := h.Ops.Security()
|
||||
if err != nil {
|
||||
c.JSON(503, gin.H{"error": "注册暂不可用"})
|
||||
return
|
||||
}
|
||||
if security.VerifyEmail {
|
||||
if err = h.Ops.ConsumeCode(req.Email, "register", req.Code); err != nil {
|
||||
c.JSON(400, gin.H{"error": "请先完成邮箱验证"})
|
||||
return
|
||||
}
|
||||
}
|
||||
user, err := h.Auth.Register(req.Username, req.Email, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -146,8 +158,17 @@ func (h *Handlers) Login(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
cfg, e := h.Ops.Security()
|
||||
if !h.quotaResponse(c, 0, e) {
|
||||
return
|
||||
}
|
||||
wait, e := h.Ops.FailureWait(req.Username, c.ClientIP(), cfg)
|
||||
if !h.quotaResponse(c, wait, e) {
|
||||
return
|
||||
}
|
||||
accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
h.Ops.RecordFailure(req.Username, c.ClientIP(), cfg)
|
||||
// 登录失败也留痕:用户存在时带 user_id(封禁/错密),不存在时为 0
|
||||
h.AdminUser.RecordLogin(
|
||||
h.Auth.GetUserIDByUsername(req.Username), req.Username,
|
||||
@@ -164,6 +185,7 @@ func (h *Handlers) Login(c *gin.Context) {
|
||||
// 登录成功留痕(IP/UA/时间)
|
||||
h.AdminUser.RecordLogin(user.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
|
||||
// dev 模式不设 Secure,生产环境需 HTTPS
|
||||
h.Ops.ClearFailure(req.Username)
|
||||
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
|
||||
@@ -50,6 +50,20 @@ func (h *Handlers) PostComments(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if requireLogin, err := h.Setting.CommentsRequireLogin(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
|
||||
return
|
||||
} else if requireLogin && viewerID == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"comments": []any{},
|
||||
"total": 0,
|
||||
"total_comments": 0,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"require_login": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
boardID, _ := h.Post.GetBoardID(uint(id))
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
// Handlers 聚合所有服务引用
|
||||
type Handlers struct {
|
||||
Ops *service.Operations
|
||||
Cfg *config.Config
|
||||
Hub *realtime.Hub
|
||||
Auth *service.AuthService
|
||||
|
||||
355
backend/handler/operations.go
Normal file
355
backend/handler/operations.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type moduleRequest struct {
|
||||
Version int64 `json:"version"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Clear []string `json:"clear"`
|
||||
Action string `json:"action"`
|
||||
Recipient string `json:"recipient"`
|
||||
Text string `json:"text"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
func (h *Handlers) ReadModule(c *gin.Context) {
|
||||
v, e := h.Ops.Read(c.Param("module"))
|
||||
if e != nil {
|
||||
c.JSON(503, gin.H{"error": "配置读取失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(200, v)
|
||||
}
|
||||
func (h *Handlers) SaveModule(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
||||
var req moduleRequest
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
c.JSON(400, gin.H{"error": "表单格式无效"})
|
||||
return
|
||||
}
|
||||
name := c.Param("module")
|
||||
actor := middleware.CurrentUser(c).ID
|
||||
if e := h.Ops.ProbeBeforeSave(c.Request.Context(), name, req.Data, req.Clear); e != nil {
|
||||
h.Ops.Audit(actor, name, "save", "服务验证失败")
|
||||
c.JSON(400, gin.H{"error": safeConfigError(e)})
|
||||
return
|
||||
}
|
||||
e := h.Ops.Save(name, req.Version, req.Data, req.Clear, actor)
|
||||
if e != nil {
|
||||
status := 400
|
||||
if errors.Is(e, service.ErrConfigConflict) {
|
||||
status = 409
|
||||
}
|
||||
h.Ops.Audit(actor, name, "save", "失败")
|
||||
c.JSON(status, gin.H{"error": safeConfigError(e)})
|
||||
return
|
||||
}
|
||||
h.ReadModule(c)
|
||||
}
|
||||
func safeConfigError(e error) string {
|
||||
s := e.Error()
|
||||
if strings.Contains(s, "SQLSTATE") || strings.Contains(s, "sql:") || strings.Contains(s, "failed to connect") {
|
||||
return "数据库暂不可用,配置未保存"
|
||||
}
|
||||
return s
|
||||
}
|
||||
func (h *Handlers) TestModule(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
||||
var req moduleRequest
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
c.JSON(400, gin.H{"error": "表单格式无效"})
|
||||
return
|
||||
}
|
||||
actor := middleware.CurrentUser(c).ID
|
||||
if wait, e := h.Ops.Quota("admin-test:"+strconv.Itoa(int(actor)), 6, 60); !h.quotaResponse(c, wait, e) {
|
||||
return
|
||||
}
|
||||
switch c.Param("module") {
|
||||
case "mail":
|
||||
if req.Action != "connection" && req.Action != "send" {
|
||||
c.JSON(400, gin.H{"error": "测试类型无效"})
|
||||
return
|
||||
}
|
||||
e := h.Ops.TestMail(c.Request.Context(), req.Data, req.Clear, req.Action == "send", req.Recipient, actor)
|
||||
if e != nil {
|
||||
c.JSON(400, gin.H{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
message := "连接、TLS 与认证通过"
|
||||
if req.Action == "send" {
|
||||
message = "服务器已接受测试邮件,不代表最终送达"
|
||||
}
|
||||
c.JSON(200, gin.H{"message": message, "tested_at": time.Now()})
|
||||
case "storage":
|
||||
e := h.Ops.TestStorage(c.Request.Context(), req.Data, req.Clear)
|
||||
if e != nil {
|
||||
h.Ops.Audit(actor, "storage", "test", "失败")
|
||||
c.JSON(400, gin.H{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
h.Ops.Audit(actor, "storage", "test", "读写清理通过")
|
||||
c.JSON(200, gin.H{"message": "读写与清理测试通过", "tested_at": time.Now()})
|
||||
case "filter":
|
||||
result, e := h.Ops.TestFilter(req.Data, req.Scope, req.Text)
|
||||
if e != nil {
|
||||
c.JSON(400, gin.H{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, result)
|
||||
default:
|
||||
c.JSON(404, gin.H{"error": "该模块没有测试操作"})
|
||||
}
|
||||
}
|
||||
func (h *Handlers) ModuleRecords(c *gin.Context) {
|
||||
var data any
|
||||
var e error
|
||||
switch c.Param("module") {
|
||||
case "mail":
|
||||
data, e = h.Ops.MailRows()
|
||||
case "storage":
|
||||
data, e = h.Ops.StorageReferences()
|
||||
case "security", "filter", "maintenance":
|
||||
data, e = h.Ops.AuditRows(c.Param("module"))
|
||||
default:
|
||||
c.JSON(404, gin.H{"error": "该模块没有记录"})
|
||||
return
|
||||
}
|
||||
if e != nil {
|
||||
c.JSON(503, gin.H{"error": "记录读取失败"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(200, gin.H{"records": data})
|
||||
}
|
||||
func (h *Handlers) quotaResponse(c *gin.Context, wait int, e error) bool {
|
||||
if e != nil {
|
||||
c.AbortWithStatusJSON(503, gin.H{"error": "安全检查暂不可用,请稍后重试"})
|
||||
return false
|
||||
}
|
||||
if wait > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(wait))
|
||||
c.AbortWithStatusJSON(429, gin.H{"error": "操作频繁,请 " + strconv.Itoa(wait) + " 秒后重试", "retry_after": wait})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (h *Handlers) administrator(c *gin.Context) bool {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
a, e := h.Auth.LoadActor(user.ID)
|
||||
return e == nil && a.HasPerm(service.PermSettings)
|
||||
}
|
||||
func authRecoveryPath(p string) bool {
|
||||
switch p {
|
||||
case "/api/login", "/api/logout", "/api/auth/refresh", "/api/me", "/api/settings", "/api/site-state", "/api/auth/code", "/api/auth/reset-password":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(p, "/api/admin/")
|
||||
}
|
||||
|
||||
// Registered after OptionalAuth so bypass always depends on validated live permissions.
|
||||
func (h *Handlers) RuntimeGuard(c *gin.Context) {
|
||||
p := c.Request.URL.Path
|
||||
if p == "/health" || strings.HasPrefix(p, "/uploads/") || authRecoveryPath(p) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if h.administrator(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
mode, e := h.Ops.Maintenance()
|
||||
if e != nil {
|
||||
c.AbortWithStatusJSON(503, gin.H{"error": "站点状态暂不可用"})
|
||||
return
|
||||
}
|
||||
safe := authRecoveryPath(p)
|
||||
if !safe && mode.Mode == "paused" {
|
||||
c.Header("Retry-After", strconv.Itoa(mode.RetryAfter))
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.AbortWithStatusJSON(503, gin.H{"error": mode.Title, "maintenance": mode})
|
||||
return
|
||||
}
|
||||
if !safe && mode.Mode == "readonly" && c.Request.Method != "GET" && c.Request.Method != "HEAD" && c.Request.Method != "OPTIONS" {
|
||||
c.AbortWithStatusJSON(503, gin.H{"error": "站点处于只读模式,暂不能提交修改"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
func (h *Handlers) BusinessQuota(c *gin.Context) {
|
||||
if c.FullPath() != "/api/posts" && c.FullPath() != "/api/posts/:id/comments" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
cfg, e := h.Ops.Security()
|
||||
if !h.quotaResponse(c, 0, e) {
|
||||
return
|
||||
}
|
||||
p := c.FullPath()
|
||||
user := middleware.CurrentUser(c)
|
||||
identity := "ip:" + c.ClientIP()
|
||||
if user != nil {
|
||||
identity = "user:" + strconv.Itoa(int(user.ID))
|
||||
}
|
||||
seconds, limit, kind := 0, 1, ""
|
||||
if c.Request.Method == "GET" && p == "/api/posts" && c.Query("q") != "" {
|
||||
seconds = 60
|
||||
limit = cfg.SearchMinute
|
||||
kind = "search"
|
||||
}
|
||||
if c.Request.Method == "POST" && user != nil {
|
||||
switch p {
|
||||
case "/api/posts":
|
||||
seconds = cfg.PostInterval
|
||||
kind = "post"
|
||||
case "/api/posts/:id/comments":
|
||||
seconds = cfg.CommentInterval
|
||||
kind = "comment"
|
||||
}
|
||||
}
|
||||
if seconds > 0 {
|
||||
if wait, e := h.Ops.Quota(kind+":"+identity, limit, seconds); !h.quotaResponse(c, wait, e) {
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
func (h *Handlers) SiteState(c *gin.Context) {
|
||||
cfg, e := h.Ops.Security()
|
||||
if e != nil {
|
||||
c.JSON(503, gin.H{"error": "状态暂不可用"})
|
||||
return
|
||||
}
|
||||
m, e := h.Ops.Maintenance()
|
||||
if e != nil {
|
||||
c.JSON(503, gin.H{"error": "状态暂不可用"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(200, gin.H{"allow_register": cfg.AllowRegister, "register_notice": cfg.RegisterNotice, "verify_email": cfg.VerifyEmail, "password_reset": cfg.PasswordReset, "maintenance": m, "bypass": h.administrator(c), "site_url": h.Cfg.SiteURL})
|
||||
}
|
||||
func (h *Handlers) SendEmailCode(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
c.JSON(400, gin.H{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
wait, e := h.Ops.SendCode(req.Email, req.Purpose, c.ClientIP())
|
||||
if !h.quotaResponse(c, wait, e) {
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "如果该邮箱可用于此操作,验证邮件将进入发送队列"})
|
||||
}
|
||||
func (h *Handlers) ResetPassword(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
c.JSON(400, gin.H{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
if wait, e := h.Ops.Quota("reset:"+c.ClientIP(), 10, 600); !h.quotaResponse(c, wait, e) {
|
||||
return
|
||||
}
|
||||
if e := h.Ops.ResetPassword(req.Email, req.Code, req.Password); e != nil {
|
||||
c.JSON(400, gin.H{"error": "验证码无效、已过期或密码格式不符合要求"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "密码已更新,请重新登录"})
|
||||
}
|
||||
func (h *Handlers) PublicObject(c *gin.Context) {
|
||||
location, err := h.Ops.PublicObjectLocation(c.Param("object"))
|
||||
if err != nil {
|
||||
c.JSON(404, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
if location != "" {
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Redirect(302, location)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
r, m, e := h.Ops.OpenObject(ctx, c.Param("object"), true)
|
||||
if e != nil {
|
||||
c.JSON(404, gin.H{"error": "文件暂不可用"})
|
||||
return
|
||||
}
|
||||
defer r.Close()
|
||||
c.Header("Content-Type", m)
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Status(200)
|
||||
_, _ = io.Copy(c.Writer, r)
|
||||
}
|
||||
func (h *Handlers) Diagnostics(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(200, h.Ops.Diagnostics(c.Request.Context()))
|
||||
}
|
||||
func (h *Handlers) MaintenanceAction(c *gin.Context) {
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
Confirm bool `json:"confirm"`
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if c.ShouldBindJSON(&req) != nil {
|
||||
c.JSON(400, gin.H{"error": "请求无效"})
|
||||
return
|
||||
}
|
||||
actor := middleware.CurrentUser(c).ID
|
||||
switch req.Action {
|
||||
case "scan":
|
||||
r, e := h.Ops.ScanTemporary()
|
||||
if e != nil {
|
||||
c.JSON(503, gin.H{"error": "扫描失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, r)
|
||||
case "clean-temporary":
|
||||
if !req.Confirm {
|
||||
c.JSON(400, gin.H{"error": "请先扫描并确认清理范围"})
|
||||
return
|
||||
}
|
||||
r, e := h.Ops.CleanTemporary(req.IDs)
|
||||
if e != nil {
|
||||
c.JSON(400, gin.H{"error": "清理失败,请重新扫描"})
|
||||
return
|
||||
}
|
||||
h.Ops.Audit(actor, "maintenance", "clean-temporary", "完成")
|
||||
c.JSON(200, r)
|
||||
case "clear-mail-logs":
|
||||
if !req.Confirm {
|
||||
c.JSON(400, gin.H{"error": "请确认仅清理过期发送记录"})
|
||||
return
|
||||
}
|
||||
n, e := h.Ops.ClearMailLogs()
|
||||
if e != nil {
|
||||
c.JSON(503, gin.H{"error": "清理失败"})
|
||||
return
|
||||
}
|
||||
h.Ops.Audit(actor, "maintenance", req.Action, "完成")
|
||||
c.JSON(200, gin.H{"message": "已清理过期终态发送记录", "count": n})
|
||||
default:
|
||||
c.JSON(400, gin.H{"error": "不支持该维护操作"})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
@@ -446,6 +449,20 @@ func (h *Handlers) DownloadPostAttachment(c *gin.Context) {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
c.Header("Content-Type", ct)
|
||||
if att.ObjectID != "" {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
r, _, err := h.Ops.OpenObject(ctx, att.ObjectID, false)
|
||||
if err != nil {
|
||||
c.JSON(503, gin.H{"error": "文件暂不可用"})
|
||||
return
|
||||
}
|
||||
defer r.Close()
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Status(200)
|
||||
_, _ = io.Copy(c.Writer, r)
|
||||
return
|
||||
}
|
||||
c.File(path)
|
||||
_ = filepath.Base(path)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type updateSettingsRequest struct {
|
||||
SiteDescription *string `json:"site_description"`
|
||||
AllowRegister *bool `json:"allow_register"`
|
||||
AllowComments *bool `json:"allow_comments"`
|
||||
CommentsRequireLogin *bool `json:"comments_require_login"`
|
||||
AllowMessages *bool `json:"allow_messages"`
|
||||
PostCooldownHours *int `json:"post_cooldown_hours"`
|
||||
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
|
||||
@@ -55,7 +56,6 @@ type updateSettingsRequest struct {
|
||||
BrandLogoSize *string `json:"brand_logo_size"`
|
||||
BrandLogoFit *string `json:"brand_logo_fit"`
|
||||
FooterLinks *[]service.FooterLink `json:"footer_links"`
|
||||
TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播
|
||||
}
|
||||
|
||||
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
@@ -65,8 +65,9 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
"site_name": saved.SiteName,
|
||||
"site_description": saved.SiteDescription,
|
||||
"allow_register": saved.AllowRegister,
|
||||
"allow_comments": saved.AllowComments,
|
||||
"allow_messages": saved.AllowMessages,
|
||||
"allow_comments": saved.AllowComments,
|
||||
"comments_require_login": saved.CommentsRequireLogin,
|
||||
"allow_messages": saved.AllowMessages,
|
||||
"post_cooldown_hours": saved.PostCooldownHours,
|
||||
"code_block_auto_fold": saved.CodeBlockAutoFold,
|
||||
"code_block_fold_lines": saved.CodeBlockFoldLines,
|
||||
@@ -100,7 +101,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
func (req *updateSettingsRequest) hasAny() bool {
|
||||
return req.Accent != nil || req.TrustReviewedPublish != nil || req.SiteName != nil ||
|
||||
req.SiteDescription != nil || req.AllowRegister != nil || req.AllowComments != nil ||
|
||||
req.AllowMessages != nil || req.PostCooldownHours != nil || req.CodeBlockAutoFold != nil ||
|
||||
req.CommentsRequireLogin != nil || req.AllowMessages != nil || req.PostCooldownHours != nil || req.CodeBlockAutoFold != nil ||
|
||||
req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil ||
|
||||
req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil ||
|
||||
req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil ||
|
||||
@@ -110,24 +111,17 @@ func (req *updateSettingsRequest) hasAny() bool {
|
||||
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
|
||||
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
|
||||
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
|
||||
req.FooterLinks != nil || req.TimelineGitImport != nil
|
||||
req.FooterLinks != nil
|
||||
}
|
||||
|
||||
// AdminGetSettings 超管读取站点设置(含 timeline_git_import,不进公开 /api/settings)
|
||||
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
|
||||
func (h *Handlers) AdminGetSettings(c *gin.Context) {
|
||||
saved, err := h.Setting.Public()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
||||
return
|
||||
}
|
||||
adapter, err := h.Setting.TimelineGitAdapterJSON()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取提交导入配置失败"})
|
||||
return
|
||||
}
|
||||
out := settingsPayload(saved)
|
||||
out["timeline_git_import"] = adapter
|
||||
c.JSON(http.StatusOK, out)
|
||||
c.JSON(http.StatusOK, settingsPayload(saved))
|
||||
}
|
||||
|
||||
// PUT /api/admin/settings
|
||||
@@ -142,6 +136,10 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.AllowRegister != nil || req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil || req.AttachmentMaxCount != nil || req.ImageMaxMB != nil {
|
||||
c.JSON(400, gin.H{"error": "请在访问与安全或文件与存储页通过版本校验保存这些设置"})
|
||||
return
|
||||
}
|
||||
if req.Accent != nil {
|
||||
if err := h.Setting.SetAccent(*req.Accent); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidAccent) {
|
||||
@@ -190,6 +188,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.CommentsRequireLogin != nil {
|
||||
if err := h.Setting.SetCommentsRequireLogin(*req.CommentsRequireLogin); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.AllowMessages != nil {
|
||||
if err := h.Setting.SetAllowMessages(*req.AllowMessages); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
@@ -419,16 +423,6 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
var savedAdapter string
|
||||
if req.TimelineGitImport != nil {
|
||||
normalized, err := h.Setting.SetTimelineGitAdapterJSON(*req.TimelineGitImport)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
savedAdapter = normalized
|
||||
}
|
||||
|
||||
saved, err := h.Setting.Public()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
|
||||
@@ -442,9 +436,6 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
})
|
||||
out := settingsPayload(saved)
|
||||
out["ok"] = true
|
||||
if req.TimelineGitImport != nil {
|
||||
out["timeline_git_import"] = savedAdapter
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,21 @@ func (h *Handlers) UserComments(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if requireLogin, err := h.Setting.CommentsRequireLogin(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
|
||||
return
|
||||
} else if requireLogin && middleware.CurrentUser(c) == nil {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"comments": []any{},
|
||||
"total": 0,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"require_login": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
|
||||
@@ -13,6 +13,7 @@ func RateLimitMiddleware(rl *service.RateLimiter, rateType string) gin.HandlerFu
|
||||
return func(c *gin.Context) {
|
||||
key := rateType + ":" + c.ClientIP()
|
||||
if !rl.Allow(key) {
|
||||
c.Header("Retry-After", "60")
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"})
|
||||
return
|
||||
}
|
||||
@@ -30,6 +31,7 @@ func RateLimitUserMiddleware(rl *service.RateLimiter, rateType string) gin.Handl
|
||||
}
|
||||
key := rateType + ":u:" + id
|
||||
if !rl.Allow(key) {
|
||||
c.Header("Retry-After", "60")
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "操作过于频繁,请稍后再试"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ func InitDB(dsn string) error {
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
|
||||
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
|
||||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||
|
||||
@@ -288,6 +288,7 @@ type PostContentUnlock struct {
|
||||
|
||||
// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
|
||||
type PostAttachment struct {
|
||||
ObjectID string `gorm:"size:64;index" json:"-"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
|
||||
66
backend/model/operations.go
Normal file
66
backend/model/operations.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ModuleConfig is versioned independently; secrets are encrypted by the service.
|
||||
type ModuleConfig struct {
|
||||
Name string `gorm:"primaryKey;size:32" json:"name"`
|
||||
Version int64 `json:"version"`
|
||||
Data string `gorm:"type:text" json:"-"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SettingsAudit struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ActorID uint `json:"actor_id"`
|
||||
Module string `gorm:"size:32" json:"module"`
|
||||
Action string `gorm:"size:64" json:"action"`
|
||||
Fields string `gorm:"type:text" json:"fields"`
|
||||
Result string `gorm:"type:text" json:"result"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
|
||||
type ActionCounter struct {
|
||||
Key string `gorm:"primaryKey;size:64"`
|
||||
Count int
|
||||
ExpiresAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
// MailTask retains no plaintext credentials, codes or links.
|
||||
type MailTask struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Dedupe string `gorm:"uniqueIndex;size:64" json:"-"`
|
||||
Kind string `gorm:"size:32" json:"kind"`
|
||||
Recipient string `gorm:"size:256" json:"recipient"`
|
||||
Payload string `gorm:"type:text" json:"-"`
|
||||
Status string `gorm:"index;size:32" json:"status"`
|
||||
Attempts int `json:"attempts"`
|
||||
Summary string `gorm:"size:128" json:"summary"`
|
||||
NextAt time.Time `gorm:"index" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type EmailChallenge struct {
|
||||
Hash string `gorm:"primaryKey;size:64"`
|
||||
Email string `gorm:"index;size:256"`
|
||||
Purpose string `gorm:"size:32"`
|
||||
ExpiresAt time.Time `gorm:"index"`
|
||||
Used bool
|
||||
}
|
||||
|
||||
// Every remote object pins the immutable configuration revision that wrote it.
|
||||
type StoredObject struct {
|
||||
ID string `gorm:"primaryKey;size:64" json:"id"`
|
||||
ConfigName string `gorm:"size:64;index" json:"-"`
|
||||
Key string `gorm:"size:512" json:"-"`
|
||||
MIME string `gorm:"size:128" json:"-"`
|
||||
Public bool `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type TemporaryUpload struct {
|
||||
ID string `gorm:"primaryKey;size:64"`
|
||||
RelativePath string `gorm:"size:512"`
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/config"
|
||||
@@ -23,6 +26,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
var proxies []string
|
||||
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
|
||||
proxies = strings.Split(v, ",")
|
||||
}
|
||||
if err := r.SetTrustedProxies(proxies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 大附件落盘到临时文件,避免 multipart 整文件进内存(默认 32MiB)
|
||||
r.MaxMultipartMemory = 4 << 20
|
||||
r.Use(gin.Recovery())
|
||||
@@ -44,6 +54,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
|
||||
boardSvc := service.NewBoardService(model.DB)
|
||||
settingSvc := service.NewSettingService(model.DB)
|
||||
ops := service.NewOperations(model.DB, cfg)
|
||||
go ops.Run(context.Background())
|
||||
postSvc := service.NewPostService(model.DB).WithSetting(settingSvc).WithDevMode(cfg.DevMode)
|
||||
commentSvc := service.NewCommentService(model.DB)
|
||||
likeSvc := service.NewLikeService(model.DB)
|
||||
@@ -54,6 +66,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
sitePageSvc := service.NewSitePageService(model.DB)
|
||||
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads")).WithSetting(settingSvc)
|
||||
postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private")).WithSetting(settingSvc)
|
||||
uploadSvc.WithOperations(ops)
|
||||
postFileSvc.WithOperations(ops)
|
||||
pointsSvc := service.NewPointsService(model.DB)
|
||||
adminUserSvc := service.NewAdminUserService(model.DB)
|
||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||
@@ -69,6 +83,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
limiter := service.DefaultRateLimiter()
|
||||
|
||||
h := &handler.Handlers{
|
||||
Ops: ops,
|
||||
Cfg: cfg,
|
||||
Hub: realtime.NewHub(),
|
||||
Auth: authSvc,
|
||||
@@ -105,6 +120,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
service.StartLikeNotifyFlusher(notifSvc)
|
||||
|
||||
authMW := middleware.NewAuthMiddleware(authSvc)
|
||||
r.Use(authMW.OptionalAuth(), h.RuntimeGuard, h.BusinessQuota)
|
||||
|
||||
// 上传文件静态服务(data/uploads → /uploads)
|
||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||
@@ -134,6 +150,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
pubAPI.GET("/pages", h.SitePagesList)
|
||||
pubAPI.GET("/pages/:slug", h.SitePageDetail)
|
||||
pubAPI.GET("/settings", h.PublicSettings)
|
||||
pubAPI.GET("/site-state", h.SiteState)
|
||||
pubAPI.GET("/media/:object", h.PublicObject)
|
||||
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
|
||||
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
|
||||
pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), h.TelemetryPageView)
|
||||
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
|
||||
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
|
||||
@@ -264,8 +284,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
|
||||
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
|
||||
|
||||
// 站点外观设置(超级管理员/站长);含 timeline_git_import
|
||||
// 站点设置(超级管理员/站长)
|
||||
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
||||
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
|
||||
opsAPI.GET("/:module", h.ReadModule)
|
||||
opsAPI.PUT("/:module", h.SaveModule)
|
||||
opsAPI.POST("/:module/test", h.TestModule)
|
||||
opsAPI.GET("/:module/records", h.ModuleRecords)
|
||||
staffAPI.GET("/diagnostics", authMW.RequirePerm(service.PermSettings), h.Diagnostics)
|
||||
staffAPI.POST("/maintenance/actions", authMW.RequirePerm(service.PermSettings), h.MaintenanceAction)
|
||||
staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings)
|
||||
staffAPI.POST("/upload/background", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackground)
|
||||
staffAPI.POST("/upload/background/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackgroundFromMedia)
|
||||
|
||||
@@ -86,6 +86,9 @@ var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
|
||||
|
||||
// Register 用户注册
|
||||
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
|
||||
if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 检查用户名是否已存在
|
||||
var count int64
|
||||
s.db.Model(&model.User{}).Where("username = ?", username).Count(&count)
|
||||
@@ -559,6 +562,9 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
|
||||
// - email 可为空;非空时需符合邮箱格式且不与他人重复
|
||||
// - signature 不超过 255 字符
|
||||
func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature string) (*model.User, error) {
|
||||
if err := NewOperations(s.db, nil).Filter("username", nickname, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nickname = strings.TrimSpace(nickname)
|
||||
if nickname == "" {
|
||||
return nil, errors.New("昵称不能为空")
|
||||
|
||||
@@ -28,6 +28,10 @@ const (
|
||||
BrandMarkImage = "image"
|
||||
BrandMarkText = "text"
|
||||
|
||||
FooterLinkAlignLeft = "left"
|
||||
FooterLinkAlignCenter = "center"
|
||||
FooterLinkAlignRight = "right"
|
||||
|
||||
BrandLogoSizeSQ = "sq"
|
||||
BrandLogoSize2x1 = "2x1"
|
||||
BrandLogoSize3x1 = "3x1"
|
||||
@@ -53,6 +57,7 @@ type FooterLink struct {
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
NewTab bool `json:"new_tab"`
|
||||
Align string `json:"align"`
|
||||
}
|
||||
|
||||
// 仅接受本站品牌目录;Logo 不含 ico,Favicon 含 ico
|
||||
@@ -219,6 +224,19 @@ type footerLinkIn struct {
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
NewTab *bool `json:"new_tab"`
|
||||
Align string `json:"align"`
|
||||
}
|
||||
|
||||
// NormalizeFooterLinkAlign 单条页脚链接所属栏位;空串视为居右。
|
||||
func NormalizeFooterLinkAlign(raw string) (string, bool) {
|
||||
switch strings.TrimSpace(raw) {
|
||||
case "", FooterLinkAlignRight:
|
||||
return FooterLinkAlignRight, true
|
||||
case FooterLinkAlignLeft, FooterLinkAlignCenter:
|
||||
return strings.TrimSpace(raw), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeFooterLinks 校验并截断页脚链接;外链未显式指定时默认新标签
|
||||
@@ -236,7 +254,11 @@ func NormalizeFooterLinks(raw []FooterLink) ([]FooterLink, error) {
|
||||
if !ok {
|
||||
return nil, ErrInvalidSiteSetting
|
||||
}
|
||||
out = append(out, FooterLink{Label: label, URL: u, NewTab: item.NewTab})
|
||||
align, ok := NormalizeFooterLinkAlign(item.Align)
|
||||
if !ok {
|
||||
return nil, ErrInvalidSiteSetting
|
||||
}
|
||||
out = append(out, FooterLink{Label: label, URL: u, NewTab: item.NewTab, Align: align})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -266,7 +288,11 @@ func parseFooterLinksJSON(raw string) ([]FooterLink, error) {
|
||||
} else if isExternalFooterURL(u) {
|
||||
newTab = true
|
||||
}
|
||||
out = append(out, FooterLink{Label: label, URL: u, NewTab: newTab})
|
||||
align, ok := NormalizeFooterLinkAlign(item.Align)
|
||||
if !ok {
|
||||
align = FooterLinkAlignRight
|
||||
}
|
||||
out = append(out, FooterLink{Label: label, URL: u, NewTab: newTab, Align: align})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -467,6 +493,8 @@ func (s *SettingService) SetFooterLinks(raw []FooterLink) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 去掉曾用的站级对齐键(现改为逐条 align)
|
||||
_ = s.deleteKey("footer_links_align")
|
||||
if len(list) == 0 {
|
||||
return s.deleteKey(SettingKeyFooterLinks)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
)
|
||||
@@ -134,6 +136,16 @@ func (s *UploadService) CopyBrandFromMedia(userID, attachmentID uint, slot strin
|
||||
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
||||
return "", errors.New("图片不存在或不属于你")
|
||||
}
|
||||
if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
defer r.Close()
|
||||
return s.SaveBrand(slot, r)
|
||||
}
|
||||
abs, ok := s.safeUploadPath(att.URL)
|
||||
if !ok {
|
||||
return "", errors.New("无效的图片地址")
|
||||
|
||||
@@ -60,10 +60,10 @@ func TestNormalizeKeywords(t *testing.T) {
|
||||
|
||||
func TestNormalizeFooterLinks(t *testing.T) {
|
||||
got, err := NormalizeFooterLinks([]FooterLink{
|
||||
{Label: "备案", URL: "https://beian.miit.gov.cn/", NewTab: true},
|
||||
{Label: "关于", URL: "/about", NewTab: false},
|
||||
{Label: "备案", URL: "https://beian.miit.gov.cn/", NewTab: true, Align: FooterLinkAlignRight},
|
||||
{Label: "关于", URL: "/about", NewTab: false, Align: FooterLinkAlignLeft},
|
||||
})
|
||||
if err != nil || len(got) != 2 || got[1].URL != "/about" {
|
||||
if err != nil || len(got) != 2 || got[1].URL != "/about" || got[1].Align != FooterLinkAlignLeft {
|
||||
t.Fatalf("got %+v err=%v", got, err)
|
||||
}
|
||||
bad := []FooterLink{
|
||||
@@ -71,6 +71,7 @@ func TestNormalizeFooterLinks(t *testing.T) {
|
||||
{Label: "x", URL: "//evil.test"},
|
||||
{Label: "", URL: "/a"},
|
||||
{Label: "x", URL: "ftp://files.test/a"},
|
||||
{Label: "x", URL: "/ok", Align: "middle"},
|
||||
}
|
||||
for _, item := range bad {
|
||||
if _, err := NormalizeFooterLinks([]FooterLink{item}); err == nil {
|
||||
@@ -79,8 +80,28 @@ func TestNormalizeFooterLinks(t *testing.T) {
|
||||
}
|
||||
raw := `[{"label":"ICP","url":"https://beian.miit.gov.cn/"}]`
|
||||
parsed, err := parseFooterLinksJSON(raw)
|
||||
if err != nil || len(parsed) != 1 || !parsed[0].NewTab {
|
||||
t.Fatalf("external default new tab, got %+v err=%v", parsed, err)
|
||||
if err != nil || len(parsed) != 1 || !parsed[0].NewTab || parsed[0].Align != FooterLinkAlignRight {
|
||||
t.Fatalf("external default new tab + right align, got %+v err=%v", parsed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFooterLinkAlign(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"", FooterLinkAlignRight, true},
|
||||
{" right ", FooterLinkAlignRight, true},
|
||||
{"left", FooterLinkAlignLeft, true},
|
||||
{"center", FooterLinkAlignCenter, true},
|
||||
{"middle", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := NormalizeFooterLinkAlign(tc.in)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Fatalf("in=%q got=%q ok=%v want=%q/%v", tc.in, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -298,6 +298,9 @@ func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Acto
|
||||
// pending 评论不计入 comment_count,审核通过时才 +1。
|
||||
// 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人)
|
||||
func (s *CommentService) Create(userID, postID uint, content string, parentID *uint, status string) (*model.Comment, *model.Comment, error) {
|
||||
if err := NewOperations(s.db, nil).Filter("comment", content, userID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil, nil, errors.New("评论内容不能为空")
|
||||
@@ -600,6 +603,9 @@ func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error {
|
||||
|
||||
// Update 编辑评论(作者本人或版主;已软删不可改);先写入旧正文快照再更新。
|
||||
func (s *CommentService) Update(actor *Actor, commentID, userID uint, content string) (*CommentNode, error) {
|
||||
if err := NewOperations(s.db, nil).Filter("comment", content, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil, errors.New("评论内容不能为空")
|
||||
|
||||
579
backend/service/operations.go
Normal file
579
backend/service/operations.go
Normal file
@@ -0,0 +1,579 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/freefire/jiang13-bbs/config"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"io"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SecurityConfig struct {
|
||||
AllowRegister bool `json:"allow_register"`
|
||||
RegisterNotice string `json:"register_notice"`
|
||||
VerifyEmail bool `json:"verify_email"`
|
||||
PasswordReset bool `json:"password_reset"`
|
||||
LoginWindow int `json:"login_window"`
|
||||
LoginFailures int `json:"login_failures"`
|
||||
PostInterval int `json:"post_interval"`
|
||||
CommentInterval int `json:"comment_interval"`
|
||||
ResendInterval int `json:"resend_interval"`
|
||||
EmailHourly int `json:"email_hourly"`
|
||||
SearchMinute int `json:"search_minute"`
|
||||
}
|
||||
type MailConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
TLS string `json:"tls"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
FromName string `json:"from_name"`
|
||||
From string `json:"from"`
|
||||
ReplyTo string `json:"reply_to"`
|
||||
Timeout int `json:"timeout"`
|
||||
Retention int `json:"retention"`
|
||||
SubjectTemplate string `json:"subject_template"`
|
||||
BodyTemplate string `json:"body_template"`
|
||||
}
|
||||
type StorageConfig struct {
|
||||
Backend string `json:"backend"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Prefix string `json:"prefix"`
|
||||
CDN string `json:"cdn"`
|
||||
PathStyle bool `json:"path_style"`
|
||||
ImageMaxMB int `json:"image_max_mb"`
|
||||
AttachmentMaxMB int `json:"attachment_max_mb"`
|
||||
AttachmentMaxCount int `json:"attachment_max_count"`
|
||||
AttachmentExtLimit bool `json:"attachment_ext_limit"`
|
||||
AttachmentExts []string `json:"attachment_exts"`
|
||||
}
|
||||
type FilterRule struct {
|
||||
ID string `json:"id"`
|
||||
Word string `json:"word"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Action string `json:"action"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Exceptions []string `json:"exceptions"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
type FilterConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Rules []FilterRule `json:"rules"`
|
||||
}
|
||||
type MaintenanceConfig struct {
|
||||
Mode string `json:"mode"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Until string `json:"until"`
|
||||
RetryAfter int `json:"retry_after"`
|
||||
Contact string `json:"contact"`
|
||||
TempDays int `json:"temp_days"`
|
||||
}
|
||||
type Operations struct {
|
||||
smtpRoots *x509.CertPool
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
Started time.Time
|
||||
}
|
||||
|
||||
var ErrConfigConflict = errors.New("配置已被其他管理员更新,请重新加载后合并修改")
|
||||
|
||||
func NewOperations(db *gorm.DB, cfg *config.Config) *Operations {
|
||||
return &Operations{db: db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}), cfg: cfg, Started: time.Now()}
|
||||
}
|
||||
func defaultModule(name string) any {
|
||||
switch name {
|
||||
case "security":
|
||||
return &SecurityConfig{AllowRegister: true, LoginWindow: 600, LoginFailures: 5, PostInterval: 6, CommentInterval: 2, ResendInterval: 60, EmailHourly: 5, SearchMinute: 30}
|
||||
case "mail":
|
||||
return &MailConfig{Port: 465, TLS: "tls", Timeout: 10, Retention: 30, SubjectTemplate: defaultMailSubject, BodyTemplate: defaultMailBody}
|
||||
case "storage":
|
||||
return &StorageConfig{Backend: "local", Region: "us-east-1", Prefix: "jiang13/uploads/", PathStyle: true, ImageMaxMB: 5, AttachmentMaxMB: 20, AttachmentMaxCount: 10, AttachmentExtLimit: true, AttachmentExts: DefaultAttachmentExts}
|
||||
case "filter":
|
||||
return &FilterConfig{Rules: []FilterRule{}}
|
||||
case "maintenance":
|
||||
return &MaintenanceConfig{Mode: "normal", Title: "站点维护中", RetryAfter: 300, TempDays: 7}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (o *Operations) read(db *gorm.DB, name string) (any, int64, error) {
|
||||
v := defaultModule(name)
|
||||
if v == nil {
|
||||
return nil, 0, errors.New("未知配置模块")
|
||||
}
|
||||
var row model.ModuleConfig
|
||||
err := db.First(&row, "name = ?", name).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err == nil {
|
||||
if err = json.Unmarshal([]byte(row.Data), v); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
st := NewSettingService(db)
|
||||
if s, ok := v.(*SecurityConfig); ok {
|
||||
s.AllowRegister, err = st.AllowRegister()
|
||||
}
|
||||
if s, ok := v.(*StorageConfig); ok {
|
||||
s.ImageMaxMB, err = st.ImageMaxMB()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
s.AttachmentMaxMB, err = st.AttachmentMaxMB()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
s.AttachmentMaxCount, err = st.AttachmentMaxCount()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
s.AttachmentExtLimit, err = st.AttachmentExtLimit()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
s.AttachmentExts, err = st.AttachmentExts()
|
||||
}
|
||||
if m, ok := v.(*MailConfig); ok {
|
||||
m.applyTemplateDefaults()
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
err = nil
|
||||
}
|
||||
return v, row.Version, err
|
||||
}
|
||||
func (o *Operations) Security() (SecurityConfig, error) {
|
||||
v, _, e := o.read(o.db, "security")
|
||||
if e != nil {
|
||||
return SecurityConfig{}, e
|
||||
}
|
||||
return *v.(*SecurityConfig), nil
|
||||
}
|
||||
func (o *Operations) Maintenance() (MaintenanceConfig, error) {
|
||||
v, _, e := o.read(o.db, "maintenance")
|
||||
if e != nil {
|
||||
return MaintenanceConfig{}, e
|
||||
}
|
||||
m := *v.(*MaintenanceConfig)
|
||||
if os.Getenv("MAINTENANCE_RECOVERY") == "1" {
|
||||
m.Mode = "normal"
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
func (o *Operations) Read(name string) (map[string]any, error) {
|
||||
v, version, e := o.read(o.db, name)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
var data map[string]any
|
||||
_ = json.Unmarshal(b, &data)
|
||||
for _, k := range secretFields(name) {
|
||||
data[k+"_configured"] = data[k] != ""
|
||||
data[k] = ""
|
||||
}
|
||||
state := "已启用"
|
||||
if name == "mail" && !v.(*MailConfig).Enabled {
|
||||
state = "未配置"
|
||||
if v.(*MailConfig).Host != "" {
|
||||
state = "未启用"
|
||||
}
|
||||
}
|
||||
if name == "filter" && !v.(*FilterConfig).Enabled {
|
||||
state = "未启用"
|
||||
}
|
||||
if name == "storage" {
|
||||
state = v.(*StorageConfig).Backend
|
||||
}
|
||||
if name == "maintenance" {
|
||||
state = v.(*MaintenanceConfig).Mode
|
||||
}
|
||||
out := map[string]any{"data": data, "version": version, "state": state, "effective": "保存后立即生效", "site_url": o.cfg.SiteURL, "local_directory": o.cfg.DataDir, "encryption_ready": o.keyReady(), "recovery_override": os.Getenv("MAINTENANCE_RECOVERY") == "1"}
|
||||
if name == "mail" {
|
||||
out["template_preview"] = o.MailPreview("示例验证码").Body
|
||||
out["reset_preview"] = o.MailPreview("示例验证码", "reset").Body
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func secretFields(name string) []string {
|
||||
switch name {
|
||||
case "mail":
|
||||
return []string{"password"}
|
||||
case "storage":
|
||||
return []string{"access_key", "secret_key"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (o *Operations) masterKey() string {
|
||||
if o.cfg != nil {
|
||||
if v := strings.TrimSpace(o.cfg.SettingsMasterKey); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("SETTINGS_MASTER_KEY"))
|
||||
}
|
||||
func (o *Operations) aead() (cipher.AEAD, error) {
|
||||
k, e := base64.StdEncoding.DecodeString(o.masterKey())
|
||||
if e != nil || len(k) != 32 {
|
||||
return nil, errors.New("请在 app.ini 的 [security] 或环境变量 SETTINGS_MASTER_KEY 配置 32 字节标准 Base64 主密钥后再保存凭据")
|
||||
}
|
||||
b, e := aes.NewCipher(k)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return cipher.NewGCM(b)
|
||||
}
|
||||
func (o *Operations) keyReady() bool { _, e := o.aead(); return e == nil }
|
||||
func (o *Operations) seal(s, domain string) (string, error) {
|
||||
a, e := o.aead()
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
n := make([]byte, a.NonceSize())
|
||||
if _, e = rand.Read(n); e != nil {
|
||||
return "", e
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(a.Seal(n, n, []byte(s), []byte(domain))), nil
|
||||
}
|
||||
func (o *Operations) open(s, domain string) (string, error) {
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
a, e := o.aead()
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
b, e := base64.StdEncoding.DecodeString(s)
|
||||
if e != nil || len(b) < a.NonceSize() {
|
||||
return "", errors.New("凭据密文无效")
|
||||
}
|
||||
p, e := a.Open(nil, b[:a.NonceSize()], b[a.NonceSize():], []byte(domain))
|
||||
if e != nil {
|
||||
return "", errors.New("凭据解密失败,请核对部署主密钥")
|
||||
}
|
||||
return string(p), nil
|
||||
}
|
||||
func (o *Operations) draft(db *gorm.DB, name string, raw json.RawMessage, clear []string) (any, int64, []string, error) {
|
||||
old, version, e := o.read(db, name)
|
||||
if e != nil {
|
||||
return nil, 0, nil, e
|
||||
}
|
||||
b, _ := json.Marshal(old)
|
||||
var previous map[string]any
|
||||
_ = json.Unmarshal(b, &previous)
|
||||
var input map[string]any
|
||||
if e = json.Unmarshal(raw, &input); e != nil {
|
||||
return nil, 0, nil, errors.New("表单格式无效")
|
||||
}
|
||||
for k := range input {
|
||||
if strings.HasSuffix(k, "_configured") {
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
if _, ok := previous[k]; !ok {
|
||||
return nil, 0, nil, fmt.Errorf("未知字段:%s", k)
|
||||
}
|
||||
}
|
||||
fields := []string{}
|
||||
for k, val := range input {
|
||||
if !reflect.DeepEqual(previous[k], val) {
|
||||
fields = append(fields, k)
|
||||
}
|
||||
}
|
||||
for _, key := range secretFields(name) {
|
||||
value, _ := input[key].(string)
|
||||
input[key] = previous[key]
|
||||
if value != "" {
|
||||
if strings.Contains(value, "***") || strings.Contains(value, "••") {
|
||||
return nil, 0, nil, fmt.Errorf("%s:不能保存脱敏占位符", key)
|
||||
}
|
||||
enc, err := o.seal(value, name+":"+key)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
input[key] = enc
|
||||
}
|
||||
for _, c := range clear {
|
||||
if c == key {
|
||||
input[key] = ""
|
||||
fields = append(fields, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, v := range input {
|
||||
previous[k] = v
|
||||
}
|
||||
b, _ = json.Marshal(previous)
|
||||
v := defaultModule(name)
|
||||
d := json.NewDecoder(bytes.NewReader(b))
|
||||
d.DisallowUnknownFields()
|
||||
if e = d.Decode(v); e != nil {
|
||||
return nil, 0, nil, errors.New("字段类型无效")
|
||||
}
|
||||
if e = o.validate(db, name, v); e != nil {
|
||||
return nil, 0, nil, e
|
||||
}
|
||||
return v, version, fields, nil
|
||||
}
|
||||
func validEmail(s string) bool {
|
||||
a, e := mail.ParseAddress(s)
|
||||
return e == nil && a.Address == s && !strings.ContainsAny(s, "\r\n")
|
||||
}
|
||||
func bounded(n, lo, hi int) bool { return n >= lo && n <= hi }
|
||||
func (o *Operations) validate(db *gorm.DB, name string, v any) error {
|
||||
bad := func(field, msg string) error { return fmt.Errorf("%s:%s", field, msg) }
|
||||
switch c := v.(type) {
|
||||
case *SecurityConfig:
|
||||
if len([]rune(c.RegisterNotice)) > 200 {
|
||||
return bad("register_notice", "最多 200 字")
|
||||
}
|
||||
for _, n := range []struct {
|
||||
k string
|
||||
n, lo, hi int
|
||||
}{{"login_window", c.LoginWindow, 60, 3600}, {"login_failures", c.LoginFailures, 3, 50}, {"post_interval", c.PostInterval, 0, 3600}, {"comment_interval", c.CommentInterval, 0, 3600}, {"resend_interval", c.ResendInterval, 30, 3600}, {"email_hourly", c.EmailHourly, 1, 20}, {"search_minute", c.SearchMinute, 1, 120}} {
|
||||
if !bounded(n.n, n.lo, n.hi) {
|
||||
return bad(n.k, fmt.Sprintf("范围 %d–%d", n.lo, n.hi))
|
||||
}
|
||||
}
|
||||
if c.VerifyEmail || c.PasswordReset {
|
||||
m, _, e := o.read(db, "mail")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !m.(*MailConfig).Enabled {
|
||||
return bad("verify_email", "须先启用邮件服务")
|
||||
}
|
||||
if e = o.validate(db, "mail", m); e != nil {
|
||||
return e
|
||||
}
|
||||
origin, originErr := url.Parse(o.cfg.SiteURL)
|
||||
if originErr != nil || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || strings.Trim(origin.Path, "/") != "" || (origin.Scheme != "https" && !(o.cfg.DevMode && origin.Scheme == "http")) {
|
||||
return bad("verify_email", "部署 SITE_URL 后才能开启账号邮件")
|
||||
}
|
||||
}
|
||||
case *MailConfig:
|
||||
c.applyTemplateDefaults()
|
||||
if strings.ContainsAny(c.SubjectTemplate, "\r\n") || len([]rune(c.SubjectTemplate)) > 200 {
|
||||
return bad("subject_template", "主题最多 200 字,且不能换行")
|
||||
}
|
||||
if len(c.BodyTemplate) > 200000 {
|
||||
return bad("body_template", "正文最多 200000 字节")
|
||||
}
|
||||
if !strings.Contains(c.BodyTemplate, "{{code}}") {
|
||||
return bad("body_template", "正文必须包含 {{code}},否则用户收不到验证码")
|
||||
}
|
||||
if !bounded(c.Port, 1, 65535) || !bounded(c.Timeout, 2, 30) || !bounded(c.Retention, 1, 365) {
|
||||
return bad("port", "端口 1–65535;超时 2–30 秒;记录保留 1–365 天")
|
||||
}
|
||||
if c.TLS != "tls" && c.TLS != "starttls" {
|
||||
return bad("tls", "仅支持 TLS 或必需 STARTTLS")
|
||||
}
|
||||
if strings.ContainsAny(c.Host, "/:\\ \r\n") || len(c.Host) > 253 {
|
||||
return bad("host", "请输入主机名")
|
||||
}
|
||||
if strings.ContainsAny(c.FromName+c.Username, "\r\n") || len(c.FromName) > 120 || len(c.Username) > 256 {
|
||||
return bad("from_name", "格式无效")
|
||||
}
|
||||
if c.Enabled {
|
||||
if c.Host == "" {
|
||||
return bad("host", "启用前填写 SMTP 主机")
|
||||
}
|
||||
if c.Username == "" {
|
||||
return bad("username", "启用前填写登录用户名")
|
||||
}
|
||||
if c.Password == "" {
|
||||
return bad("password", "启用前填写密码或授权码。要继续使用已保存的授权码,请不要移除它")
|
||||
}
|
||||
if !validEmail(c.From) {
|
||||
return bad("from", "启用前填写有效的发件邮箱")
|
||||
}
|
||||
if c.ReplyTo != "" && !validEmail(c.ReplyTo) {
|
||||
return bad("reply_to", "邮箱格式无效")
|
||||
}
|
||||
if _, e := o.open(c.Password, "mail:password"); e != nil {
|
||||
return e
|
||||
}
|
||||
} else {
|
||||
s, _, e := o.read(db, "security")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
x := s.(*SecurityConfig)
|
||||
if x.VerifyEmail || x.PasswordReset {
|
||||
return bad("enabled", "注册验证或密码找回正在使用邮件,请先关闭依赖")
|
||||
}
|
||||
}
|
||||
case *StorageConfig:
|
||||
if c.Backend != "local" && c.Backend != "s3" {
|
||||
return bad("backend", "存储类型无效")
|
||||
}
|
||||
if c.Backend == "s3" {
|
||||
u, e := url.Parse(c.Endpoint)
|
||||
if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.Trim(u.Path, "/") != "" || (u.Scheme != "https" && !(o.cfg.DevMode && u.Scheme == "http")) {
|
||||
return bad("endpoint", "须为 HTTPS origin(开发可 HTTP)")
|
||||
}
|
||||
if !regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`).MatchString(c.Bucket) || strings.Contains(c.Bucket, "..") {
|
||||
return bad("bucket", "桶名称无效")
|
||||
}
|
||||
if !regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`).MatchString(c.Region) {
|
||||
return bad("region", "区域无效")
|
||||
}
|
||||
if c.AccessKey == "" {
|
||||
return bad("access_key", "启用 S3 前填写 Access Key。要继续使用已保存的值,请不要移除它")
|
||||
}
|
||||
if c.SecretKey == "" {
|
||||
return bad("secret_key", "启用 S3 前填写 Secret Key。要继续使用已保存的值,请不要移除它")
|
||||
}
|
||||
}
|
||||
if !regexp.MustCompile(`^[a-zA-Z0-9_/-]{1,100}/$`).MatchString(c.Prefix) || strings.HasPrefix(c.Prefix, "/") || strings.Contains(c.Prefix, "//") {
|
||||
return bad("prefix", "必须为应用专用的相对目录并以 / 结束")
|
||||
}
|
||||
if c.CDN != "" {
|
||||
u, e := url.Parse(c.CDN)
|
||||
if e != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return bad("cdn", "须为 HTTPS 地址")
|
||||
}
|
||||
}
|
||||
if !bounded(c.ImageMaxMB, 1, 50) || !bounded(c.AttachmentMaxMB, 1, 100) || !bounded(c.AttachmentMaxCount, 1, 20) {
|
||||
return bad("image_max_mb", "图片 1–50 MiB,附件 1–100 MiB,数量 1–20")
|
||||
}
|
||||
if len(c.AttachmentExts) == 0 || len(c.AttachmentExts) > 80 {
|
||||
return bad("attachment_exts", "需要 1–80 个扩展名")
|
||||
}
|
||||
for _, x := range c.AttachmentExts {
|
||||
if !attachmentExtRe.MatchString(x) {
|
||||
return bad("attachment_exts", "扩展名只含小写字母与数字")
|
||||
}
|
||||
}
|
||||
case *FilterConfig:
|
||||
if len(c.Rules) > 2000 {
|
||||
return bad("rules", "最多 2000 条规则")
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for i := range c.Rules {
|
||||
r := &c.Rules[i]
|
||||
r.Word = strings.TrimSpace(r.Word)
|
||||
if r.ID == "" || len(r.ID) > 64 || ids[r.ID] {
|
||||
return bad("rules", "规则 ID 无效或重复")
|
||||
}
|
||||
ids[r.ID] = true
|
||||
if len([]rune(r.Word)) < 1 || len([]rune(r.Word)) > 80 || len(r.Note) > 500 || len(r.Exceptions) > 20 {
|
||||
return bad("rules", "词语 1–80 字,例外最多 20 个")
|
||||
}
|
||||
if r.Action != "block" && r.Action != "log" {
|
||||
return bad("rules", "动作无效")
|
||||
}
|
||||
if len(r.Scopes) == 0 {
|
||||
return bad("rules", "请选择适用范围")
|
||||
}
|
||||
for _, s := range r.Scopes {
|
||||
if s != "username" && s != "title" && s != "body" && s != "comment" {
|
||||
return bad("rules", "范围无效")
|
||||
}
|
||||
}
|
||||
for _, s := range r.Exceptions {
|
||||
if len(s) > 240 || strings.TrimSpace(s) == "" {
|
||||
return bad("rules", "例外词无效")
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.Enabled && len(c.Rules) == 0 {
|
||||
return bad("enabled", "添加规则后才能启用")
|
||||
}
|
||||
case *MaintenanceConfig:
|
||||
if c.Mode != "normal" && c.Mode != "readonly" && c.Mode != "paused" {
|
||||
return bad("mode", "模式无效")
|
||||
}
|
||||
if !bounded(c.RetryAfter, 30, 86400) || !bounded(c.TempDays, 1, 365) {
|
||||
return bad("retry_after", "重试 30–86400 秒,临时保留 1–365 天")
|
||||
}
|
||||
if len(c.Title) > 240 || len(c.Message) > 4000 || len(c.Contact) > 500 {
|
||||
return bad("message", "文案过长")
|
||||
}
|
||||
if c.Until != "" {
|
||||
if _, e := time.Parse(time.RFC3339, c.Until); e != nil {
|
||||
return bad("until", "使用含时区的 ISO 时间")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (o *Operations) Save(name string, version int64, raw json.RawMessage, clear []string, actor uint) error {
|
||||
return o.db.Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Exec("SELECT pg_advisory_xact_lock(130013)").Error; e != nil {
|
||||
return e
|
||||
}
|
||||
v, current, fields, e := o.draft(tx, name, raw, clear)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if version != current {
|
||||
return ErrConfigConflict
|
||||
}
|
||||
st := NewSettingService(tx)
|
||||
if c, ok := v.(*SecurityConfig); ok {
|
||||
if e = st.SetAllowRegister(c.AllowRegister); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if c, ok := v.(*StorageConfig); ok {
|
||||
for _, f := range []func() error{func() error { return st.SetImageMaxMB(c.ImageMaxMB) }, func() error { return st.SetAttachmentMaxMB(c.AttachmentMaxMB) }, func() error { return st.SetAttachmentMaxCount(c.AttachmentMaxCount) }, func() error { return st.SetAttachmentExtLimit(c.AttachmentExtLimit) }, func() error { return st.SetAttachmentExts(c.AttachmentExts) }} {
|
||||
if e = f(); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
if e = tx.Save(&model.ModuleConfig{Name: name, Version: current + 1, Data: string(b)}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if name == "storage" {
|
||||
if e = tx.Create(&model.ModuleConfig{Name: fmt.Sprintf("storage-%d", current+1), Version: current + 1, Data: string(b)}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
f, _ := json.Marshal(fields)
|
||||
return tx.Create(&model.SettingsAudit{ActorID: actor, Module: name, Action: "save", Fields: string(f), Result: "已生效"}).Error
|
||||
})
|
||||
}
|
||||
func (o *Operations) Audit(actor uint, module, action, result string) {
|
||||
o.db.Create(&model.SettingsAudit{ActorID: actor, Module: module, Action: action, Fields: "[]", Result: result})
|
||||
}
|
||||
func (o *Operations) AuditRows(module string) ([]model.SettingsAudit, error) {
|
||||
var a []model.SettingsAudit
|
||||
switch module {
|
||||
case "security", "filter", "maintenance":
|
||||
e := o.db.Where("module = ?", module).Order("id desc").Limit(100).Find(&a).Error
|
||||
return a, e
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
func randomID() string {
|
||||
b := make([]byte, 24)
|
||||
if _, e := io.ReadFull(rand.Reader, b); e != nil {
|
||||
panic(e)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
159
backend/service/operations_filter.go
Normal file
159
backend/service/operations_filter.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"html"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type FilterHit struct {
|
||||
ID string `json:"id"`
|
||||
Word string `json:"word"`
|
||||
Scope string `json:"scope"`
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
Action string `json:"action"`
|
||||
Excepted bool `json:"excepted"`
|
||||
}
|
||||
type FilterResult struct {
|
||||
Result string `json:"result"`
|
||||
Text string `json:"text"`
|
||||
Hits []FilterHit `json:"hits"`
|
||||
Skipped []string `json:"skipped"`
|
||||
}
|
||||
|
||||
func normalizeFilter(s string) string {
|
||||
return strings.ToLower(norm.NFKC.String(html.UnescapeString(s)))
|
||||
}
|
||||
func readableMarkdown(s string) string {
|
||||
src := []byte(s)
|
||||
doc := goldmark.DefaultParser().Parse(text.NewReader(src))
|
||||
var b strings.Builder
|
||||
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
if n.Type() == ast.TypeBlock {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
switch n.Kind() {
|
||||
case ast.KindCodeBlock, ast.KindFencedCodeBlock, ast.KindCodeSpan, ast.KindHTMLBlock, ast.KindRawHTML, ast.KindAutoLink:
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
if t, ok := n.(*ast.Text); ok {
|
||||
b.Write(t.Segment.Value(src))
|
||||
if t.SoftLineBreak() || t.HardLineBreak() {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
if t, ok := n.(*ast.String); ok {
|
||||
b.Write(t.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
return b.String()
|
||||
}
|
||||
func MatchFilter(c FilterConfig, scope, input string) FilterResult {
|
||||
result := FilterResult{Result: "pass", Text: input, Hits: []FilterHit{}, Skipped: []string{}}
|
||||
if scope == "body" || scope == "comment" {
|
||||
result.Text = readableMarkdown(input)
|
||||
result.Skipped = []string{"代码块", "行内代码", "链接目标及自动链接", "HTML 标签/HTML 块"}
|
||||
}
|
||||
result.Text = normalizeFilter(result.Text)
|
||||
if !c.Enabled {
|
||||
return result
|
||||
}
|
||||
for _, r := range c.Rules {
|
||||
if !r.Enabled {
|
||||
continue
|
||||
}
|
||||
applies := false
|
||||
for _, s := range r.Scopes {
|
||||
if s == scope {
|
||||
applies = true
|
||||
}
|
||||
}
|
||||
if !applies {
|
||||
continue
|
||||
}
|
||||
word := normalizeFilter(r.Word)
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
for pos := 0; pos < len(result.Text); {
|
||||
i := strings.Index(result.Text[pos:], word)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
start := pos + i
|
||||
end := start + len(word)
|
||||
excepted := false
|
||||
for _, ex := range r.Exceptions {
|
||||
ex = normalizeFilter(ex)
|
||||
if ex == "" {
|
||||
continue
|
||||
}
|
||||
for off := 0; off < len(result.Text); {
|
||||
k := strings.Index(result.Text[off:], ex)
|
||||
if k < 0 {
|
||||
break
|
||||
}
|
||||
a := off + k
|
||||
if a <= start && a+len(ex) >= end {
|
||||
excepted = true
|
||||
}
|
||||
off = a + len(ex)
|
||||
}
|
||||
}
|
||||
result.Hits = append(result.Hits, FilterHit{ID: r.ID, Word: r.Word, Scope: scope, Start: utf8.RuneCountInString(result.Text[:start]), End: utf8.RuneCountInString(result.Text[:end]), Action: r.Action, Excepted: excepted})
|
||||
if !excepted {
|
||||
if r.Action == "block" {
|
||||
result.Result = "block"
|
||||
} else if result.Result == "pass" {
|
||||
result.Result = "log"
|
||||
}
|
||||
}
|
||||
pos = end
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func (o *Operations) Filter(scope, input string, actor uint) error {
|
||||
v, _, e := o.read(o.db, "filter")
|
||||
if e != nil {
|
||||
return errors.New("内容过滤暂不可用,请稍后重试")
|
||||
}
|
||||
r := MatchFilter(*v.(*FilterConfig), scope, input)
|
||||
if len(r.Hits) > 0 {
|
||||
ids := []string{}
|
||||
for _, h := range r.Hits {
|
||||
if !h.Excepted {
|
||||
ids = append(ids, h.ID)
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(ids)
|
||||
o.Audit(actor, "filter", scope+":"+r.Result, string(b))
|
||||
}
|
||||
if r.Result == "block" {
|
||||
return errors.New("内容未通过站点规则,请修改后重试")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (o *Operations) TestFilter(raw json.RawMessage, scope, input string) (FilterResult, error) {
|
||||
if len(input) > 100000 {
|
||||
return FilterResult{}, errors.New("测试文本最多 100000 字节")
|
||||
}
|
||||
v, _, _, e := o.draft(o.db, "filter", raw, nil)
|
||||
if e != nil {
|
||||
return FilterResult{}, e
|
||||
}
|
||||
c := *v.(*FilterConfig)
|
||||
c.Enabled = true
|
||||
return MatchFilter(c, scope, input), nil
|
||||
}
|
||||
450
backend/service/operations_mail.go
Normal file
450
backend/service/operations_mail.go
Normal file
@@ -0,0 +1,450 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
stdhtml "html"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type mailPayload struct{ To, Subject, Body string }
|
||||
|
||||
func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id string) error {
|
||||
password, e := o.open(c.Password, "mail:password")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Duration(c.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
conn, e := safeDial(ctx, "tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer conn.Close()
|
||||
deadline, _ := ctx.Deadline()
|
||||
_ = conn.SetDeadline(deadline)
|
||||
tlsCfg := &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12, RootCAs: o.smtpRoots}
|
||||
if c.TLS == "tls" {
|
||||
t := tls.Client(conn, tlsCfg)
|
||||
if e = t.HandshakeContext(ctx); e != nil {
|
||||
return errors.New("TLS 协商或证书校验失败")
|
||||
}
|
||||
conn = t
|
||||
}
|
||||
client, e := smtp.NewClient(conn, c.Host)
|
||||
if e != nil {
|
||||
return errors.New("SMTP 握手失败")
|
||||
}
|
||||
defer client.Close()
|
||||
if c.TLS == "starttls" {
|
||||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||
return errors.New("服务器未提供必需 STARTTLS")
|
||||
}
|
||||
if e = client.StartTLS(tlsCfg); e != nil {
|
||||
return errors.New("TLS 协商或证书校验失败")
|
||||
}
|
||||
}
|
||||
if e = client.Auth(smtp.PlainAuth("", c.Username, password, c.Host)); e != nil {
|
||||
return errors.New("SMTP 认证失败")
|
||||
}
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if !validEmail(p.To) {
|
||||
return errors.New("测试收件邮箱无效")
|
||||
}
|
||||
if e = client.Mail(c.From); e != nil {
|
||||
return errors.New("服务器拒绝发件人")
|
||||
}
|
||||
if e = client.Rcpt(p.To); e != nil {
|
||||
return errors.New("服务器拒绝收件人")
|
||||
}
|
||||
w, e := client.Data()
|
||||
if e != nil {
|
||||
return errors.New("提交邮件失败")
|
||||
}
|
||||
if c.FromName == "" {
|
||||
st, e := NewSettingService(o.db).Public()
|
||||
if e == nil {
|
||||
c.FromName = st.SiteName
|
||||
}
|
||||
}
|
||||
from := (&mail.Address{Name: c.FromName, Address: c.From}).String()
|
||||
reply := c.ReplyTo
|
||||
if reply == "" {
|
||||
reply = c.From
|
||||
}
|
||||
// QQ / 网关常按 7bit 路径转发:HTML 必须用 quoted-printable,避免长行与中文破坏 style。
|
||||
var msg bytes.Buffer
|
||||
msg.WriteString("From: " + from + "\r\n")
|
||||
msg.WriteString("To: " + p.To + "\r\n")
|
||||
msg.WriteString("Reply-To: " + reply + "\r\n")
|
||||
msg.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", p.Subject) + "\r\n")
|
||||
msg.WriteString("Message-ID: <" + id + "@jiang13.local>\r\n")
|
||||
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
||||
msg.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||
if e = writeQuotedPrintableHTML(&msg, p.Body); e != nil {
|
||||
return errors.New("提交邮件失败")
|
||||
}
|
||||
if _, e = io.Copy(w, &msg); e != nil {
|
||||
return errors.New("提交邮件失败或超时")
|
||||
}
|
||||
if e = w.Close(); e != nil {
|
||||
return errors.New("服务器未确认接受邮件")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const defaultMailSubject = "{{site_name}} · {{purpose}}"
|
||||
|
||||
// 旧版内置正文:读取时若仍是此值,升级为新默认模板。
|
||||
const legacyMailBody = "{{logo}}<h1>{{site_name}} · {{purpose}}</h1><p>您的验证码:</p><p><strong>{{code}}</strong></p><p>15 分钟内有效。如果不是您本人操作,请忽略本邮件。</p><p><a href=\"{{link}}\">前往站点完成{{purpose}}</a>(请粘贴以上验证码)</p>"
|
||||
|
||||
const defaultMailBody = `<div class="email-wrap" style="margin:0;padding:32px 16px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#1e293b;">
|
||||
<style>
|
||||
.email-wrap{margin:0;padding:32px 16px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#1e293b;}
|
||||
.email-card{max-width:520px;margin:0 auto;background:#fff;border:1px solid #eef2f6;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,.06);overflow:hidden;padding:40px 36px 32px;}
|
||||
.email-logo,.email-wrap img{display:block;margin:0 auto 24px;max-width:180px;height:auto;}
|
||||
.email-title{margin:0 0 8px;font-size:22px;font-weight:650;color:#0f172a;text-align:center;letter-spacing:-.02em;}
|
||||
.email-lead{margin:0 0 24px;font-size:15px;color:#475569;text-align:center;}
|
||||
.email-code-box{margin:0 0 24px;padding:22px 16px;text-align:center;background:#f8fafc;border:1px solid #e2e8f0;border-radius:16px;}
|
||||
.email-code-label{margin:0 0 8px;font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#475569;}
|
||||
.email-code{margin:0;font-size:40px;font-weight:700;letter-spacing:.18em;line-height:1.2;color:#0f172a;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
|
||||
.email-hint{margin:0 0 20px;font-size:14px;color:#334155;text-align:center;}
|
||||
.email-btn-wrap{text-align:center;margin:0 0 18px;}
|
||||
.email-btn{display:inline-block;padding:14px 32px;border-radius:999px;background:#1e293b;color:#fff !important;text-decoration:none;font-size:15px;font-weight:600;}
|
||||
.email-link{margin:0 0 24px;font-size:12px;color:#475569;text-align:center;word-break:break-all;}
|
||||
.email-link a{color:#1d4ed8;}
|
||||
.email-hr{height:1px;margin:0 0 20px;background:#e2e8f0;border:0;}
|
||||
.email-foot{margin:0;font-size:13px;line-height:1.7;color:#475569;text-align:center;}
|
||||
.email-foot strong{color:#334155;font-weight:600;}
|
||||
@media (max-width:480px){.email-card{padding:28px 20px;border-radius:20px;}.email-code{font-size:32px;letter-spacing:.12em;}.email-btn{display:block;}}
|
||||
</style>
|
||||
<div class="email-card" style="max-width:520px;margin:0 auto;background:#ffffff;border:1px solid #eef2f6;border-radius:24px;padding:40px 36px 32px;">
|
||||
{{logo}}
|
||||
<h1 class="email-title" style="margin:0 0 8px;font-size:22px;font-weight:650;color:#0f172a;text-align:center;">{{purpose}}</h1>
|
||||
<p class="email-lead" style="margin:0 0 24px;font-size:15px;color:#475569;text-align:center;">您好,感谢使用 {{site_name}}。请使用下面的验证码完成{{purpose}}。</p>
|
||||
<div class="email-code-box" style="margin:0 0 24px;padding:22px 16px;text-align:center;background:#f8fafc;border:1px solid #e2e8f0;border-radius:16px;">
|
||||
<div class="email-code-label" style="margin:0 0 8px;font-size:12px;font-weight:600;letter-spacing:.08em;color:#475569;">您的验证码</div>
|
||||
<div class="email-code" style="margin:0;font-size:40px;font-weight:700;letter-spacing:.18em;color:#0f172a;font-family:ui-monospace,Menlo,Consolas,monospace;">{{code}}</div>
|
||||
</div>
|
||||
<p class="email-hint" style="margin:0 0 20px;font-size:14px;color:#334155;text-align:center;">验证码 15 分钟内有效。您也可以点击下方按钮前往站点继续操作。</p>
|
||||
<div class="email-btn-wrap" style="text-align:center;margin:0 0 18px;">
|
||||
<a class="email-btn" href="{{link}}" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:14px 32px;border-radius:999px;background:#1e293b;color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;">前往完成{{purpose}}</a>
|
||||
</div>
|
||||
<p class="email-link" style="margin:0 0 24px;font-size:12px;color:#475569;text-align:center;word-break:break-all;">若按钮无法点击,请复制链接:<br><a href="{{link}}" target="_blank" rel="noopener noreferrer" style="color:#1d4ed8;">{{link}}</a></p>
|
||||
<div class="email-hr" style="height:1px;margin:0 0 20px;background:#e2e8f0;"></div>
|
||||
<p class="email-foot" style="margin:0;font-size:13px;line-height:1.7;color:#475569;text-align:center;">本邮件由 <strong style="color:#334155;">{{site_name}}</strong> 系统发出,请勿直接回复。<br>如非本人操作,请忽略本邮件。<br><span style="color:#334155;">{{site_url}}</span></p>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
func (c *MailConfig) applyTemplateDefaults() {
|
||||
if strings.TrimSpace(c.SubjectTemplate) == "" {
|
||||
c.SubjectTemplate = defaultMailSubject
|
||||
}
|
||||
body := strings.TrimSpace(c.BodyTemplate)
|
||||
if body == "" || body == legacyMailBody {
|
||||
c.BodyTemplate = defaultMailBody
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Operations) MailPreview(code string, kinds ...string) mailPayload {
|
||||
kind := ""
|
||||
if len(kinds) > 0 {
|
||||
kind = kinds[0]
|
||||
}
|
||||
c := MailConfig{}
|
||||
if o.db != nil {
|
||||
if v, _, e := o.read(o.db, "mail"); e == nil {
|
||||
c = *v.(*MailConfig)
|
||||
}
|
||||
}
|
||||
return o.renderMail(c, code, kind)
|
||||
}
|
||||
|
||||
func (o *Operations) renderMail(c MailConfig, code, kind string) mailPayload {
|
||||
c.applyTemplateDefaults()
|
||||
siteName, logoURL := "", ""
|
||||
if o.db != nil {
|
||||
if st, e := NewSettingService(o.db).Public(); e == nil {
|
||||
siteName, logoURL = st.SiteName, st.LogoLightURL
|
||||
}
|
||||
}
|
||||
purpose, path := "账号验证", "/register"
|
||||
if kind == "reset" {
|
||||
purpose, path = "密码找回", "/reset-password"
|
||||
}
|
||||
base, dev := "", false
|
||||
if o.cfg != nil {
|
||||
base = strings.TrimRight(o.cfg.SiteURL, "/")
|
||||
dev = o.cfg.DevMode
|
||||
}
|
||||
link := ""
|
||||
if base != "" {
|
||||
link = base + path
|
||||
}
|
||||
absLogo := mailLogoURL(dev, base, logoURL)
|
||||
body := strings.NewReplacer(
|
||||
"{{site_name}}", stdhtml.EscapeString(siteName),
|
||||
"{{purpose}}", stdhtml.EscapeString(purpose),
|
||||
"{{code}}", stdhtml.EscapeString(code),
|
||||
"{{link}}", stdhtml.EscapeString(link),
|
||||
"{{site_url}}", stdhtml.EscapeString(base),
|
||||
"{{logo_url}}", stdhtml.EscapeString(absLogo),
|
||||
"{{logo}}", mailLogoImg(absLogo),
|
||||
).Replace(c.BodyTemplate)
|
||||
subject := strings.NewReplacer(
|
||||
"{{site_name}}", mailOneLine(siteName),
|
||||
"{{purpose}}", purpose,
|
||||
"{{code}}", mailOneLine(code),
|
||||
"{{link}}", mailOneLine(link),
|
||||
"{{site_url}}", mailOneLine(base),
|
||||
"{{logo_url}}", "",
|
||||
"{{logo}}", "",
|
||||
).Replace(c.SubjectTemplate)
|
||||
return mailPayload{Subject: mailOneLine(subject), Body: prepareMailHTML(body)}
|
||||
}
|
||||
|
||||
func mailOneLine(s string) string {
|
||||
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
|
||||
}
|
||||
|
||||
func mailLogoURL(dev bool, base, logoURL string) string {
|
||||
if logoURL == "" || base == "" {
|
||||
return ""
|
||||
}
|
||||
origin, e := url.Parse(base)
|
||||
ref, re := url.Parse(logoURL)
|
||||
if e != nil || re != nil {
|
||||
return ""
|
||||
}
|
||||
u := origin.ResolveReference(ref)
|
||||
if u.Scheme != "https" && !(dev && u.Scheme == "http") {
|
||||
return ""
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func mailLogoImg(abs string) string {
|
||||
if abs == "" {
|
||||
return ""
|
||||
}
|
||||
return "<img class=\"email-logo\" width=\"180\" alt=\"站点 Logo\" src=\"" + stdhtml.EscapeString(abs) + "\" style=\"display:block;margin:0 auto 24px;max-width:180px;height:auto;\">"
|
||||
}
|
||||
|
||||
func (o *Operations) TestMail(ctx context.Context, raw json.RawMessage, clear []string, send bool, to string, actor uint) error {
|
||||
v, _, _, e := o.draft(o.db, "mail", raw, clear)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
c := *v.(*MailConfig)
|
||||
c.Enabled = true
|
||||
if e = o.validate(o.db, "mail", &c); e != nil {
|
||||
return e
|
||||
}
|
||||
var p *mailPayload
|
||||
if send {
|
||||
preview := o.renderMail(c, "测试邮件,无有效验证码", "")
|
||||
preview.To = to
|
||||
p = &preview
|
||||
}
|
||||
e = o.smtp(ctx, c, p, randomID())
|
||||
result := "连接、TLS 与认证通过"
|
||||
if send {
|
||||
result = "服务器已接受(不代表送达)"
|
||||
}
|
||||
if e != nil {
|
||||
result = e.Error()
|
||||
}
|
||||
o.Audit(actor, "mail", "test", result)
|
||||
return e
|
||||
}
|
||||
func (o *Operations) EnqueueMail(tx *gorm.DB, to, kind, code, dedupe string) error {
|
||||
p := o.MailPreview(code, kind)
|
||||
p.To = to
|
||||
b, _ := json.Marshal(p)
|
||||
encrypted, e := o.seal(string(b), "mail-task")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
masked := "***"
|
||||
parts := strings.Split(to, "@")
|
||||
if len(parts) == 2 {
|
||||
masked = "***@" + parts[1]
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.MailTask{Dedupe: dedupe, Kind: kind, Recipient: masked, Payload: encrypted, Status: "queued", NextAt: time.Now()}).Error
|
||||
}
|
||||
|
||||
// A lease is committed before I/O; expired leases are recovered after a restart.
|
||||
func (o *Operations) ProcessMail(ctx context.Context) error {
|
||||
v, _, e := o.read(o.db, "mail")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
c := *v.(*MailConfig)
|
||||
if !c.Enabled {
|
||||
return nil
|
||||
}
|
||||
o.db.Model(&model.MailTask{}).Where("status = ? AND attempts >= 3 AND next_at <= ?", "sending", time.Now()).Updates(map[string]any{"status": "failed", "summary": "重试次数已用完,最终接受状态未知", "payload": ""})
|
||||
var task model.MailTask
|
||||
e = o.db.Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).Where("status IN ? AND next_at <= ?", []string{"queued", "retry", "sending"}, time.Now()).Order("id").First(&task).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
task.Attempts++
|
||||
task.Status = "sending"
|
||||
task.NextAt = time.Now().Add(2 * time.Minute)
|
||||
return tx.Save(&task).Error
|
||||
})
|
||||
if errors.Is(e, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
plaintext, e := o.open(task.Payload, "mail-task")
|
||||
var p mailPayload
|
||||
if e == nil {
|
||||
e = json.Unmarshal([]byte(plaintext), &p)
|
||||
}
|
||||
if e == nil {
|
||||
e = o.smtp(ctx, c, &p, task.Dedupe)
|
||||
}
|
||||
status, summary := "accepted", "服务器已接受(不代表送达)"
|
||||
if e != nil {
|
||||
status = "retry"
|
||||
summary = "发送失败,请检查邮件服务"
|
||||
if task.Attempts >= 3 {
|
||||
status = "failed"
|
||||
}
|
||||
}
|
||||
updates := map[string]any{"status": status, "summary": summary, "next_at": time.Now().Add(time.Duration(task.Attempts) * time.Minute)}
|
||||
if status == "accepted" || status == "failed" {
|
||||
updates["payload"] = ""
|
||||
}
|
||||
return o.db.Model(&model.MailTask{}).Where("id = ? AND attempts = ? AND status = ?", task.ID, task.Attempts, "sending").Updates(updates).Error
|
||||
}
|
||||
func (o *Operations) Run(ctx context.Context) {
|
||||
tick := time.NewTicker(2 * time.Second)
|
||||
defer tick.Stop()
|
||||
n := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
_ = o.ProcessMail(ctx)
|
||||
n++
|
||||
if n%300 == 0 {
|
||||
o.db.Where("expires_at < ?", time.Now()).Delete(&model.ActionCounter{})
|
||||
o.db.Where("expires_at < ?", time.Now().Add(-24*time.Hour)).Delete(&model.EmailChallenge{})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func (o *Operations) MailRows() ([]model.MailTask, error) {
|
||||
var a []model.MailTask
|
||||
e := o.db.Order("id desc").Limit(100).Find(&a).Error
|
||||
return a, e
|
||||
}
|
||||
func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if !validEmail(email) {
|
||||
return 0, errors.New("邮箱格式无效")
|
||||
}
|
||||
cfg, e := o.Security()
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
if purpose != "register" && purpose != "reset" {
|
||||
return 0, errors.New("用途无效")
|
||||
}
|
||||
if (purpose == "register" && (!cfg.VerifyEmail || !cfg.AllowRegister)) || (purpose == "reset" && !cfg.PasswordReset) {
|
||||
return 0, errors.New("该账号邮件功能未开启")
|
||||
}
|
||||
for _, q := range []struct {
|
||||
k string
|
||||
n, s int
|
||||
}{{"email:ip:" + ip, 20, 3600}, {"email:resend:" + purpose + ":" + email, 1, cfg.ResendInterval}, {"email:hour:" + email, cfg.EmailHourly, 3600}} {
|
||||
wait, e := o.Quota(q.k, q.n, q.s)
|
||||
if e != nil || wait > 0 {
|
||||
return wait, e
|
||||
}
|
||||
}
|
||||
if purpose == "reset" {
|
||||
var n int64
|
||||
if e = o.db.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&n).Error; e != nil {
|
||||
return 0, e
|
||||
}
|
||||
if n != 1 {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
code := randomID()
|
||||
hash := counterKey(email + ":" + purpose + ":" + code)
|
||||
e = o.db.Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Create(&model.EmailChallenge{Hash: hash, Email: email, Purpose: purpose, ExpiresAt: time.Now().Add(15 * time.Minute)}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
return o.EnqueueMail(tx, email, purpose, code, hash)
|
||||
})
|
||||
return 0, e
|
||||
}
|
||||
func (o *Operations) ConsumeCode(email, purpose, code string) error {
|
||||
r := o.db.Model(&model.EmailChallenge{}).Where("hash = ? AND used = false AND expires_at > ?", counterKey(strings.ToLower(strings.TrimSpace(email))+":"+purpose+":"+code), time.Now()).Update("used", true)
|
||||
if r.Error != nil {
|
||||
return r.Error
|
||||
}
|
||||
if r.RowsAffected != 1 {
|
||||
return fmt.Errorf("验证码无效或已过期")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Probe merged drafts so partial API updates cannot bypass activation validation.
|
||||
func (o *Operations) ProbeBeforeSave(ctx context.Context, name string, raw json.RawMessage, clear []string) error {
|
||||
v, _, _, e := o.draft(o.db, name, raw, clear)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e = o.validate(o.db, name, v); e != nil {
|
||||
return e
|
||||
}
|
||||
switch c := v.(type) {
|
||||
case *StorageConfig:
|
||||
return o.TestStorage(ctx, raw, clear)
|
||||
case *MailConfig:
|
||||
if c.Enabled {
|
||||
return o.smtp(ctx, *c, nil, randomID())
|
||||
}
|
||||
case *SecurityConfig:
|
||||
old, e := o.Security()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if (c.VerifyEmail && !old.VerifyEmail) || (c.PasswordReset && !old.PasswordReset) {
|
||||
mail, _, e := o.read(o.db, "mail")
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return o.smtp(ctx, *mail.(*MailConfig), nil, randomID())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
265
backend/service/operations_mail_html.go
Normal file
265
backend/service/operations_mail_html.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime/quotedprintable"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// 手机 QQ / 163 等会丢弃 <style>;发送与预览前把简单 class 规则内联,并做成完整 HTML 文档。
|
||||
var (
|
||||
mailCSSCommentRe = regexp.MustCompile(`(?s)/\*.*?\*/`)
|
||||
mailCSSRuleRe = regexp.MustCompile(`([^{}@]+)\{([^{}]+)\}`)
|
||||
)
|
||||
|
||||
func prepareMailHTML(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return raw
|
||||
}
|
||||
doc, err := html.Parse(strings.NewReader(raw))
|
||||
if err != nil {
|
||||
return wrapMailDocument(raw)
|
||||
}
|
||||
css := map[string]string{}
|
||||
collectAndRemoveMailStyles(doc, css)
|
||||
if len(css) > 0 {
|
||||
applyMailClassStyles(doc, css)
|
||||
}
|
||||
ensureMailDocumentHead(doc)
|
||||
var buf bytes.Buffer
|
||||
if err := html.Render(&buf, doc); err != nil {
|
||||
return wrapMailDocument(raw)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func wrapMailDocument(body string) string {
|
||||
lower := strings.ToLower(body)
|
||||
if strings.Contains(lower, "<html") {
|
||||
return body
|
||||
}
|
||||
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0;padding:0;">` + body + `</body></html>`
|
||||
}
|
||||
|
||||
func collectAndRemoveMailStyles(n *html.Node, css map[string]string) {
|
||||
if n.Type == html.ElementNode && n.Data == "style" {
|
||||
var b strings.Builder
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.TextNode {
|
||||
b.WriteString(c.Data)
|
||||
}
|
||||
}
|
||||
parseMailCSSRules(b.String(), css)
|
||||
// 删除 style 节点
|
||||
parent := n.Parent
|
||||
if parent != nil {
|
||||
next := n.NextSibling
|
||||
parent.RemoveChild(n)
|
||||
if next != nil {
|
||||
collectAndRemoveMailStyles(next, css)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; {
|
||||
next := c.NextSibling
|
||||
collectAndRemoveMailStyles(c, css)
|
||||
c = next
|
||||
}
|
||||
}
|
||||
|
||||
func parseMailCSSRules(src string, css map[string]string) {
|
||||
src = mailCSSCommentRe.ReplaceAllString(src, "")
|
||||
src = stripMailAtRules(src)
|
||||
for _, m := range mailCSSRuleRe.FindAllStringSubmatch(src, -1) {
|
||||
decls := normalizeMailDecls(m[2])
|
||||
if decls == "" {
|
||||
continue
|
||||
}
|
||||
for _, sel := range strings.Split(m[1], ",") {
|
||||
sel = strings.TrimSpace(sel)
|
||||
// 仅支持单一 class:.email-card
|
||||
if !strings.HasPrefix(sel, ".") || strings.ContainsAny(sel, " \t>+~[:#") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimPrefix(sel, ".")
|
||||
if name == "" || strings.Contains(name, ".") {
|
||||
continue
|
||||
}
|
||||
css[name] = mergeMailDecls(css[name], decls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去掉 @media / @supports 等块,避免大括号干扰简单解析。
|
||||
func stripMailAtRules(src string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(src); {
|
||||
if src[i] == '@' {
|
||||
j := i
|
||||
for j < len(src) && src[j] != '{' {
|
||||
j++
|
||||
}
|
||||
if j >= len(src) {
|
||||
break
|
||||
}
|
||||
depth := 0
|
||||
for j < len(src) {
|
||||
if src[j] == '{' {
|
||||
depth++
|
||||
} else if src[j] == '}' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
j++
|
||||
break
|
||||
}
|
||||
}
|
||||
j++
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
b.WriteByte(src[i])
|
||||
i++
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func normalizeMailDecls(s string) string {
|
||||
parts := strings.Split(s, ";")
|
||||
out := make([]string, 0, len(parts))
|
||||
seen := map[string]int{}
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
key, _, ok := strings.Cut(p, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
// 后写覆盖先写
|
||||
if idx, ok := seen[key]; ok {
|
||||
out[idx] = p
|
||||
continue
|
||||
}
|
||||
seen[key] = len(out)
|
||||
out = append(out, p)
|
||||
}
|
||||
return strings.Join(out, ";")
|
||||
}
|
||||
|
||||
func mergeMailDecls(base, extra string) string {
|
||||
if base == "" {
|
||||
return normalizeMailDecls(extra)
|
||||
}
|
||||
if extra == "" {
|
||||
return normalizeMailDecls(base)
|
||||
}
|
||||
return normalizeMailDecls(base + ";" + extra)
|
||||
}
|
||||
|
||||
func applyMailClassStyles(n *html.Node, css map[string]string) {
|
||||
if n.Type == html.ElementNode {
|
||||
class := ""
|
||||
styleIdx := -1
|
||||
styleVal := ""
|
||||
for i, a := range n.Attr {
|
||||
switch strings.ToLower(a.Key) {
|
||||
case "class":
|
||||
class = a.Val
|
||||
case "style":
|
||||
styleIdx = i
|
||||
styleVal = a.Val
|
||||
}
|
||||
}
|
||||
if class != "" {
|
||||
var fromClass string
|
||||
for _, c := range strings.Fields(class) {
|
||||
if d, ok := css[c]; ok {
|
||||
fromClass = mergeMailDecls(fromClass, d)
|
||||
}
|
||||
}
|
||||
if fromClass != "" {
|
||||
// 元素已有 style 优先(等价于内联覆盖 class)
|
||||
merged := mergeMailDecls(fromClass, styleVal)
|
||||
if styleIdx >= 0 {
|
||||
n.Attr[styleIdx].Val = merged
|
||||
} else {
|
||||
n.Attr = append(n.Attr, html.Attribute{Key: "style", Val: merged})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
applyMailClassStyles(c, css)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureMailDocumentHead(doc *html.Node) {
|
||||
var htmlNode *html.Node
|
||||
for c := doc.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.ElementNode && c.Data == "html" {
|
||||
htmlNode = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if htmlNode == nil {
|
||||
return
|
||||
}
|
||||
hasLang := false
|
||||
for _, a := range htmlNode.Attr {
|
||||
if strings.EqualFold(a.Key, "lang") {
|
||||
hasLang = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLang {
|
||||
htmlNode.Attr = append(htmlNode.Attr, html.Attribute{Key: "lang", Val: "zh-CN"})
|
||||
}
|
||||
var head *html.Node
|
||||
for c := htmlNode.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.ElementNode && c.Data == "head" {
|
||||
head = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if head == nil {
|
||||
head = &html.Node{Type: html.ElementNode, Data: "head"}
|
||||
htmlNode.InsertBefore(head, htmlNode.FirstChild)
|
||||
}
|
||||
hasCharset := false
|
||||
for c := head.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.ElementNode && c.Data == "meta" {
|
||||
for _, a := range c.Attr {
|
||||
if strings.EqualFold(a.Key, "charset") {
|
||||
hasCharset = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasCharset {
|
||||
meta := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: "meta",
|
||||
Attr: []html.Attribute{{Key: "charset", Val: "UTF-8"}},
|
||||
}
|
||||
head.InsertBefore(meta, head.FirstChild)
|
||||
}
|
||||
}
|
||||
|
||||
func writeQuotedPrintableHTML(w interface{ Write([]byte) (int, error) }, body string) error {
|
||||
qp := quotedprintable.NewWriter(w)
|
||||
if _, err := qp.Write([]byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
return qp.Close()
|
||||
}
|
||||
131
backend/service/operations_maintenance.go
Normal file
131
backend/service/operations_maintenance.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/version"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (o *Operations) ResetPassword(email, code, password string) error {
|
||||
cfg, e := o.Security()
|
||||
if e != nil || !cfg.PasswordReset {
|
||||
return errors.New("找回密码未开启")
|
||||
}
|
||||
if len(password) < 6 || len(password) > 64 {
|
||||
return errors.New("密码长度须为 6–64 字节")
|
||||
}
|
||||
hash, e := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return o.db.Transaction(func(tx *gorm.DB) error {
|
||||
scoped := *o
|
||||
scoped.db = tx
|
||||
if e := scoped.ConsumeCode(email, "reset", code); e != nil {
|
||||
return e
|
||||
}
|
||||
var users []model.User
|
||||
if e := tx.Where("LOWER(email) = ?", strings.ToLower(strings.TrimSpace(email))).Limit(2).Find(&users).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if len(users) != 1 {
|
||||
return errors.New("账号不可用")
|
||||
}
|
||||
return tx.Model(&model.User{}).Where("id = ?", users[0].ID).Updates(map[string]any{"password": string(hash), "token_version": gorm.Expr("token_version + 1")}).Error
|
||||
})
|
||||
}
|
||||
func (o *Operations) Diagnostics(ctx context.Context) map[string]any {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
dbState := "正常"
|
||||
sql, e := o.db.DB()
|
||||
if e != nil {
|
||||
dbState = "异常"
|
||||
} else if sql.PingContext(ctx) != nil {
|
||||
dbState = "异常"
|
||||
}
|
||||
local := "正常"
|
||||
if o.LocalWritable() != nil {
|
||||
local = "异常"
|
||||
}
|
||||
v, _, e := o.read(o.db, "storage")
|
||||
storage := "未知"
|
||||
if e == nil {
|
||||
storage = "本地:" + local
|
||||
if v.(*StorageConfig).Backend == "s3" {
|
||||
storage = "S3:未知(使用存储页测试)"
|
||||
}
|
||||
}
|
||||
var last model.SettingsAudit
|
||||
result := map[string]any{"state": "未知"}
|
||||
if o.db.Where("module = ? AND action = ?", "mail", "test").Order("id desc").First(&last).Error == nil {
|
||||
result = map[string]any{"result": last.Result, "tested_at": last.CreatedAt, "note": "历史测试,不代表持续健康"}
|
||||
}
|
||||
var queued int64
|
||||
queueErr := o.db.WithContext(ctx).Model(&model.MailTask{}).Where("status IN ?", []string{"queued", "retry", "sending"}).Count(&queued).Error
|
||||
queueState := "正常 · PostgreSQL 持久邮件队列"
|
||||
if queueErr != nil {
|
||||
queueState = "异常 · 暂时无法读取邮件队列"
|
||||
}
|
||||
return map[string]any{"version": version.Version, "uptime_seconds": int(time.Since(o.Started).Seconds()), "database": dbState, "cache": "未配置独立应用缓存", "tasks": queueState, "queued_mail": queued, "storage": storage, "mail_test": result}
|
||||
}
|
||||
|
||||
// Draft references live in browsers, so unattached database rows are NEVER deleted.
|
||||
func (o *Operations) ScanTemporary() (map[string]any, error) {
|
||||
m, e := o.Maintenance()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
count, size := 0, int64(0)
|
||||
cutoff := time.Now().Add(-time.Duration(m.TempDays) * 24 * time.Hour)
|
||||
for _, dir := range []string{filepath.Join(o.cfg.DataDir, "uploads"), filepath.Join(o.cfg.DataDir, "private")} {
|
||||
e = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Type()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(d.Name(), ".partial") {
|
||||
return nil
|
||||
}
|
||||
info, e := d.Info()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
count++
|
||||
size += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
candidates, e := o.temporaryCandidates()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var drafts int64
|
||||
if e = o.db.Model(&model.PostAttachment{}).Where("post_id = 0").Count(&drafts).Error; e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return map[string]any{"candidates": candidates, "expired_partial_count": count, "expired_partial_bytes": size, "protected_draft_attachments": drafts, "retention_days": m.TempDays, "message": "保留所有已入库图片和草稿附件。只清理受本版本上传锁保护且已过期的候选文件;未知来源的旧 partial 不在线删除。"}, nil
|
||||
}
|
||||
func (o *Operations) ClearMailLogs() (int64, error) {
|
||||
v, _, e := o.read(o.db, "mail")
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
c := v.(*MailConfig)
|
||||
r := o.db.Where("status IN ? AND updated_at < ?", []string{"accepted", "failed"}, time.Now().Add(-time.Duration(c.Retention)*24*time.Hour)).Delete(&model.MailTask{})
|
||||
return r.RowsAffected, r.Error
|
||||
}
|
||||
120
backend/service/operations_security.go
Normal file
120
backend/service/operations_security.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func counterKey(key string) string { x := sha256.Sum256([]byte(key)); return hex.EncodeToString(x[:]) }
|
||||
|
||||
// PostgreSQL row locks provide shared atomic quotas across application instances.
|
||||
func (o *Operations) Quota(key string, limit, seconds int) (int, error) {
|
||||
if seconds <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
wait := 0
|
||||
e := o.db.Transaction(func(tx *gorm.DB) error {
|
||||
key = counterKey(key)
|
||||
if e := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.ActionCounter{Key: key, ExpiresAt: time.Now().Add(time.Duration(seconds) * time.Second)}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
var c model.ActionCounter
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&c, "key = ?", key).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
now := time.Now()
|
||||
if !now.Before(c.ExpiresAt) {
|
||||
c.Count = 0
|
||||
c.ExpiresAt = now.Add(time.Duration(seconds) * time.Second)
|
||||
}
|
||||
if c.Count >= limit {
|
||||
wait = int(time.Until(c.ExpiresAt).Seconds()) + 1
|
||||
return nil
|
||||
}
|
||||
c.Count++
|
||||
return tx.Save(&c).Error
|
||||
})
|
||||
return wait, e
|
||||
}
|
||||
func (o *Operations) FailureWait(account, ip string, c SecurityConfig) (int, error) {
|
||||
var rows []model.ActionCounter
|
||||
if e := o.db.Where("key IN ? AND expires_at > ?", []string{counterKey("failure:account:" + strings.ToLower(strings.TrimSpace(account))), counterKey("failure:ip:" + ip)}, time.Now()).Find(&rows).Error; e != nil {
|
||||
return 0, e
|
||||
}
|
||||
for _, r := range rows {
|
||||
limit := c.LoginFailures
|
||||
if r.Key == counterKey("failure:ip:"+ip) {
|
||||
limit *= 4
|
||||
}
|
||||
if r.Count >= limit {
|
||||
return int(time.Until(r.ExpiresAt).Seconds()) + 1, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
func (o *Operations) RecordFailure(account, ip string, c SecurityConfig) {
|
||||
_, _ = o.Quota("failure:account:"+strings.ToLower(strings.TrimSpace(account)), c.LoginFailures, c.LoginWindow)
|
||||
_, _ = o.Quota("failure:ip:"+ip, c.LoginFailures*4, c.LoginWindow)
|
||||
}
|
||||
func (o *Operations) ClearFailure(account string) {
|
||||
o.db.Delete(&model.ActionCounter{}, "key = ?", counterKey("failure:account:"+strings.ToLower(strings.TrimSpace(account))))
|
||||
}
|
||||
|
||||
// Resolve once, validate every address, and dial the validated IP to prevent DNS rebinding.
|
||||
// Private targets require an exact deployment allowlist entry, not a settings checkbox.
|
||||
func safeDial(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, e := net.SplitHostPort(address)
|
||||
if e != nil {
|
||||
return nil, errors.New("连接地址无效")
|
||||
}
|
||||
allow := false
|
||||
for _, v := range strings.Split(os.Getenv("SERVICE_PRIVATE_HOSTS"), ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(v), host) {
|
||||
allow = true
|
||||
}
|
||||
}
|
||||
ips, e := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if e != nil || len(ips) == 0 {
|
||||
return nil, errors.New("地址解析失败")
|
||||
}
|
||||
for _, a := range ips {
|
||||
if !allow && (restrictedServiceIP(a.IP) || !a.IP.IsGlobalUnicast() || a.IP.IsPrivate() || a.IP.IsLoopback() || a.IP.IsLinkLocalUnicast() || a.IP.IsUnspecified()) {
|
||||
return nil, errors.New("目标地址受限;私网服务需部署 SERVICE_PRIVATE_HOSTS")
|
||||
}
|
||||
}
|
||||
var last error
|
||||
for _, a := range ips {
|
||||
c, e := (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port))
|
||||
if e == nil {
|
||||
return c, nil
|
||||
}
|
||||
last = e
|
||||
}
|
||||
_ = last
|
||||
return nil, fmt.Errorf("连接失败或超时")
|
||||
}
|
||||
|
||||
func restrictedServiceIP(ip net.IP) bool {
|
||||
a, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
a = a.Unmap()
|
||||
for _, p := range []string{"100.64.0.0/10", "192.0.0.0/24", "198.18.0.0/15", "2001:db8::/32"} {
|
||||
if netip.MustParsePrefix(p).Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
243
backend/service/operations_storage.go
Normal file
243
backend/service/operations_storage.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (o *Operations) s3(c StorageConfig) (*minio.Client, error) {
|
||||
u, e := url.Parse(c.Endpoint)
|
||||
if e != nil {
|
||||
return nil, errors.New("存储地址无效")
|
||||
}
|
||||
access, e := o.open(c.AccessKey, "storage:access_key")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
secret, e := o.open(c.SecretKey, "storage:secret_key")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
lookup := minio.BucketLookupDNS
|
||||
if c.PathStyle {
|
||||
lookup = minio.BucketLookupPath
|
||||
}
|
||||
tr := &http.Transport{DialContext: safeDial, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 15 * time.Second, DisableKeepAlives: true}
|
||||
return minio.New(u.Host, &minio.Options{Creds: credentials.NewStaticV4(access, secret, ""), Secure: u.Scheme == "https", Region: c.Region, BucketLookup: lookup, Transport: boundedS3Transport{base: tr, host: func() string {
|
||||
if c.PathStyle {
|
||||
return u.Host
|
||||
}
|
||||
return c.Bucket + "." + u.Host
|
||||
}(), scheme: u.Scheme}})
|
||||
}
|
||||
func (o *Operations) TestStorage(ctx context.Context, raw json.RawMessage, clear []string) error {
|
||||
v, _, _, e := o.draft(o.db, "storage", raw, clear)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
c := *v.(*StorageConfig)
|
||||
if c.Backend == "local" {
|
||||
return o.LocalWritable()
|
||||
}
|
||||
client, e := o.s3(c)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
key := c.Prefix + "tests/" + randomID()
|
||||
payload := []byte("jiang13 storage probe")
|
||||
if _, e = client.PutObject(ctx, c.Bucket, key, bytes.NewReader(payload), int64(len(payload)), minio.PutObjectOptions{ContentType: "text/plain", DisableMultipart: true}); e != nil {
|
||||
return errors.New("存储写入失败(连接、凭据或权限)")
|
||||
}
|
||||
cleanup := func() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return client.RemoveObject(ctx, c.Bucket, key, minio.RemoveObjectOptions{})
|
||||
}
|
||||
object, e := client.GetObject(ctx, c.Bucket, key, minio.GetObjectOptions{})
|
||||
if e != nil {
|
||||
_ = cleanup()
|
||||
return errors.New("存储读取失败")
|
||||
}
|
||||
b, e := io.ReadAll(io.LimitReader(object, 128))
|
||||
_ = object.Close()
|
||||
cleanErr := cleanup()
|
||||
if e != nil || !bytes.Equal(b, payload) {
|
||||
return errors.New("存储读取校验失败")
|
||||
}
|
||||
if cleanErr != nil {
|
||||
return errors.New("存储清理失败;测试对象保留于应用 tests 前缀")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (o *Operations) LocalWritable() error {
|
||||
dir := filepath.Join(o.cfg.DataDir, "uploads")
|
||||
f, e := os.CreateTemp(dir, ".probe-")
|
||||
if e != nil {
|
||||
return errors.New("本地上传目录不可写")
|
||||
}
|
||||
name := f.Name()
|
||||
_, e = f.Write([]byte("probe"))
|
||||
_ = f.Close()
|
||||
removeErr := os.Remove(name)
|
||||
if e != nil || removeErr != nil {
|
||||
return errors.New("本地存储读写或清理失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Uploads are validated locally first, then moved to the selected target. No fallback.
|
||||
func (o *Operations) StoreFile(path, mimeType string, public bool) (string, error) {
|
||||
v, version, e := o.read(o.db, "storage")
|
||||
if e != nil {
|
||||
return "", errors.New("读取存储配置失败")
|
||||
}
|
||||
c := *v.(*StorageConfig)
|
||||
if c.Backend == "local" {
|
||||
return "", nil
|
||||
}
|
||||
client, e := o.s3(c)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
f, e := os.Open(path)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
defer f.Close()
|
||||
stat, e := f.Stat()
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
id := randomID()
|
||||
key := c.Prefix + "objects/" + id
|
||||
if _, e = client.PutObject(ctx, c.Bucket, key, f, stat.Size(), minio.PutObjectOptions{ContentType: mimeType, DisableMultipart: true}); e != nil {
|
||||
return "", errors.New("上传到 S3 失败,当前目标未自动切换")
|
||||
}
|
||||
obj := model.StoredObject{ID: id, ConfigName: fmt.Sprintf("storage-%d", version), Key: key, MIME: mimeType, Public: public}
|
||||
if e = o.db.Create(&obj).Error; e != nil {
|
||||
_ = client.RemoveObject(ctx, c.Bucket, key, minio.RemoveObjectOptions{})
|
||||
return "", errors.New("保存文件记录失败")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
func (o *Operations) OpenObject(ctx context.Context, id string, requirePublic bool) (io.ReadCloser, string, error) {
|
||||
var obj model.StoredObject
|
||||
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {
|
||||
return nil, "", errors.New("文件不存在")
|
||||
}
|
||||
if requirePublic && !obj.Public {
|
||||
return nil, "", errors.New("文件不存在")
|
||||
}
|
||||
var row model.ModuleConfig
|
||||
if e := o.db.First(&row, "name = ?", obj.ConfigName).Error; e != nil {
|
||||
return nil, "", errors.New("历史存储配置不可用")
|
||||
}
|
||||
var c StorageConfig
|
||||
if e := json.Unmarshal([]byte(row.Data), &c); e != nil {
|
||||
return nil, "", errors.New("历史存储配置无效")
|
||||
}
|
||||
client, e := o.s3(c)
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
object, e := client.GetObject(ctx, c.Bucket, obj.Key, minio.GetObjectOptions{})
|
||||
if e != nil {
|
||||
return nil, "", errors.New("读取存储失败")
|
||||
}
|
||||
if _, e = object.Stat(); e != nil {
|
||||
_ = object.Close()
|
||||
return nil, "", errors.New("文件暂不可用")
|
||||
}
|
||||
return object, obj.MIME, nil
|
||||
}
|
||||
func (o *Operations) StorageReferences() ([]map[string]any, error) {
|
||||
type row struct {
|
||||
ConfigName string
|
||||
Count int64
|
||||
}
|
||||
var rows []row
|
||||
e := o.db.Model(&model.StoredObject{}).Select("config_name, count(*) as count").Group("config_name").Scan(&rows).Error
|
||||
out := []map[string]any{}
|
||||
for _, r := range rows {
|
||||
out = append(out, map[string]any{"revision": r.ConfigName, "references": r.Count})
|
||||
}
|
||||
return out, e
|
||||
}
|
||||
func RemoteObjectID(url string) string { return strings.TrimPrefix(url, "/api/media/") }
|
||||
|
||||
type boundedS3Transport struct {
|
||||
base http.RoundTripper
|
||||
host, scheme string
|
||||
}
|
||||
|
||||
func (t boundedS3Transport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
if !strings.EqualFold(r.URL.Host, t.host) || r.URL.Scheme != t.scheme {
|
||||
return nil, errors.New("拒绝存储重定向到未配置目标")
|
||||
}
|
||||
return t.base.RoundTrip(r)
|
||||
}
|
||||
func (o *Operations) PublicObjectLocation(id string) (string, error) {
|
||||
var object model.StoredObject
|
||||
if e := o.db.First(&object, "id = ? AND public = true", id).Error; e != nil {
|
||||
return "", errors.New("文件不存在")
|
||||
}
|
||||
var row model.ModuleConfig
|
||||
if e := o.db.First(&row, "name = ?", object.ConfigName).Error; e != nil {
|
||||
return "", e
|
||||
}
|
||||
var c StorageConfig
|
||||
if e := json.Unmarshal([]byte(row.Data), &c); e != nil {
|
||||
return "", e
|
||||
}
|
||||
if c.CDN == "" {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimRight(c.CDN, "/") + "/" + object.Key, nil
|
||||
}
|
||||
|
||||
// RemoveObject revokes public access before best-effort remote cleanup.
|
||||
// Failed cleanup retains the historic configuration reference for manual retry.
|
||||
func (o *Operations) RemoveObject(id string) error {
|
||||
var obj model.StoredObject
|
||||
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if e := o.db.Model(&obj).Update("public", false).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
var row model.ModuleConfig
|
||||
if e := o.db.First(&row, "name = ?", obj.ConfigName).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
var c StorageConfig
|
||||
if e := json.Unmarshal([]byte(row.Data), &c); e != nil {
|
||||
return e
|
||||
}
|
||||
client, e := o.s3(c)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if e = client.RemoveObject(ctx, c.Bucket, obj.Key, minio.RemoveObjectOptions{}); e != nil {
|
||||
return errors.New("远程文件清理失败,已撤销公开访问")
|
||||
}
|
||||
return o.db.Delete(&obj).Error
|
||||
}
|
||||
213
backend/service/operations_temporary.go
Normal file
213
backend/service/operations_temporary.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func temporaryLockKey(path string) int64 {
|
||||
key := filepath.ToSlash(filepath.Clean(path))
|
||||
for _, root := range []string{"/uploads/", "/private/"} {
|
||||
if i := strings.LastIndex(key, root); i >= 0 {
|
||||
key = key[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
s := sha256.Sum256([]byte("temporary:" + key))
|
||||
return int64(binary.BigEndian.Uint64(s[:8]))
|
||||
}
|
||||
func (o *Operations) lockTemporary(path string, try bool) (*sql.Conn, bool, error) {
|
||||
db, e := o.db.DB()
|
||||
if e != nil {
|
||||
return nil, false, e
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
c, e := db.Conn(ctx)
|
||||
if e != nil {
|
||||
return nil, false, e
|
||||
}
|
||||
if try {
|
||||
var ok bool
|
||||
e = c.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", temporaryLockKey(path)).Scan(&ok)
|
||||
if e != nil || !ok {
|
||||
c.Close()
|
||||
return nil, false, e
|
||||
}
|
||||
} else {
|
||||
if _, e = c.ExecContext(ctx, "SELECT pg_advisory_lock($1)", temporaryLockKey(path)); e != nil {
|
||||
c.Close()
|
||||
return nil, false, e
|
||||
}
|
||||
}
|
||||
return c, true, nil
|
||||
}
|
||||
func unlockTemporary(c *sql.Conn, path string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, e := c.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", temporaryLockKey(path))
|
||||
if e != nil {
|
||||
_ = c.Raw(func(any) error { return driver.ErrBadConn })
|
||||
}
|
||||
_ = c.Close()
|
||||
}
|
||||
|
||||
// The database session lock lasts for the complete upload, including a stalled writer.
|
||||
// A crashed process releases it automatically; cleaners can never unlink an active upload.
|
||||
func (o *Operations) BeginTemporary(path string) (func(), error) {
|
||||
rel, e := filepath.Rel(o.cfg.DataDir, path)
|
||||
if e != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
||||
return nil, errors.New("临时文件目录无效")
|
||||
}
|
||||
c, _, e := o.lockTemporary(path, false)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
row := model.TemporaryUpload{ID: counterKey(filepath.ToSlash(rel)), RelativePath: filepath.ToSlash(rel)}
|
||||
if e = o.db.Create(&row).Error; e != nil {
|
||||
unlockTemporary(c, path)
|
||||
return nil, e
|
||||
}
|
||||
return func() {
|
||||
if _, e := os.Lstat(path); os.IsNotExist(e) {
|
||||
o.db.Delete(&model.TemporaryUpload{}, "id = ?", row.ID)
|
||||
}
|
||||
unlockTemporary(c, path)
|
||||
}, nil
|
||||
}
|
||||
func (o *Operations) temporaryPath(row model.TemporaryUpload) (string, error) {
|
||||
rel := filepath.FromSlash(row.RelativePath)
|
||||
if filepath.IsAbs(rel) || strings.HasPrefix(filepath.Clean(rel), "..") || !strings.HasSuffix(rel, ".partial") {
|
||||
return "", errors.New("临时路径不在允许范围")
|
||||
}
|
||||
path := filepath.Join(o.cfg.DataDir, rel)
|
||||
parent := filepath.ToSlash(filepath.Dir(rel))
|
||||
if parent != "uploads/images" && parent != "uploads/backgrounds" && parent != "private/files" {
|
||||
return "", errors.New("目录不在允许范围")
|
||||
}
|
||||
root, e := filepath.EvalSymlinks(o.cfg.DataDir)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
actual, e := filepath.EvalSymlinks(path)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
within, e := filepath.Rel(root, actual)
|
||||
if e != nil || strings.HasPrefix(within, "..") || filepath.IsAbs(within) {
|
||||
return "", errors.New("拒绝目录外文件")
|
||||
}
|
||||
info, e := os.Lstat(path)
|
||||
if e != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return "", errors.New("拒绝非普通文件")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
type TemporaryCandidate struct {
|
||||
ID string `json:"id"`
|
||||
Size int64 `json:"size"`
|
||||
Modified time.Time `json:"modified"`
|
||||
}
|
||||
|
||||
func (o *Operations) temporaryCandidates() ([]TemporaryCandidate, error) {
|
||||
m, e := o.Maintenance()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Duration(m.TempDays) * 24 * time.Hour)
|
||||
var rows []model.TemporaryUpload
|
||||
if e = o.db.Where("created_at < ?", cutoff).Order("created_at").Limit(100).Find(&rows).Error; e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out := []TemporaryCandidate{}
|
||||
for _, row := range rows {
|
||||
path, e := o.temporaryPath(row)
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
c, ok, e := o.lockTemporary(path, true)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
info, e := os.Stat(path)
|
||||
if e == nil && info.ModTime().Before(cutoff) {
|
||||
out = append(out, TemporaryCandidate{row.ID, info.Size(), info.ModTime()})
|
||||
}
|
||||
unlockTemporary(c, path)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (o *Operations) CleanTemporary(ids []string) (map[string]any, error) {
|
||||
if len(ids) == 0 || len(ids) > 100 {
|
||||
return nil, errors.New("每次请选择扫描出的 1–100 个临时文件")
|
||||
}
|
||||
m, e := o.Maintenance()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Duration(m.TempDays) * 24 * time.Hour)
|
||||
removed, skipped, failed := 0, 0, 0
|
||||
for _, id := range ids {
|
||||
var row model.TemporaryUpload
|
||||
if o.db.First(&row, "id = ? AND created_at < ?", id, cutoff).Error != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
path, e := o.temporaryPath(row)
|
||||
if e != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
c, ok, e := o.lockTemporary(path, true)
|
||||
if e != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
info, e := os.Stat(path)
|
||||
if e != nil || !info.ModTime().Before(cutoff) {
|
||||
unlockTemporary(c, path)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
// Raw Markdown references are checked again while holding the upload lock.
|
||||
referenced := false
|
||||
for _, table := range []string{"posts", "comments"} {
|
||||
var n int64
|
||||
e = o.db.Table(table).Where("content LIKE ?", "%"+filepath.Base(path)+"%").Count(&n).Error
|
||||
if e != nil || n > 0 {
|
||||
referenced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if referenced {
|
||||
unlockTemporary(c, path)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if e = os.Remove(path); e != nil {
|
||||
failed++
|
||||
} else {
|
||||
o.db.Delete(&model.TemporaryUpload{}, "id = ?", id)
|
||||
removed++
|
||||
}
|
||||
unlockTemporary(c, path)
|
||||
}
|
||||
return map[string]any{"message": "临时文件清理完成", "removed": removed, "skipped": skipped, "failed": failed}, nil
|
||||
}
|
||||
625
backend/service/operations_test.go
Normal file
625
backend/service/operations_test.go
Normal file
@@ -0,0 +1,625 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/freefire/jiang13-bbs/config"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testKey(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("SETTINGS_MASTER_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
||||
}
|
||||
func testOps(t *testing.T) *Operations {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("OPS_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set OPS_TEST_DATABASE_URL to an isolated PostgreSQL database")
|
||||
}
|
||||
u, e := url.Parse(dsn)
|
||||
if e != nil || !strings.HasPrefix(strings.TrimPrefix(u.Path, "/"), "ops_test") {
|
||||
t.Fatal("test database name must begin ops_test")
|
||||
}
|
||||
db, e := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
schema := "ops_test_" + fmt.Sprint(time.Now().UnixNano())
|
||||
if e = db.Exec("CREATE SCHEMA " + schema).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("search_path", schema)
|
||||
u.RawQuery = q.Encode()
|
||||
scoped, e := gorm.Open(postgres.Open(u.String()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sql, _ := scoped.DB()
|
||||
_ = sql.Close()
|
||||
db.Exec("DROP SCHEMA " + schema + " CASCADE")
|
||||
sql, _ = db.DB()
|
||||
_ = sql.Close()
|
||||
})
|
||||
if e = scoped.AutoMigrate(&model.TemporaryUpload{}, &model.ModuleConfig{}, &model.SiteSetting{}, &model.SettingsAudit{}, &model.ActionCounter{}, &model.MailTask{}, &model.EmailChallenge{}, &model.StoredObject{}, &model.User{}, &model.PostAttachment{}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
_ = os.MkdirAll(filepath.Join(dir, "uploads"), 0700)
|
||||
_ = os.MkdirAll(filepath.Join(dir, "private"), 0700)
|
||||
return NewOperations(scoped, &config.Config{DataDir: dir, DevMode: true, SiteURL: "https://forum.example.com"})
|
||||
}
|
||||
func raw(v any) json.RawMessage { b, _ := json.Marshal(v); return b }
|
||||
func TestOperationalSecrets(t *testing.T) {
|
||||
testKey(t)
|
||||
o := &Operations{}
|
||||
enc, e := o.seal("do-not-leak", "mail:password")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
enc2, _ := o.seal("do-not-leak", "mail:password")
|
||||
if enc == enc2 || strings.Contains(enc, "do-not-leak") {
|
||||
t.Fatal("nonce or encryption failure")
|
||||
}
|
||||
p, e := o.open(enc, "mail:password")
|
||||
if e != nil || p != "do-not-leak" {
|
||||
t.Fatal("roundtrip")
|
||||
}
|
||||
if _, e = o.open(enc, "storage:secret_key"); e == nil {
|
||||
t.Fatal("domain substitution accepted")
|
||||
}
|
||||
t.Setenv("SETTINGS_MASTER_KEY", "")
|
||||
if _, e = o.open(enc, "mail:password"); e == nil {
|
||||
t.Fatal("missing key accepted")
|
||||
}
|
||||
}
|
||||
func TestFilterNormalizationExceptionsAndMarkdown(t *testing.T) {
|
||||
c := FilterConfig{Enabled: true, Rules: []FilterRule{{ID: "a", Word: "bad", Scopes: []string{"body"}, Action: "block", Enabled: true, Exceptions: []string{"badminton"}}, {ID: "b", Word: "hello", Scopes: []string{"body"}, Action: "log", Enabled: true}}}
|
||||
r := MatchFilter(c, "body", "HELLO badminton and BAD **bad** [label](https://bad.example)\n\n\u0060bad\u0060\n\n\u0060\u0060\u0060go\nbad\n\u0060\u0060\u0060")
|
||||
if r.Result != "block" {
|
||||
t.Fatal(r)
|
||||
}
|
||||
blocks, excepted := 0, 0
|
||||
for _, h := range r.Hits {
|
||||
if h.Action == "block" {
|
||||
if h.Excepted {
|
||||
excepted++
|
||||
} else {
|
||||
blocks++
|
||||
}
|
||||
}
|
||||
}
|
||||
if blocks != 2 || excepted != 1 {
|
||||
t.Fatalf("blocks=%d excepted=%d text=%q", blocks, excepted, r.Text)
|
||||
}
|
||||
if MatchFilter(c, "title", "bad").Result != "pass" {
|
||||
t.Fatal("scope leak")
|
||||
}
|
||||
if MatchFilter(c, "body", "badminton").Result != "pass" {
|
||||
t.Fatal("exception not applied")
|
||||
}
|
||||
}
|
||||
func TestRateLimiterUsesRequestCategory(t *testing.T) {
|
||||
r := NewRateLimiter()
|
||||
r.SetLimit(RateLogin, 1)
|
||||
if !r.Allow("login:127.0.0.1") || r.Allow("login:127.0.0.1") {
|
||||
t.Fatal("per-IP key bypassed limit")
|
||||
}
|
||||
if !r.Allow("login:127.0.0.2") {
|
||||
t.Fatal("different IP denied")
|
||||
}
|
||||
}
|
||||
func TestPrivateServiceTargetDenied(t *testing.T) {
|
||||
t.Setenv("SERVICE_PRIVATE_HOSTS", "")
|
||||
c, e := safeDial(context.Background(), "tcp", "127.0.0.1:25")
|
||||
if c != nil {
|
||||
c.Close()
|
||||
}
|
||||
if e == nil || !strings.Contains(e.Error(), "受限") {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
func TestModuleConflictAndAtomicDependency(t *testing.T) {
|
||||
testKey(t)
|
||||
o := testOps(t)
|
||||
cfg := defaultModule("security").(*SecurityConfig)
|
||||
cfg.CommentInterval = 15
|
||||
var wins atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
e := o.Save("security", 0, raw(cfg), nil, 1)
|
||||
if e == nil {
|
||||
wins.Add(1)
|
||||
} else if !errors.Is(e, ErrConfigConflict) {
|
||||
t.Errorf("unexpected: %v", e)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if wins.Load() != 1 {
|
||||
t.Fatalf("concurrent winners: %d", wins.Load())
|
||||
}
|
||||
reopened := NewOperations(o.db, o.cfg)
|
||||
s, e := reopened.Security()
|
||||
if e != nil || s.CommentInterval != 15 {
|
||||
t.Fatal("restart persistence", e)
|
||||
}
|
||||
cfg.VerifyEmail = true
|
||||
cfg.AllowRegister = false
|
||||
if e = o.Save("security", 1, raw(cfg), nil, 1); e == nil {
|
||||
t.Fatal("dependency accepted")
|
||||
}
|
||||
s, _ = o.Security()
|
||||
if !s.AllowRegister || s.VerifyEmail {
|
||||
t.Fatal("failed save changed active config")
|
||||
}
|
||||
m := defaultModule("mail").(*MailConfig)
|
||||
m.Enabled = true
|
||||
m.Host = "smtp.example.com"
|
||||
m.Username = "user"
|
||||
m.Password = "secret-value"
|
||||
m.From = "sender@example.com"
|
||||
if e = o.Save("mail", 0, raw(m), nil, 1); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
got, e := o.Read("mail")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
b, _ := json.Marshal(got)
|
||||
if strings.Contains(string(b), "secret-value") || strings.Contains(string(b), "mail:password") {
|
||||
t.Fatal("secret exposed")
|
||||
}
|
||||
cfg.AllowRegister = true
|
||||
if e = o.Save("security", 1, raw(cfg), nil, 1); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = o.Save("mail", 1, raw(map[string]any{"enabled": false}), nil, 1); e == nil {
|
||||
t.Fatal("mail dependency bypass")
|
||||
}
|
||||
if e = o.Save("mail", 1, raw(map[string]any{}), []string{"password"}, 1); e == nil {
|
||||
t.Fatal("dependent credential cleared")
|
||||
}
|
||||
if e = o.Save("mail", 1, raw(map[string]any{"password": ""}), nil, 1); e != nil {
|
||||
t.Fatal("blank failed to retain secret", e)
|
||||
}
|
||||
}
|
||||
func TestSharedQuotaAndFiniteLoginRecovery(t *testing.T) {
|
||||
o := testOps(t)
|
||||
var wins atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for range 20 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
wait, e := o.Quota("same", 5, 60)
|
||||
if e != nil {
|
||||
t.Error(e)
|
||||
} else if wait == 0 {
|
||||
wins.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if wins.Load() != 5 {
|
||||
t.Fatal(wins.Load())
|
||||
}
|
||||
cfg := *defaultModule("security").(*SecurityConfig)
|
||||
for range cfg.LoginFailures {
|
||||
o.RecordFailure("Victim", "ip", cfg)
|
||||
}
|
||||
wait, e := o.FailureWait("victim", "other", cfg)
|
||||
if e != nil || wait <= 0 || wait > cfg.LoginWindow+1 {
|
||||
t.Fatal(wait, e)
|
||||
}
|
||||
o.db.Model(&model.ActionCounter{}).Where("key = ?", counterKey("failure:account:victim")).Update("expires_at", time.Now().Add(-time.Second))
|
||||
if wait, e = o.FailureWait("victim", "other", cfg); e != nil || wait != 0 {
|
||||
t.Fatal("account did not recover")
|
||||
}
|
||||
}
|
||||
func smtpFixture(t *testing.T, authFail, stall bool) (string, int, *x509.CertPool, *atomic.Int32) {
|
||||
t.Helper()
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
der, e := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
keyDER, _ := x509.MarshalECPrivateKey(key)
|
||||
cert, _ := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
||||
pool := x509.NewCertPool()
|
||||
pool.AppendCertsFromPEM(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
ln, e := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
var accepted atomic.Int32
|
||||
go func() {
|
||||
for {
|
||||
conn, e := ln.Accept()
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(4 * time.Second))
|
||||
if stall {
|
||||
time.Sleep(3 * time.Second)
|
||||
return
|
||||
}
|
||||
io.WriteString(conn, "220 test SMTP\r\n")
|
||||
r := bufio.NewReader(conn)
|
||||
for {
|
||||
line, e := r.ReadString('\n')
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "EHLO"):
|
||||
io.WriteString(conn, "250-test\r\n250 AUTH PLAIN\r\n")
|
||||
case strings.HasPrefix(line, "AUTH"):
|
||||
if authFail {
|
||||
io.WriteString(conn, "535 rejected\r\n")
|
||||
} else {
|
||||
io.WriteString(conn, "235 accepted\r\n")
|
||||
}
|
||||
case strings.HasPrefix(line, "DATA"):
|
||||
io.WriteString(conn, "354 go\r\n")
|
||||
for {
|
||||
l, e := r.ReadString('\n')
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
if l == ".\r\n" {
|
||||
break
|
||||
}
|
||||
}
|
||||
accepted.Add(1)
|
||||
io.WriteString(conn, "250 queued\r\n")
|
||||
case strings.HasPrefix(line, "QUIT"):
|
||||
io.WriteString(conn, "221 bye\r\n")
|
||||
return
|
||||
default:
|
||||
io.WriteString(conn, "250 ok\r\n")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
host, port, _ := net.SplitHostPort(ln.Addr().String())
|
||||
n := 0
|
||||
fmt.Sscan(port, &n)
|
||||
return host, n, pool, &accepted
|
||||
}
|
||||
func TestSMTPConnectionAcceptAuthAndTimeout(t *testing.T) {
|
||||
testKey(t)
|
||||
t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1")
|
||||
for _, mode := range []string{"success", "auth", "timeout", "certificate"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
host, port, roots, accepted := smtpFixture(t, mode == "auth", mode == "timeout")
|
||||
o := &Operations{smtpRoots: roots}
|
||||
if mode == "certificate" {
|
||||
o.smtpRoots = nil
|
||||
}
|
||||
secret, _ := o.seal("smtp-test-secret", "mail:password")
|
||||
c := MailConfig{Host: host, Port: port, TLS: "tls", Username: "test", Password: secret, From: "test@example.com", FromName: "Test", Timeout: 2}
|
||||
e := o.smtp(context.Background(), c, &mailPayload{To: "admin@example.com", Subject: "Test", Body: "<p>test</p>"}, "test")
|
||||
if mode == "success" {
|
||||
if e != nil || accepted.Load() != 1 {
|
||||
t.Fatal(e, accepted.Load())
|
||||
}
|
||||
} else if e == nil {
|
||||
t.Fatal("failure expected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestStorageProbeAndHistoricPrivateObject(t *testing.T) {
|
||||
testKey(t)
|
||||
t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1")
|
||||
o := testOps(t)
|
||||
var mu sync.Mutex
|
||||
objects := map[string][]byte{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
w.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "PUT":
|
||||
var body io.Reader = r.Body
|
||||
if strings.Contains(r.Header.Get("Content-Encoding"), "aws-chunked") || strings.HasPrefix(r.Header.Get("X-Amz-Content-Sha256"), "STREAMING-") {
|
||||
body = httputil.NewChunkedReader(r.Body)
|
||||
}
|
||||
b, _ := io.ReadAll(body)
|
||||
objects[r.URL.Path] = b
|
||||
w.Header().Set("ETag", `"test"`)
|
||||
w.WriteHeader(200)
|
||||
case "GET", "HEAD":
|
||||
b, ok := objects[r.URL.Path]
|
||||
if !ok {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(b)))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("ETag", `"test"`)
|
||||
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||
if r.Method == "GET" {
|
||||
w.Write(b)
|
||||
}
|
||||
case "DELETE":
|
||||
delete(objects, r.URL.Path)
|
||||
w.WriteHeader(204)
|
||||
default:
|
||||
w.WriteHeader(400)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := defaultModule("storage").(*StorageConfig)
|
||||
c.Backend = "s3"
|
||||
c.Endpoint = srv.URL
|
||||
c.Bucket = "test-bucket"
|
||||
c.AccessKey = "test-access"
|
||||
c.SecretKey = "test-secret"
|
||||
if e := o.TestStorage(context.Background(), raw(c), nil); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if len(objects) != 0 {
|
||||
t.Fatal("probe leaked object")
|
||||
}
|
||||
if e := o.Save("storage", 0, raw(c), nil, 1); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "file.txt")
|
||||
os.WriteFile(path, []byte("historic"), 0600)
|
||||
id, e := o.StoreFile(path, "text/plain", false)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
c.Backend = "local"
|
||||
c.AccessKey = ""
|
||||
c.SecretKey = ""
|
||||
if e = o.Save("storage", 1, raw(c), nil, 1); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, _, e = o.OpenObject(context.Background(), id, true); e == nil {
|
||||
t.Fatal("private object publicly accessible")
|
||||
}
|
||||
r, _, e := o.OpenObject(context.Background(), id, false)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer r.Close()
|
||||
b, _ := io.ReadAll(r)
|
||||
if string(b) != "historic" {
|
||||
t.Fatalf("%q", b)
|
||||
}
|
||||
_ = r.Close()
|
||||
if e = o.RemoveObject(id); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, _, e = o.OpenObject(context.Background(), id, false); e == nil {
|
||||
t.Fatal("deleted object accessible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemporaryCleanupProtectsActiveReferencedAndDraftFiles(t *testing.T) {
|
||||
o := testOps(t)
|
||||
if e := o.db.Exec("CREATE TABLE posts (content text); CREATE TABLE comments (content text)").Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
dir := filepath.Join(o.cfg.DataDir, "uploads", "images")
|
||||
_ = os.MkdirAll(dir, 0700)
|
||||
path := filepath.Join(dir, "managed.png.partial")
|
||||
release, e := o.BeginTemporary(path)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_ = os.WriteFile(path, []byte("temporary"), 0600)
|
||||
old := time.Now().Add(-8 * 24 * time.Hour)
|
||||
_ = os.Chtimes(path, old, old)
|
||||
o.db.Model(&model.TemporaryUpload{}).Where("id = ?", counterKey("uploads/images/managed.png.partial")).Update("created_at", old)
|
||||
candidates, e := o.temporaryCandidates()
|
||||
if e != nil || len(candidates) != 0 {
|
||||
t.Fatal("active upload selected", e)
|
||||
}
|
||||
release()
|
||||
candidates, e = o.temporaryCandidates()
|
||||
if e != nil || len(candidates) != 1 {
|
||||
t.Fatal("expired inactive upload not selected", e)
|
||||
}
|
||||
_ = o.db.Exec("INSERT INTO posts(content) VALUES (?)", "referenced "+filepath.Base(path)).Error
|
||||
r, e := o.CleanTemporary([]string{candidates[0].ID})
|
||||
if e != nil || r["removed"] != 0 {
|
||||
t.Fatal("reference deleted", r, e)
|
||||
}
|
||||
o.db.Exec("DELETE FROM posts")
|
||||
draft := filepath.Join(dir, "draft.png")
|
||||
_ = os.WriteFile(draft, []byte("draft"), 0600)
|
||||
_ = os.Chtimes(draft, old, old)
|
||||
unknown := filepath.Join(dir, "legacy.png.partial")
|
||||
_ = os.WriteFile(unknown, []byte("unknown"), 0600)
|
||||
_ = os.Chtimes(unknown, old, old)
|
||||
r, e = o.CleanTemporary([]string{candidates[0].ID})
|
||||
if e != nil || r["removed"] != 1 {
|
||||
t.Fatal(r, e)
|
||||
}
|
||||
for _, p := range []string{draft, unknown} {
|
||||
if _, e = os.Stat(p); e != nil {
|
||||
t.Fatal("protected file removed")
|
||||
}
|
||||
}
|
||||
r, e = o.CleanTemporary([]string{candidates[0].ID})
|
||||
if e != nil || r["removed"] != 0 {
|
||||
t.Fatal("duplicate cleanup", r, e)
|
||||
}
|
||||
}
|
||||
func TestDurableMailRetriesAndCodeSingleUse(t *testing.T) {
|
||||
testKey(t)
|
||||
t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1")
|
||||
o := testOps(t)
|
||||
host, port, roots, _ := smtpFixture(t, true, false)
|
||||
o.smtpRoots = roots
|
||||
c := defaultModule("mail").(*MailConfig)
|
||||
c.Enabled = true
|
||||
c.Host = host
|
||||
c.Port = port
|
||||
c.From = "test@example.com"
|
||||
c.FromName = "Test"
|
||||
c.Username = "test"
|
||||
c.Password = "test"
|
||||
if e := o.Save("mail", 0, raw(c), nil, 1); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e := o.EnqueueMail(o.db, "user@example.com", "register", "single-code", "same-task"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e := o.EnqueueMail(o.db, "user@example.com", "register", "single-code", "same-task"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for range 3 {
|
||||
if e := o.ProcessMail(context.Background()); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
o.db.Model(&model.MailTask{}).Where("dedupe = ?", "same-task").Update("next_at", time.Now().Add(-time.Second))
|
||||
}
|
||||
rows, e := o.MailRows()
|
||||
if e != nil || len(rows) != 1 || rows[0].Status != "failed" || rows[0].Attempts != 3 || rows[0].Payload != "" {
|
||||
t.Fatal(rows, e)
|
||||
}
|
||||
challenge := model.EmailChallenge{Hash: counterKey("user@example.com:register:single-code"), Email: "user@example.com", Purpose: "register", ExpiresAt: time.Now().Add(time.Minute)}
|
||||
if e = o.db.Create(&challenge).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = o.ConsumeCode("user@example.com", "register", "single-code"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = o.ConsumeCode("user@example.com", "register", "single-code"); e == nil {
|
||||
t.Fatal("code replay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailQueueRecoveryAndDestinationQuota(t *testing.T) {
|
||||
testKey(t)
|
||||
t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1")
|
||||
o := testOps(t)
|
||||
host, port, roots, accepted := smtpFixture(t, false, false)
|
||||
c := defaultModule("mail").(*MailConfig)
|
||||
c.Enabled = true
|
||||
c.Host = host
|
||||
c.Port = port
|
||||
c.Username = "test"
|
||||
c.Password = "secret"
|
||||
c.From = "test@example.com"
|
||||
c.FromName = "Test"
|
||||
if e := o.Save("mail", 0, raw(c), nil, 1); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
security := defaultModule("security").(*SecurityConfig)
|
||||
security.VerifyEmail = true
|
||||
if e := o.Save("security", 0, raw(security), nil, 1); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if wait, e := o.SendCode("USER@example.com", "register", "192.0.2.1"); e != nil || wait != 0 {
|
||||
t.Fatal(wait, e)
|
||||
}
|
||||
if wait, e := o.SendCode("user@example.com", "register", "192.0.2.2"); e != nil || wait < 1 {
|
||||
t.Fatal("destination quota bypassed", wait, e)
|
||||
}
|
||||
var task model.MailTask
|
||||
if e := o.db.First(&task).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if strings.Contains(task.Payload, "user@example.com") {
|
||||
t.Fatal("unencrypted queue")
|
||||
}
|
||||
o.db.Model(&task).Updates(map[string]any{"status": "sending", "attempts": 1, "next_at": time.Now().Add(-time.Minute)})
|
||||
restarted := NewOperations(o.db, o.cfg)
|
||||
restarted.smtpRoots = roots
|
||||
if e := restarted.ProcessMail(context.Background()); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var finished model.MailTask
|
||||
o.db.First(&finished, task.ID)
|
||||
if finished.Status != "accepted" || finished.Attempts != 2 || finished.Payload != "" || accepted.Load() != 1 {
|
||||
t.Fatal("queue did not recover", finished.Status, finished.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTemplateGuards(t *testing.T) {
|
||||
var cfg MailConfig
|
||||
cfg.applyTemplateDefaults()
|
||||
if cfg.BodyTemplate != defaultMailBody || cfg.SubjectTemplate != defaultMailSubject {
|
||||
t.Fatal("defaults not applied")
|
||||
}
|
||||
cfg.BodyTemplate = legacyMailBody
|
||||
cfg.applyTemplateDefaults()
|
||||
if cfg.BodyTemplate != defaultMailBody {
|
||||
t.Fatal("legacy default not upgraded")
|
||||
}
|
||||
o := &Operations{cfg: &config.Config{SiteURL: "https://forum.example.com", DevMode: true}}
|
||||
if e := o.validate(nil, "mail", &MailConfig{Port: 465, TLS: "tls", Timeout: 10, Retention: 30, BodyTemplate: "<script></script><p>no code</p>"}); e == nil || !strings.Contains(e.Error(), "{{code}}") {
|
||||
t.Fatal(e)
|
||||
}
|
||||
custom := `<!DOCTYPE html><html><body><style>.x{color:red}</style><script>void 0</script><p class="x">{{code}}</p><a href="{{link}}">go</a></body></html>`
|
||||
p := o.renderMail(MailConfig{BodyTemplate: custom}, "<b>x</b>", "reset")
|
||||
if strings.Contains(p.Body, "<b>x</b>") || !strings.Contains(p.Body, "<b>x</b>") || !strings.Contains(p.Body, "<script>") {
|
||||
t.Fatal(p.Body)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(p.Body), "<style") {
|
||||
t.Fatal("style blocks should be inlined for mail clients", p.Body)
|
||||
}
|
||||
if !strings.Contains(p.Body, "color:red") && !strings.Contains(p.Body, "color: red") {
|
||||
t.Fatal("class styles should be inlined", p.Body)
|
||||
}
|
||||
if !strings.Contains(p.Subject, "密码找回") || strings.Contains(p.Subject, "\n") {
|
||||
t.Fatal(p.Subject)
|
||||
}
|
||||
if !strings.Contains(p.Body, "https://forum.example.com/reset-password") {
|
||||
t.Fatal(p.Body)
|
||||
}
|
||||
def := o.renderMail(MailConfig{}, "123456", "")
|
||||
if strings.Contains(strings.ToLower(def.Body), "<style") {
|
||||
t.Fatal("default template style should be inlined")
|
||||
}
|
||||
if !strings.Contains(def.Body, "background:#f5f7fb") && !strings.Contains(def.Body, "background: #f5f7fb") {
|
||||
t.Fatal("default wrap style missing after prepare", def.Body)
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
|
||||
// 帖子操作错误
|
||||
var (
|
||||
ErrPostNotFound = errors.New("帖子不存在")
|
||||
ErrPostForbidden = errors.New("无权限操作此帖子")
|
||||
ErrPostDeleteMeta = errors.New("选「其他」时须填写删除理由")
|
||||
ErrPostInvalidType = errors.New("无效的删除类型")
|
||||
ErrPostNotFound = errors.New("帖子不存在")
|
||||
ErrPostForbidden = errors.New("无权限操作此帖子")
|
||||
ErrPostDeleteMeta = errors.New("选「其他」时须填写删除理由")
|
||||
ErrPostInvalidType = errors.New("无效的删除类型")
|
||||
)
|
||||
|
||||
// PostService 帖子服务
|
||||
@@ -65,7 +65,7 @@ func toPostListItems(posts []model.Post) []PostListItem {
|
||||
TypeStatus: ComputeTypeStatus(pt, p.TypeMeta),
|
||||
ContentAccess: model.NormalizeContentAccess(p.ContentAccess),
|
||||
AccessPoints: p.AccessPoints,
|
||||
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
|
||||
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
|
||||
CommentCount: p.CommentCount, Status: p.Status, CreatedAt: p.CreatedAt,
|
||||
Board: p.Board, User: p.User,
|
||||
})
|
||||
@@ -505,11 +505,11 @@ func buildPostDetail(post *model.Post) *PostDetail {
|
||||
return &PostDetail{
|
||||
ID: post.ID, BoardID: post.BoardID, UserID: post.UserID,
|
||||
Title: post.Title, Content: post.Content, Tags: post.Tags,
|
||||
PostType: model.NormalizePostType(post.PostType),
|
||||
PostType: model.NormalizePostType(post.PostType),
|
||||
ContentAccess: model.NormalizeContentAccess(post.ContentAccess),
|
||||
AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta,
|
||||
AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta,
|
||||
TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta),
|
||||
Pinned: post.Pinned, Recommended: post.Recommended, Status: post.Status,
|
||||
Pinned: post.Pinned, Recommended: post.Recommended, Status: post.Status,
|
||||
LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount,
|
||||
Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt,
|
||||
Board: post.Board, User: post.User,
|
||||
@@ -694,6 +694,11 @@ func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) {
|
||||
|
||||
// Create 创建帖子。status 由 handler 按角色计算
|
||||
func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
||||
for scope, text := range map[string]string{"title": in.Title, "body": in.Content} {
|
||||
if err := NewOperations(s.db, nil).Filter(scope, text, in.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
title := strings.TrimSpace(in.Title)
|
||||
content := strings.TrimSpace(in.Content)
|
||||
if title == "" {
|
||||
@@ -856,6 +861,11 @@ func validateTypeMeta(postType, meta string) error {
|
||||
|
||||
// Update 更新帖子(作者本人,或对该板块有审核权的管理成员)
|
||||
func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInput) (*PostDetail, error) {
|
||||
for scope, text := range map[string]string{"title": in.Title, "body": in.Content} {
|
||||
if err := NewOperations(s.db, nil).Filter(scope, text, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
|
||||
@@ -31,6 +31,7 @@ var (
|
||||
|
||||
// PostFileService 帖子文件附件(私有目录 + API 下载)
|
||||
type PostFileService struct {
|
||||
ops *Operations
|
||||
db *gorm.DB
|
||||
dir string // data/private/files
|
||||
setting *SettingService
|
||||
@@ -132,6 +133,13 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
|
||||
stored := hex.EncodeToString(raw) + ext
|
||||
full := absPath(s.dir, stored)
|
||||
tmp := full + ".partial"
|
||||
if s.ops != nil {
|
||||
release, e := s.ops.BeginTemporary(tmp)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
@@ -168,6 +176,17 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
|
||||
Size: int(written),
|
||||
PricePoints: pricePoints,
|
||||
}
|
||||
if s.ops != nil {
|
||||
id, e := s.ops.StoreFile(full, att.MIME, false)
|
||||
if e != nil {
|
||||
_ = os.Remove(full)
|
||||
return nil, e
|
||||
}
|
||||
att.ObjectID = id
|
||||
if id != "" {
|
||||
_ = os.Remove(full)
|
||||
}
|
||||
}
|
||||
if err := s.db.Create(att).Error; err != nil {
|
||||
_ = os.Remove(full)
|
||||
return nil, err
|
||||
@@ -268,6 +287,9 @@ func (s *PostFileService) DeleteOwn(userID, attID uint) error {
|
||||
if err := s.db.Delete(&att).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if s.ops != nil && att.ObjectID != "" {
|
||||
return s.ops.RemoveObject(att.ObjectID)
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
return nil
|
||||
}
|
||||
@@ -332,3 +354,5 @@ func (s *PostFileService) EnsureAttachmentUnlocked(userID uint, att *model.PostA
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (s *PostFileService) WithOperations(o *Operations) { s.ops = o }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -33,12 +34,22 @@ func (r *RateLimiter) Allow(key string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
limit, ok := r.limits[key]
|
||||
limit, ok := r.limits[strings.SplitN(key, ":", 2)[0]]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if len(r.records) > 10000 {
|
||||
for k, v := range r.records {
|
||||
if len(v) == 0 || now.Sub(v[len(v)-1]) > time.Minute {
|
||||
delete(r.records, k)
|
||||
}
|
||||
}
|
||||
if len(r.records) > 20000 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
windowStart := now.Add(-1 * time.Minute)
|
||||
|
||||
// 清理过期记录
|
||||
@@ -76,14 +87,14 @@ const (
|
||||
// DefaultRateLimiter 创建默认速率限制器
|
||||
func DefaultRateLimiter() *RateLimiter {
|
||||
rl := NewRateLimiter()
|
||||
rl.SetLimit(RateLogin, 20) // 登录 20/分钟
|
||||
rl.SetLimit(RateRegister, 10) // 注册 10/分钟
|
||||
rl.SetLimit(RatePost, 10) // 发帖 10/分钟
|
||||
rl.SetLimit(RateComment, 30) // 评论 30/分钟
|
||||
rl.SetLimit(RateChat, 30) // 群聊消息 30/分钟
|
||||
rl.SetLimit(RateUpload, 20) // 图片上传 20/分钟
|
||||
rl.SetLimit(RateInteract, 40) // 互动 40/分钟
|
||||
rl.SetLimit(RateHidePassword, 20) // 密码尝试 20/分钟(按 IP)
|
||||
rl.SetLimit(RateTimelineGit, 10) // 时间线 Git 导入 10/分钟
|
||||
rl.SetLimit(RateLogin, 20) // 登录 20/分钟
|
||||
rl.SetLimit(RateRegister, 10) // 注册 10/分钟
|
||||
rl.SetLimit(RatePost, 10) // 发帖 10/分钟
|
||||
rl.SetLimit(RateComment, 30) // 评论 30/分钟
|
||||
rl.SetLimit(RateChat, 30) // 群聊消息 30/分钟
|
||||
rl.SetLimit(RateUpload, 20) // 图片上传 20/分钟
|
||||
rl.SetLimit(RateInteract, 40) // 互动 40/分钟
|
||||
rl.SetLimit(RateHidePassword, 20) // 密码尝试 20/分钟(按 IP)
|
||||
rl.SetLimit(RateTimelineGit, 10) // 时间线 Git 导入 10/分钟
|
||||
return rl
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ const (
|
||||
SettingKeyAllowRegister = "allow_register"
|
||||
// SettingKeyAllowComments 全站开放评论;缺行视为开启(默认开)
|
||||
SettingKeyAllowComments = "allow_comments"
|
||||
// SettingKeyCommentsRequireLogin 评论仅登录可见;缺行视为关闭(游客可读)
|
||||
SettingKeyCommentsRequireLogin = "comments_require_login"
|
||||
// SettingKeyAllowMessages 全站开放私聊/群聊;缺行视为开启(默认开)
|
||||
SettingKeyAllowMessages = "allow_messages"
|
||||
SettingKeyPostCooldownHours = "post_cooldown_hours"
|
||||
@@ -100,8 +102,9 @@ type PublicSiteSettings struct {
|
||||
SiteName string `json:"site_name"`
|
||||
SiteDescription string `json:"site_description"`
|
||||
AllowRegister bool `json:"allow_register"`
|
||||
AllowComments bool `json:"allow_comments"`
|
||||
AllowMessages bool `json:"allow_messages"`
|
||||
AllowComments bool `json:"allow_comments"`
|
||||
CommentsRequireLogin bool `json:"comments_require_login"`
|
||||
AllowMessages bool `json:"allow_messages"`
|
||||
PostCooldownHours int `json:"post_cooldown_hours"`
|
||||
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
|
||||
CodeBlockFoldLines int `json:"code_block_fold_lines"`
|
||||
@@ -181,6 +184,26 @@ func (s *SettingService) setBoolDefaultTrue(key string, on bool) error {
|
||||
return s.putValue(key, "false")
|
||||
}
|
||||
|
||||
// 缺行视为关;仅显式 true/1/on/yes 为开。
|
||||
func parseBoolDefaultFalse(v string, found bool) bool {
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(strings.ToLower(v)) {
|
||||
case "true", "1", "on", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SettingService) setBoolDefaultFalse(key string, on bool) error {
|
||||
if !on {
|
||||
return s.deleteKey(key)
|
||||
}
|
||||
return s.putValue(key, "true")
|
||||
}
|
||||
|
||||
// Public 返回解析后的公开站点设置(缺省已填默认值)
|
||||
func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
out := PublicSiteSettings{
|
||||
@@ -189,6 +212,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
TrustReviewedPublish: true,
|
||||
AllowRegister: true,
|
||||
AllowComments: true,
|
||||
CommentsRequireLogin: false,
|
||||
AllowMessages: true,
|
||||
PostCooldownHours: DefaultCooldownHours,
|
||||
CodeBlockAutoFold: true,
|
||||
@@ -254,6 +278,12 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
}
|
||||
out.AllowComments = comments
|
||||
|
||||
requireLogin, err := s.CommentsRequireLogin()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.CommentsRequireLogin = requireLogin
|
||||
|
||||
messages, err := s.AllowMessages()
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -447,6 +477,20 @@ func (s *SettingService) SetAllowComments(on bool) error {
|
||||
return s.setBoolDefaultTrue(SettingKeyAllowComments, on)
|
||||
}
|
||||
|
||||
// CommentsRequireLogin 评论是否仅登录可见。缺行视为关闭。
|
||||
func (s *SettingService) CommentsRequireLogin() (bool, error) {
|
||||
v, found, err := s.getValue(SettingKeyCommentsRequireLogin)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return parseBoolDefaultFalse(v, found), nil
|
||||
}
|
||||
|
||||
// SetCommentsRequireLogin 持久化「登录可见评论」;关闭时删键保持「缺行=默认关」。
|
||||
func (s *SettingService) SetCommentsRequireLogin(on bool) error {
|
||||
return s.setBoolDefaultFalse(SettingKeyCommentsRequireLogin, on)
|
||||
}
|
||||
|
||||
// AllowMessages 是否允许全站私聊/群聊。缺行视为开启。
|
||||
func (s *SettingService) AllowMessages() (bool, error) {
|
||||
v, found, err := s.getValue(SettingKeyAllowMessages)
|
||||
|
||||
@@ -9,16 +9,12 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// SettingKeyTimelineGitImport 提交导入适配器 JSON(不进公开 settings)
|
||||
const SettingKeyTimelineGitImport = "timeline_git_import"
|
||||
|
||||
const (
|
||||
maxAdapterJSONBytes = 64 * 1024
|
||||
maxAdapterRegexLen = 512
|
||||
maxAdapterSources = 20
|
||||
maxAdapterRegexLen = 512
|
||||
maxAdapterSources = 20
|
||||
)
|
||||
|
||||
// TimelineGitAdapter 声明式提交导入配置
|
||||
// TimelineGitAdapter 声明式提交导入配置(内置,不可在站点设置中编辑)
|
||||
type TimelineGitAdapter struct {
|
||||
MaxPages int `json:"max_pages"`
|
||||
MaxCommits int `json:"max_commits"`
|
||||
@@ -27,15 +23,15 @@ type TimelineGitAdapter struct {
|
||||
|
||||
// TimelineGitSourceCfg 单个 Git 源
|
||||
type TimelineGitSourceCfg struct {
|
||||
ID string `json:"id"`
|
||||
Host string `json:"host"` // 精确主机或 "*"
|
||||
ListPath string `json:"list_path"`
|
||||
CommitPath string `json:"commit_path,omitempty"` // 单条 commit 页
|
||||
APIURL string `json:"api_url"`
|
||||
CommitAPI string `json:"commit_api,omitempty"`
|
||||
Query map[string]string `json:"query"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Pagination string `json:"pagination"` // link_header | query_page
|
||||
ID string `json:"id"`
|
||||
Host string `json:"host"` // 精确主机或 "*"
|
||||
ListPath string `json:"list_path"`
|
||||
CommitPath string `json:"commit_path,omitempty"` // 单条 commit 页
|
||||
APIURL string `json:"api_url"`
|
||||
CommitAPI string `json:"commit_api,omitempty"`
|
||||
Query map[string]string `json:"query"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Pagination string `json:"pagination"` // link_header | query_page
|
||||
Item TimelineGitItemMap `json:"item"`
|
||||
}
|
||||
|
||||
@@ -97,29 +93,6 @@ func DefaultTimelineGitAdapterJSON() string {
|
||||
}`
|
||||
}
|
||||
|
||||
// ValidateTimelineGitAdapterJSON 校验并规范化适配器 JSON
|
||||
func ValidateTimelineGitAdapterJSON(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", errors.New("适配器不能为空")
|
||||
}
|
||||
if len(raw) > maxAdapterJSONBytes {
|
||||
return "", errors.New("适配器过大")
|
||||
}
|
||||
var cfg TimelineGitAdapter
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return "", fmt.Errorf("JSON 无效: %w", err)
|
||||
}
|
||||
if err := validateTimelineGitAdapter(&cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func validateTimelineGitAdapter(cfg *TimelineGitAdapter) error {
|
||||
if cfg.MaxPages < 1 || cfg.MaxPages > 50 {
|
||||
return errors.New("max_pages 须在 1–50")
|
||||
@@ -177,54 +150,14 @@ func validateTimelineGitAdapter(cfg *TimelineGitAdapter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TimelineGitAdapterJSON 读取站点适配器;缺行返回默认
|
||||
func (s *SettingService) TimelineGitAdapterJSON() (string, error) {
|
||||
v, found, err := s.getValue(SettingKeyTimelineGitImport)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found || strings.TrimSpace(v) == "" {
|
||||
return DefaultTimelineGitAdapterJSON(), nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SetTimelineGitAdapterJSON 保存适配器(已校验)
|
||||
func (s *SettingService) SetTimelineGitAdapterJSON(raw string) (string, error) {
|
||||
normalized, err := ValidateTimelineGitAdapterJSON(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.putValue(SettingKeyTimelineGitImport, normalized); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// ResetTimelineGitAdapter 恢复默认
|
||||
func (s *SettingService) ResetTimelineGitAdapter() (string, error) {
|
||||
def := DefaultTimelineGitAdapterJSON()
|
||||
normalized, err := ValidateTimelineGitAdapterJSON(def)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.putValue(SettingKeyTimelineGitImport, normalized); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) loadTimelineGitAdapter() (*TimelineGitAdapter, error) {
|
||||
raw, err := s.TimelineGitAdapterJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := DefaultTimelineGitAdapterJSON()
|
||||
var cfg TimelineGitAdapter
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return nil, errors.New("适配器无效,请在站点设置中修复或恢复默认")
|
||||
return nil, errors.New("内置提交导入规则无效")
|
||||
}
|
||||
if err := validateTimelineGitAdapter(&cfg); err != nil {
|
||||
return nil, errors.New("适配器无效,请在站点设置中修复或恢复默认")
|
||||
return nil, errors.New("内置提交导入规则无效")
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"golang.org/x/image/webp"
|
||||
@@ -37,6 +39,7 @@ const (
|
||||
|
||||
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
|
||||
type UploadService struct {
|
||||
ops *Operations
|
||||
db *gorm.DB
|
||||
dir string // 上传根目录(如 data/uploads)
|
||||
setting *SettingService
|
||||
@@ -122,6 +125,17 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment,
|
||||
Height: cfg.Height,
|
||||
}
|
||||
|
||||
if s.ops != nil {
|
||||
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
|
||||
if e != nil {
|
||||
_ = os.Remove(fullPath)
|
||||
return nil, e
|
||||
}
|
||||
if id != "" {
|
||||
att.URL = "/api/media/" + id
|
||||
_ = os.Remove(fullPath)
|
||||
}
|
||||
}
|
||||
// 附件记录与用户头像更新在同一事务内完成
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(att).Error; err != nil {
|
||||
@@ -204,6 +218,13 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
|
||||
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||
fullPath := filepath.Join(s.dir, "images", filename)
|
||||
tmp := fullPath + ".partial"
|
||||
if s.ops != nil {
|
||||
release, e := s.ops.BeginTemporary(tmp)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
@@ -270,6 +291,17 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
|
||||
Width: w,
|
||||
Height: h,
|
||||
}
|
||||
if s.ops != nil {
|
||||
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
|
||||
if e != nil {
|
||||
_ = os.Remove(fullPath)
|
||||
return nil, e
|
||||
}
|
||||
if id != "" {
|
||||
att.URL = "/api/media/" + id
|
||||
_ = os.Remove(fullPath)
|
||||
}
|
||||
}
|
||||
if err := s.db.Create(att).Error; err != nil {
|
||||
_ = os.Remove(fullPath)
|
||||
return nil, err
|
||||
@@ -310,6 +342,13 @@ func (s *UploadService) SaveBackground(src io.Reader) (string, error) {
|
||||
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||
fullPath := filepath.Join(s.dir, "backgrounds", filename)
|
||||
tmp := fullPath + ".partial"
|
||||
if s.ops != nil {
|
||||
release, e := s.ops.BeginTemporary(tmp)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
@@ -370,6 +409,16 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri
|
||||
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
||||
return "", errors.New("图片不存在或不属于你")
|
||||
}
|
||||
if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
defer r.Close()
|
||||
return s.SaveBackground(r)
|
||||
}
|
||||
abs, ok := s.safeUploadPath(att.URL)
|
||||
if !ok {
|
||||
return "", errors.New("无效的图片地址")
|
||||
@@ -411,7 +460,7 @@ func (s *UploadService) RemoveBackgroundIfUnused(oldURL, siteURL, adminURL strin
|
||||
// UseAvatar 选用一张【本人历史上传】的头像
|
||||
func (s *UploadService) UseAvatar(userID uint, url string) error {
|
||||
url = strings.TrimSpace(url)
|
||||
if !strings.HasPrefix(url, "/uploads/avatars/") || len(url) > 512 {
|
||||
if (!strings.HasPrefix(url, "/uploads/avatars/") && !strings.HasPrefix(url, "/api/media/")) || len(url) > 512 {
|
||||
return errors.New("无效的头像地址")
|
||||
}
|
||||
var count int64
|
||||
@@ -469,6 +518,12 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
|
||||
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
|
||||
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// DB 已清理后再删物理文件;文件删除失败只记日志(不影响用户侧结果)
|
||||
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(att.URL, "/uploads/")))
|
||||
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
|
||||
@@ -476,3 +531,5 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }
|
||||
|
||||
72
backend/tests/operations-api.mjs
Normal file
72
backend/tests/operations-api.mjs
Normal file
@@ -0,0 +1,72 @@
|
||||
// Run only against an isolated development backend and matching Next.js frontend.
|
||||
import assert from "node:assert/strict";
|
||||
const base = process.env.OPS_TEST_BASE_URL;
|
||||
if (process.env.OPS_TEST_CONFIRM !== "isolated" || !base || !/^http:\/\/(127\.0\.0\.1|localhost):3301$/.test(base)) throw Error("Requires isolated test backend on port 3301");
|
||||
const jars = { admin: {}, user: {}, anon: {} };
|
||||
async function request(path, method = "GET", body, who = "admin", csrf = true) {
|
||||
const jar = jars[who]; const headers = { "Content-Type": "application/json", Cookie: Object.entries(jar).map(([k,v])=>k+"="+v).join("; ") };
|
||||
if (csrf && jar.j13_csrf) headers["X-CSRF-Token"] = jar.j13_csrf;
|
||||
const r = await fetch(base+path,{method,headers,...(body === undefined ? {} : {body:JSON.stringify(body)})});
|
||||
for (const c of r.headers.getSetCookie()) { const pair=c.split(";")[0];const i=pair.indexOf("=");jar[pair.slice(0,i)]=pair.slice(i+1); }
|
||||
const text=await r.text();let data;try{data=JSON.parse(text)}catch{data=text};return {status:r.status,data,retry:r.headers.get("retry-after")};
|
||||
}
|
||||
const admin = {username:"ops_admin",email:"admin@example.test",password:"Isolated-test-2026"};
|
||||
const user = {username:"ops_member",email:"member@example.test",password:"Isolated-test-2026"};
|
||||
for (const [who,body] of [["admin",admin],["user",user]]) {
|
||||
let r=await request("/api/login","POST",body,who);
|
||||
if (r.status!==200) r=await request("/api/register","POST",body,who);
|
||||
assert.equal(r.status,200,JSON.stringify(r.data));
|
||||
}
|
||||
const priorMode=(await request("/api/admin/settings/modules/maintenance")).data;priorMode.data.mode="normal";assert.equal((await request("/api/admin/settings/modules/maintenance","PUT",priorMode)).status,200);
|
||||
await request("/api/admin/settings","PUT",{post_cooldown_hours:0});
|
||||
const riskAccount="risk-"+Date.now();
|
||||
const modulePath = n => "/api/admin/settings/modules/"+n;
|
||||
const read=async n=>(await request(modulePath(n))).data;
|
||||
const save=async(n,s)=>(await request(modulePath(n),"PUT",{version:s.version,data:s.data}));
|
||||
for (const path of [modulePath("mail"),modulePath("storage"),"/api/admin/diagnostics"]) {
|
||||
assert.equal((await request(path,"GET",undefined,"anon")).status,401);
|
||||
assert.equal((await request(path,"GET",undefined,"user")).status,403);
|
||||
}
|
||||
for (const path of [modulePath("mail")+"/records",modulePath("filter")+"/records"]) assert.equal((await request(path,"GET",undefined,"user")).status,403);
|
||||
assert.equal((await request(modulePath("security"),"PUT",{},"user")).status,403);
|
||||
assert.equal((await request("/api/admin/maintenance/actions","POST",{action:"scan"},"user")).status,403);
|
||||
assert.equal((await request(modulePath("mail")+"/test","POST",{data:{},action:"connection"},"user")).status,403);
|
||||
assert.equal((await request(modulePath("security"),"PUT",{},"admin",false)).status,403);
|
||||
let sec=await read("security");const originalSec=structuredClone(sec.data);
|
||||
sec.data.allow_register=false;assert.equal((await save("security",sec)).status,200);
|
||||
assert.equal((await request("/api/register","POST",{username:"blocked",password:"Blocked-test-2026"},"anon")).status,403);
|
||||
sec=await read("security");sec.data={...sec.data,allow_register:true,comment_interval:15,login_failures:3};
|
||||
const concurrent=await Promise.all([save("security",sec),save("security",sec)]);
|
||||
assert.deepEqual(concurrent.map(x=>x.status).sort(),[200,409]);
|
||||
console.log("PASS permissions, CSRF, registration, concurrent version conflict");
|
||||
for(let i=0;i<3;i++) assert.equal((await request("/api/login","POST",{username:riskAccount,password:"wrong"},"anon")).status,401);
|
||||
let blocked=await request("/api/login","POST",{username:riskAccount,password:"wrong"},"anon");assert.equal(blocked.status,429);assert.ok(Number(blocked.retry)>0);
|
||||
console.log("PASS direct API login protection and Retry-After");
|
||||
let filter=await read("filter");const originalFilter=structuredClone(filter.data);
|
||||
filter.data={enabled:true,rules:[{id:"api-test",word:"blockedphrase",scopes:["body","comment","username"],action:"block",enabled:true,exceptions:["notblockedphrase"],note:"isolated test"}]};
|
||||
assert.equal((await save("filter",filter)).status,200);
|
||||
const board=(await request("/api/boards")).data.boards[0].id;
|
||||
const post=await request("/api/posts","POST",{board_id:board,title:"Operational validation",content:"Safe validation content"});
|
||||
assert.equal(post.status,200,JSON.stringify(post.data));const postID=post.data.post.id;
|
||||
let r=await request("/api/posts/"+postID,"PUT",{title:"Operational validation",content:"blockedphrase",board_id:board});assert.equal(r.status,400);
|
||||
const tested=await request(modulePath("filter")+"/test","POST",{data:filter.data,scope:"body",text:"notblockedphrase and blockedphrase"});
|
||||
assert.equal(tested.data.result,"block");assert.equal(tested.data.hits[0].excepted,true);
|
||||
r=await request("/api/posts/"+postID+"/comments","POST",{content:"Validation comment"},"user");assert.equal(r.status,200,JSON.stringify(r.data));
|
||||
r=await request("/api/posts/"+postID+"/comments","POST",{content:"Second comment"},"user");assert.equal(r.status,429);assert.ok(r.retry);
|
||||
console.log("PASS create/edit filter, scoped exception, comment interval");
|
||||
let m=await read("maintenance");m.data.mode="readonly";assert.equal((await save("maintenance",m)).status,200);
|
||||
assert.equal((await request("/api/posts/"+postID+"/like","POST",{},"user")).status,503);
|
||||
assert.equal((await request("/api/posts","GET",undefined,"anon")).status,200);
|
||||
m=await read("maintenance");m.data.mode="paused";m.data.title="隔离测试维护中";assert.equal((await save("maintenance",m)).status,200);
|
||||
r=await request("/api/posts","GET",undefined,"anon");assert.equal(r.status,503);assert.ok(r.retry);
|
||||
assert.equal((await request(modulePath("maintenance"))).status,200);
|
||||
assert.equal((await request("/api/login","POST",user,"anon")).status,200);
|
||||
const html=await fetch("http://localhost:3000/");assert.equal(html.status,503);assert.ok(html.headers.get("retry-after"));assert.match(await html.text(),/隔离测试维护中/);
|
||||
const robots=await fetch("http://localhost:3000/robots.txt");assert.equal(robots.status,200);
|
||||
m=await read("maintenance");m.data.mode="normal";assert.equal((await save("maintenance",m)).status,200);
|
||||
assert.equal((await fetch("http://localhost:3000/")).status,200);
|
||||
filter=await read("filter");filter.data=originalFilter;assert.equal((await save("filter",filter)).status,200);
|
||||
sec=await read("security");sec.data=originalSec;assert.equal((await save("security",sec)).status,200);
|
||||
console.log("PASS read-only, paused API/page 503, robots, administrator recovery, restoration");
|
||||
console.log("ALL API CHECKS PASSED; disposable browser login: ops_admin / Isolated-test-2026");
|
||||
|
||||
Reference in New Issue
Block a user