From 6f054a903c5b74cb4c2b9757ce8b99af7970a068 Mon Sep 17 00:00:00 2001 From: freefire Date: Wed, 23 Sep 2026 00:17:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=AB=99=E7=82=B9=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E8=AE=BE=E7=BD=AE=EF=BC=9A=E5=88=86=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E3=80=81=E7=99=BB=E5=BD=95=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E3=80=81=E9=82=AE=E4=BB=B6=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=86=85=E8=81=94=E4=B8=8E=E5=85=B3=E9=97=AD=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=20SSR=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 11 + backend/app.ini.example | 8 + backend/config/config.go | 159 ++- backend/config/config_test.go | 118 ++ backend/go.mod | 14 +- backend/go.sum | 26 +- backend/handler/auth.go | 26 +- backend/handler/comment.go | 14 + backend/handler/handlers.go | 1 + backend/handler/operations.go | 355 ++++++ backend/handler/post.go | 17 + backend/handler/setting.go | 45 +- backend/handler/user.go | 15 + backend/middleware/ratelimit.go | 2 + backend/model/db.go | 1 + backend/model/models.go | 1 + backend/model/operations.go | 66 + backend/router/router.go | 29 +- backend/service/auth.go | 6 + backend/service/brand.go | 32 +- backend/service/brand_store.go | 12 + backend/service/brand_test.go | 31 +- backend/service/comment.go | 6 + backend/service/operations.go | 579 +++++++++ backend/service/operations_filter.go | 159 +++ backend/service/operations_mail.go | 450 +++++++ backend/service/operations_mail_html.go | 265 ++++ backend/service/operations_maintenance.go | 131 ++ backend/service/operations_security.go | 120 ++ backend/service/operations_storage.go | 243 ++++ backend/service/operations_temporary.go | 213 ++++ backend/service/operations_test.go | 625 ++++++++++ backend/service/post.go | 26 +- backend/service/post_file.go | 24 + backend/service/ratelimit.go | 31 +- backend/service/setting.go | 48 +- backend/service/timeline_git_adapter.go | 97 +- backend/service/upload.go | 59 +- backend/tests/operations-api.mjs | 72 ++ deploy/.env.example | 10 + deploy/CHANGELOG.md | 11 + deploy/docker-compose.yml | 4 + docs/screenshots/settings/filter-desktop.jpg | Bin 0 -> 81931 bytes docs/screenshots/settings/filter-mobile.jpg | Bin 0 -> 39700 bytes docs/screenshots/settings/mail-desktop.jpg | Bin 0 -> 82575 bytes docs/screenshots/settings/mail-mobile.jpg | Bin 0 -> 38056 bytes .../settings/maintenance-desktop.jpg | Bin 0 -> 85414 bytes .../settings/maintenance-mobile.jpg | Bin 0 -> 38532 bytes .../settings/security-desktop-dark.jpg | Bin 0 -> 76524 bytes .../screenshots/settings/security-desktop.jpg | Bin 0 -> 78372 bytes .../settings/security-mobile-dark.jpg | Bin 0 -> 36801 bytes docs/screenshots/settings/security-mobile.jpg | Bin 0 -> 36140 bytes docs/screenshots/settings/storage-desktop.jpg | Bin 0 -> 87771 bytes docs/screenshots/settings/storage-mobile.jpg | Bin 0 -> 42378 bytes docs/site-operations.md | 144 +++ frontend/app/admin/content/ContentAdmin.tsx | 161 +-- frontend/app/admin/content/page.tsx | 9 +- .../app/admin/settings/AccessSettings.tsx | 280 +++++ .../app/admin/settings/BasicIdentityPanel.tsx | 229 ++-- frontend/app/admin/settings/BasicSettings.tsx | 39 + frontend/app/admin/settings/BrandSeoPanel.tsx | 289 +++-- frontend/app/admin/settings/FilterRules.tsx | 382 ++++++ .../app/admin/settings/FooterLinksPanel.tsx | 241 ++-- .../app/admin/settings/ModuleSettings.tsx | 27 + .../app/admin/settings/OperationsPanel.tsx | 1075 +++++++++++++++++ frontend/app/admin/settings/SettingsAdmin.tsx | 864 ------------- frontend/app/admin/settings/SettingsShell.tsx | 97 ++ frontend/app/admin/settings/access/page.tsx | 12 + frontend/app/admin/settings/basic/page.tsx | 12 + frontend/app/admin/settings/filter/page.tsx | 10 + frontend/app/admin/settings/layout.tsx | 15 + frontend/app/admin/settings/mail/page.tsx | 10 + .../app/admin/settings/maintenance/page.tsx | 10 + frontend/app/admin/settings/page.tsx | 35 +- frontend/app/admin/settings/storage/page.tsx | 10 + frontend/app/globals.css | 1068 ++++++++++++---- frontend/app/layout.tsx | 25 +- frontend/app/login/page.tsx | 14 +- frontend/app/post/[id]/page.tsx | 20 +- frontend/app/register/RegisterClient.tsx | 225 ++++ frontend/app/register/page.tsx | 156 +-- frontend/app/reset-password/page.tsx | 169 +++ frontend/app/u/[id]/page.tsx | 12 +- frontend/components/CommentSection.tsx | 7 +- frontend/components/CommentsLoginRequired.tsx | 30 + .../components/CommentsPolicyProvider.tsx | 50 +- frontend/components/Footer.tsx | 63 +- frontend/components/Header.tsx | 12 +- frontend/components/OperationalBanner.tsx | 17 + frontend/components/SiteBrandProvider.tsx | 33 +- frontend/components/SiteChrome.tsx | 3 - .../components/admin/AdminSettingsLayout.tsx | 2 +- frontend/lib/api.ts | 82 +- frontend/lib/brand.ts | 20 +- frontend/lib/publicMetaSnapshot.ts | 40 + frontend/lib/realtime.ts | 3 +- frontend/lib/serverData.ts | 5 +- frontend/middleware.ts | 143 ++- 98 files changed, 8239 insertions(+), 2073 deletions(-) create mode 100644 backend/handler/operations.go create mode 100644 backend/model/operations.go create mode 100644 backend/service/operations.go create mode 100644 backend/service/operations_filter.go create mode 100644 backend/service/operations_mail.go create mode 100644 backend/service/operations_mail_html.go create mode 100644 backend/service/operations_maintenance.go create mode 100644 backend/service/operations_security.go create mode 100644 backend/service/operations_storage.go create mode 100644 backend/service/operations_temporary.go create mode 100644 backend/service/operations_test.go create mode 100644 backend/tests/operations-api.mjs create mode 100644 deploy/CHANGELOG.md create mode 100644 docs/screenshots/settings/filter-desktop.jpg create mode 100644 docs/screenshots/settings/filter-mobile.jpg create mode 100644 docs/screenshots/settings/mail-desktop.jpg create mode 100644 docs/screenshots/settings/mail-mobile.jpg create mode 100644 docs/screenshots/settings/maintenance-desktop.jpg create mode 100644 docs/screenshots/settings/maintenance-mobile.jpg create mode 100644 docs/screenshots/settings/security-desktop-dark.jpg create mode 100644 docs/screenshots/settings/security-desktop.jpg create mode 100644 docs/screenshots/settings/security-mobile-dark.jpg create mode 100644 docs/screenshots/settings/security-mobile.jpg create mode 100644 docs/screenshots/settings/storage-desktop.jpg create mode 100644 docs/screenshots/settings/storage-mobile.jpg create mode 100644 docs/site-operations.md create mode 100644 frontend/app/admin/settings/AccessSettings.tsx create mode 100644 frontend/app/admin/settings/BasicSettings.tsx create mode 100644 frontend/app/admin/settings/FilterRules.tsx create mode 100644 frontend/app/admin/settings/ModuleSettings.tsx create mode 100644 frontend/app/admin/settings/OperationsPanel.tsx delete mode 100644 frontend/app/admin/settings/SettingsAdmin.tsx create mode 100644 frontend/app/admin/settings/SettingsShell.tsx create mode 100644 frontend/app/admin/settings/access/page.tsx create mode 100644 frontend/app/admin/settings/basic/page.tsx create mode 100644 frontend/app/admin/settings/filter/page.tsx create mode 100644 frontend/app/admin/settings/layout.tsx create mode 100644 frontend/app/admin/settings/mail/page.tsx create mode 100644 frontend/app/admin/settings/maintenance/page.tsx create mode 100644 frontend/app/admin/settings/storage/page.tsx create mode 100644 frontend/app/register/RegisterClient.tsx create mode 100644 frontend/app/reset-password/page.tsx create mode 100644 frontend/components/CommentsLoginRequired.tsx create mode 100644 frontend/components/OperationalBanner.tsx create mode 100644 frontend/lib/publicMetaSnapshot.ts diff --git a/.env.example b/.env.example index bac536f..2ec0749 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,14 @@ HTTP_PORT=3001 # 开发模式 DEV_MODE=true + +# 正式站点 origin,生产使用 HTTPS;邮件 / Canonical / Sitemap 共用 +SITE_URL=http://localhost:3000 +# 32 字节随机值的标准 Base64;不填写时禁止保存邮件/S3凭据。独立备份,禁止提交真实密钥。 +SETTINGS_MASTER_KEY= +# 逗号分隔的精确内部服务主机名;默认拒绝回环、私网和保留网段 +SERVICE_PRIVATE_HOSTS= +# 仅填写实际可信反向代理 IP/CIDR;默认不信任转发头 +TRUSTED_PROXIES= +# 紧急恢复:1 强制正常运行,修复后台配置后撤销并重启 API +MAINTENANCE_RECOVERY=0 diff --git a/backend/app.ini.example b/backend/app.ini.example index b3c32b6..86e5656 100644 --- a/backend/app.ini.example +++ b/backend/app.ini.example @@ -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 = diff --git a/backend/config/config.go b/backend/config/config.go index 0502ca1..56b5c58 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -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,此列表作兜底。 diff --git a/backend/config/config_test.go b/backend/config/config_test.go index f97c052..8702c06 100644 --- a/backend/config/config_test.go +++ b/backend/config/config_test.go @@ -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) + } + } +} diff --git a/backend/go.mod b/backend/go.mod index a177175..e0b4db8 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ) diff --git a/backend/go.sum b/backend/go.sum index 7d3fc6f..8db50c7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/handler/auth.go b/backend/handler/auth.go index c2b1bfd..ef3ffa6 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -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{ diff --git a/backend/handler/comment.go b/backend/handler/comment.go index 5a06dbf..12c33de 100644 --- a/backend/handler/comment.go +++ b/backend/handler/comment.go @@ -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)) diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 6d45199..5cb252d 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -10,6 +10,7 @@ import ( // Handlers 聚合所有服务引用 type Handlers struct { + Ops *service.Operations Cfg *config.Config Hub *realtime.Hub Auth *service.AuthService diff --git a/backend/handler/operations.go b/backend/handler/operations.go new file mode 100644 index 0000000..374bfc2 --- /dev/null +++ b/backend/handler/operations.go @@ -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": "不支持该维护操作"}) + } +} diff --git a/backend/handler/post.go b/backend/handler/post.go index c01cc86..336d23b 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -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) } diff --git a/backend/handler/setting.go b/backend/handler/setting.go index 6638fcf..cac56de 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -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) } diff --git a/backend/handler/user.go b/backend/handler/user.go index 736cb0b..27a0208 100644 --- a/backend/handler/user.go +++ b/backend/handler/user.go @@ -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")) diff --git a/backend/middleware/ratelimit.go b/backend/middleware/ratelimit.go index ebfac66..31412d0 100644 --- a/backend/middleware/ratelimit.go +++ b/backend/middleware/ratelimit.go @@ -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 } diff --git a/backend/model/db.go b/backend/model/db.go index 07f4227..70188cc 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -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{}, diff --git a/backend/model/models.go b/backend/model/models.go index c79f01b..e1a060b 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -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"` diff --git a/backend/model/operations.go b/backend/model/operations.go new file mode 100644 index 0000000..dd12794 --- /dev/null +++ b/backend/model/operations.go @@ -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"` +} diff --git a/backend/router/router.go b/backend/router/router.go index 438aaff..580f2aa 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -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) diff --git a/backend/service/auth.go b/backend/service/auth.go index 2d3695b..a8306c7 100644 --- a/backend/service/auth.go +++ b/backend/service/auth.go @@ -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("昵称不能为空") diff --git a/backend/service/brand.go b/backend/service/brand.go index a2c98a6..0de2ca9 100644 --- a/backend/service/brand.go +++ b/backend/service/brand.go @@ -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) } diff --git a/backend/service/brand_store.go b/backend/service/brand_store.go index 217e2e9..839424d 100644 --- a/backend/service/brand_store.go +++ b/backend/service/brand_store.go @@ -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("无效的图片地址") diff --git a/backend/service/brand_test.go b/backend/service/brand_test.go index 4011f67..59acfb5 100644 --- a/backend/service/brand_test.go +++ b/backend/service/brand_test.go @@ -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) + } } } diff --git a/backend/service/comment.go b/backend/service/comment.go index 01619fa..464025b 100644 --- a/backend/service/comment.go +++ b/backend/service/comment.go @@ -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("评论内容不能为空") diff --git a/backend/service/operations.go b/backend/service/operations.go new file mode 100644 index 0000000..a6a2dac --- /dev/null +++ b/backend/service/operations.go @@ -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) +} diff --git a/backend/service/operations_filter.go b/backend/service/operations_filter.go new file mode 100644 index 0000000..6cd6672 --- /dev/null +++ b/backend/service/operations_filter.go @@ -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 +} diff --git a/backend/service/operations_mail.go b/backend/service/operations_mail.go new file mode 100644 index 0000000..c53ca51 --- /dev/null +++ b/backend/service/operations_mail.go @@ -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}}

{{site_name}} · {{purpose}}

您的验证码:

{{code}}

15 分钟内有效。如果不是您本人操作,请忽略本邮件。

前往站点完成{{purpose}}(请粘贴以上验证码)

" + +const defaultMailBody = `` + +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 "\"站点" +} + +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 +} diff --git a/backend/service/operations_mail_html.go b/backend/service/operations_mail_html.go new file mode 100644 index 0000000..eea279b --- /dev/null +++ b/backend/service/operations_mail_html.go @@ -0,0 +1,265 @@ +package service + +import ( + "bytes" + "mime/quotedprintable" + "regexp" + "strings" + + "golang.org/x/net/html" +) + +// 手机 QQ / 163 等会丢弃

{{code}}

go` + p := o.renderMail(MailConfig{BodyTemplate: custom}, "x", "reset") + if strings.Contains(p.Body, "x") || !strings.Contains(p.Body, "<b>x</b>") || !strings.Contains(p.Body, "