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) { 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) }