init
This commit is contained in:
commit
b15b95781c
108 changed files with 14802 additions and 0 deletions
347
internal/config/config.go
Normal file
347
internal/config/config.go
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
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) != ".."
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue