To gitea and beyond, let's go(-yco)

This commit is contained in:
2025-11-10 19:12:09 +01:00
parent 8f6133392d
commit 71a031342b
245 changed files with 83994 additions and 0 deletions

318
internal/config/config.go Normal file
View File

@@ -0,0 +1,318 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
Database DatabaseConfig
Server ServerConfig
JWT JWTConfig
SMTP SMTPConfig
App AppConfig
RateLimit RateLimitConfig
LogDir string
PIDDir string
}
type DatabaseConfig struct {
Host string
Port string
User string
Password string
Name string
SSLMode string
}
type ServerConfig struct {
Port string
Host string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
MaxHeaderBytes int
EnableTLS bool
TLSCertFile string
TLSKeyFile string
}
type JWTConfig struct {
Secret string
Expiration int
RefreshExpiration int
Issuer string
Audience string
KeyRotation KeyRotationConfig
}
type KeyRotationConfig struct {
Enabled bool
CurrentKey string
PreviousKey string
KeyID string
}
type SMTPConfig struct {
Host string
Port int
Username string
Password string
From string
Timeout time.Duration
}
type AppConfig struct {
BaseURL string
Debug bool
AdminEmail string
BcryptCost int
Title string
}
type RateLimitConfig struct {
AuthLimit int
GeneralLimit int
HealthLimit int
MetricsLimit int
TrustProxyHeaders bool
}
func Load() (*Config, error) {
config := &Config{
Database: DatabaseConfig{
Host: getEnv("DB_HOST", "localhost"),
Port: getEnv("DB_PORT", "5432"),
User: getEnv("DB_USER", "postgres"),
Password: getEnv("DB_PASSWORD", ""),
Name: getEnv("DB_NAME", "goyco"),
SSLMode: getEnv("DB_SSLMODE", "disable"),
},
Server: ServerConfig{
Port: getEnv("SERVER_PORT", "8080"),
Host: getEnv("SERVER_HOST", "0.0.0.0"),
ReadTimeout: time.Duration(getEnvAsInt("SERVER_READ_TIMEOUT", 30)) * time.Second,
WriteTimeout: time.Duration(getEnvAsInt("SERVER_WRITE_TIMEOUT", 30)) * time.Second,
IdleTimeout: time.Duration(getEnvAsInt("SERVER_IDLE_TIMEOUT", 120)) * time.Second,
MaxHeaderBytes: getEnvAsInt("SERVER_MAX_HEADER_BYTES", 1<<20),
EnableTLS: getEnvAsBool("SERVER_ENABLE_TLS", false),
TLSCertFile: getEnv("SERVER_TLS_CERT_FILE", ""),
TLSKeyFile: getEnv("SERVER_TLS_KEY_FILE", ""),
},
JWT: JWTConfig{
Secret: getEnv("JWT_SECRET", "your-secret-key"),
Expiration: getEnvAsInt("JWT_EXPIRATION", 1),
RefreshExpiration: getEnvAsInt("JWT_REFRESH_EXPIRATION", 168),
Issuer: getEnv("JWT_ISSUER", "goyco"),
Audience: getEnv("JWT_AUDIENCE", "goyco-users"),
KeyRotation: KeyRotationConfig{
Enabled: getEnvAsBool("JWT_KEY_ROTATION_ENABLED", false),
CurrentKey: getEnv("JWT_CURRENT_KEY", ""),
PreviousKey: getEnv("JWT_PREVIOUS_KEY", ""),
KeyID: getEnv("JWT_KEY_ID", "default"),
},
},
SMTP: SMTPConfig{
Host: getEnv("SMTP_HOST", ""),
Port: getEnvAsInt("SMTP_PORT", 587),
Username: getEnv("SMTP_USERNAME", ""),
Password: getEnv("SMTP_PASSWORD", ""),
From: getEnv("SMTP_FROM", ""),
Timeout: time.Duration(getEnvAsInt("SMTP_TIMEOUT", 30)) * time.Second,
},
App: AppConfig{
BaseURL: getEnv("APP_BASE_URL", ""),
Debug: getEnvAsBool("DEBUG", false),
AdminEmail: getEnv("ADMIN_EMAIL", ""),
BcryptCost: getEnvAsInt("BCRYPT_COST", 10),
Title: getEnv("TITLE", "Goyco"),
},
RateLimit: RateLimitConfig{
AuthLimit: getEnvAsInt("RATE_LIMIT_AUTH", 5),
GeneralLimit: getEnvAsInt("RATE_LIMIT_GENERAL", 100),
HealthLimit: getEnvAsInt("RATE_LIMIT_HEALTH", 60),
MetricsLimit: getEnvAsInt("RATE_LIMIT_METRICS", 10),
TrustProxyHeaders: getEnvAsBool("RATE_LIMIT_TRUST_PROXY", false),
},
LogDir: getEnv("LOG_DIR", "/var/log/"),
PIDDir: getEnv("PID_DIR", "/run"),
}
if config.App.BaseURL == "" {
config.App.BaseURL = fmt.Sprintf("http://%s:%s", config.Server.Host, config.Server.Port)
}
if config.Database.Password == "" {
return nil, fmt.Errorf("DB_PASSWORD is required")
}
if strings.TrimSpace(config.SMTP.Host) == "" {
return nil, fmt.Errorf("SMTP_HOST is required")
}
if config.SMTP.Port <= 0 {
return nil, fmt.Errorf("SMTP_PORT must be greater than 0")
}
if strings.TrimSpace(config.SMTP.From) == "" {
return nil, fmt.Errorf("SMTP_FROM is required")
}
if strings.TrimSpace(config.App.AdminEmail) == "" {
return nil, fmt.Errorf("ADMIN_EMAIL is required")
}
if config.Server.EnableTLS {
if strings.TrimSpace(config.Server.TLSCertFile) == "" {
return nil, fmt.Errorf("SERVER_TLS_CERT_FILE is required when SERVER_ENABLE_TLS is true")
}
if strings.TrimSpace(config.Server.TLSKeyFile) == "" {
return nil, fmt.Errorf("SERVER_TLS_KEY_FILE is required when SERVER_ENABLE_TLS is true")
}
}
if err := validateJWTConfig(&config.JWT); err != nil {
return nil, err
}
if err := validateAppConfig(&config.App); err != nil {
return nil, err
}
return config, nil
}
func (c *Config) GetConnectionString() string {
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s client_encoding=UTF8",
c.Database.Host,
c.Database.Port,
c.Database.User,
c.Database.Password,
c.Database.Name,
c.Database.SSLMode,
)
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvAsInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}
func getEnvAsBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" {
if boolValue, err := strconv.ParseBool(value); err == nil {
return boolValue
}
}
return defaultValue
}
func validateJWTConfig(jwt *JWTConfig) error {
if err := validateJWTSecret(jwt.Secret); err != nil {
return err
}
if strings.TrimSpace(jwt.Issuer) == "" {
return fmt.Errorf("JWT_ISSUER is required and cannot be empty")
}
if strings.TrimSpace(jwt.Audience) == "" {
return fmt.Errorf("JWT_AUDIENCE is required and cannot be empty")
}
if jwt.Expiration <= 0 {
return fmt.Errorf("JWT_EXPIRATION must be greater than 0")
}
if jwt.RefreshExpiration <= 0 {
return fmt.Errorf("JWT_REFRESH_EXPIRATION must be greater than 0")
}
if jwt.RefreshExpiration <= jwt.Expiration {
return fmt.Errorf("JWT_REFRESH_EXPIRATION must be greater than JWT_EXPIRATION")
}
if jwt.KeyRotation.Enabled {
if strings.TrimSpace(jwt.KeyRotation.CurrentKey) == "" {
return fmt.Errorf("JWT_CURRENT_KEY is required when key rotation is enabled")
}
if err := validateJWTSecret(jwt.KeyRotation.CurrentKey); err != nil {
return fmt.Errorf("JWT_CURRENT_KEY validation failed: %w", err)
}
if jwt.KeyRotation.PreviousKey != "" {
if err := validateJWTSecret(jwt.KeyRotation.PreviousKey); err != nil {
return fmt.Errorf("JWT_PREVIOUS_KEY validation failed: %w", err)
}
}
if strings.TrimSpace(jwt.KeyRotation.KeyID) == "" {
return fmt.Errorf("JWT_KEY_ID is required when key rotation is enabled")
}
}
return nil
}
func validateJWTSecret(secret string) error {
trimmed := strings.TrimSpace(secret)
if trimmed == "" {
return fmt.Errorf("JWT secret is required and cannot be empty")
}
invalidSecrets := []string{
"your-secret-key",
"secret",
"jwt-secret",
"my-secret",
"change-me",
"default-secret",
"123456",
"password",
"admin",
"test",
"development",
"production",
"staging",
}
for _, invalid := range invalidSecrets {
if strings.EqualFold(trimmed, invalid) {
return fmt.Errorf("JWT secret cannot be a placeholder value like %q - please set a secure, random secret", invalid)
}
}
if len(trimmed) < 32 {
return fmt.Errorf("JWT secret must be at least 32 characters long for security (current length: %d)", len(trimmed))
}
return nil
}
func validateAppConfig(app *AppConfig) error {
if app.BcryptCost < 10 {
return fmt.Errorf("BCRYPT_COST must be at least 10 for security (current: %d)", app.BcryptCost)
}
if app.BcryptCost > 14 {
return fmt.Errorf("BCRYPT_COST must be at most 14 to avoid performance issues (current: %d)", app.BcryptCost)
}
return nil
}