From 99ea9427bd7d171e2fdd5dddca133c36af874161 Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 28 Aug 2026 17:08:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=AF=E5=8A=A8=E6=97=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=A1=A5=E9=BD=90=20config.yaml=20=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E7=9A=84=E9=85=8D=E7=BD=AE=E9=A1=B9=E5=B9=B6=E5=9B=9E=E5=86=99?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + config/config.go | 111 ++++++++++++++++++++++++++++++++++++------ config/config_test.go | 105 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3475e3c..7997cbe 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ go run . | Linux | `/etc/blog_go/config.yaml` | `/srv/blog_go/` | | Windows | `./win/etc/blog_go/config.yaml` | `./win/srv/blog_go/` | +已存在的配置文件若缺少配置项(例如升级前的旧文件没有 `database` 段),启动时会自动用默认值补齐并写回。 + ```yaml # config.yaml database: diff --git a/config/config.go b/config/config.go index ecc0d09..d58541d 100644 --- a/config/config.go +++ b/config/config.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "gopkg.in/yaml.v3" ) @@ -54,8 +55,20 @@ var defaultTrustedProxies = []string{"127.0.0.1", "::1"} const defaultPort = "8080" -// mysqlExampleDBName 作为示例写入新建的配置文件。 -const mysqlExampleDBName = "blog_go" +// MySQL 示例值,用于写入新建配置文件或补齐缺失的数据库段。 +const ( + mysqlExampleDBName = "blog_go" + mysqlExampleUser = "user" + mysqlExamplePassword = "password" + mysqlExampleHost = "127.0.0.1" + mysqlExamplePort = "3306" +) + +// databaseKeys / webKeys 用于检查配置文件中缺失的子键。 +var ( + databaseKeys = []string{"type", "db_name", "username", "password", "host", "port"} + webKeys = []string{"port", "socket", "trusted_proxies"} +) // getConfigPath 返回按操作系统区分的配置目录和配置文件路径。 func getConfigPath() (dir, file string) { @@ -122,10 +135,10 @@ func LoadConfig(customPath string) *Config { Database: DatabaseConfig{ Type: "sqlite", DBName: mysqlExampleDBName, - Username: "user", - Password: "password", - Host: "127.0.0.1", - Port: "3306", + Username: mysqlExampleUser, + Password: mysqlExamplePassword, + Host: mysqlExampleHost, + Port: mysqlExamplePort, }, Web: WebConfig{ Port: defaultPort, @@ -135,14 +148,7 @@ func LoadConfig(customPath string) *Config { 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) - } + writeConfigFile(configFile, cfg) // SECURITY_TODO #11:配置文件保存会话密钥;仅允许所有者读取 // (install_linux.sh 已应用 0640 权限)。 log.Printf("Default config created at %s", configFile) @@ -158,9 +164,84 @@ func LoadConfig(customPath string) *Config { 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) } - return applyDefaults(cfg, defaultPath, configFile) + cfg = applyDefaults(cfg, defaultPath, configFile) + + // 检查配置文件里缺失的键(如旧版本没有 database 段),缺失项自动补全并回写。 + if missing := missingConfigKeys(data); len(missing) > 0 { + fillDatabaseExamples(&cfg.Database) + writeConfigFile(configFile, cfg) + log.Printf("Config file %s was missing: %s. Added defaults.", configFile, strings.Join(missing, ", ")) + } + + return cfg +} + +// writeConfigFile 以 0640 权限写入配置文件;已有文件保留原权限。 +func writeConfigFile(configFile string, cfg *Config) { + data, err := yaml.Marshal(cfg) + if err != nil { + log.Fatalf("Failed to marshal config: %v", err) + } + if err := os.WriteFile(configFile, data, 0640); err != nil { + log.Fatalf("Failed to write config file %s: %v", configFile, err) + } +} + +// missingConfigKeys 返回配置文件中缺失的键(含顶层与 database/web 子键)。 +// 解析失败时不返回任何缺失(调用方已按 malformed 路径处理)。 +func missingConfigKeys(data []byte) []string { + raw := map[string]any{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil + } + var missing []string + if db, ok := raw["database"].(map[string]any); ok { + for _, k := range databaseKeys { + if _, ok := db[k]; !ok { + missing = append(missing, "database."+k) + } + } + } else { + missing = append(missing, "database") + } + if web, ok := raw["web"].(map[string]any); ok { + for _, k := range webKeys { + if _, ok := web[k]; !ok { + missing = append(missing, "web."+k) + } + } + } else { + missing = append(missing, "web") + } + if _, ok := raw["path"]; !ok { + missing = append(missing, "path") + } + return missing +} + +// fillDatabaseExamples 用示例值填充数据库段中的空字段,用于补齐缺失配置后的回写。 +func fillDatabaseExamples(d *DatabaseConfig) { + if d.Type == "" { + d.Type = "sqlite" + } + if d.DBName == "" { + d.DBName = mysqlExampleDBName + } + if d.Username == "" { + d.Username = mysqlExampleUser + } + if d.Password == "" { + d.Password = mysqlExamplePassword + } + if d.Host == "" { + d.Host = mysqlExampleHost + } + if d.Port == "" { + d.Port = mysqlExamplePort + } } // applyDefaults 以合理的默认值填充零值字段。 diff --git a/config/config_test.go b/config/config_test.go index e94080a..25eb697 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,9 +1,13 @@ package config import ( + "bytes" "os" "path/filepath" + "strings" "testing" + + "gopkg.in/yaml.v3" ) // TestConfigFileCreatedNotWorldReadable 覆盖 SECURITY_TODO #11: @@ -34,3 +38,104 @@ func TestMySQLDSN(t *testing.T) { t.Fatalf("MySQLDSN() = %q, want %q", got, want) } } + +// TestLoadConfigFillsMissingKeys 覆盖启动时补齐缺失配置项并回写文件: +// 旧格式配置(无 database 段)应被补全,且不覆盖已存在的 path。 +func TestLoadConfigFillsMissingKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + secret := strings.Repeat("a", 64) + old := "secret: " + secret + "\npath: ./data\n" + if err := os.WriteFile(path, []byte(old), 0640); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg := LoadConfig(path) + + if cfg.Database.Type != "sqlite" || cfg.Database.DBName != mysqlExampleDBName || + cfg.Database.Username != mysqlExampleUser || cfg.Database.Password != mysqlExamplePassword || + cfg.Database.Host != mysqlExampleHost || cfg.Database.Port != mysqlExamplePort { + t.Fatalf("database defaults not filled: %+v", cfg.Database) + } + if cfg.Web.Port != defaultPort { + t.Fatalf("web port = %q, want %q", cfg.Web.Port, defaultPort) + } + if cfg.Path != "./data" { + t.Fatalf("path = %q, want ./data (must not be overwritten)", cfg.Path) + } + + st, err := os.Stat(path) + if err != nil { + t.Fatalf("stat config: %v", err) + } + if perm := st.Mode().Perm(); perm != 0640 { + t.Fatalf("config perms = %v, want 0640", perm) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read config: %v", err) + } + back := &Config{} + if err := yaml.Unmarshal(data, back); err != nil { + t.Fatalf("reload config: %v", err) + } + if back.Database.DBName != mysqlExampleDBName { + t.Fatalf("rewritten file db_name = %q, want %q", back.Database.DBName, mysqlExampleDBName) + } +} + +// TestLoadConfigKeepsCompleteFile 覆盖配置完整时不回写文件。 +func TestLoadConfigKeepsCompleteFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + content := "database:\n" + + " type: mysql\n" + + " db_name: myblog\n" + + " username: root\n" + + " password: p@ss\n" + + " host: db.local\n" + + " port: \"3307\"\n" + + "web:\n" + + " port: \"8080\"\n" + + " socket: \"\"\n" + + " trusted_proxies:\n" + + " - 127.0.0.1\n" + + " - ::1\n" + + "path: ./data\n" + + "secret: " + strings.Repeat("a", 64) + "\n" + if err := os.WriteFile(path, []byte(content), 0640); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg := LoadConfig(path) + + if cfg.Database.Password != "p@ss" { + t.Fatalf("password = %q, want p@ss (must not be overwritten)", cfg.Database.Password) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read config: %v", err) + } + if !bytes.Equal(after, []byte(content)) { + t.Fatalf("complete config file was rewritten:\n%s", after) + } +} + +// TestLoadConfigMalformedNoRewrite 覆盖解析失败的配置文件不被回写。 +func TestLoadConfigMalformedNoRewrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + // web.port 为序列不能解码到 string,yaml.Unmarshal 报错。 + content := "secret: " + strings.Repeat("a", 64) + "\nweb:\n port: [8080]\n" + if err := os.WriteFile(path, []byte(content), 0640); err != nil { + t.Fatalf("write config: %v", err) + } + + LoadConfig(path) + + after, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read config: %v", err) + } + if !bytes.Equal(after, []byte(content)) { + t.Fatalf("malformed config was rewritten:\n%s", after) + } +}