150 lines
4.4 KiB
Go
150 lines
4.4 KiB
Go
// Package config 读取采购服务的启动配置。凭据只允许来自显式环境变量,避免把秘密写入代码或仓库。
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
adminUsernameEnv = "CMBUYER_ADMIN_USERNAME"
|
|
adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT"
|
|
sessionSecretEnv = "CMBUYER_SESSION_SECRET"
|
|
cookieSecureEnv = "CMBUYER_COOKIE_SECURE"
|
|
databaseSourceEnv = "CMBUYER_DATABASE_SOURCE"
|
|
authorizationTTLEnv = "CMBUYER_AUTHORIZATION_TTL"
|
|
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
|
|
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
|
|
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
|
|
minimumSecretLength = 32
|
|
)
|
|
|
|
// Config 是启动采购服务所需的最小安全配置。
|
|
type Config struct {
|
|
AdminUsername string
|
|
AdminPasswordBcrypt string
|
|
SessionSecret []byte
|
|
CookieSecure bool
|
|
DatabaseSource string
|
|
AuthorizationTTL time.Duration
|
|
MaxTaskQuantity int
|
|
MaxTotalPrice string
|
|
EvidenceDirectory string
|
|
}
|
|
|
|
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
|
func LoadFromEnv() (Config, error) {
|
|
return Load(os.LookupEnv)
|
|
}
|
|
|
|
// Load 使用 lookup 读取配置,以便在不污染进程环境的情况下测试启动边界。
|
|
func Load(lookup func(string) (string, bool)) (Config, error) {
|
|
username, err := required(lookup, adminUsernameEnv)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
passwordHash, err := required(lookup, adminPasswordBcryptEnv)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil {
|
|
return Config{}, fmt.Errorf("%s is not a valid bcrypt hash", adminPasswordBcryptEnv)
|
|
}
|
|
|
|
secret, err := required(lookup, sessionSecretEnv)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if len([]byte(secret)) < minimumSecretLength {
|
|
return Config{}, fmt.Errorf("%s must be at least %d bytes", sessionSecretEnv, minimumSecretLength)
|
|
}
|
|
|
|
cookieSecure := false
|
|
if value, present := lookup(cookieSecureEnv); present {
|
|
switch value {
|
|
case "true":
|
|
cookieSecure = true
|
|
case "false":
|
|
cookieSecure = false
|
|
default:
|
|
return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv)
|
|
}
|
|
}
|
|
databaseSource, err := required(lookup, databaseSourceEnv)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
ttlText, err := required(lookup, authorizationTTLEnv)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
ttl, err := time.ParseDuration(ttlText)
|
|
if err != nil || ttl <= 0 {
|
|
return Config{}, fmt.Errorf("%s must be a positive duration", authorizationTTLEnv)
|
|
}
|
|
quantityText, err := required(lookup, maxTaskQuantityEnv)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
maxQuantity, err := strconv.Atoi(quantityText)
|
|
if err != nil || maxQuantity < 1 {
|
|
return Config{}, fmt.Errorf("%s must be a positive integer", maxTaskQuantityEnv)
|
|
}
|
|
maxPrice, err := required(lookup, maxTotalPriceEnv)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if !canonicalMoney(maxPrice) {
|
|
return Config{}, fmt.Errorf("%s must be a canonical positive decimal", maxTotalPriceEnv)
|
|
}
|
|
evidenceDirectory, err := required(lookup, evidenceDirectoryEnv)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
|
|
return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv)
|
|
}
|
|
|
|
return Config{
|
|
AdminUsername: username,
|
|
AdminPasswordBcrypt: passwordHash,
|
|
SessionSecret: []byte(secret),
|
|
CookieSecure: cookieSecure,
|
|
DatabaseSource: databaseSource,
|
|
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
|
|
EvidenceDirectory: evidenceDirectory,
|
|
}, nil
|
|
}
|
|
|
|
func canonicalMoney(value string) bool {
|
|
parts := strings.Split(value, ".")
|
|
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
|
|
return false
|
|
}
|
|
for _, part := range parts {
|
|
for _, ch := range part {
|
|
if ch < '0' || ch > '9' {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return strings.Trim(parts[0]+parts[1], "0") != ""
|
|
}
|
|
|
|
func required(lookup func(string) (string, bool), name string) (string, error) {
|
|
value, present := lookup(name)
|
|
if !present || strings.TrimSpace(value) == "" {
|
|
return "", errors.New(name + " must be set")
|
|
}
|
|
|
|
return value, nil
|
|
}
|