完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 00:17:00 +08:00
parent acde7ee288
commit 6f054a903c
98 changed files with 8239 additions and 2073 deletions

View File

@@ -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 =

View File

@@ -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,此列表作兜底。

View File

@@ -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)
}
}
}

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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{

View File

@@ -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))

View File

@@ -10,6 +10,7 @@ import (
// Handlers 聚合所有服务引用
type Handlers struct {
Ops *service.Operations
Cfg *config.Config
Hub *realtime.Hub
Auth *service.AuthService

View 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": "不支持该维护操作"})
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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"))

View File

@@ -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
}

View File

@@ -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{},

View File

@@ -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"`

View 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"`
}

View File

@@ -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)

View File

@@ -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("昵称不能为空")

View File

@@ -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)
}

View File

@@ -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("无效的图片地址")

View File

@@ -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)
}
}
}

View File

@@ -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("评论内容不能为空")

View 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)
}

View 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
}

View 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
}

View 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()
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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, "&lt;b&gt;x&lt;/b&gt;") || !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)
}
}

View File

@@ -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

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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 }

View 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");