package platformsettings import ( "context" "encoding/json" "errors" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/security" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // SystemCompanyID is the reserved companies.id used to hold platform settings // inside company_settings (reuse existing table — no migration). // Fixed UUID v4-shaped value; never expose as a selectable tenant. var SystemCompanyID = uuid.MustParse("00000000-0000-4000-8000-000000000001") // SystemCompanyName is stored on the sentinel companies row; filtered from admin lists. const SystemCompanyName = "__platform_settings__" // Service loads and updates platform integration settings. type Service struct { Pool *pgxpool.Pool Key []byte Env EnvConfig } // NewService builds a Service. Encryption key follows APP_ENCRYPTION_KEY chain. func NewService(pool *pgxpool.Pool, env EnvConfig) *Service { keyMaterial := firstNonEmpty(env.AppEncryptionKey, env.CredentialsEncryptionKey, env.TokenSigningSecret) return &Service{ Pool: pool, Key: DeriveKey(keyMaterial, env.DatabaseURL), Env: env, } } // IsSystemCompany reports whether id is the platform-settings sentinel. func IsSystemCompany(id uuid.UUID) bool { return id == SystemCompanyID } type storedDoc struct { OpenAI openaiStored `json:"openai"` AIConfigs map[string]aiConfigStored `json:"ai_roles,omitempty"` SMTP smtpStored `json:"smtp"` OAuth oauthStored `json:"oauth"` Values map[string]string `json:"values"` } type openaiStored struct { BaseURL string `json:"base_url"` Model string `json:"model"` APIKeyEnc string `json:"api_key_enc"` APIKeyLast4 string `json:"api_key_last4"` } type smtpStored struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port string `json:"port"` User string `json:"user"` From string `json:"from"` PasswordEnc string `json:"password_enc"` PasswordLast4 string `json:"password_last4"` ResendAPIKeyEnc string `json:"resend_api_key_enc,omitempty"` ResendAPIKeyLast4 string `json:"resend_api_key_last4,omitempty"` EmailDryRun *bool `json:"email_dry_run,omitempty"` } type oauthStored struct { Google googleOAuthStored `json:"google"` } type googleOAuthStored struct { Enabled bool `json:"enabled"` ClientID string `json:"client_id"` ClientSecretEnc string `json:"client_secret_enc"` ClientSecretLast4 string `json:"client_secret_last4"` } func firstNonEmpty(vals ...string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { return v } } return "" } func (s *Service) ensureSystemCompany(ctx context.Context) error { _, err := s.Pool.Exec(ctx, ` INSERT INTO companies (id, name, language) VALUES ($1, $2, 'en') ON CONFLICT (id) DO NOTHING`, SystemCompanyID, SystemCompanyName) return err } func (s *Service) loadDoc(ctx context.Context) (storedDoc, time.Time, error) { if s == nil || s.Pool == nil { return storedDoc{Values: map[string]string{}}, time.Time{}, nil } var raw []byte var updated time.Time err := s.Pool.QueryRow(ctx, ` SELECT settings, updated_at FROM company_settings WHERE company_id = $1`, SystemCompanyID).Scan(&raw, &updated) if errors.Is(err, pgx.ErrNoRows) { return storedDoc{Values: map[string]string{}}, time.Time{}, nil } if err != nil { return storedDoc{}, time.Time{}, err } doc := storedDoc{Values: map[string]string{}} if len(raw) > 0 && string(raw) != "null" { if err := json.Unmarshal(raw, &doc); err != nil { return storedDoc{}, time.Time{}, err } } if doc.Values == nil { doc.Values = map[string]string{} } return doc, updated, nil } func (s *Service) saveDoc(ctx context.Context, doc storedDoc) error { if s == nil || s.Pool == nil { return ClientMsg("database unavailable") } if err := s.ensureSystemCompany(ctx); err != nil { return err } if doc.Values == nil { doc.Values = map[string]string{} } raw, err := json.Marshal(doc) if err != nil { return err } _, err = s.Pool.Exec(ctx, ` INSERT INTO company_settings (company_id, settings, updated_at) VALUES ($1, $2::jsonb, now()) ON CONFLICT (company_id) DO UPDATE SET settings = EXCLUDED.settings, updated_at = now()`, SystemCompanyID, raw) return err } // GetPublic returns the admin-safe view (masked secrets + env fallback flags). func (s *Service) GetPublic(ctx context.Context) (PublicView, error) { doc, updated, err := s.loadDoc(ctx) if err != nil { return PublicView{}, err } view := PublicView{ Values: map[string]string{}, OAuth: OAuthPublic{}, } for k, v := range doc.Values { if isSecretValueKey(k) { if strings.TrimSpace(v) == "" { view.Values[k] = "" continue } plain, decErr := DecryptSecret(s.Key, v) if decErr != nil { view.Values[k] = "••••" continue } view.Values[k] = maskSecret(plain) continue } view.Values[k] = v } if !updated.IsZero() { u := updated.UTC().Format(time.RFC3339) view.Updated = &u } view.OpenAI = s.publicOpenAI(doc.OpenAI) view.AIConfigs = s.publicAIConfigs(doc) view.SMTP = s.publicSMTP(doc.SMTP) view.Mail = mailPublicFromSMTP(view.SMTP) view.OAuth.Google = s.publicGoogle(doc.OAuth.Google) return view, nil } func (s *Service) publicOpenAI(st openaiStored) OpenAIPublic { hasDB := strings.TrimSpace(st.APIKeyEnc) != "" envKey := strings.TrimSpace(s.Env.OpenAIAPIKey) != "" out := OpenAIPublic{ BaseURL: strings.TrimSpace(st.BaseURL), Model: strings.TrimSpace(st.Model), Source: SourceNone, } if hasDB { out.Configured = true out.HasAPIKey = true out.APIKeyLast4 = st.APIKeyLast4 out.APIKeyMasked = maskLast4(st.APIKeyLast4) out.Source = SourceDB return out } if envKey { out.Configured = true out.HasAPIKey = true out.Source = SourceEnv if out.BaseURL == "" { out.BaseURL = strings.TrimSpace(s.Env.OpenAIBaseURL) } if out.Model == "" { out.Model = strings.TrimSpace(s.Env.OpenAIModel) } return out } if out.BaseURL != "" || out.Model != "" { out.Configured = true out.Source = SourceDB } return out } func (s *Service) publicSMTP(st smtpStored) SMTPPublic { hasDBPass := strings.TrimSpace(st.PasswordEnc) != "" hasDBHost := strings.TrimSpace(st.Host) != "" hasDBResend := strings.TrimSpace(st.ResendAPIKeyEnc) != "" envHost := strings.TrimSpace(s.Env.SMTPHost) != "" envResend := strings.TrimSpace(s.Env.ResendAPIKey) != "" dryRun, drySrc := s.emailDryRunFromStored(st) out := SMTPPublic{ Enabled: st.Enabled, Host: strings.TrimSpace(st.Host), Port: strings.TrimSpace(st.Port), User: strings.TrimSpace(st.User), From: strings.TrimSpace(st.From), EmailDryRun: dryRun, Source: SourceNone, } if hasDBHost || hasDBPass || hasDBResend || st.Enabled || st.EmailDryRun != nil { out.Configured = hasDBHost || hasDBPass || hasDBResend out.HasPassword = hasDBPass out.PasswordLast4 = st.PasswordLast4 out.PasswordMasked = maskLast4(st.PasswordLast4) out.HasResendAPIKey = hasDBResend out.ResendAPIKeyLast4 = st.ResendAPIKeyLast4 out.ResendAPIKeyMasked = maskLast4(st.ResendAPIKeyLast4) out.Source = SourceDB if drySrc == SourceEnv && st.EmailDryRun == nil { // keep EmailDryRun from env when DB did not set it } return out } if envHost || s.Env.SMTPEnabled || envResend { out.Configured = true out.Enabled = s.Env.SMTPEnabled out.Host = strings.TrimSpace(s.Env.SMTPHost) out.Port = strings.TrimSpace(s.Env.SMTPPort) out.User = strings.TrimSpace(s.Env.SMTPUser) out.From = strings.TrimSpace(s.Env.SMTPFrom) out.HasPassword = strings.TrimSpace(s.Env.SMTPPassword) != "" out.HasResendAPIKey = envResend out.Source = SourceEnv return out } return out } func (s *Service) emailDryRunFromStored(st smtpStored) (dry bool, src string) { if st.EmailDryRun != nil { return *st.EmailDryRun, SourceDB } if s.Env.EmailDryRunSet { return s.Env.EmailDryRun, SourceEnv } return true, SourceNone } func (s *Service) publicGoogle(st googleOAuthStored) GoogleOAuthPublic { hasDB := strings.TrimSpace(st.ClientSecretEnc) != "" || strings.TrimSpace(st.ClientID) != "" envOK := strings.TrimSpace(s.Env.GoogleClientID) != "" || strings.TrimSpace(s.Env.GoogleClientSecret) != "" out := GoogleOAuthPublic{ Enabled: st.Enabled, ClientID: strings.TrimSpace(st.ClientID), Source: SourceNone, } if hasDB { out.Configured = true out.HasClientSecret = strings.TrimSpace(st.ClientSecretEnc) != "" out.ClientSecretLast4 = st.ClientSecretLast4 out.ClientSecretMasked = maskLast4(st.ClientSecretLast4) out.Source = SourceDB return out } if envOK { out.Configured = true out.ClientID = strings.TrimSpace(s.Env.GoogleClientID) out.HasClientSecret = strings.TrimSpace(s.Env.GoogleClientSecret) != "" out.Source = SourceEnv return out } return out } // Update applies a partial patch and returns the refreshed public view. func (s *Service) Update(ctx context.Context, in UpdateInput) (PublicView, error) { doc, _, err := s.loadDoc(ctx) if err != nil { return PublicView{}, err } if doc.Values == nil { doc.Values = map[string]string{} } if in.OpenAI != nil { if err := s.patchOpenAI(&doc.OpenAI, *in.OpenAI); err != nil { return PublicView{}, err } syncProcessingFromOpenAI(&doc) } if in.AIConfigs != nil { if err := s.patchAIConfigs(&doc, in.AIConfigs); err != nil { return PublicView{}, err } if _, ok := in.AIConfigs[AIRoleProcessing]; ok { syncOpenAIFromProcessing(&doc) } } if in.SMTP != nil { if err := s.patchSMTP(&doc.SMTP, *in.SMTP); err != nil { return PublicView{}, err } } if in.Mail != nil { if err := s.patchSMTP(&doc.SMTP, mailUpdateToSMTP(*in.Mail)); err != nil { return PublicView{}, err } } if in.OAuth != nil && in.OAuth.Google != nil { if err := s.patchGoogle(&doc.OAuth.Google, *in.OAuth.Google); err != nil { return PublicView{}, err } } if in.Values != nil { for k, vp := range in.Values { key := strings.TrimSpace(k) if key == "" { return PublicView{}, ClientMsg("values keys must be non-empty") } if strings.ContainsAny(key, " \t\n\r") { return PublicView{}, ClientMsg("values keys must not contain whitespace") } if !isAllowedValueKey(key) { return PublicView{}, ClientMsg("unknown settings key") } if vp == nil { delete(doc.Values, key) continue } val := *vp if key == KeyEPRELFicheLanguage { normalized, nerr := NormalizeEPRELFicheLanguage(val) if nerr != nil { return PublicView{}, nerr } val = normalized } if key == KeyEPRELBaseURL { u := strings.TrimSpace(val) if u != "" { normalized, uerr := security.ValidatePublicHTTPSURL(u) if uerr != nil || normalized == "" { return PublicView{}, ClientMsg("invalid eprel base_url") } val = strings.TrimRight(normalized, "/") } } if isSecretValueKey(key) { plain := strings.TrimSpace(val) if plain == "" { // Keep existing secret when admin submits blank (masked UI). continue } enc, encErr := EncryptSecret(s.Key, plain) if encErr != nil { return PublicView{}, encErr } doc.Values[key] = enc continue } doc.Values[key] = val } } if err := s.saveDoc(ctx, doc); err != nil { return PublicView{}, err } return s.GetPublic(ctx) } func (s *Service) patchOpenAI(st *openaiStored, in OpenAIUpdate) error { if in.BaseURL != nil { u := strings.TrimSpace(*in.BaseURL) if u != "" { normalized, err := security.ValidatePublicHTTPSURL(u) if err != nil || normalized == "" { return ClientMsg("invalid openai base_url") } u = strings.TrimRight(normalized, "/") } st.BaseURL = u } if in.Model != nil { st.Model = strings.TrimSpace(*in.Model) } if in.ClearAPIKey { st.APIKeyEnc = "" st.APIKeyLast4 = "" return nil } if in.APIKey != nil { plain := strings.TrimSpace(*in.APIKey) if plain == "" { return nil // empty string = keep existing (same as omit for convenience) } enc, err := EncryptSecret(s.Key, plain) if err != nil { return err } st.APIKeyEnc = enc st.APIKeyLast4 = last4(plain) } return nil } func (s *Service) patchSMTP(st *smtpStored, in SMTPUpdate) error { if in.Enabled != nil { st.Enabled = *in.Enabled } if in.Host != nil { st.Host = strings.TrimSpace(*in.Host) } if in.Port != nil { st.Port = strings.TrimSpace(*in.Port) } if in.User != nil { st.User = strings.TrimSpace(*in.User) } if in.From != nil { st.From = strings.TrimSpace(*in.From) } if in.EmailDryRun != nil { v := *in.EmailDryRun st.EmailDryRun = &v } if in.ClearPassword { st.PasswordEnc = "" st.PasswordLast4 = "" } else if in.Password != nil { plain := strings.TrimSpace(*in.Password) if plain != "" { enc, err := EncryptSecret(s.Key, plain) if err != nil { return err } st.PasswordEnc = enc st.PasswordLast4 = last4(plain) } } if in.ClearResendAPIKey { st.ResendAPIKeyEnc = "" st.ResendAPIKeyLast4 = "" } else if in.ResendAPIKey != nil { plain := strings.TrimSpace(*in.ResendAPIKey) if plain != "" { enc, err := EncryptSecret(s.Key, plain) if err != nil { return err } st.ResendAPIKeyEnc = enc st.ResendAPIKeyLast4 = last4(plain) } } return nil } func (s *Service) patchGoogle(st *googleOAuthStored, in GoogleOAuthUpdate) error { if in.Enabled != nil { st.Enabled = *in.Enabled } if in.ClientID != nil { st.ClientID = strings.TrimSpace(*in.ClientID) } if in.ClearClientSecret { st.ClientSecretEnc = "" st.ClientSecretLast4 = "" return nil } if in.ClientSecret != nil { plain := strings.TrimSpace(*in.ClientSecret) if plain == "" { return nil } enc, err := EncryptSecret(s.Key, plain) if err != nil { return err } st.ClientSecretEnc = enc st.ClientSecretLast4 = last4(plain) } return nil } // ResolveOpenAI returns plaintext OpenAI credentials (DB preferred, then env). // Prefer ResolveAIConfig(AIRoleProcessing) for role-aware callers. func (s *Service) ResolveOpenAI(ctx context.Context) (ResolvedOpenAI, error) { cfg, err := s.ResolveAIConfig(ctx, AIRoleProcessing) if err != nil { return ResolvedOpenAI{}, err } return ResolvedOpenAI{ APIKey: cfg.APIKey, BaseURL: cfg.BaseURL, Model: cfg.Model, Source: cfg.Source, }, nil } // ResolveSMTP returns plaintext SMTP settings (DB preferred, then env). func (s *Service) ResolveSMTP(ctx context.Context) (ResolvedSMTP, error) { doc, _, err := s.loadDoc(ctx) if err != nil { return ResolvedSMTP{}, err } st := doc.SMTP hasDB := strings.TrimSpace(st.Host) != "" || strings.TrimSpace(st.PasswordEnc) != "" || st.Enabled if hasDB { out := ResolvedSMTP{ Enabled: st.Enabled, Host: strings.TrimSpace(st.Host), Port: strings.TrimSpace(st.Port), User: strings.TrimSpace(st.User), From: strings.TrimSpace(st.From), Source: SourceDB, } if out.Port == "" { out.Port = "587" } if strings.TrimSpace(st.PasswordEnc) != "" { plain, err := DecryptSecret(s.Key, st.PasswordEnc) if err != nil { return ResolvedSMTP{}, err } out.Password = plain } return out, nil } return ResolvedSMTP{ Enabled: s.Env.SMTPEnabled, Host: strings.TrimSpace(s.Env.SMTPHost), Port: firstNonEmpty(strings.TrimSpace(s.Env.SMTPPort), "587"), User: strings.TrimSpace(s.Env.SMTPUser), Password: s.Env.SMTPPassword, From: strings.TrimSpace(s.Env.SMTPFrom), Source: func() string { if s.Env.SMTPEnabled || strings.TrimSpace(s.Env.SMTPHost) != "" { return SourceEnv } return SourceNone }(), }, nil } // ResolveOAuthGoogle returns plaintext Google OAuth credentials. func (s *Service) ResolveOAuthGoogle(ctx context.Context) (ResolvedOAuthGoogle, error) { doc, _, err := s.loadDoc(ctx) if err != nil { return ResolvedOAuthGoogle{}, err } st := doc.OAuth.Google hasDB := strings.TrimSpace(st.ClientID) != "" || strings.TrimSpace(st.ClientSecretEnc) != "" if hasDB { out := ResolvedOAuthGoogle{ Enabled: st.Enabled, ClientID: strings.TrimSpace(st.ClientID), Source: SourceDB, } if strings.TrimSpace(st.ClientSecretEnc) != "" { plain, err := DecryptSecret(s.Key, st.ClientSecretEnc) if err != nil { return ResolvedOAuthGoogle{}, err } out.ClientSecret = plain } return out, nil } return ResolvedOAuthGoogle{ Enabled: strings.TrimSpace(s.Env.GoogleClientID) != "" && strings.TrimSpace(s.Env.GoogleClientSecret) != "", ClientID: strings.TrimSpace(s.Env.GoogleClientID), ClientSecret: s.Env.GoogleClientSecret, Source: SourceEnv, }, nil } // GetKV reads a non-secret platform value. ok is false when unset. func (s *Service) GetKV(ctx context.Context, key string) (value string, ok bool, err error) { key = strings.TrimSpace(key) if key == "" { return "", false, ClientMsg("key required") } doc, _, err := s.loadDoc(ctx) if err != nil { return "", false, err } v, ok := doc.Values[key] if !ok { return "", false, nil } if isSecretValueKey(key) || strings.HasPrefix(v, encPrefix) { plain, err := DecryptSecret(s.Key, v) if err != nil { return "", false, err } return plain, true, nil } return v, true, nil } // SetKV writes a non-secret platform value (empty value stores ""). func (s *Service) SetKV(ctx context.Context, key, value string) error { key = strings.TrimSpace(key) if key == "" { return ClientMsg("key required") } if !isAllowedValueKey(key) { return ClientMsg("unknown settings key") } if key == KeyEPRELFicheLanguage { normalized, err := NormalizeEPRELFicheLanguage(value) if err != nil { return err } value = normalized } if key == KeyEPRELBaseURL { u := strings.TrimSpace(value) if u != "" { normalized, err := security.ValidatePublicHTTPSURL(u) if err != nil || normalized == "" { return ClientMsg("invalid eprel base_url") } value = strings.TrimRight(normalized, "/") } } doc, _, err := s.loadDoc(ctx) if err != nil { return err } if doc.Values == nil { doc.Values = map[string]string{} } doc.Values[key] = value if isSecretValueKey(key) && strings.TrimSpace(value) != "" { enc, err := EncryptSecret(s.Key, strings.TrimSpace(value)) if err != nil { return err } doc.Values[key] = enc } return s.saveDoc(ctx, doc) }