* feat(events): let websocket clients declare channels on connect Observing the events hub over `/api/v1/events/ws` costs more than it should. A read-only consumer has to send a `subscribe` frame within five seconds or be closed with a policy violation, which means implementing the handshake and holding the write half of the socket open purely to satisfy it. That cost is contract, not transport: `subscribe` is the only inbound message this endpoint accepts. Accept the selection on the URL instead. `?channels=catalog,user_state` subscribes on connect, answers with the same `subscribed` frame and per-channel snapshots the handshake produces, and is never put on the grace-period clock. A connection that declares nothing is unchanged — it still owes a subscribe frame within five seconds. Two supporting changes: - Channel selection now resolves through one shared function used by both paths, so the URL and handshake cannot drift on who may subscribe to what. Role, profile-binding, and validity checks are unchanged. - An unrecognized channel name is reported in the existing `rejected` array as `unknown_channel` rather than closing the connection. Closing took down every other channel the client held over one bad name, and a client cannot always know which channels its role allows before asking. Forbidden and profile-scoped channels were already handled this way. `required_action` in the hello frame is `"none"` for a declared connection and `"subscribe"` otherwise; the web type is widened to match. No wire field changes type or disappears, so this stays additive under the v1 rules. Part of #523 AI disclosure: tool Claude Code, model claude-opus-5, fully AI-generated, reviewed and verified by the author before submission. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(events): address review findings on declared-channel subscriptions Three findings from automated review, all verified against the code before acting on them. **Start the reader before declared-channel snapshots (regression).** configureWebSocket installs an absolute read deadline that only pongs extend, and gorilla processes pongs solely inside ReadMessage (conn.go:950, reached only via advanceFrame). The declared path built its snapshots before starting the reader goroutine, so a snapshot slower than the deadline — a loaded jobs/sessions/scans/history query — would kill an otherwise healthy connection the instant reading began. The handshake path never had this problem because its snapshots run downstream of an active reader. Regression test stalls a tasks snapshot past the deadline; it fails with the previous ordering. **Advertise the feature through a capability endpoint.** Adding a client-visible subscription mode without one leaves a read-only client unable to tell, before connecting, whether ?channels= will be honored: an older server ignores it and closes the connection after the grace period, so the client must retain the very handshake this removes. GET /api/v1/events/capability reports both modes, the grace period the handler actually enforces, and the known channels, following the existing per-subsystem convention. **Deduplicate rejections, not just acceptances.** Asking twice for one forbidden channel produced two identical `rejected` entries. Pre-existing — the dedup check sat after the rejection branches — but cheap to correct in the function this PR extracted. Part of #523 AI disclosure: tool Claude Code, model claude-opus-5, fully AI-generated, reviewed and verified by the author before submission. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(events): frame the capability endpoint as a staleness probe Clients are expected to run a current build rather than negotiate down to an old server, so the endpoint is not a branch-on-capability contract. Its value is letting a client distinguish "this server does not do that" from "the connection failed" — the two are indistinguishable from the socket alone, since an older server ignores ?channels= and then closes on the grace period — so it can tell the user the deployment is out of date instead of failing opaquely. Comment-only; no behavior or wire change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(events): bound the subscribed answer and reap unsubscribed connections Second review pass on the declared-channel path. Four issues, all at the edges of the new URL surface rather than in the design itself. Rejections amplified the request. Making an unknown channel non-fatal removed the brake that used to close the connection on the first bad name, and every refusal quotes the name it refuses — so a large ?channels= of distinct garbage produced a far larger `subscribed` frame, buffered server-side. Cap a selection at 32 distinct channels, report the overrun once instead of per name, truncate an echoed name at 64 bytes, and set a 64 KiB read limit on the socket so an oversize frame cannot be buffered whole before it is rejected. The grace period was disarmed by declaring, not by subscribing. Both `?channels=` with no names and a non-admin naming only an admin channel came up subscribed to nothing and were never reaped, each holding a hub subscriber, two goroutines, and an envelope channel that every published event fans into. Disarm on holding a subscription instead. Selection now resolves before the hello frame — it is pure, so nothing moves ahead of the reader — which lets required_action say "subscribe" when the connection really does still owe one. Repeating the parameter dropped channels silently. `?channels=a&channels=b` honored only the first and reported nothing rejected. Read every occurrence. The capability endpoint advertised `plugins`, which is host-to-plugin runtime dispatch and is granted to no role. An admin following the endpoint's stated purpose got `forbidden` while already being admin, and the hardcoded "Admin access required" made it a dead end rather than a soft failure. Split evt.ClientChannels out of AllChannels, advertise that, and word the refusal so it does not promise a remedy that does not exist. A test pins that an admin can subscribe to everything the endpoint names. Each guard was verified to bite by reverting it and watching the test fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
705 lines
23 KiB
Go
705 lines
23 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/adminjob"
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
"github.com/Silo-Server/silo-server/internal/auth"
|
|
evt "github.com/Silo-Server/silo-server/internal/events"
|
|
"github.com/Silo-Server/silo-server/internal/historyimport"
|
|
"github.com/Silo-Server/silo-server/internal/notifications"
|
|
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
|
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/oklog/ulid/v2"
|
|
)
|
|
|
|
type historyImportActiveLister interface {
|
|
ListActiveRuns(ctx context.Context, userID int) ([]historyimport.Run, error)
|
|
ListAdminActiveRuns(ctx context.Context, sourceID *int) ([]historyimport.Run, error)
|
|
}
|
|
|
|
type taskInfoLister interface {
|
|
ListTasks(includeHidden bool) []taskmanager.TaskInfo
|
|
}
|
|
|
|
const maxRealtimeScanSnapshotRuns = 500
|
|
|
|
// subscribeGracePeriod bounds how long a connection that is not subscribed to
|
|
// anything may stay silent before it is closed. It exists to distinguish a
|
|
// client mid-handshake from one that connected and stalled; a connection that
|
|
// arrived with a usable ?channels= selection is never put on this clock.
|
|
const subscribeGracePeriod = 5 * time.Second
|
|
|
|
// maxRequestedChannels bounds how many distinct channels one selection may
|
|
// name. Every refusal is echoed back with its channel name, so without a cap a
|
|
// selection of many unknown names is an amplifier: the request is bounded by
|
|
// the header/frame limit, the answer was not. Any real client names a subset of
|
|
// the ten client channels; the slack is only so a caller learns it overshot
|
|
// rather than silently losing channels.
|
|
const maxRequestedChannels = 32
|
|
|
|
// maxChannelNameLength bounds the channel name echoed in a rejection. A name
|
|
// longer than any real channel is garbage by definition, and quoting it back in
|
|
// full is the second half of the same amplification.
|
|
const maxChannelNameLength = 64
|
|
|
|
// maxEventsFrameBytes bounds an inbound frame on this socket. The only message
|
|
// it accepts is a subscribe frame naming at most maxRequestedChannels channels,
|
|
// which is orders of magnitude smaller; the limit exists so a client cannot
|
|
// make the server buffer an arbitrarily large frame before it is rejected.
|
|
const maxEventsFrameBytes = 64 * 1024
|
|
|
|
// required_action values in the hello frame: whether the client still owes a
|
|
// subscribe frame, or already holds a subscription it declared on the URL.
|
|
const (
|
|
requiredActionSubscribe = "subscribe"
|
|
requiredActionNone = "none"
|
|
)
|
|
|
|
// frameTypeSubscribed is the acknowledgement both selection paths answer with.
|
|
const frameTypeSubscribed = "subscribed"
|
|
|
|
type activeScanLister interface {
|
|
ListActiveSnapshot(ctx context.Context, limit int) ([]evt.ScanRun, error)
|
|
}
|
|
|
|
type EventsHandler struct {
|
|
hub *evt.Hub
|
|
jobs *AdminJobsHandler
|
|
admin *AdminHandler
|
|
tasks taskInfoLister
|
|
scans *evt.ScanRegistry
|
|
persistedScans activeScanLister
|
|
historyImports historyImportActiveLister
|
|
notifications *notifications.System
|
|
}
|
|
|
|
// SetNotificationsSystem wires the user-notification system: websocket
|
|
// handshake tickets and the notifications channel snapshot.
|
|
func (h *EventsHandler) SetNotificationsSystem(system *notifications.System) {
|
|
if h != nil {
|
|
h.notifications = system
|
|
}
|
|
}
|
|
|
|
func NewEventsHandler(
|
|
hub *evt.Hub,
|
|
jobs *AdminJobsHandler,
|
|
admin *AdminHandler,
|
|
tasks taskInfoLister,
|
|
scans *evt.ScanRegistry,
|
|
persistedScans *scanqueue.Service,
|
|
historyImports historyImportActiveLister,
|
|
) *EventsHandler {
|
|
return &EventsHandler{
|
|
hub: hub,
|
|
jobs: jobs,
|
|
admin: admin,
|
|
tasks: tasks,
|
|
scans: scans,
|
|
persistedScans: persistedScans,
|
|
historyImports: historyImports,
|
|
}
|
|
}
|
|
|
|
func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
if h == nil || h.hub == nil {
|
|
http.Error(w, "events unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
claims := apimw.GetClaims(r.Context())
|
|
if claims == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Browsers cannot set custom headers on websocket handshakes, so profile
|
|
// identity arrives as a short-lived single-use ticket minted via
|
|
// POST /events/ws-ticket. A connection without a ticket stays unbound and
|
|
// simply cannot subscribe to the profile-scoped notifications channel.
|
|
boundProfileID := ""
|
|
if ticket := r.URL.Query().Get("ticket"); ticket != "" && h.notifications != nil {
|
|
ticketUserID, ticketProfileID, ok := h.notifications.Tickets.Consume(r.Context(), ticket)
|
|
if ok && ticketUserID == claims.UserID {
|
|
boundProfileID = ticketProfileID
|
|
} else {
|
|
// Expired, reused, consumed on a different node, or minted for
|
|
// another user: degrade to an unbound connection instead of
|
|
// failing the handshake. The binding grants nothing on its own —
|
|
// the client retries it when its notifications subscription is
|
|
// rejected — whereas a hard 403 would take down every realtime
|
|
// channel over a notifications-only concern.
|
|
slog.WarnContext(r.Context(), "events: websocket ticket rejected; connection unbound", "component", "api",
|
|
"user_id", claims.UserID)
|
|
}
|
|
}
|
|
|
|
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
// The Android/KMP client has a long history of silent handshake
|
|
// failures here (gorilla writes the 4xx itself); log the exact
|
|
// upgrade-relevant request shape so a failing client is diagnosable
|
|
// from the server alone.
|
|
slog.WarnContext(r.Context(), "events websocket upgrade failed", "component", "api",
|
|
"error", err,
|
|
"user_id", claims.UserID,
|
|
"proto", r.Proto,
|
|
"method", r.Method,
|
|
"connection_header", r.Header.Get("Connection"),
|
|
"upgrade_header", r.Header.Get("Upgrade"),
|
|
"ws_version", r.Header.Get("Sec-Websocket-Version"),
|
|
"ws_key_present", r.Header.Get("Sec-Websocket-Key") != "",
|
|
"user_agent", r.UserAgent(),
|
|
)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
configureWebSocket(conn)
|
|
conn.SetReadLimit(maxEventsFrameBytes)
|
|
|
|
ctx, cancel := context.WithCancel(r.Context())
|
|
defer cancel()
|
|
|
|
eventsCh, unsubscribe := h.hub.Subscribe()
|
|
defer unsubscribe()
|
|
startWebSocketPingLoop(ctx, func() error {
|
|
return writeWebSocketControl(conn, websocket.PingMessage, nil)
|
|
})
|
|
|
|
allowedChannels := allowedChannelsForRole(claims.Role)
|
|
|
|
// A connection may declare its channels on the URL instead of sending a
|
|
// subscribe frame. That is all an observer needs — subscribe is the only
|
|
// inbound message this endpoint accepts — so declaring up front lets a
|
|
// read-only client skip the handshake, the grace period, and the write
|
|
// half of the socket entirely.
|
|
declaredChannels, declared := parseDeclaredChannels(r.URL.Query())
|
|
|
|
// Resolved before the hello frame so required_action can report what the
|
|
// connection actually owes. Resolution is pure — no query, no I/O — so
|
|
// nothing here can stall ahead of the reader that starts below; only the
|
|
// snapshots the accepted channels produce can, and those still run after
|
|
// it.
|
|
declaredSubs := make(map[evt.EventChannel]struct{})
|
|
var declaredAccepted []evt.EventChannel
|
|
var declaredRejected []evt.EventsRejectedChannel
|
|
if declared {
|
|
declaredSubs, declaredAccepted, declaredRejected =
|
|
resolveChannelSelection(declaredChannels, allowedChannels, boundProfileID)
|
|
}
|
|
|
|
// A declaration that yielded no subscription discharges nothing: the client
|
|
// still owes a subscribe frame, and saying "none" would send it to wait
|
|
// silently for events on a connection due to be closed in five seconds.
|
|
requiredAction := requiredActionSubscribe
|
|
if len(declaredSubs) > 0 {
|
|
requiredAction = requiredActionNone
|
|
}
|
|
connectionID := ulid.Make().String()
|
|
if err := writeWebSocketJSON(conn, evt.EventsHelloMessage{
|
|
Type: "hello",
|
|
SchemaVersion: 1,
|
|
ConnectionID: connectionID,
|
|
AvailableChannels: allowedChannels,
|
|
RequiredAction: requiredAction,
|
|
}); err != nil {
|
|
return
|
|
}
|
|
|
|
// The reader starts before any snapshot query runs. configureWebSocket
|
|
// installs an absolute read deadline that only pongs extend, and gorilla
|
|
// processes pongs solely inside ReadMessage — so a snapshot slow enough to
|
|
// outlast the deadline (a loaded jobs/sessions/scans query) would kill an
|
|
// otherwise healthy connection the moment reading began. The handshake path
|
|
// is safe for free, building its snapshots downstream of an active reader;
|
|
// the declared path has to arrange that ordering deliberately.
|
|
readMessages := make(chan []byte, 8)
|
|
readDone := make(chan struct{})
|
|
go func() {
|
|
defer close(readDone)
|
|
for {
|
|
_, data, readErr := conn.ReadMessage()
|
|
if readErr != nil {
|
|
return
|
|
}
|
|
select {
|
|
case readMessages <- data:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
subscriptions := declaredSubs
|
|
if declared {
|
|
// The same subscribed frame the handshake produces, so a client sees
|
|
// one acknowledgement shape however it selected its channels — and
|
|
// still learns which of its requests were refused, and why.
|
|
if err := writeWebSocketJSON(conn, evt.EventsSubscribedMessage{
|
|
Type: frameTypeSubscribed,
|
|
Channels: declaredAccepted,
|
|
Rejected: declaredRejected,
|
|
}); err != nil {
|
|
return
|
|
}
|
|
for _, channel := range declaredAccepted {
|
|
if err := h.writeSnapshotFrame(conn, r, claims, boundProfileID, channel); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// The clock is disarmed by holding a subscription, not by having declared
|
|
// one. A declaration that resolved to nothing — ?channels= with no names,
|
|
// or a selection every entry of which was refused — leaves the connection
|
|
// in exactly the state the grace period exists to reap: subscribed to
|
|
// nothing, with no reason to expect it to ever receive a frame, while still
|
|
// holding a hub subscriber, two goroutines, and an envelope channel that
|
|
// every published event fans into. Such a client still owes a subscribe
|
|
// frame, and its rejected list told it why. A nil channel blocks forever,
|
|
// which is exactly the disarmed case.
|
|
var deadlineC <-chan time.Time
|
|
if len(subscriptions) == 0 {
|
|
deadline := time.NewTimer(subscribeGracePeriod)
|
|
defer deadline.Stop()
|
|
deadlineC = deadline.C
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-readDone:
|
|
return
|
|
case <-deadlineC:
|
|
writeWebSocketError(conn, "bad_request", "subscribe is required within "+subscribeGracePeriod.String())
|
|
_ = writeWebSocketControl(
|
|
conn,
|
|
websocket.CloseMessage,
|
|
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "subscribe required"),
|
|
)
|
|
return
|
|
case data := <-readMessages:
|
|
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, boundProfileID, data, allowedChannels)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !handled {
|
|
continue
|
|
}
|
|
// Obligation discharged: detach the timer so a later tick cannot
|
|
// close a connection that has since subscribed. The timer itself
|
|
// is stopped by the deferred Stop above.
|
|
deadlineC = nil
|
|
subscriptions = nextSubs
|
|
case env, ok := <-eventsCh:
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, subscribed := subscriptions[env.Channel]; !subscribed {
|
|
continue
|
|
}
|
|
if !allowsEventForClaims(claims, boundProfileID, env) {
|
|
continue
|
|
}
|
|
if err := h.writeEventFrame(conn, r, claims, boundProfileID, env); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *EventsHandler) handleEventsClientMessage(
|
|
conn *websocket.Conn,
|
|
r *http.Request,
|
|
claims *auth.Claims,
|
|
boundProfileID string,
|
|
data []byte,
|
|
allowed []evt.EventChannel,
|
|
) (map[evt.EventChannel]struct{}, bool, bool) {
|
|
var base struct {
|
|
Type string `json:"type"`
|
|
}
|
|
if err := readWebSocketJSON(data, &base); err != nil {
|
|
writeWebSocketError(conn, "bad_request", "Malformed JSON")
|
|
_ = writeWebSocketControl(
|
|
conn,
|
|
websocket.CloseMessage,
|
|
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "malformed json"),
|
|
)
|
|
return nil, false, false
|
|
}
|
|
if base.Type != "subscribe" {
|
|
writeWebSocketError(conn, "bad_request", "Unknown message type")
|
|
_ = writeWebSocketControl(
|
|
conn,
|
|
websocket.CloseMessage,
|
|
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "unknown message type"),
|
|
)
|
|
return nil, false, false
|
|
}
|
|
|
|
var message evt.EventsSubscribeMessage
|
|
if err := readWebSocketJSON(data, &message); err != nil {
|
|
writeWebSocketError(conn, "bad_request", "Invalid subscribe payload")
|
|
_ = writeWebSocketControl(
|
|
conn,
|
|
websocket.CloseMessage,
|
|
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "invalid subscribe payload"),
|
|
)
|
|
return nil, false, false
|
|
}
|
|
|
|
nextSubs, accepted, rejected := resolveChannelSelection(message.Channels, allowed, boundProfileID)
|
|
|
|
if err := writeWebSocketJSON(conn, evt.EventsSubscribedMessage{
|
|
Type: frameTypeSubscribed,
|
|
RequestID: message.RequestID,
|
|
Channels: accepted,
|
|
Rejected: rejected,
|
|
}); err != nil {
|
|
return nil, false, false
|
|
}
|
|
|
|
for _, channel := range accepted {
|
|
if err := h.writeSnapshotFrame(conn, r, claims, boundProfileID, channel); err != nil {
|
|
return nil, false, false
|
|
}
|
|
}
|
|
|
|
return nextSubs, true, true
|
|
}
|
|
|
|
// resolveChannelSelection decides which of the requested channels a connection
|
|
// may subscribe to. It is the single place that answers that question, shared
|
|
// by the URL-declared path and the subscribe frame so the two cannot drift.
|
|
//
|
|
// Every rejection is reported rather than fatal: an unknown, forbidden, or
|
|
// profile-requiring channel costs the caller that channel and nothing else.
|
|
// Closing the connection instead would take down every other channel it holds
|
|
// over one bad name, and a client cannot always know which channels its role
|
|
// allows before it asks.
|
|
//
|
|
// Because refusals are answered instead of closing the connection, the answer
|
|
// has to be bounded independently of the request: at most maxRequestedChannels
|
|
// distinct names are considered, and an overrun is reported once rather than
|
|
// per excess name.
|
|
func resolveChannelSelection(
|
|
requested []evt.EventChannel,
|
|
allowed []evt.EventChannel,
|
|
boundProfileID string,
|
|
) (map[evt.EventChannel]struct{}, []evt.EventChannel, []evt.EventsRejectedChannel) {
|
|
allowedSet := make(map[evt.EventChannel]struct{}, len(allowed))
|
|
for _, channel := range allowed {
|
|
allowedSet[channel] = struct{}{}
|
|
}
|
|
validSet := make(map[evt.EventChannel]struct{}, len(evt.ClientChannels))
|
|
for _, channel := range evt.ClientChannels {
|
|
validSet[channel] = struct{}{}
|
|
}
|
|
|
|
capacity := min(len(requested), maxRequestedChannels)
|
|
subs := make(map[evt.EventChannel]struct{}, capacity)
|
|
accepted := make([]evt.EventChannel, 0, capacity)
|
|
rejected := make([]evt.EventsRejectedChannel, 0, capacity)
|
|
// A repeated channel is answered once whether it was accepted or refused.
|
|
// Only the accepted side deduplicated before this change, so asking twice
|
|
// for one forbidden channel produced two identical rejections.
|
|
seen := make(map[evt.EventChannel]struct{}, capacity)
|
|
|
|
reject := func(channel evt.EventChannel, code, message string) {
|
|
// A garbage name is echoed back only far enough to be recognizable.
|
|
if len(channel) > maxChannelNameLength {
|
|
channel = channel[:maxChannelNameLength]
|
|
}
|
|
rejected = append(rejected, evt.EventsRejectedChannel{
|
|
Channel: channel,
|
|
Code: code,
|
|
Message: message,
|
|
})
|
|
}
|
|
|
|
for _, channel := range requested {
|
|
if _, dup := seen[channel]; dup {
|
|
continue
|
|
}
|
|
if len(seen) >= maxRequestedChannels {
|
|
// One entry for the whole overrun, not one per name: the point of
|
|
// the cap is that the response cannot grow with the request.
|
|
reject("", "too_many_channels",
|
|
"At most "+strconv.Itoa(maxRequestedChannels)+" channels may be requested at once")
|
|
break
|
|
}
|
|
seen[channel] = struct{}{}
|
|
|
|
switch {
|
|
case !contains(validSet, channel):
|
|
reject(channel, "unknown_channel", "Unknown channel")
|
|
case !contains(allowedSet, channel):
|
|
// Deliberately not "admin access required": the caller's role is
|
|
// the usual reason a channel is refused but not the only one, and a
|
|
// remedy that does not exist reads as a bug in the client's own
|
|
// permissions rather than a channel it was never going to get.
|
|
reject(channel, "forbidden", "This channel is not available to this connection")
|
|
// The notifications channel is profile-scoped: it requires a
|
|
// connection bound to a profile via a websocket ticket.
|
|
case channel == evt.ChannelNotifications && boundProfileID == "":
|
|
reject(channel, "profile_required", "A profile-bound websocket ticket is required")
|
|
default:
|
|
subs[channel] = struct{}{}
|
|
accepted = append(accepted, channel)
|
|
}
|
|
}
|
|
|
|
return subs, accepted, rejected
|
|
}
|
|
|
|
func contains(set map[evt.EventChannel]struct{}, channel evt.EventChannel) bool {
|
|
_, ok := set[channel]
|
|
return ok
|
|
}
|
|
|
|
// parseDeclaredChannels reads the optional ?channels= selection a connection
|
|
// may declare on the URL, letting a read-only observer skip the subscribe
|
|
// handshake entirely. An absent parameter returns ok=false, which keeps the
|
|
// handshake (and its grace period) in force; an empty or all-blank value is a
|
|
// deliberate declaration of nothing, so it returns ok=true and no channels.
|
|
//
|
|
// Every occurrence of the parameter is read, not just the first. Repeating a
|
|
// query parameter is as natural a spelling as a comma-separated list, and
|
|
// honoring one and silently discarding the rest loses channels with no
|
|
// diagnostic — the connection would come up subscribed to less than it asked
|
|
// for and report nothing rejected.
|
|
func parseDeclaredChannels(query url.Values) ([]evt.EventChannel, bool) {
|
|
values, ok := query["channels"]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
|
|
channels := make([]evt.EventChannel, 0, 4)
|
|
for _, value := range values {
|
|
for _, raw := range strings.Split(value, ",") {
|
|
name := strings.TrimSpace(raw)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
channels = append(channels, evt.EventChannel(name))
|
|
}
|
|
}
|
|
return channels, true
|
|
}
|
|
|
|
func allowedChannelsForRole(role string) []evt.EventChannel {
|
|
channels := []evt.EventChannel{
|
|
evt.ChannelCatalog,
|
|
evt.ChannelHistoryImport,
|
|
evt.ChannelUserState,
|
|
evt.ChannelUserSettings,
|
|
evt.ChannelNotifications,
|
|
}
|
|
if role == "admin" {
|
|
channels = append(channels,
|
|
evt.ChannelJobs,
|
|
evt.ChannelSessions,
|
|
evt.ChannelTasks,
|
|
evt.ChannelScans,
|
|
evt.ChannelSettings,
|
|
)
|
|
}
|
|
return channels
|
|
}
|
|
|
|
func allowsEventForClaims(claims *auth.Claims, boundProfileID string, env evt.Envelope) bool {
|
|
if claims == nil {
|
|
return false
|
|
}
|
|
if env.AdminOnly && claims.Role != "admin" {
|
|
return false
|
|
}
|
|
if env.Channel == evt.ChannelNotifications {
|
|
// Notifications are personal: even admins only receive their own
|
|
// profile's deliveries, and only on a profile-bound connection.
|
|
return boundProfileID != "" &&
|
|
env.UserID == claims.UserID &&
|
|
env.ProfileID == boundProfileID
|
|
}
|
|
if env.UserID > 0 && claims.Role != "admin" && env.UserID != claims.UserID {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func marshalJSON(value any) json.RawMessage {
|
|
if value == nil {
|
|
return json.RawMessage("null")
|
|
}
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return json.RawMessage("null")
|
|
}
|
|
return data
|
|
}
|
|
|
|
func (h *EventsHandler) snapshotForChannel(
|
|
r *http.Request,
|
|
claims *auth.Claims,
|
|
boundProfileID string,
|
|
channel evt.EventChannel,
|
|
) (json.RawMessage, error) {
|
|
switch channel {
|
|
case evt.ChannelCatalog, evt.ChannelUserState:
|
|
return json.RawMessage("null"), nil
|
|
case evt.ChannelNotifications:
|
|
// Recent unread deliveries for the bound profile so reconnecting
|
|
// clients hydrate without a separate REST call. Same row shape as the
|
|
// inbox list API.
|
|
if h == nil || h.notifications == nil || boundProfileID == "" {
|
|
return json.RawMessage("[]"), nil
|
|
}
|
|
rows, err := h.notifications.Deliveries.RecentUnread(r.Context(), boundProfileID, 25)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalJSON(h.notifications.PayloadsForRows(r.Context(), rows)), nil
|
|
case evt.ChannelJobs:
|
|
if h == nil || h.jobs == nil || h.jobs.repo == nil {
|
|
return json.RawMessage("[]"), nil
|
|
}
|
|
jobs, err := h.jobs.repo.List(r.Context(), adminjob.ListJobsOptions{Limit: 50})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
response := make([]adminJobResponse, 0, len(jobs))
|
|
for _, job := range jobs {
|
|
response = append(response, adminJobToResponse(r, job, h.jobs.store))
|
|
}
|
|
return marshalJSON(response), nil
|
|
case evt.ChannelSessions:
|
|
if h == nil || h.admin == nil {
|
|
return json.RawMessage("[]"), nil
|
|
}
|
|
sessions, err := h.admin.loadPlaybackSessions(r.Context(), r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalJSON(sessions), nil
|
|
case evt.ChannelTasks:
|
|
if h == nil || h.tasks == nil {
|
|
return json.RawMessage("[]"), nil
|
|
}
|
|
return marshalJSON(h.tasks.ListTasks(false)), nil
|
|
case evt.ChannelScans:
|
|
if h == nil {
|
|
return json.RawMessage("[]"), nil
|
|
}
|
|
runs := make([]evt.ScanRun, 0, maxRealtimeScanSnapshotRuns)
|
|
remaining := maxRealtimeScanSnapshotRuns
|
|
if h.persistedScans != nil && remaining > 0 {
|
|
persisted, err := h.persistedScans.ListActiveSnapshot(r.Context(), remaining)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(persisted) > remaining {
|
|
persisted = persisted[:remaining]
|
|
}
|
|
runs = append(runs, persisted...)
|
|
remaining -= len(persisted)
|
|
}
|
|
if h.scans != nil && remaining > 0 {
|
|
runs = append(runs, h.scans.ListActiveLimit(remaining)...)
|
|
}
|
|
return marshalJSON(runs), nil
|
|
case evt.ChannelHistoryImport:
|
|
if h == nil || h.historyImports == nil {
|
|
return json.RawMessage("[]"), nil
|
|
}
|
|
if claims != nil && claims.Role == "admin" {
|
|
runs, err := h.historyImports.ListAdminActiveRuns(r.Context(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalJSON(runs), nil
|
|
}
|
|
runs, err := h.historyImports.ListActiveRuns(r.Context(), claims.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalJSON(runs), nil
|
|
default:
|
|
return json.RawMessage("null"), nil
|
|
}
|
|
}
|
|
|
|
func (h *EventsHandler) writeSnapshotFrame(
|
|
conn *websocket.Conn,
|
|
r *http.Request,
|
|
claims *auth.Claims,
|
|
boundProfileID string,
|
|
channel evt.EventChannel,
|
|
) error {
|
|
data, err := h.snapshotForChannel(r, claims, boundProfileID, channel)
|
|
if err != nil {
|
|
slog.ErrorContext(r.Context(),
|
|
"events: failed to build initial snapshot", "component", "api",
|
|
"channel",
|
|
channel,
|
|
"user_id",
|
|
claims.UserID,
|
|
"error",
|
|
err,
|
|
)
|
|
writeWebSocketError(conn, "internal_error", "Failed to load snapshot")
|
|
return nil
|
|
}
|
|
return writeWebSocketJSON(conn, evt.EventsSnapshotMessage{
|
|
Type: "snapshot",
|
|
Channel: channel,
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
func (h *EventsHandler) writeEventFrame(
|
|
conn *websocket.Conn,
|
|
r *http.Request,
|
|
claims *auth.Claims,
|
|
boundProfileID string,
|
|
env evt.Envelope,
|
|
) error {
|
|
data := env.Data
|
|
if len(data) == 0 || (env.Channel == evt.ChannelSessions && env.Event == "sessions.replaced") {
|
|
snapshot, err := h.snapshotForChannel(r, claims, boundProfileID, env.Channel)
|
|
if err != nil {
|
|
// Drop the frame but keep the stream open (same contract as
|
|
// writeSnapshotFrame): durable state covers the gap on the next
|
|
// event or reconnect, while closing the socket tears down every
|
|
// channel the client subscribed to.
|
|
slog.ErrorContext(r.Context(), "events: failed to build event payload", "component", "api", "channel", env.Channel, "event", env.Event, "error", err)
|
|
return nil
|
|
}
|
|
data = snapshot
|
|
}
|
|
if len(data) == 0 {
|
|
data = json.RawMessage("null")
|
|
}
|
|
|
|
return writeWebSocketJSON(conn, evt.EventsEventMessage{
|
|
Type: "event",
|
|
Channel: env.Channel,
|
|
Event: env.Event,
|
|
EventID: env.EventID,
|
|
Timestamp: env.Timestamp.UTC().Format(time.RFC3339Nano),
|
|
Data: data,
|
|
})
|
|
}
|