ultisuite-backend/internal/config/config.go
R3D347HR4Y b90edf317c
Some checks failed
CI / Go tests (push) Has been cancelled
CI / Integration tests (push) Has been cancelled
CI / DB migrations (push) Has been cancelled
feat(scan): add VirusTotal upload antivirus
Admin-stored API key with env fallback; scan drive/mail/IMAP uploads.
Fail-open if VT down, 422 on malware; migration for virus_scan_status.
2026-06-07 22:05:27 +02:00

386 lines
12 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
// Authentik API (suite app auto-provisioning)
AuthentikAPIURL string
AuthentikAPIToken string
AuthentikPublicHTTPS bool
// Suite OIDC clients (Authentik applications)
NCOIDCClientID string
NCOIDCClientSecret string
OnlyOfficeOIDCClientID string
OnlyOfficeOIDCClientSecret string
ImmichOIDCClientID string
ImmichOIDCClientSecret string
DriveOIDCClientID string
DriveOIDCClientSecret string
// Nextcloud
NextcloudEnabled bool
NextcloudURL string
NextcloudPublicURL string
NCAdminUser string
NCAdminPass string
// OnlyOffice
OnlyOfficeEnabled bool
OnlyOfficeURL string
OnlyOfficePublicURL string
OnlyOfficeAPIInternalURL string
OnlyOfficeJWTSecret string
UltidPublicURL string
DrivePublicURL 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
// VirusTotal (optional env fallback for org file_policies.virustotal_api_key)
VirusTotalAPIKey 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"),
AuthentikAPIURL: envOrDefault("AUTHENTIK_API_URL", "http://authentik-server:9000"),
AuthentikAPIToken: secrets.Env("AUTHENTIK_API_TOKEN"),
AuthentikPublicHTTPS: authentikPublicHTTPS(),
NCOIDCClientID: envOrDefault("NC_OIDC_CLIENT_ID", "ulti-nextcloud"),
NCOIDCClientSecret: secrets.Env("NC_OIDC_CLIENT_SECRET"),
OnlyOfficeOIDCClientID: envOrDefault("ONLYOFFICE_OIDC_CLIENT_ID", "ulti-onlyoffice"),
OnlyOfficeOIDCClientSecret: secrets.Env("ONLYOFFICE_OIDC_CLIENT_SECRET"),
ImmichOIDCClientID: envOrDefault("IMMICH_OIDC_CLIENT_ID", "ulti-immich"),
ImmichOIDCClientSecret: secrets.Env("IMMICH_OIDC_CLIENT_SECRET"),
DriveOIDCClientID: os.Getenv("DRIVE_OIDC_CLIENT_ID"),
DriveOIDCClientSecret: secrets.Env("DRIVE_OIDC_CLIENT_SECRET"),
NextcloudEnabled: envBool("NEXTCLOUD_ENABLED", true),
NextcloudURL: envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"),
NextcloudPublicURL: nextcloudPublicURL(),
NCAdminUser: envOrDefault("NC_ADMIN_USER", "admin"),
NCAdminPass: envOrDefaultSecret("NC_ADMIN_PASSWORD", "changeme"),
OnlyOfficeEnabled: envBool("ONLYOFFICE_ENABLED", false),
OnlyOfficeURL: envOrDefault("ONLYOFFICE_URL", "http://onlyoffice"),
OnlyOfficePublicURL: envOrDefault("ONLYOFFICE_PUBLIC_URL", "http://localhost/office"),
OnlyOfficeAPIInternalURL: envOrDefault("ONLYOFFICE_API_INTERNAL_URL", "http://ultid:8080"),
OnlyOfficeJWTSecret: secrets.Env("ONLYOFFICE_JWT_SECRET"),
UltidPublicURL: envOrDefault("ULTID_PUBLIC_URL", "http://localhost"),
DrivePublicURL: drivePublicURL(),
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"),
VirusTotalAPIKey: secrets.Env("VIRUSTOTAL_API_KEY"),
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
}
// nextcloudPublicURL is the external WebDAV base (OVERWRITECLIURL), used for MOVE/COPY Destination headers.
func nextcloudPublicURL() string {
if v := strings.TrimSpace(os.Getenv("NC_PUBLIC_URL")); v != "" {
return strings.TrimRight(v, "/")
}
domain := envOrDefault("DOMAIN", "localhost")
proto := envOrDefault("NC_OVERWRITE_PROTOCOL", "http")
return proto + "://" + domain + "/cloud"
}
// drivePublicURL is the browser-facing UltiDrive base used in public share links.
func drivePublicURL() string {
if v := strings.TrimSpace(os.Getenv("DRIVE_PUBLIC_URL")); v != "" {
return strings.TrimRight(v, "/")
}
return strings.TrimRight(envOrDefault("ULTID_PUBLIC_URL", "http://localhost"), "/") + "/drive"
}
func defaultHealthJitsiURL(publicURL string) string {
trimmed := strings.TrimRight(publicURL, "/")
trimmed = strings.TrimSuffix(trimmed, "/meet")
return trimmed + "/about/health"
}
func authentikPublicHTTPS() bool {
if envBool("AUTHENTIK_PUBLIC_HTTPS", false) {
return true
}
host := strings.TrimSpace(os.Getenv("AUTHENTIK_HOST"))
return strings.HasPrefix(strings.ToLower(host), "https://")
}