Files
Primora/apps/backend/internal/config/config.go
T
Tomas Dvorak e25eb92339 feat: phase 4 integrations — connectors, rybbit analytics, webhooks
- core.integrations (migration 00005) with AES-256-GCM-encrypted
  credentials (PRIMORA_ENCRYPTION_KEY, hex/base64, dev fallback);
  has_credentials serialization only — secrets never leave the server
- /projects/:id/integrations list/create/delete/test; health check hits
  the stored base URL with a 5s timeout and records status
- Rybbit connector: GET …/integrations/:id/analytics proxies
  /api/organizations + /api/sites/* server-side into a normalized shape
- core.webhooks + core.webhook_deliveries; CRUD, deliveries, test, and
  POST …/deploy-markers. Events: issue.created (new fingerprint on
  ingest), deploy.marker, webhook.test. X-Primora-Signature HMAC-SHA256
  over the raw body; in-process dispatcher, 3 attempts, quadratic
  backoff, 10s timeout, pending-scan on boot
- HTTPS enforced for public targets; HTTP allowed for loopback/private/
  self-hosted sinks
- Integrations page (connectors + webhooks tabs, inline analytics,
  delivery log, secret-shown-once), demo-mode stubs
- 12 new primora_* MCP tools
- workspace 0.4.0

Authored By: TDvorak <[email protected]>
2026-09-16 18:47:31 +02:00

154 lines
4.3 KiB
Go

package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
Env string
ServerPort string
DatabaseURL string
DragonflyURL string
StorageRoot string
DBXDataDir string
AuthInternalBaseURL string
PublicURL string
UserRateLimitPerMin int
APIKeyRateLimitPerMin int
JWTIssuer string
JWTAudience string
JWTSecret string
JWTTTLSeconds int
MailFrom string
ResendAPIKey string
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPassword string
SMTPSecure bool
EncryptionKey string
}
// devEncryptionKey is the built-in credential key for local development only.
// Set PRIMORA_ENCRYPTION_KEY in any real deployment — credentials written with
// this key are readable by anyone with database access.
const devEncryptionKey = "3031323334353637383961626364656630313233343536373839616263646566"
func Load() (Config, error) {
cfg := Config{
Env: getenv("NODE_ENV", "development"),
ServerPort: getenv("BACKEND_PORT", "8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
DragonflyURL: getenv("DRAGONFLY_URL", "redis://localhost:6379/0"),
StorageRoot: getenv("BACKEND_STORAGE_ROOT", "./tmp/storage"),
DBXDataDir: os.Getenv("DBX_DATA_DIR"),
AuthInternalBaseURL: getenv("AUTH_INTERNAL_BASE_URL", "http://auth:3001"),
PublicURL: getenv("VITE_APP_URL", "http://localhost"),
UserRateLimitPerMin: 240,
APIKeyRateLimitPerMin: 600,
JWTIssuer: getenv("JWT_ISSUER", "primora-auth"),
JWTAudience: getenv("JWT_AUDIENCE", "primora-api"),
JWTSecret: os.Getenv("JWT_SECRET"),
MailFrom: getenv("MAIL_FROM", "Primora <[email protected]>"),
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
SMTPHost: getenv("SMTP_HOST", "localhost"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
}
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
if err != nil {
return Config{}, fmt.Errorf("parse SMTP_PORT: %w", err)
}
cfg.SMTPPort = smtpPort
jwtTTLSeconds, err := strconv.Atoi(getenv("JWT_TTL_SECONDS", "900"))
if err != nil {
return Config{}, fmt.Errorf("parse JWT_TTL_SECONDS: %w", err)
}
cfg.JWTTTLSeconds = jwtTTLSeconds
userRateLimitPerMin, err := parseNonNegativeIntEnv("USER_RATE_LIMIT_PER_MINUTE", cfg.UserRateLimitPerMin)
if err != nil {
return Config{}, err
}
cfg.UserRateLimitPerMin = userRateLimitPerMin
apiKeyRateLimitPerMin, err := parseNonNegativeIntEnv("API_KEY_RATE_LIMIT_PER_MINUTE", cfg.APIKeyRateLimitPerMin)
if err != nil {
return Config{}, err
}
cfg.APIKeyRateLimitPerMin = apiKeyRateLimitPerMin
smtpSecure, err := strconv.ParseBool(getenv("SMTP_SECURE", "false"))
if err != nil {
return Config{}, fmt.Errorf("parse SMTP_SECURE: %w", err)
}
cfg.SMTPSecure = smtpSecure
var missing []string
if cfg.DatabaseURL == "" {
missing = append(missing, "DATABASE_URL")
}
if cfg.JWTSecret == "" {
missing = append(missing, "JWT_SECRET")
}
if cfg.StorageRoot == "" {
missing = append(missing, "BACKEND_STORAGE_ROOT")
}
if cfg.AuthInternalBaseURL == "" {
missing = append(missing, "AUTH_INTERNAL_BASE_URL")
}
if cfg.ResendAPIKey == "" && cfg.SMTPHost == "" {
missing = append(missing, "RESEND_API_KEY or SMTP_HOST")
}
if cfg.EncryptionKey == "" {
if cfg.Env == "production" {
missing = append(missing, "PRIMORA_ENCRYPTION_KEY")
} else {
cfg.EncryptionKey = devEncryptionKey
}
}
if len(missing) > 0 {
return Config{}, errors.New("missing required environment values: " + strings.Join(missing, ", "))
}
if cfg.DBXDataDir == "" {
cfg.DBXDataDir = filepath.Join(cfg.StorageRoot, "dbx")
}
return cfg, nil
}
func getenv(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}
func parseNonNegativeIntEnv(key string, fallback int) (int, error) {
raw := 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)
}
if value < 0 {
return 0, fmt.Errorf("%s must be >= 0", key)
}
return value, nil
}