Files
jiang13-bbs/backend/config/config.go
freefire d784b0ea7a feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS
站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 05:27:33 +08:00

230 lines
6.0 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
DBDSN string
DevMode bool
SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用
CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量)
}
// Parse 解析配置:环境变量 > app.ini > 默认值。
// 工作目录不跟 os.Getwd() 走:从 cwd 向上查找 backend 根(含 app.ini 或源码标记),
// 避免在仓库根 / cmd/jiang13 启动时把上传文件写进另一套 data/。
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,
}
// 尝试加载 app.ini
iniPath := filepath.Join(workPath, "app.ini")
if _, err := os.Stat(iniPath); err == nil {
f, err := ini.Load(iniPath)
if err != nil {
return nil, fmt.Errorf("加载 app.ini 失败: %w", err)
}
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("paths"); s.HasKey("DATA") {
dataRel := s.Key("DATA").String()
if !filepath.IsAbs(dataRel) {
cfg.DataDir = filepath.Join(workPath, dataRel)
} else {
cfg.DataDir = dataRel
}
}
if s := f.Section("app"); s.HasKey("DEV_MODE") {
cfg.DevMode = s.Key("DEV_MODE").MustBool(true)
}
}
// 环境变量覆盖
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 := 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
}
// 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)
}