package server import ( "context" "encoding/json" "log/slog" "strconv" "strings" "sync" "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource" ) // DialogHub fans control-logic dialog requests out to connected WebSocket // clients whose identity matches the dialog's user/group filter, and routes // input responses back to the dialog's target server variable. // // It implements controllogic.Notifier; the engine calls Notify when an // action.dialog node runs. Input dialogs are remembered as pending so a later // dialogResponse can be correlated by id and validated against its recipient // filter — this is why panels can write the response target even though direct // srv writes are otherwise gated to control-logic editors. type DialogHub struct { broker *broker.Broker policy *access.Policy audit audit.Recorder log *slog.Logger mu sync.Mutex clients map[*wsClient]struct{} pending map[string]controllogic.Dialog // input dialogs awaiting a response } // NewDialogHub builds an empty hub. rec is never nil after construction. func NewDialogHub(brk *broker.Broker, policy *access.Policy, rec audit.Recorder, log *slog.Logger) *DialogHub { if rec == nil { rec = audit.Nop() } return &DialogHub{ broker: brk, policy: policy, audit: rec, log: log, clients: map[*wsClient]struct{}{}, pending: map[string]controllogic.Dialog{}, } } func (h *DialogHub) add(c *wsClient) { h.mu.Lock() h.clients[c] = struct{}{} h.mu.Unlock() } func (h *DialogHub) remove(c *wsClient) { h.mu.Lock() delete(h.clients, c) h.mu.Unlock() } // dialogOut is the client-bound JSON form of a dialog request. type dialogOut struct { Type string `json:"type"` // always "dialog" ID string `json:"id"` Kind string `json:"kind"` // "info" | "error" | "input" Title string `json:"title,omitempty"` Message string `json:"message,omitempty"` } // Notify implements controllogic.Notifier: serialise the dialog and push it to // every connected client matching its user/group filter. func (h *DialogHub) Notify(d controllogic.Dialog) { b, err := json.Marshal(dialogOut{ Type: "dialog", ID: d.ID, Kind: d.Kind, Title: d.Title, Message: d.Message, }) if err != nil { return } h.mu.Lock() if d.Kind == "input" && strings.TrimSpace(d.Target) != "" { h.pending[d.ID] = d } var targets []*wsClient for c := range h.clients { if h.matches(c.user, d) { targets = append(targets, c) } } h.mu.Unlock() for _, c := range targets { select { case c.outCh <- b: default: } } } // matches reports whether user is a recipient of d. An empty user+group filter // targets everyone; otherwise the user must be named or in a named group. func (h *DialogHub) matches(user string, d controllogic.Dialog) bool { if len(d.Users) == 0 && len(d.Groups) == 0 { return true } if h.policy != nil { user = h.policy.ResolveUser(user) } for _, u := range d.Users { if u == user { return true } } if len(d.Groups) > 0 && h.policy != nil { groups := map[string]bool{} for _, g := range h.policy.GroupsOf(user) { groups[g] = true } for _, g := range d.Groups { if groups[g] { return true } } } return false } // cancel drops a pending input dialog without writing (user dismissed it). func (h *DialogHub) cancel(id string) { h.mu.Lock() delete(h.pending, id) h.mu.Unlock() } // respond writes an input dialog's response to its target server variable. The // dialog must be pending and the responding client must have been a recipient; // this gate replaces the usual srv write-permission check for sanctioned // control-logic responses. func (h *DialogHub) respond(ctx context.Context, c *wsClient, id string, value float64) { h.mu.Lock() d, ok := h.pending[id] if ok { delete(h.pending, id) } h.mu.Unlock() if !ok || !h.matches(c.user, d) { return } ds, name, ok := parseDialogTarget(d.Target) if !ok || ds == "local" { return } src, ok := h.broker.Source(ds) if !ok { h.log.Warn("dialog response: unknown data source", "ds", ds, "target", d.Target) return } ev := audit.Event{ Actor: c.user, ActorType: audit.ActorUser, Action: "signal.write", DS: ds, Signal: name, Value: strconv.FormatFloat(value, 'g', -1, 64), Detail: "control logic dialog response", IP: c.ip, Outcome: audit.OutcomeOK, } wctx := datasource.WithUser(ctx, c.user) if err := src.Write(wctx, name, value); err != nil { h.log.Warn("dialog response: write failed", "ds", ds, "signal", name, "err", err) ev.Outcome = audit.OutcomeError ev.Error = err.Error() } h.audit.Record(ev) } // parseDialogTarget splits a "ds:name" dialog target on the first ':'. A bare // name (no ':') defaults to the persistent server-variable source "srv". func parseDialogTarget(t string) (ds, name string, ok bool) { t = strings.TrimSpace(t) if t == "" { return "", "", false } if i := strings.IndexByte(t, ':'); i >= 0 { return t[:i], t[i+1:], true } return "srv", t, true }