This commit is contained in:
delete 2026-06-10 20:51:17 +03:00
commit b15b95781c
108 changed files with 14802 additions and 0 deletions

347
internal/config/config.go Normal file
View 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) != ".."
}