347 lines
8.9 KiB
Go
347 lines
8.9 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const appName = "Update Server"
|
|
|
|
type Config struct {
|
|
AppName string
|
|
HTTPAddr string
|
|
BaseURL string
|
|
DataDir string
|
|
SQLitePath string
|
|
ArtifactsDir string
|
|
MigrationsDir string
|
|
TemplatesDir string
|
|
StaticDir string
|
|
MaxUploadBytes int64
|
|
AdminEmail string
|
|
AdminPassword string
|
|
SessionCookieName string
|
|
CSRFCookieName string
|
|
SessionTTL time.Duration
|
|
SecureCookies bool
|
|
TrustProxyHeaders bool
|
|
ReadTimeout time.Duration
|
|
ReadHeaderTimeout time.Duration
|
|
WriteTimeout time.Duration
|
|
IdleTimeout time.Duration
|
|
ShutdownTimeout time.Duration
|
|
MaxHeaderBytes int
|
|
LoginRateLimitPerMinute int
|
|
LoginRateLimitBurst int
|
|
ClientRateLimitPerMinute int
|
|
ClientRateLimitBurst int
|
|
LogLevel slog.Level
|
|
}
|
|
|
|
func Load() (Config, error) {
|
|
baseURL := getenv("APP_BASE_URL", "http://127.0.0.1:8080")
|
|
parsedBaseURL, err := validateBaseURL(baseURL)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
dataDir, err := filepath.Abs(getenv("DATA_DIR", "data-dev"))
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("resolve DATA_DIR: %w", err)
|
|
}
|
|
|
|
sqlitePath, err := resolvePath("SQLITE_PATH", filepath.Join(dataDir, "db.sqlite"))
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
artifactsDir, err := resolvePath("ARTIFACTS_DIR", filepath.Join(dataDir, "artifacts"))
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
migrationsDir, err := resolvePath("MIGRATIONS_DIR", "migrations")
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
templatesDir, err := resolvePath("TEMPLATES_DIR", "web/templates")
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
staticDir, err := resolvePath("STATIC_DIR", "web/static")
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
|
|
adminPassword := os.Getenv("ADMIN_PASSWORD")
|
|
if adminEmail == "" && strings.TrimSpace(adminPassword) != "" {
|
|
return Config{}, fmt.Errorf("ADMIN_PASSWORD requires ADMIN_EMAIL")
|
|
}
|
|
|
|
if adminEmail != "" && strings.TrimSpace(adminPassword) == "" {
|
|
return Config{}, fmt.Errorf("ADMIN_EMAIL requires ADMIN_PASSWORD")
|
|
}
|
|
|
|
sessionCookieName := getenv("SESSION_COOKIE_NAME", "update_server_session")
|
|
if strings.TrimSpace(sessionCookieName) == "" {
|
|
return Config{}, fmt.Errorf("SESSION_COOKIE_NAME must not be empty")
|
|
}
|
|
|
|
csrfCookieName := getenv("CSRF_COOKIE_NAME", "update_server_csrf")
|
|
if strings.TrimSpace(csrfCookieName) == "" {
|
|
return Config{}, fmt.Errorf("CSRF_COOKIE_NAME must not be empty")
|
|
}
|
|
|
|
sessionTTL, err := parseDuration("SESSION_TTL", "24h")
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
if sessionTTL <= 0 {
|
|
return Config{}, fmt.Errorf("SESSION_TTL must be greater than zero")
|
|
}
|
|
|
|
maxUploadBytes, err := parseInt64("MAX_UPLOAD_BYTES", 1<<30)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
if maxUploadBytes <= 0 {
|
|
return Config{}, fmt.Errorf("MAX_UPLOAD_BYTES must be greater than zero")
|
|
}
|
|
|
|
readTimeout, err := parseDuration("APP_READ_TIMEOUT", "10s")
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
readHeaderTimeout, err := parseDuration("APP_READ_HEADER_TIMEOUT", "5s")
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
writeTimeout, err := parseDuration("APP_WRITE_TIMEOUT", "60s")
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
idleTimeout, err := parseDuration("APP_IDLE_TIMEOUT", "60s")
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
shutdownTimeout, err := parseDuration("APP_SHUTDOWN_TIMEOUT", "10s")
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
maxHeaderBytes, err := parseInt("APP_MAX_HEADER_BYTES", 1<<20)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
if maxHeaderBytes <= 0 {
|
|
return Config{}, fmt.Errorf("APP_MAX_HEADER_BYTES must be greater than zero")
|
|
}
|
|
|
|
trustProxyHeaders, err := parseBool("TRUST_PROXY_HEADERS", false)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
loginRateLimitPerMinute, err := parseInt("APP_LOGIN_RATE_LIMIT_PER_MINUTE", 10)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
loginRateLimitBurst, err := parseInt("APP_LOGIN_RATE_LIMIT_BURST", 5)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
clientRateLimitPerMinute, err := parseInt("APP_CLIENT_RATE_LIMIT_PER_MINUTE", 120)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
clientRateLimitBurst, err := parseInt("APP_CLIENT_RATE_LIMIT_BURST", 60)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
for key, value := range map[string]int{
|
|
"APP_LOGIN_RATE_LIMIT_PER_MINUTE": loginRateLimitPerMinute,
|
|
"APP_LOGIN_RATE_LIMIT_BURST": loginRateLimitBurst,
|
|
"APP_CLIENT_RATE_LIMIT_PER_MINUTE": clientRateLimitPerMinute,
|
|
"APP_CLIENT_RATE_LIMIT_BURST": clientRateLimitBurst,
|
|
} {
|
|
if value <= 0 {
|
|
return Config{}, fmt.Errorf("%s must be greater than zero", key)
|
|
}
|
|
}
|
|
|
|
logLevel, err := parseLogLevel(getenv("APP_LOG_LEVEL", "INFO"))
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
if pathWithin(artifactsDir, staticDir) {
|
|
return Config{}, fmt.Errorf("ARTIFACTS_DIR must be outside STATIC_DIR")
|
|
}
|
|
|
|
return Config{
|
|
AppName: appName,
|
|
HTTPAddr: getenv("APP_ADDR", ":8080"),
|
|
BaseURL: baseURL,
|
|
DataDir: dataDir,
|
|
SQLitePath: sqlitePath,
|
|
ArtifactsDir: artifactsDir,
|
|
MigrationsDir: migrationsDir,
|
|
TemplatesDir: templatesDir,
|
|
StaticDir: staticDir,
|
|
MaxUploadBytes: maxUploadBytes,
|
|
AdminEmail: adminEmail,
|
|
AdminPassword: adminPassword,
|
|
SessionCookieName: sessionCookieName,
|
|
CSRFCookieName: csrfCookieName,
|
|
SessionTTL: sessionTTL,
|
|
SecureCookies: strings.EqualFold(parsedBaseURL.Scheme, "https"),
|
|
TrustProxyHeaders: trustProxyHeaders,
|
|
ReadTimeout: readTimeout,
|
|
ReadHeaderTimeout: readHeaderTimeout,
|
|
WriteTimeout: writeTimeout,
|
|
IdleTimeout: idleTimeout,
|
|
ShutdownTimeout: shutdownTimeout,
|
|
MaxHeaderBytes: maxHeaderBytes,
|
|
LoginRateLimitPerMinute: loginRateLimitPerMinute,
|
|
LoginRateLimitBurst: loginRateLimitBurst,
|
|
ClientRateLimitPerMinute: clientRateLimitPerMinute,
|
|
ClientRateLimitBurst: clientRateLimitBurst,
|
|
LogLevel: logLevel,
|
|
}, nil
|
|
}
|
|
|
|
func getenv(key, fallback string) string {
|
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
return value
|
|
}
|
|
|
|
return fallback
|
|
}
|
|
|
|
func validateBaseURL(raw string) (*url.URL, error) {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse APP_BASE_URL: %w", err)
|
|
}
|
|
|
|
if parsed.Scheme == "" || parsed.Host == "" {
|
|
return nil, fmt.Errorf("APP_BASE_URL must include scheme and host, got %q", raw)
|
|
}
|
|
|
|
if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
|
|
return nil, fmt.Errorf("APP_BASE_URL scheme must be http or https, got %q", parsed.Scheme)
|
|
}
|
|
|
|
return parsed, nil
|
|
}
|
|
|
|
func resolvePath(key, fallback string) (string, error) {
|
|
path, err := filepath.Abs(getenv(key, fallback))
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve %s: %w", key, err)
|
|
}
|
|
|
|
return path, nil
|
|
}
|
|
|
|
func parseDuration(key, fallback string) (time.Duration, error) {
|
|
raw := getenv(key, fallback)
|
|
value, err := time.ParseDuration(raw)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("parse %s: %w", key, err)
|
|
}
|
|
|
|
return value, nil
|
|
}
|
|
|
|
func parseInt64(key string, fallback int64) (int64, error) {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return fallback, nil
|
|
}
|
|
|
|
value, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("parse %s: %w", key, err)
|
|
}
|
|
|
|
return value, nil
|
|
}
|
|
|
|
func parseInt(key string, fallback int) (int, error) {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return fallback, nil
|
|
}
|
|
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("parse %s: %w", key, err)
|
|
}
|
|
|
|
return value, nil
|
|
}
|
|
|
|
func parseBool(key string, fallback bool) (bool, error) {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return fallback, nil
|
|
}
|
|
|
|
value, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return false, fmt.Errorf("parse %s: %w", key, err)
|
|
}
|
|
|
|
return value, nil
|
|
}
|
|
|
|
func parseLogLevel(raw string) (slog.Level, error) {
|
|
switch strings.ToUpper(strings.TrimSpace(raw)) {
|
|
case "DEBUG":
|
|
return slog.LevelDebug, nil
|
|
case "INFO":
|
|
return slog.LevelInfo, nil
|
|
case "WARN", "WARNING":
|
|
return slog.LevelWarn, nil
|
|
case "ERROR":
|
|
return slog.LevelError, nil
|
|
default:
|
|
return 0, fmt.Errorf("APP_LOG_LEVEL must be one of DEBUG, INFO, WARN, ERROR")
|
|
}
|
|
}
|
|
|
|
func pathWithin(candidate, parent string) bool {
|
|
rel, err := filepath.Rel(filepath.Clean(parent), filepath.Clean(candidate))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
if rel == "." {
|
|
return true
|
|
}
|
|
|
|
rel = filepath.ToSlash(rel)
|
|
return rel != ".." && !strings.HasPrefix(rel, "../") && path.Clean(rel) != ".."
|
|
}
|