Files
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

673 lines
18 KiB
Go

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)
}