package store import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "github.com/jackc/pgx/v5" ) // IntegrationsKey is the app_settings row holding every configured external destination. // // One document rather than a table, for the reason the feature policy is one document: // what an integration *is* belongs to the code, and only the operator's choices belong in // the database. A schema per integration would make adding a second one a migration. const IntegrationsKey = "integrations" // MaxIntegrations bounds the list. This is a household's operations console, not a // notification fan-out service, and an unbounded list is an unbounded number of outbound // requests per event. const MaxIntegrations = 20 // IntegrationDiscord is the only kind today. The kind is stored rather than inferred from // the URL, because a second webhook-shaped destination — Slack, ntfy — would be // indistinguishable otherwise and would silently receive Discord's payload. const IntegrationDiscord = "discord" // Integration is one configured destination. // // Events is an explicit selection rather than a "send everything" flag with exceptions: // an integration that quietly gained a new event type because one was added to the // gateway is one that starts posting things nobody asked for. An empty selection // therefore sends nothing, and the console says so. type Integration struct { ID string `json:"id"` Kind string `json:"kind"` Name string `json:"name"` Enabled bool `json:"enabled"` URL string `json:"url"` Events []string `json:"events"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // Redacted is the integration as the console may read it. // // The webhook URL *is* the credential — anybody holding it can post into the channel — so // it is never returned, exactly as the MDBList key and the OpenSubtitles login are never // returned. What the console needs is whether one is set and enough of it to recognise // which channel this row is, which is what Hint carries. type RedactedIntegration struct { Integration URL string `json:"url,omitempty"` // always empty; the field stays for shape HasURL bool `json:"hasUrl"` Hint string `json:"hint,omitempty"` } type IntegrationSettings struct { Integrations []Integration `json:"integrations"` UpdatedAt time.Time `json:"updatedAt"` } // webhookHint is the recognisable, non-secret part of a Discord webhook URL: the channel // id in the path, and never the token after it. A hint that included any of the token // would be a leak wearing a shorter name. func webhookHint(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } parts := strings.Split(strings.TrimSuffix(raw, "/"), "/") // .../api/webhooks// if len(parts) >= 2 { if id := parts[len(parts)-2]; id != "" && len(id) <= 32 { return "…/" + id } } return "configured" } // Redact prepares an integration for the console. func (i Integration) Redact() RedactedIntegration { return RedactedIntegration{ Integration: Integration{ ID: i.ID, Kind: i.Kind, Name: i.Name, Enabled: i.Enabled, Events: i.Events, CreatedAt: i.CreatedAt, UpdatedAt: i.UpdatedAt, }, HasURL: strings.TrimSpace(i.URL) != "", Hint: webhookHint(i.URL), } } func normalizeIntegrations(settings IntegrationSettings) IntegrationSettings { seen := map[string]bool{} cleaned := make([]Integration, 0, len(settings.Integrations)) for _, integration := range settings.Integrations { integration.ID = strings.TrimSpace(integration.ID) if integration.ID == "" || seen[integration.ID] || len(cleaned) >= MaxIntegrations { continue } seen[integration.ID] = true if integration.Kind == "" { integration.Kind = IntegrationDiscord } integration.Name = strings.TrimSpace(integration.Name) if integration.Name == "" { integration.Name = "Discord" } if runes := []rune(integration.Name); len(runes) > 60 { integration.Name = string(runes[:60]) } integration.URL = strings.TrimSpace(integration.URL) // An integration with no destination cannot be on. Storing it as enabled would // give the console a switch that reports "on" over something that can never // deliver, which is the state the subtitle settings deliberately refuse too. if integration.URL == "" { integration.Enabled = false } events := make([]string, 0, len(integration.Events)) eventSeen := map[string]bool{} for _, event := range integration.Events { event = strings.TrimSpace(event) if event == "" || eventSeen[event] || len(events) >= 40 { continue } eventSeen[event] = true events = append(events, event) } integration.Events = events cleaned = append(cleaned, integration) } settings.Integrations = cleaned return settings } func (s *Store) Integrations(ctx context.Context) (IntegrationSettings, error) { empty := IntegrationSettings{Integrations: []Integration{}} var raw []byte err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, IntegrationsKey).Scan(&raw) if errors.Is(err, pgx.ErrNoRows) { return empty, nil } if err != nil { return empty, fmt.Errorf("store: read integrations: %w", err) } var settings IntegrationSettings if err := json.Unmarshal(raw, &settings); err != nil { return empty, fmt.Errorf("store: decode integrations: %w", err) } settings = normalizeIntegrations(settings) if settings.Integrations == nil { settings.Integrations = []Integration{} } return settings, nil } func (s *Store) SetIntegrations(ctx context.Context, settings IntegrationSettings) error { settings = normalizeIntegrations(settings) settings.UpdatedAt = time.Now().UTC() raw, err := json.Marshal(settings) if err != nil { return err } _, err = s.pool.Exec(ctx, ` INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2::jsonb, now()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, IntegrationsKey, string(raw)) if err != nil { return fmt.Errorf("store: write integrations: %w", err) } return nil }