Files
2026-08-14 09:40:03 +12:00

178 lines
6.0 KiB
Go

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/<channel id>/<token>
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
}