200 lines
5.1 KiB
Go
200 lines
5.1 KiB
Go
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
|
|
}
|