2026-08-14 09:40:03 +12:00
|
|
|
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.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.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) {
|
2026-08-17 07:34:23 +12:00
|
|
|
if s.rejectWorkDuringQuietTime(w) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-14 09:40:03 +12:00
|
|
|
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)
|
|
|
|
|
}
|