673 lines
18 KiB
Go
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)
|
||
|
|
}
|