- 48 个 Go 文件所有注释(行注释/块注释/行尾注释,含 _test.go)翻译为中文 - 保留技术标识符:SECURITY_TODO(n)、unsafe-inline、sqlite/mysql、路由参数等 - 代码、字符串字面量、日志消息保持英文原文,零逻辑改动 - go build/vet 通过,go test -count=1 ./... 全绿
174 lines
5.4 KiB
Go
174 lines
5.4 KiB
Go
package config
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
// Config 保存全部应用配置。
|
||
type Config struct {
|
||
Database DatabaseConfig `yaml:"database"`
|
||
Web WebConfig `yaml:"web"`
|
||
Path string `yaml:"path"`
|
||
Secret string `yaml:"secret"`
|
||
}
|
||
|
||
// DatabaseConfig 保存数据库相关配置。
|
||
type DatabaseConfig struct {
|
||
Type string `yaml:"type"` // "sqlite"(默认)或 "mysql"
|
||
DSN string `yaml:"dsn"` // MySQL 连接字符串(type 为 "mysql" 时必填)
|
||
}
|
||
|
||
// WebConfig 保存 Web 服务器监听配置。
|
||
type WebConfig struct {
|
||
Port string `yaml:"port"` // TCP 端口,"" 或 "0" 表示禁用
|
||
Socket string `yaml:"socket"` // Unix Socket 路径,"" 表示禁用
|
||
// TrustedProxies 列出可信代理 IP/CIDR,这些代理的 X-Forwarded-For /
|
||
// X-Forwarded-Proto 请求头将被信任(例如位于应用前方的 Caddy/nginx
|
||
// 服务器)。默认为回环地址。如果应用直接暴露给客户端,请保持默认值,
|
||
// 以免客户端伪造 X-Forwarded-For 欺骗记录中的 IP。
|
||
TrustedProxies []string `yaml:"trusted_proxies"`
|
||
}
|
||
|
||
// defaultTrustedProxies 在配置中省略 trusted_proxies 时使用。
|
||
var defaultTrustedProxies = []string{"127.0.0.1", "::1"}
|
||
|
||
const defaultPort = "8080"
|
||
|
||
// mysqlExampleDSN 会写入新建的配置文件,作为参考示例。
|
||
const mysqlExampleDSN = "user:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local"
|
||
|
||
// getConfigPath 返回按操作系统区分的配置目录和配置文件路径。
|
||
func getConfigPath() (dir, file string) {
|
||
switch runtime.GOOS {
|
||
case "windows":
|
||
dir = filepath.Join(".", "win", "etc", "blog_go")
|
||
case "darwin":
|
||
dir = filepath.Join(".", "mac", "etc", "blog_go")
|
||
default:
|
||
dir = filepath.Join("/", "etc", "blog_go")
|
||
}
|
||
file = filepath.Join(dir, "config.yaml")
|
||
return
|
||
}
|
||
|
||
// getDefaultStoragePath 返回按操作系统区分的默认存储路径。
|
||
func getDefaultStoragePath() string {
|
||
switch runtime.GOOS {
|
||
case "windows":
|
||
return filepath.Join(".", "win", "srv", "blog_go")
|
||
case "darwin":
|
||
return filepath.Join(".", "mac", "srv", "blog_go")
|
||
default:
|
||
return filepath.Join("/", "srv", "blog_go")
|
||
}
|
||
}
|
||
|
||
// generateSecret 为会话密钥生成密码学随机的十六进制字符串。
|
||
// crypto/rand 的失败无法恢复,因此程序将直接终止,而不会退回到可预测的值。
|
||
func generateSecret() string {
|
||
b := make([]byte, 32)
|
||
if _, err := rand.Read(b); err != nil {
|
||
log.Fatalf("Failed to generate session secret: %v", err)
|
||
}
|
||
return hex.EncodeToString(b)
|
||
}
|
||
|
||
// getDefaultSocketPath 返回按操作系统区分的默认 Unix Socket 路径。
|
||
func getDefaultSocketPath() string {
|
||
if runtime.GOOS == "linux" {
|
||
return "/run/blog_go/web.sock"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// LoadConfig 读取配置文件;若不存在则按默认值创建。
|
||
// 若 customPath 非空,则覆盖按操作系统区分的配置文件路径。
|
||
func LoadConfig(customPath string) *Config {
|
||
configDir, configFile := getConfigPath()
|
||
if customPath != "" {
|
||
configFile = customPath
|
||
configDir = filepath.Dir(configFile)
|
||
}
|
||
defaultPath := getDefaultStoragePath()
|
||
|
||
// 检查配置文件是否存在;不存在则按默认值创建。
|
||
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||
log.Printf("Config file not found at %s, creating with defaults...", configFile)
|
||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||
log.Fatalf("Failed to create config directory %s: %v", configDir, err)
|
||
}
|
||
|
||
cfg := &Config{
|
||
Database: DatabaseConfig{
|
||
Type: "sqlite",
|
||
DSN: mysqlExampleDSN,
|
||
},
|
||
Web: WebConfig{
|
||
Port: defaultPort,
|
||
Socket: getDefaultSocketPath(),
|
||
},
|
||
Path: defaultPath,
|
||
Secret: generateSecret(),
|
||
}
|
||
|
||
data, err := yaml.Marshal(cfg)
|
||
if err != nil {
|
||
log.Fatalf("Failed to marshal default config: %v", err)
|
||
}
|
||
|
||
if err := os.WriteFile(configFile, data, 0640); err != nil {
|
||
log.Fatalf("Failed to write config file %s: %v", configFile, err)
|
||
}
|
||
// SECURITY_TODO #11:配置文件保存会话密钥;仅允许所有者读取
|
||
// (install_linux.sh 已应用 0640 权限)。
|
||
log.Printf("Default config created at %s", configFile)
|
||
return cfg
|
||
}
|
||
|
||
// 读取现有配置文件。
|
||
data, err := os.ReadFile(configFile)
|
||
if err != nil {
|
||
log.Fatalf("Failed to read config file %s: %v", configFile, err)
|
||
}
|
||
|
||
cfg := &Config{}
|
||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||
log.Printf("Warning: malformed config file %s: %v, using defaults", configFile, err)
|
||
}
|
||
|
||
return applyDefaults(cfg, defaultPath, configFile)
|
||
}
|
||
|
||
// applyDefaults 以合理的默认值填充零值字段。
|
||
func applyDefaults(cfg *Config, defaultPath, configFile string) *Config {
|
||
// 如果整个 web 块为空(旧配置中没有 "web" 键),
|
||
// 填充默认端口,使应用仍能从 8080 启动。
|
||
if cfg.Web.Port == "" && cfg.Web.Socket == "" {
|
||
cfg.Web.Port = defaultPort
|
||
}
|
||
if len(cfg.Web.TrustedProxies) == 0 {
|
||
cfg.Web.TrustedProxies = defaultTrustedProxies
|
||
}
|
||
if cfg.Database.Type == "" {
|
||
cfg.Database.Type = "sqlite"
|
||
}
|
||
if cfg.Path == "" {
|
||
cfg.Path = defaultPath
|
||
}
|
||
if cfg.Secret == "" {
|
||
// 配置文件存在但没有密钥。拒绝启动:静默生成的回退值要么
|
||
// 可预测(旧的 hostname+pid 方案),要么导致每次重启都使所有会话失效。
|
||
log.Fatalf("Config file %s is missing a session secret. "+
|
||
"Add a random value, e.g. `secret: %s`, and restart.",
|
||
configFile, generateSecret())
|
||
}
|
||
return cfg
|
||
}
|