- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
593 lines
19 KiB
Go
593 lines
19 KiB
Go
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)
|
||
}
|