mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-07-29 06:53:49 +00:00
257 lines
6.8 KiB
Go
257 lines
6.8 KiB
Go
package app
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultAppMode = "development"
|
|
defaultAPIPort = "8080"
|
|
defaultShutdownTimeout = 10 * time.Second
|
|
)
|
|
|
|
var (
|
|
defaultLocalCORSOrigins = []string{
|
|
"http://localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
"http://localhost:3001",
|
|
"http://127.0.0.1:3001",
|
|
}
|
|
insecureSecretPlaceholders = map[string]struct{}{
|
|
"": {},
|
|
"replace-me-with-a-long-random-secret": {},
|
|
"replace-me-with-a-dedicated-mail-secret": {},
|
|
"productier-local-mail-key": {},
|
|
"changeme": {},
|
|
"change-me": {},
|
|
"replace-me": {},
|
|
}
|
|
)
|
|
|
|
type runtimeConfig struct {
|
|
mode string
|
|
apiPort string
|
|
authServiceURL string
|
|
shutdownTimeout time.Duration
|
|
corsAllowOrigins []string
|
|
mailSecret string
|
|
metricsAuthToken string
|
|
}
|
|
|
|
func loadRuntimeConfig() (runtimeConfig, error) {
|
|
mode, err := parseAppMode(os.Getenv("APP_ENV"))
|
|
if err != nil {
|
|
return runtimeConfig{}, err
|
|
}
|
|
|
|
apiPort, err := parsePort(os.Getenv("API_PORT"), defaultAPIPort, "API_PORT")
|
|
if err != nil {
|
|
return runtimeConfig{}, err
|
|
}
|
|
|
|
authServiceURL, err := parseAbsoluteHTTPURL(
|
|
valueOrDefault(strings.TrimSpace(os.Getenv("AUTH_SERVICE_URL")), "http://localhost:3001"),
|
|
"AUTH_SERVICE_URL",
|
|
)
|
|
if err != nil {
|
|
return runtimeConfig{}, err
|
|
}
|
|
|
|
shutdownTimeout, err := parseDuration(
|
|
os.Getenv("API_SHUTDOWN_TIMEOUT"),
|
|
defaultShutdownTimeout,
|
|
"API_SHUTDOWN_TIMEOUT",
|
|
)
|
|
if err != nil {
|
|
return runtimeConfig{}, err
|
|
}
|
|
|
|
corsAllowOrigins, err := parseCORSAllowOrigins(mode, os.Getenv("CORS_ALLOW_ORIGINS"))
|
|
if err != nil {
|
|
return runtimeConfig{}, err
|
|
}
|
|
|
|
mailSecret, err := resolveMailSecret(mode)
|
|
if err != nil {
|
|
return runtimeConfig{}, err
|
|
}
|
|
|
|
metricsAuthToken, err := resolveMetricsAuthToken(mode)
|
|
if err != nil {
|
|
return runtimeConfig{}, err
|
|
}
|
|
|
|
return runtimeConfig{
|
|
mode: mode,
|
|
apiPort: apiPort,
|
|
authServiceURL: authServiceURL,
|
|
shutdownTimeout: shutdownTimeout,
|
|
corsAllowOrigins: corsAllowOrigins,
|
|
mailSecret: mailSecret,
|
|
metricsAuthToken: metricsAuthToken,
|
|
}, nil
|
|
}
|
|
|
|
func parseAppMode(raw string) (string, error) {
|
|
mode := strings.TrimSpace(strings.ToLower(raw))
|
|
if mode == "" {
|
|
return defaultAppMode, nil
|
|
}
|
|
switch mode {
|
|
case "development", "test", "staging", "production":
|
|
return mode, nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported APP_ENV %q (allowed: development, test, staging, production)", mode)
|
|
}
|
|
}
|
|
|
|
func parsePort(raw string, fallback string, envName string) (string, error) {
|
|
port := strings.TrimSpace(raw)
|
|
if port == "" {
|
|
port = fallback
|
|
}
|
|
numeric, err := strconv.Atoi(port)
|
|
if err != nil || numeric < 1 || numeric > 65535 {
|
|
return "", fmt.Errorf("%s must be a valid TCP port (1-65535)", envName)
|
|
}
|
|
return strconv.Itoa(numeric), nil
|
|
}
|
|
|
|
func parseDuration(raw string, fallback time.Duration, envName string) (time.Duration, error) {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
duration, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be a valid duration (example: 10s): %w", envName, err)
|
|
}
|
|
if duration <= 0 {
|
|
return 0, fmt.Errorf("%s must be greater than zero", envName)
|
|
}
|
|
return duration, nil
|
|
}
|
|
|
|
func parseAbsoluteHTTPURL(raw string, envName string) (string, error) {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
return "", fmt.Errorf("%s is required", envName)
|
|
}
|
|
parsed, err := url.Parse(value)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%s must be a valid URL: %w", envName, err)
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return "", fmt.Errorf("%s must use http or https", envName)
|
|
}
|
|
if parsed.Host == "" {
|
|
return "", fmt.Errorf("%s must include a host", envName)
|
|
}
|
|
return strings.TrimRight(parsed.String(), "/"), nil
|
|
}
|
|
|
|
func parseCORSAllowOrigins(mode string, raw string) ([]string, error) {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
if mode == "staging" || mode == "production" {
|
|
return nil, errors.New("CORS_ALLOW_ORIGINS is required in staging/production (comma-separated origins)")
|
|
}
|
|
return append([]string(nil), defaultLocalCORSOrigins...), nil
|
|
}
|
|
|
|
parts := strings.Split(value, ",")
|
|
origins := make([]string, 0, len(parts))
|
|
seen := make(map[string]struct{}, len(parts))
|
|
for _, part := range parts {
|
|
origin := strings.TrimSpace(part)
|
|
if origin == "" {
|
|
continue
|
|
}
|
|
if origin == "*" {
|
|
return nil, errors.New("CORS_ALLOW_ORIGINS cannot include '*' when credentials are enabled")
|
|
}
|
|
validated, err := parseOrigin(origin, "CORS_ALLOW_ORIGINS")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, exists := seen[validated]; exists {
|
|
continue
|
|
}
|
|
seen[validated] = struct{}{}
|
|
origins = append(origins, validated)
|
|
}
|
|
if len(origins) == 0 {
|
|
return nil, errors.New("CORS_ALLOW_ORIGINS must include at least one valid origin")
|
|
}
|
|
return origins, nil
|
|
}
|
|
|
|
func parseOrigin(raw string, envName string) (string, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return "", fmt.Errorf("%s must contain valid origins: %w", envName, err)
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return "", fmt.Errorf("%s origins must use http or https", envName)
|
|
}
|
|
if parsed.Host == "" {
|
|
return "", fmt.Errorf("%s origins must include a host", envName)
|
|
}
|
|
if parsed.Path != "" && parsed.Path != "/" {
|
|
return "", fmt.Errorf("%s origins cannot include URL paths", envName)
|
|
}
|
|
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return "", fmt.Errorf("%s origins cannot include query or fragment components", envName)
|
|
}
|
|
return parsed.Scheme + "://" + parsed.Host, nil
|
|
}
|
|
|
|
func resolveMailSecret(mode string) (string, error) {
|
|
mailSecret := strings.TrimSpace(os.Getenv("MAIL_ENCRYPTION_KEY"))
|
|
if mailSecret == "" {
|
|
mailSecret = strings.TrimSpace(os.Getenv("BETTER_AUTH_SECRET"))
|
|
}
|
|
|
|
if mode != "staging" && mode != "production" {
|
|
return mailSecret, nil
|
|
}
|
|
if isInsecureSecret(mailSecret) {
|
|
return "", errors.New("set a strong MAIL_ENCRYPTION_KEY (or BETTER_AUTH_SECRET fallback) for staging/production")
|
|
}
|
|
return mailSecret, nil
|
|
}
|
|
|
|
func resolveMetricsAuthToken(mode string) (string, error) {
|
|
token := strings.TrimSpace(os.Getenv("METRICS_AUTH_TOKEN"))
|
|
if token == "" {
|
|
return "", nil
|
|
}
|
|
|
|
if mode == "production" && isInsecureSecret(token) {
|
|
return "", errors.New("METRICS_AUTH_TOKEN must be a strong non-placeholder secret when set in production")
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
func isInsecureSecret(secret string) bool {
|
|
normalized := strings.TrimSpace(strings.ToLower(secret))
|
|
if _, exists := insecureSecretPlaceholders[normalized]; exists {
|
|
return true
|
|
}
|
|
return len(strings.TrimSpace(secret)) < 16
|
|
}
|
|
|
|
func valueOrDefault(value string, fallback string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|