Files
jiang13-bbs/backend/service/operations.go

580 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}