Files
pargar/backend/internal/config/config.go
T

325 lines
9.5 KiB
Go
Raw Normal View History

package config
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
Port string
DatabaseURL string
RedisURL string
JWTSecret string
PublicURL string
FrontendOrigins []string
TrustedProxyCIDRs []*net.IPNet
MetricsToken string
StorageDriver string
StorageDir string
StorageEndpoint string
StorageRegion string
StorageAccess string
StorageSecret string
StorageBucket string
HeartRegenHours int
AccessTokenTTL time.Duration
RefreshTokenTTL time.Duration // remember-me / long-lived refresh
RefreshTokenTTLSession time.Duration // short-lived refresh when rememberMe is false
MediaURLTTL time.Duration
DevMode bool
// CaptchaExposeAnswer returns plaintext captcha answers in GET /api/auth/captcha.
// Must never be enabled outside local tests (requires DevMode as well).
CaptchaExposeAnswer bool
SeedCurriculum bool
OTELEndpoint string
OTELServiceName string
DonationEnabled bool
DonationNote string
PhysicalCertEnabled bool
PhysicalCertPriceIRR int
PhysicalCertWindowDays int
// RegistrationEnforceIP limits one successful registration per client IP.
// Default false: behind NAT / shared egress the peer IP is not a real client identity.
RegistrationEnforceIP bool
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getEnvInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
// defaultTrustedProxyCIDRs covers loopback and typical Docker/private networks.
const defaultTrustedProxyCIDRs = "127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
func parseCIDRs(raw string) ([]*net.IPNet, error) {
parts := strings.Split(raw, ",")
out := make([]*net.IPNet, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
_, network, err := net.ParseCIDR(p)
if err != nil {
return nil, err
}
out = append(out, network)
}
return out, nil
}
func Load() *Config {
jwtSecret := getEnv("JWT_SECRET", "")
if jwtSecret == "" {
jwtSecret = developmentSecret()
}
// Fail closed: production-safe default unless explicitly enabled.
devMode := getEnv("DEV_MODE", "false") == "true"
origins := parseOrigins(getEnv("FRONTEND_ORIGINS", ""))
if len(origins) == 0 {
origins = []string{
"https://localhost", "https://127.0.0.1",
"http://localhost:3000", "http://127.0.0.1:3000",
"http://localhost:3001", "http://127.0.0.1:3001",
"http://localhost", "http://127.0.0.1",
}
}
seedCurriculum := getEnv("SEED_CURRICULUM", "") == "true"
if getEnv("SEED_CURRICULUM", "") == "" && devMode {
seedCurriculum = true
}
cidrs, err := parseCIDRs(getEnv("TRUSTED_PROXY_CIDRS", defaultTrustedProxyCIDRs))
if err != nil {
// Fall back to defaults rather than refusing to boot on a typo in optional env.
cidrs, _ = parseCIDRs(defaultTrustedProxyCIDRs)
}
return &Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: resolveDatabaseURL(),
RedisURL: resolveRedisURL(),
JWTSecret: jwtSecret,
PublicURL: strings.TrimRight(getEnv("PUBLIC_URL", "https://localhost"), "/"),
FrontendOrigins: origins,
TrustedProxyCIDRs: cidrs,
MetricsToken: strings.TrimSpace(getEnv("METRICS_TOKEN", "")),
StorageDriver: getEnv("STORAGE_DRIVER", "s3"),
StorageDir: getEnv("STORAGE_DIR", "media"),
StorageEndpoint: getEnv("STORAGE_ENDPOINT", "http://rustfs:9000"),
StorageRegion: getEnv("STORAGE_REGION", "us-east-1"),
StorageAccess: getEnv("STORAGE_ACCESS_KEY", ""),
StorageSecret: getEnv("STORAGE_SECRET_KEY", ""),
StorageBucket: getEnv("STORAGE_BUCKET", "pargar"),
HeartRegenHours: getEnvInt("HEART_REGEN_HOURS", 4),
AccessTokenTTL: 15 * time.Minute,
RefreshTokenTTL: 30 * 24 * time.Hour,
RefreshTokenTTLSession: 24 * time.Hour,
MediaURLTTL: 2 * time.Hour,
DevMode: devMode,
CaptchaExposeAnswer: devMode && getEnv("CAPTCHA_EXPOSE_ANSWER", "") == "true",
SeedCurriculum: seedCurriculum,
OTELEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", ""),
OTELServiceName: getEnv("OTEL_SERVICE_NAME", "pargar-api"),
DonationEnabled: getEnv("DONATION_ENABLED", "true") == "true",
DonationNote: getEnv("DONATION_NOTE", "اگر مایلید از مسیر حمایت کنید، وارد شوید و در گفتگو با تیم هماهنگ کنید — جزئیات فقط خصوصی رد و بدل می‌شود."),
PhysicalCertEnabled: getEnv("PHYSICAL_CERT_ENABLED", "false") == "true",
PhysicalCertPriceIRR: getEnvInt("PHYSICAL_CERT_PRICE_IRR", 2_500_000),
PhysicalCertWindowDays: getEnvInt("PHYSICAL_CERT_WINDOW_DAYS", 30),
RegistrationEnforceIP: getEnv("REGISTRATION_ENFORCE_IP", "false") == "true",
}
}
// resolveDatabaseURL prefers DATABASE_URL; otherwise builds from POSTGRES_* with proper encoding.
func resolveDatabaseURL() string {
if u := strings.TrimSpace(os.Getenv("DATABASE_URL")); u != "" {
return u
}
pass := os.Getenv("POSTGRES_PASSWORD")
host := getEnv("POSTGRES_HOST", "")
if pass == "" || host == "" {
return "postgres://pargar:local-dev-only-password@localhost:5432/pargar?sslmode=disable"
}
user := getEnv("POSTGRES_USER", "pargar")
db := getEnv("POSTGRES_DB", "pargar")
ssl := getEnv("POSTGRES_SSLMODE", "disable")
port := getEnv("POSTGRES_PORT", "5432")
u := &url.URL{
Scheme: "postgres",
User: url.UserPassword(user, pass),
Host: net.JoinHostPort(host, port),
Path: "/" + db,
}
q := u.Query()
q.Set("sslmode", ssl)
u.RawQuery = q.Encode()
return u.String()
}
// resolveRedisURL prefers REDIS_URL; otherwise builds from REDIS_HOST + REDIS_PASSWORD.
func resolveRedisURL() string {
if u := strings.TrimSpace(os.Getenv("REDIS_URL")); u != "" {
return u
}
host := getEnv("REDIS_HOST", "")
if host == "" {
return "redis://localhost:6379"
}
port := getEnv("REDIS_PORT", "6379")
pass := os.Getenv("REDIS_PASSWORD")
u := &url.URL{
Scheme: "redis",
Host: net.JoinHostPort(host, port),
}
if pass != "" {
u.User = url.UserPassword("", pass)
}
if db := strings.TrimSpace(os.Getenv("REDIS_DB")); db != "" {
u.Path = "/" + db
}
return u.String()
}
func parseOrigins(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, strings.TrimRight(p, "/"))
}
}
return out
}
var insecureSecretMarkers = []string{
"change-me",
"local-dev-only",
"insecure-default",
"password",
"secret",
"admin",
}
func isInsecureSecret(v string) bool {
s := strings.ToLower(strings.TrimSpace(v))
if s == "" {
return true
}
// Exact trivial values.
for _, m := range insecureSecretMarkers {
if s == m || s == m+"123" || s == m+"!" {
return true
}
}
// Known chart / compose placeholders.
placeholders := []string{"change-me", "local-dev-only", "insecure-default"}
for _, p := range placeholders {
if strings.Contains(s, p) {
return true
}
}
return false
}
func (c *Config) Validate() error {
if c.DevMode {
return nil
}
jwt := strings.TrimSpace(os.Getenv("JWT_SECRET"))
if len(jwt) < 32 {
return errors.New("JWT_SECRET must be at least 32 characters when DEV_MODE is false")
}
if isInsecureSecret(jwt) {
return errors.New("JWT_SECRET looks like a placeholder; set a strong random value")
}
adminPass := getEnv("ADMIN_PASSWORD", "")
if adminPass == "" {
return errors.New("ADMIN_PASSWORD must be set when DEV_MODE is false")
}
if isInsecureSecret(adminPass) || len(adminPass) < 12 {
return errors.New("ADMIN_PASSWORD looks weak or like a placeholder; set a strong value")
}
if c.StorageDriver == "s3" {
if c.StorageAccess == "" || c.StorageSecret == "" {
return errors.New("STORAGE_ACCESS_KEY and STORAGE_SECRET_KEY must be set when DEV_MODE is false")
}
if isInsecureSecret(c.StorageSecret) {
return errors.New("STORAGE_SECRET_KEY looks like a placeholder; set a strong random value")
}
}
return nil
}
func (c *Config) AllowedOrigins() []string {
seen := map[string]bool{}
var out []string
add := func(o string) {
o = strings.TrimRight(strings.TrimSpace(o), "/")
if o == "" || seen[o] {
return
}
seen[o] = true
out = append(out, o)
}
for _, o := range c.FrontendOrigins {
add(o)
}
add(c.PublicURL)
return out
}
// IsTrustedProxy reports whether ip is allowed to supply X-Real-IP / X-Forwarded-For.
func (c *Config) IsTrustedProxy(ip net.IP) bool {
if ip == nil {
return false
}
for _, network := range c.TrustedProxyCIDRs {
if network.Contains(ip) {
return true
}
}
return false
}
// CookieSecure reports whether session cookies should set the Secure flag.
func (c *Config) CookieSecure() bool {
return strings.HasPrefix(strings.ToLower(c.PublicURL), "https://")
}
func developmentSecret() string {
var raw [32]byte
if _, err := rand.Read(raw[:]); err != nil {
panic("could not generate development JWT secret")
}
return hex.EncodeToString(raw[:])
}
// MustValidate is a helper for tests asserting Validate messages.
func FormatValidateError(err error) string {
if err == nil {
return ""
}
return fmt.Sprintf("%v", err)
}