Improving all side of app
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user