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) } // randomCode 生成 6 位数字邮箱验证码,供用户阅读输入。 func randomCode() string { const n = 6 b := make([]byte, n) if _, e := io.ReadFull(rand.Reader, b); e != nil { panic(e) } for i := range b { b[i] = '0' + b[i]%10 } return string(b) }