0.1.38 gateway
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user