Files
memby/server/internal/api/admin_integrations.go
T
2026-08-17 19:09:17 +12:00

269 lines
11 KiB
Go

package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/store"
)
// integrationEvent is one selectable event type, as the console renders it.
//
// The catalogue is here rather than in the integrations package for the reason the
// preference catalogue is in the API: it is *wording*, and the dispatcher must not care
// what an event is called in order to deliver it. A type published by something this list
// predates is still deliverable — the selection is a string — it simply has no friendly
// label until somebody adds one.
type integrationEvent struct {
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description"`
Group string `json:"group"`
}
var integrationEventCatalogue = []integrationEvent{
{adminevents.TypeLogin, "User signed in", "A television signed in with a known device.", "Access"},
{adminevents.TypeDeviceRegistered, "New device", "A television signed in for the first time.", "Access"},
{adminevents.TypeDeviceFirstUse, "First use today", "A television opened Memby for the first time that day.", "Access"},
{adminevents.TypeDeviceUpdated, "App updated", "A television finished updating to a new build.", "Access"},
{adminevents.TypeLoginFailed, "Sign-in refused", "Emby refused the credentials offered.", "Access"},
{adminevents.TypeLogout, "User signed out", "A television signed itself out.", "Access"},
{adminevents.TypeDeviceRemoved, "Device removed", "A television was removed from an account.", "Access"},
{adminevents.TypeDeviceRenamed, "Device renamed", "A television was given a new name.", "Access"},
{adminevents.TypeAdminSignIn, "Admin sign-in", "Somebody signed into this console.", "Access"},
{adminevents.TypeServerStarted, "Server started", "The gateway came up, usually after a deployment.", "System"},
{adminevents.TypeMaintenanceChanged, "Maintenance changed", "Memby was taken offline or brought back.", "System"},
{adminevents.TypeSettingsChanged, "Gateway settings changed", "A server-level setting was changed in the console.", "System"},
{adminevents.TypeTaskCompleted, "Scheduled task finished", "A background job did some work.", "System"},
{adminevents.TypeTaskFailed, "Scheduled task failed", "A background job could not complete.", "System"},
{adminevents.TypeIntegrationFailed, "Integration failed", "An outgoing webhook could not be delivered.", "System"},
{adminevents.TypeLibrarySync, "Library synchronised", "The catalogue import added or changed titles.", "Content"},
{adminevents.TypeEmbyUnreachable, "Emby unreachable", "The Emby server stopped answering.", "Content"},
{adminevents.TypeEmbyRecovered, "Emby recovered", "The Emby server started answering again.", "Content"},
}
type adminIntegrationRow struct {
store.RedactedIntegration
Health store.IntegrationHealth `json:"health"`
Deliveries []store.IntegrationDelivery `json:"deliveries"`
}
type adminIntegrationsResponse struct {
Integrations []adminIntegrationRow `json:"integrations"`
Catalogue []integrationEvent `json:"catalogue"`
Dropped int64 `json:"dropped"`
}
// handleAdminIntegrations lists every configured destination with its health and its last
// few attempts.
//
// The delivery history rides along rather than being a route of its own, because the two
// questions an operator has — "is it working" and "why did that one not arrive" — are
// asked at the same moment, and a page that had to fetch twice would show a healthy tick
// above a table of failures for as long as the second request took.
func (s *Server) handleAdminIntegrations(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.Integrations(r.Context())
if err != nil {
s.log.Error("integration settings read failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read the integrations")
return
}
response := adminIntegrationsResponse{
Integrations: []adminIntegrationRow{},
Catalogue: integrationEventCatalogue,
Dropped: s.integrations.Dropped(),
}
for _, integration := range settings.Integrations {
row := adminIntegrationRow{
RedactedIntegration: integration.Redact(),
Deliveries: []store.IntegrationDelivery{},
}
if health, err := s.store.IntegrationHealthFor(r.Context(), integration.ID); err == nil {
row.Health = health
}
if deliveries, err := s.store.IntegrationDeliveries(r.Context(), integration.ID, 10); err == nil {
row.Deliveries = deliveries
}
response.Integrations = append(response.Integrations, row)
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, response)
}
type saveIntegrationRequest 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"`
}
// handleAdminSaveIntegration creates or updates one destination.
//
// The URL is *optional on an update*, and that is the whole reason this is not a plain
// overwrite: the console is never sent the webhook address back (it is the credential),
// so a form that submitted what it was showing would replace a working webhook with an
// empty string on every unrelated edit. An absent URL therefore means "leave it as it
// is", and clearing one is done by removing the integration.
func (s *Server) handleAdminSaveIntegration(w http.ResponseWriter, r *http.Request) {
var req saveIntegrationRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
req.URL = strings.TrimSpace(req.URL)
if req.URL != "" {
if err := validateWebhookURL(req.URL); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
settings, err := s.store.Integrations(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not read the integrations")
return
}
id := strings.TrimSpace(req.ID)
found := false
for i := range settings.Integrations {
if settings.Integrations[i].ID != id || id == "" {
continue
}
found = true
settings.Integrations[i].Name = req.Name
settings.Integrations[i].Enabled = req.Enabled
settings.Integrations[i].Events = req.Events
if req.URL != "" {
settings.Integrations[i].URL = req.URL
}
settings.Integrations[i].UpdatedAt = time.Now().UTC()
}
if !found {
if req.URL == "" {
writeError(w, http.StatusBadRequest, "a new integration needs a webhook address")
return
}
if len(settings.Integrations) >= store.MaxIntegrations {
writeError(w, http.StatusBadRequest, "too many integrations")
return
}
kind := strings.TrimSpace(req.Kind)
if kind == "" {
kind = store.IntegrationDiscord
}
settings.Integrations = append(settings.Integrations, store.Integration{
ID: newIntegrationID(), Kind: kind, Name: req.Name,
Enabled: req.Enabled, URL: req.URL, Events: req.Events,
CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
})
}
if err := s.store.SetIntegrations(r.Context(), settings); err != nil {
s.log.Error("integration save failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save the integration")
return
}
// The dispatcher caches configuration for a few seconds; without this an operator's
// save would appear not to have taken until the cache aged out, which reads as the
// switch not working.
s.integrations.Invalidate()
s.loggerFor(r.Context()).Info("integration saved",
"integration", req.Name, "enabled", req.Enabled, "events", len(req.Events))
s.handleAdminIntegrations(w, r)
}
// handleAdminDeleteIntegration removes a destination and its delivery history.
func (s *Server) handleAdminDeleteIntegration(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("integrationID"))
settings, err := s.store.Integrations(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not read the integrations")
return
}
kept := make([]store.Integration, 0, len(settings.Integrations))
removed := false
for _, integration := range settings.Integrations {
if integration.ID == id {
removed = true
continue
}
kept = append(kept, integration)
}
if !removed {
writeError(w, http.StatusNotFound, "no such integration")
return
}
settings.Integrations = kept
if err := s.store.SetIntegrations(r.Context(), settings); err != nil {
writeError(w, http.StatusInternalServerError, "could not remove the integration")
return
}
s.integrations.Invalidate()
s.loggerFor(r.Context()).Info("integration removed", "integration_id", id)
s.handleAdminIntegrations(w, r)
}
// handleAdminTestIntegration posts a synthetic message and answers with what happened.
//
// Synchronous on purpose: a test is a question, and an operator who pressed it needs the
// answer here rather than on a delivery history they would have to go and refresh.
func (s *Server) handleAdminTestIntegration(w http.ResponseWriter, r *http.Request) {
if s.rejectWorkDuringQuietTime(w) {
return
}
id := strings.TrimSpace(r.PathValue("integrationID"))
if err := s.integrations.Test(r.Context(), id); err != nil {
s.loggerFor(r.Context()).Warn("integration test failed",
"integration_id", id, "error", err)
writeJSON(w, http.StatusOK, map[string]any{
"ok": false, "message": err.Error(),
})
return
}
s.loggerFor(r.Context()).Info("integration test delivered", "integration_id", id)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true, "message": "Delivered. Check the channel.",
})
}
// validateWebhookURL refuses anything that is not an https webhook.
//
// Not defence in depth so much as the one check worth making: this address is fetched by
// the gateway, from inside the household's network, so a plain-http or non-absolute URL
// is either a typo or an attempt to point the gateway at something local. Discord's own
// webhooks are https by definition, so nothing legitimate is refused.
func validateWebhookURL(raw string) error {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return errBadWebhook
}
if parsed.Scheme != "https" {
return errBadWebhook
}
return nil
}
var errBadWebhook = &webhookError{"a webhook address must be a full https:// URL"}
type webhookError struct{ message string }
func (e *webhookError) Error() string { return e.message }
func newIntegrationID() string {
raw := make([]byte, 8)
if _, err := rand.Read(raw); err != nil {
// A collision is the only consequence, and the caller checks for a duplicate id.
return "integration"
}
return hex.EncodeToString(raw)
}