Add admin-owned broadcast destinations ("community channels"): Discord or
generic webhooks fed straight from release_events by a per-channel watermark
sweep, announcing newly added movies/episodes as grouped digest posts plus
configurable media request lifecycle events (submitted/approved/declined/
fulfilled).
- Extend release_events with a kind discriminator and add a movie
availability spine (movie_availability + kind-keyed
notification_content_seed_state; first full scan seeds silently so
upgrades never flood the movie back catalog)
- Sweep worker reads events by (created_at, id) cursor with batch-window
grouping, per-channel backoff, and auto-disable; request events post
best-effort via new requests.LifecycleNotifier hooks
- Reuse the webhook stack throughout: URL encryption (new AAD namespace),
SSRF guard, embed limits, HMAC signing; shared type/name validation
extracted for both services
- Admin CRUD API under /admin/notifications/server-channels and a Server
Channels section in the notifications admin settings UI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
233 lines
8.9 KiB
Go
233 lines
8.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
"github.com/Silo-Server/silo-server/internal/notifications"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// AdminServerChannelsHandler exposes admin CRUD for server notification
|
|
// channels (community broadcast destinations). All routes are mounted inside
|
|
// the admin-only group.
|
|
type AdminServerChannelsHandler struct {
|
|
system *notifications.System
|
|
}
|
|
|
|
// NewAdminServerChannelsHandler creates the handler.
|
|
func NewAdminServerChannelsHandler(system *notifications.System) *AdminServerChannelsHandler {
|
|
return &AdminServerChannelsHandler{system: system}
|
|
}
|
|
|
|
func (h *AdminServerChannelsHandler) service() *notifications.ServerChannelService {
|
|
if h == nil || h.system == nil {
|
|
return nil
|
|
}
|
|
return h.system.ServerChannels
|
|
}
|
|
|
|
// serverChannelResponse is the API view of a server channel. Like webhooks it
|
|
// never includes the destination URL (Discord webhook tokens are bearer
|
|
// credentials in the URL path) or the stored signing secret — only url_host.
|
|
type serverChannelResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
URLHost string `json:"url_host"`
|
|
Enabled bool `json:"enabled"`
|
|
NotifyNewMovies bool `json:"notify_new_movies"`
|
|
NotifyNewEpisodes bool `json:"notify_new_episodes"`
|
|
NotifyRequestSubmitted bool `json:"notify_request_submitted"`
|
|
NotifyRequestApproved bool `json:"notify_request_approved"`
|
|
NotifyRequestDeclined bool `json:"notify_request_declined"`
|
|
NotifyRequestFulfilled bool `json:"notify_request_fulfilled"`
|
|
ConsecutiveFailures int `json:"consecutive_failures"`
|
|
DisabledReason *string `json:"disabled_reason"`
|
|
LastSuccessAt *time.Time `json:"last_success_at"`
|
|
LastFailureAt *time.Time `json:"last_failure_at"`
|
|
LastFailureStatus *int `json:"last_failure_status"`
|
|
LastFailureMessage *string `json:"last_failure_message"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
// SigningSecret is present only in create / rotate-secret responses.
|
|
SigningSecret string `json:"signing_secret,omitempty"`
|
|
}
|
|
|
|
func serverChannelToResponse(ch notifications.ServerChannel) serverChannelResponse {
|
|
return serverChannelResponse{
|
|
ID: ch.ID,
|
|
Name: ch.Name,
|
|
Type: ch.Type,
|
|
URLHost: ch.URLHost,
|
|
Enabled: ch.Enabled,
|
|
NotifyNewMovies: ch.NotifyNewMovies,
|
|
NotifyNewEpisodes: ch.NotifyNewEpisodes,
|
|
NotifyRequestSubmitted: ch.NotifyRequestSubmitted,
|
|
NotifyRequestApproved: ch.NotifyRequestApproved,
|
|
NotifyRequestDeclined: ch.NotifyRequestDeclined,
|
|
NotifyRequestFulfilled: ch.NotifyRequestFulfilled,
|
|
ConsecutiveFailures: ch.ConsecutiveFailures,
|
|
DisabledReason: ch.DisabledReason,
|
|
LastSuccessAt: ch.LastSuccessAt,
|
|
LastFailureAt: ch.LastFailureAt,
|
|
LastFailureStatus: ch.LastFailureStatus,
|
|
LastFailureMessage: ch.LastFailureMessage,
|
|
CreatedAt: ch.CreatedAt,
|
|
}
|
|
}
|
|
|
|
type serverChannelRequest struct {
|
|
Name *string `json:"name"`
|
|
URL *string `json:"url"`
|
|
Type *string `json:"type"`
|
|
Enabled *bool `json:"enabled"`
|
|
NotifyNewMovies *bool `json:"notify_new_movies"`
|
|
NotifyNewEpisodes *bool `json:"notify_new_episodes"`
|
|
NotifyRequestSubmitted *bool `json:"notify_request_submitted"`
|
|
NotifyRequestApproved *bool `json:"notify_request_approved"`
|
|
NotifyRequestDeclined *bool `json:"notify_request_declined"`
|
|
NotifyRequestFulfilled *bool `json:"notify_request_fulfilled"`
|
|
}
|
|
|
|
func (r serverChannelRequest) toInput() notifications.ServerChannelInput {
|
|
return notifications.ServerChannelInput{
|
|
Name: r.Name,
|
|
URL: r.URL,
|
|
Type: r.Type,
|
|
Enabled: r.Enabled,
|
|
NotifyNewMovies: r.NotifyNewMovies,
|
|
NotifyNewEpisodes: r.NotifyNewEpisodes,
|
|
NotifyRequestSubmitted: r.NotifyRequestSubmitted,
|
|
NotifyRequestApproved: r.NotifyRequestApproved,
|
|
NotifyRequestDeclined: r.NotifyRequestDeclined,
|
|
NotifyRequestFulfilled: r.NotifyRequestFulfilled,
|
|
}
|
|
}
|
|
|
|
func writeServerChannelError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, notifications.ErrServerChannelsDisabled):
|
|
writeError(w, http.StatusForbidden, "server_channels_disabled", "Server channels are disabled")
|
|
case errors.Is(err, notifications.ErrServerChannelNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Server channel not found")
|
|
case errors.Is(err, notifications.ErrServerChannelLimit):
|
|
writeError(w, http.StatusUnprocessableEntity, "limit_reached", "Server channel limit reached")
|
|
case errors.Is(err, notifications.ErrServerChannelInvalid):
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Server channel operation failed")
|
|
}
|
|
}
|
|
|
|
// HandleList handles GET /admin/notifications/server-channels.
|
|
func (h *AdminServerChannelsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
|
|
service := h.service()
|
|
if service == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
|
|
return
|
|
}
|
|
channels, err := service.List(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list server channels")
|
|
return
|
|
}
|
|
responses := make([]serverChannelResponse, 0, len(channels))
|
|
for _, ch := range channels {
|
|
responses = append(responses, serverChannelToResponse(ch))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"channels": responses})
|
|
}
|
|
|
|
// HandleCreate handles POST /admin/notifications/server-channels. For generic
|
|
// channels the response carries the signing secret exactly once.
|
|
func (h *AdminServerChannelsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
|
|
service := h.service()
|
|
if service == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
|
|
return
|
|
}
|
|
var req serverChannelRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
ch, signingSecret, err := service.Create(r.Context(), apimw.GetUserID(r.Context()), req.toInput())
|
|
if err != nil {
|
|
writeServerChannelError(w, err)
|
|
return
|
|
}
|
|
response := serverChannelToResponse(*ch)
|
|
response.SigningSecret = signingSecret
|
|
writeJSON(w, http.StatusCreated, response)
|
|
}
|
|
|
|
// HandleUpdate handles PUT /admin/notifications/server-channels/{id}.
|
|
func (h *AdminServerChannelsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
|
|
service := h.service()
|
|
if service == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
|
|
return
|
|
}
|
|
var req serverChannelRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
ch, err := service.Update(r.Context(), chi.URLParam(r, "id"), req.toInput())
|
|
if err != nil {
|
|
writeServerChannelError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, serverChannelToResponse(*ch))
|
|
}
|
|
|
|
// HandleDelete handles DELETE /admin/notifications/server-channels/{id}.
|
|
// Idempotent.
|
|
func (h *AdminServerChannelsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
|
|
service := h.service()
|
|
if service == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
|
|
return
|
|
}
|
|
if err := service.Delete(r.Context(), chi.URLParam(r, "id")); err != nil {
|
|
writeServerChannelError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// HandleRotateSecret handles POST /admin/notifications/server-channels/{id}/rotate-secret.
|
|
func (h *AdminServerChannelsHandler) HandleRotateSecret(w http.ResponseWriter, r *http.Request) {
|
|
service := h.service()
|
|
if service == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
|
|
return
|
|
}
|
|
signingSecret, err := service.RotateSecret(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeServerChannelError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"signing_secret": signingSecret})
|
|
}
|
|
|
|
// HandleTest handles POST /admin/notifications/server-channels/{id}/test. The
|
|
// test send is synchronous and never touches the watermark or failure
|
|
// counters.
|
|
func (h *AdminServerChannelsHandler) HandleTest(w http.ResponseWriter, r *http.Request) {
|
|
service := h.service()
|
|
if service == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
|
|
return
|
|
}
|
|
result, err := service.Test(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeServerChannelError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|