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

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

View File

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