ultisuite-backend/internal/config/config.go
2026-05-24 00:03:36 +02:00

308 lines
8.9 KiB
Go

package config
import (
"errors"
"os"
"strconv"
"strings"
"time"
"github.com/ultisuite/ulti-backend/internal/secrets"
)
type Config struct {
Port int
Domain string
AppEnv string
// Browser clients (web app on another origin than the API gateway).
CORSAllowedOrigins []string
// PostgreSQL
DatabaseURL string
// KeyDB
KeyDBAddr string
KeyDBPassword string
KeyDBDB int
// Object Storage (S3-compatible)
RustFSEndpoint string
RustFSAccessKey string
RustFSSecretKey string
RustFSUseSSL bool
RustFSRegion string
// OIDC
OIDCIssuer string
OIDCClientID string
OIDCClientSecret string
// Nextcloud
NextcloudEnabled bool
NextcloudURL string
NCAdminUser string
NCAdminPass string
// Jitsi
JitsiEnabled bool
JitsiDomain string
JitsiAppID string
JitsiAppSecret string
JitsiPublicURL string
// Immich
ImmichEnabled bool
ImmichAPIURL string
// Mail
MailAttachmentsBucket string
MailSyncInterval time.Duration
MailOutboxInterval time.Duration
MailOutboxMaxRetries int
MailSendRatePerMinute int
MailSendBurst int
MailSMTPCircuitFailures int
MailSMTPCircuitCooldown time.Duration
MailCredentialKeys string
MailActiveCredentialKeyID string
MailWebhookSharedSecret string
MailGoogleOAuthClientID string
MailGoogleOAuthClientSecret string
MailMicrosoftOAuthClientID string
MailMicrosoftOAuthSecret string
MailMicrosoftOAuthTenant string
MailOAuthRedirectURL string
MailAppURL string
// Secret rotation policy
SecretRotationMaxAge time.Duration
OIDCSecretRotatedAt time.Time
SMTPCredentialKeyRotatedAt time.Time
WebhookSharedSecretRotatedAt time.Time
// Search
SearchEngine string
MeilisearchURL string
MeilisearchKey string
MeilisearchIndex string
TypesenseURL string
TypesenseKey string
TypesenseCollection string
// Observability
HealthNextcloudURL string
HealthImmichURL string
HealthJitsiURL string
HealthHTTPTimeout time.Duration
}
func Load() (*Config, error) {
port := 8080
if v := os.Getenv("ULTID_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
port = p
}
}
return &Config{
Port: port,
Domain: envOrDefault("DOMAIN", "localhost"),
AppEnv: strings.ToLower(envOrDefault("ULTID_ENV", envOrDefault("APP_ENV", "development"))),
CORSAllowedOrigins: parseCSVEnv("ULTID_CORS_ALLOWED_ORIGINS"),
DatabaseURL: envOrDefault("ULTID_DB_URL", "postgres://ulti:changeme@localhost:5432/ultidb?sslmode=disable"),
KeyDBAddr: envOrDefault("ULTID_KEYDB_URL", "localhost:6379"),
KeyDBPassword: secrets.Env("ULTID_KEYDB_PASSWORD"),
KeyDBDB: envInt("ULTID_KEYDB_DB", 0),
RustFSEndpoint: envOrDefault("ULTID_RUSTFS_ENDPOINT", "localhost:9000"),
RustFSAccessKey: secrets.Env("ULTID_RUSTFS_ACCESS_KEY"),
RustFSSecretKey: secrets.Env("ULTID_RUSTFS_SECRET_KEY"),
RustFSUseSSL: envBool("ULTID_RUSTFS_USE_SSL", false),
RustFSRegion: envOrDefault("ULTID_RUSTFS_REGION", "us-east-1"),
OIDCIssuer: os.Getenv("ULTID_OIDC_ISSUER"),
OIDCClientID: os.Getenv("ULTID_OIDC_CLIENT_ID"),
OIDCClientSecret: secrets.Env("ULTID_OIDC_CLIENT_SECRET"),
NextcloudEnabled: envBool("NEXTCLOUD_ENABLED", true),
NextcloudURL: envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"),
NCAdminUser: envOrDefault("NC_ADMIN_USER", "admin"),
NCAdminPass: envOrDefaultSecret("NC_ADMIN_PASSWORD", "changeme"),
JitsiEnabled: envBool("JITSI_ENABLED", true),
JitsiDomain: envOrDefault("JITSI_DOMAIN", "meet.jitsi"),
JitsiAppID: envOrDefault("JITSI_APP_ID", "ulti"),
JitsiAppSecret: envOrDefaultSecret("JITSI_APP_SECRET", "changeme-jwt-secret"),
JitsiPublicURL: envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"),
ImmichEnabled: envBool("IMMICH_ENABLED", true),
ImmichAPIURL: envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"),
MailAttachmentsBucket: envOrDefault("MAIL_ATTACHMENTS_BUCKET", "mail-attachments"),
MailSyncInterval: envDuration("MAIL_SYNC_INTERVAL", 2*time.Minute),
MailOutboxInterval: envDuration("MAIL_OUTBOX_INTERVAL", 10*time.Second),
MailOutboxMaxRetries: envInt("MAIL_OUTBOX_MAX_RETRIES", 8),
MailSendRatePerMinute: envInt("MAIL_SEND_RATE_PER_MINUTE", 30),
MailSendBurst: envInt("MAIL_SEND_BURST", 10),
MailSMTPCircuitFailures: envInt("MAIL_SMTP_CIRCUIT_FAILURES", 5),
MailSMTPCircuitCooldown: envDuration("MAIL_SMTP_CIRCUIT_COOLDOWN", 5*time.Minute),
MailCredentialKeys: secrets.Env("MAIL_CREDENTIAL_KEYS"),
MailActiveCredentialKeyID: envOrDefault("MAIL_ACTIVE_CREDENTIAL_KEY_ID", ""),
MailWebhookSharedSecret: secrets.Env("MAIL_WEBHOOK_SHARED_SECRET"),
MailGoogleOAuthClientID: os.Getenv("MAIL_GOOGLE_OAUTH_CLIENT_ID"),
MailGoogleOAuthClientSecret: secrets.Env("MAIL_GOOGLE_OAUTH_CLIENT_SECRET"),
MailMicrosoftOAuthClientID: os.Getenv("MAIL_MICROSOFT_OAUTH_CLIENT_ID"),
MailMicrosoftOAuthSecret: secrets.Env("MAIL_MICROSOFT_OAUTH_CLIENT_SECRET"),
MailMicrosoftOAuthTenant: envOrDefault("MAIL_MICROSOFT_OAUTH_TENANT", "common"),
MailOAuthRedirectURL: os.Getenv("MAIL_OAUTH_REDIRECT_URL"),
MailAppURL: envOrDefault("MAIL_APP_URL", envOrDefault("NEXT_PUBLIC_APP_URL", "http://localhost:3000")),
SecretRotationMaxAge: envDuration("SECRET_ROTATION_MAX_AGE", 90*24*time.Hour),
OIDCSecretRotatedAt: envTime("ULTID_OIDC_CLIENT_SECRET_ROTATED_AT"),
SMTPCredentialKeyRotatedAt: envTime("MAIL_CREDENTIAL_KEY_ROTATED_AT"),
WebhookSharedSecretRotatedAt: envTime("MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT"),
SearchEngine: envOrDefault("SEARCH_ENGINE", "postgres"),
MeilisearchURL: os.Getenv("MEILISEARCH_URL"),
MeilisearchKey: secrets.Env("MEILISEARCH_API_KEY"),
MeilisearchIndex: envOrDefault("MEILISEARCH_INDEX", "ulti"),
TypesenseURL: os.Getenv("TYPESENSE_URL"),
TypesenseKey: secrets.Env("TYPESENSE_API_KEY"),
TypesenseCollection: envOrDefault("TYPESENSE_COLLECTION", "ulti"),
HealthNextcloudURL: envOrDefault("HEALTH_NEXTCLOUD_URL", joinURL(envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"), "/status.php")),
HealthImmichURL: envOrDefault("HEALTH_IMMICH_URL", joinURL(envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"), "/server-info/ping")),
HealthJitsiURL: envOrDefault("HEALTH_JITSI_URL", defaultHealthJitsiURL(envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"))),
HealthHTTPTimeout: envDuration("HEALTH_HTTP_TIMEOUT", 3*time.Second),
}, nil
}
func (c *Config) IsProduction() bool {
return c != nil && c.AppEnv == "production"
}
func (c *Config) ValidateSecretRotation() error {
if c == nil {
return nil
}
maxAge := c.SecretRotationMaxAge
if maxAge <= 0 {
maxAge = 90 * 24 * time.Hour
}
now := time.Now()
checks := []struct {
name string
rotatedAt time.Time
}{
{name: "ULTID_OIDC_CLIENT_SECRET", rotatedAt: c.OIDCSecretRotatedAt},
{name: "MAIL_CREDENTIAL_KEY", rotatedAt: c.SMTPCredentialKeyRotatedAt},
{name: "MAIL_WEBHOOK_SHARED_SECRET", rotatedAt: c.WebhookSharedSecretRotatedAt},
}
var stale []string
for _, check := range checks {
if check.rotatedAt.IsZero() || now.Sub(check.rotatedAt) > maxAge {
stale = append(stale, check.name)
}
}
if len(stale) == 0 {
return nil
}
return errors.New("stale or missing rotation metadata for: " + strings.Join(stale, ", "))
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func parseCSVEnv(key string) []string {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func envOrDefaultSecret(key, fallback string) string {
if v := secrets.Env(key); v != "" {
return v
}
return fallback
}
func envBool(key string, fallback bool) bool {
v := os.Getenv(key)
if v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return b
}
func envInt(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
func envDuration(key string, fallback time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return fallback
}
d, err := time.ParseDuration(v)
if err != nil {
return fallback
}
return d
}
func envTime(key string) time.Time {
v := os.Getenv(key)
if v == "" {
return time.Time{}
}
ts, err := time.Parse(time.RFC3339, v)
if err != nil {
return time.Time{}
}
return ts
}
func joinURL(base, path string) string {
return strings.TrimRight(base, "/") + path
}
func defaultHealthJitsiURL(publicURL string) string {
trimmed := strings.TrimRight(publicURL, "/")
trimmed = strings.TrimSuffix(trimmed, "/meet")
return trimmed + "/about/health"
}