Files
jiang13-bbs/backend/config/config.go

317 lines
9.7 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 config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/ini.v1"
)
// Config 应用全局配置
type Config struct {
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 {
return nil, err
}
// 默认值
cfg := &Config{
WorkPath: workPath,
Port: 3001,
DataDir: filepath.Join(workPath, "data"),
DBDSN: "postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable",
DevMode: true,
}
iniPath := filepath.Join(workPath, "app.ini")
f, err := loadAndEnsureAppIni(iniPath)
if err != nil {
return nil, err
}
if f != nil {
applyIni(cfg, f)
}
// 环境变量覆盖
if v := os.Getenv("HTTP_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
cfg.Port = p
}
}
if v := os.Getenv("DB_DSN"); v != "" {
cfg.DBDSN = v
}
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"
}
if v := strings.TrimSpace(os.Getenv("SITE_URL")); v != "" {
cfg.SiteURL = strings.TrimRight(v, "/")
}
if v := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); v != "" {
cfg.CORSOrigins = splitCSVOrigins(v)
}
if v := strings.TrimSpace(os.Getenv("DATA_DIR")); v != "" {
abs, err := filepath.Abs(v)
if err != nil {
return nil, fmt.Errorf("解析 DATA_DIR 失败: %w", err)
}
cfg.DataDir = abs
}
if err := rejectStrayDataDir(cfg.DataDir); err != nil {
return nil, err
}
// 确保数据目录存在
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
return nil, fmt.Errorf("创建数据目录失败: %w", err)
}
// JWT 密钥:留空则自动生成并持久化
if strings.TrimSpace(cfg.JWTSecret) == "" {
secretFile := filepath.Join(cfg.DataDir, ".jwt_secret")
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
cfg.JWTSecret = string(data)
} else {
cfg.JWTSecret = generateSecret(32)
_ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600)
}
}
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,此列表作兜底。
func (c *Config) AllowOrigin(origin string) bool {
if origin == "" {
return false
}
origin = strings.TrimRight(origin, "/")
if c.DevMode {
return origin == "http://localhost:3000" || origin == "http://127.0.0.1:3000"
}
if c.SiteURL != "" && origin == c.SiteURL {
return true
}
for _, o := range c.CORSOrigins {
if origin == o {
return true
}
}
return false
}
func splitCSVOrigins(v string) []string {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
s := strings.TrimRight(strings.TrimSpace(p), "/")
if s != "" {
out = append(out, s)
}
}
return out
}
// resolveWorkPath 定位后端根目录(app.ini 与 data/ 所在处)。
// 优先级:JIANG13_WORK_PATH > 从 cwd 向上找 app.ini > 源码树 backend/ > cwd。
func resolveWorkPath() (string, error) {
if v := strings.TrimSpace(os.Getenv("JIANG13_WORK_PATH")); v != "" {
abs, err := filepath.Abs(v)
if err != nil {
return "", fmt.Errorf("解析 JIANG13_WORK_PATH 失败: %w", err)
}
return abs, nil
}
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("获取工作目录失败: %w", err)
}
dir := cwd
for range 8 {
if hasAppIni(dir) {
return dir, nil
}
nested := filepath.Join(dir, "backend")
if hasAppIni(nested) {
return nested, nil
}
if isSourceBackendRoot(dir) {
return dir, nil
}
if isSourceBackendRoot(nested) {
return nested, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return cwd, nil
}
func hasAppIni(dir string) bool {
_, err := os.Stat(filepath.Join(dir, "app.ini"))
return err == nil
}
func isSourceBackendRoot(dir string) bool {
_, e1 := os.Stat(filepath.Join(dir, "go.mod"))
_, e2 := os.Stat(filepath.Join(dir, "cmd", "jiang13"))
_, e3 := os.Stat(filepath.Join(dir, "app.ini.example"))
return e1 == nil && e2 == nil && e3 == nil
}
// rejectStrayDataDir 拦住把上传写进 cmd/jiang13/data 的错误 cwd。
func rejectStrayDataDir(dataDir string) error {
slash := filepath.ToSlash(dataDir)
if strings.Contains(slash, "/cmd/jiang13/") {
return fmt.Errorf("拒绝使用数据目录 %s:这是在 cmd/jiang13 下启动产生的错误路径。请在 backend/ 执行 go run ./cmd/jiang13,或设置 JIANG13_WORK_PATH 指向 backend 目录", dataDir)
}
return nil
}
func generateSecret(n int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = chars[i%len(chars)]
}
return string(b)
}