Files
uopi/internal/api/api.go
T
2026-06-23 17:59:40 +02:00

1837 lines
64 KiB
Go

// Package api implements the REST API handlers for uopi.
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"runtime"
"strconv"
"strings"
"time"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/metrics"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage"
)
// Handler holds the dependencies shared across all API endpoints.
type Handler struct {
broker *broker.Broker
synthetic *synthetic.Synthetic // nil if not enabled
store *storage.Store
cfg *confmgr.Store
policy *access.Policy
acl *panelacl.Store
ctrlLogic *controllogic.Store
ctrlEngine *controllogic.Engine
audit audit.Recorder // never nil; audit.Nop when disabled
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
uiDefaultZoom float64 // base UI scale sent to clients; 0 means 1.0
log *slog.Logger
}
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, uiDefaultZoom float64, log *slog.Logger) *Handler {
if rec == nil {
rec = audit.Nop()
}
return &Handler{
broker: b,
synthetic: synth,
store: store,
cfg: cfg,
policy: policy,
acl: acl,
ctrlLogic: ctrlLogic,
ctrlEngine: ctrlEngine,
audit: rec,
channelFinderURL: channelFinderURL,
archiverURL: archiverURL,
uiDefaultZoom: uiDefaultZoom,
log: log,
}
}
// Register mounts all API routes onto mux under the given prefix.
// Requires Go 1.22+ for method+path routing.
func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/me", h.getMe)
mux.HandleFunc("GET "+prefix+"/audit", h.getAudit)
mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources)
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
mux.HandleFunc("GET "+prefix+"/channel-finder", h.channelFinder)
mux.HandleFunc("GET "+prefix+"/archiver/search", h.archiverSearch)
mux.HandleFunc("GET "+prefix+"/interfaces", h.listInterfaces)
mux.HandleFunc("POST "+prefix+"/interfaces", h.createInterface)
mux.HandleFunc("POST "+prefix+"/interfaces/reorder", h.reorderInterfaces)
mux.HandleFunc("GET "+prefix+"/interfaces/{id}", h.getInterface)
mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface)
mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface)
mux.HandleFunc("POST "+prefix+"/interfaces/{id}/clone", h.cloneInterface)
mux.HandleFunc("GET "+prefix+"/interfaces/{id}/versions", h.listVersions)
mux.HandleFunc("GET "+prefix+"/interfaces/{id}/versions/{version}", h.getVersion)
mux.HandleFunc("PUT "+prefix+"/interfaces/{id}/versions/{version}/tag", h.setVersionTag)
mux.HandleFunc("POST "+prefix+"/interfaces/{id}/versions/{version}/promote", h.promoteVersion)
mux.HandleFunc("POST "+prefix+"/interfaces/{id}/versions/{version}/fork", h.forkVersion)
// Per-panel ownership / sharing (Phase 2)
mux.HandleFunc("GET "+prefix+"/interfaces/{id}/acl", h.getPanelACL)
mux.HandleFunc("PUT "+prefix+"/interfaces/{id}/acl", h.putPanelACL)
// Panel folders / hierarchy (Phase 3)
mux.HandleFunc("GET "+prefix+"/folders", h.listFolders)
mux.HandleFunc("POST "+prefix+"/folders", h.createFolder)
mux.HandleFunc("PUT "+prefix+"/folders/{id}", h.updateFolder)
mux.HandleFunc("DELETE "+prefix+"/folders/{id}", h.deleteFolder)
// User groups defined in config (read-only; for the sharing UI)
mux.HandleFunc("GET "+prefix+"/usergroups", h.listUserGroups)
// Admin pane: runtime access management + server statistics. Every route is
// gated by CanAdmin (the admins allowlist).
mux.HandleFunc("GET "+prefix+"/admin/access", h.getAdminAccess)
mux.HandleFunc("PUT "+prefix+"/admin/users/{user}", h.putAdminUser)
mux.HandleFunc("POST "+prefix+"/admin/groups", h.createAdminGroup)
mux.HandleFunc("PUT "+prefix+"/admin/groups/{name}", h.updateAdminGroup)
mux.HandleFunc("DELETE "+prefix+"/admin/groups/{name}", h.deleteAdminGroup)
mux.HandleFunc("GET "+prefix+"/admin/stats", h.getAdminStats)
// Signal group tree
mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups)
// Synthetic signal CRUD
mux.HandleFunc("GET "+prefix+"/synthetic", h.listSynthetic)
mux.HandleFunc("POST "+prefix+"/synthetic", h.createSynthetic)
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
// Single-shot debug trace of an unsaved synthetic graph (live editor overlay).
mux.HandleFunc("POST "+prefix+"/synthetic/trace", h.traceSynthetic)
// Synthetic signal git-style versioning (list / view / promote / fork).
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions", h.listSyntheticVersions)
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions/{version}", h.getSyntheticVersion)
mux.HandleFunc("POST "+prefix+"/synthetic/{name}/versions/{version}/promote", h.promoteSyntheticVersion)
mux.HandleFunc("POST "+prefix+"/synthetic/{name}/versions/{version}/fork", h.forkSyntheticVersion)
// Server-side control logic CRUD (mutations are write-gated by the access
// middleware; each mutation reloads the running engine).
mux.HandleFunc("GET "+prefix+"/controllogic", h.listControlLogic)
mux.HandleFunc("POST "+prefix+"/controllogic", h.createControlLogic)
mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic)
mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic)
mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic)
// Control-logic git-style versioning (list / view / promote / fork).
mux.HandleFunc("GET "+prefix+"/controllogic/{id}/versions", h.listControlLogicVersions)
mux.HandleFunc("GET "+prefix+"/controllogic/{id}/versions/{version}", h.getControlLogicVersion)
mux.HandleFunc("POST "+prefix+"/controllogic/{id}/versions/{version}/promote", h.promoteControlLogicVersion)
mux.HandleFunc("POST "+prefix+"/controllogic/{id}/versions/{version}/fork", h.forkControlLogicVersion)
// Configuration manager — config sets (schemas) and instances (values),
// both versioned git-style. Mutations are write-gated by the access
// middleware; "apply" writes each value to its target signal.
mux.HandleFunc("GET "+prefix+"/config/sets", h.listConfigSets)
mux.HandleFunc("POST "+prefix+"/config/sets", h.createConfigSet)
mux.HandleFunc("GET "+prefix+"/config/sets/{id}", h.getConfigSet)
mux.HandleFunc("PUT "+prefix+"/config/sets/{id}", h.updateConfigSet)
mux.HandleFunc("DELETE "+prefix+"/config/sets/{id}", h.deleteConfigSet)
mux.HandleFunc("GET "+prefix+"/config/sets/{id}/versions", h.listConfigSetVersions)
mux.HandleFunc("GET "+prefix+"/config/sets/{id}/versions/{version}", h.getConfigSetVersion)
mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/promote", h.promoteConfigSet)
mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/fork", h.forkConfigSet)
mux.HandleFunc("POST "+prefix+"/config/sets/{id}/snapshot", h.snapshotConfigSet)
mux.HandleFunc("GET "+prefix+"/config/sets/diff", h.diffConfigSets)
mux.HandleFunc("GET "+prefix+"/config/instances", h.listConfigInstances)
mux.HandleFunc("POST "+prefix+"/config/instances", h.createConfigInstance)
mux.HandleFunc("GET "+prefix+"/config/instances/{id}", h.getConfigInstance)
mux.HandleFunc("PUT "+prefix+"/config/instances/{id}", h.updateConfigInstance)
mux.HandleFunc("DELETE "+prefix+"/config/instances/{id}", h.deleteConfigInstance)
mux.HandleFunc("GET "+prefix+"/config/instances/{id}/versions", h.listConfigInstanceVersions)
mux.HandleFunc("GET "+prefix+"/config/instances/{id}/versions/{version}", h.getConfigInstanceVersion)
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/promote", h.promoteConfigInstance)
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/fork", h.forkConfigInstance)
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/apply", h.applyConfigInstance)
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/validate", h.validateConfigInstance)
mux.HandleFunc("GET "+prefix+"/config/instances/{id}/livediff", h.diffConfigInstanceLive)
mux.HandleFunc("GET "+prefix+"/config/instances/diff", h.diffConfigInstances)
// Config rules — CUE validation/transformation logic bound to a set, run
// when instances of that set are saved. Versioned git-style; "check"
// evaluates an unsaved source for the live editor.
mux.HandleFunc("GET "+prefix+"/config/rules", h.listConfigRules)
mux.HandleFunc("POST "+prefix+"/config/rules", h.createConfigRule)
mux.HandleFunc("POST "+prefix+"/config/rules/check", h.checkConfigRule)
mux.HandleFunc("POST "+prefix+"/config/rules/preview", h.previewConfigRule)
mux.HandleFunc("GET "+prefix+"/config/rules/{id}", h.getConfigRule)
mux.HandleFunc("PUT "+prefix+"/config/rules/{id}", h.updateConfigRule)
mux.HandleFunc("DELETE "+prefix+"/config/rules/{id}", h.deleteConfigRule)
mux.HandleFunc("GET "+prefix+"/config/rules/{id}/versions", h.listConfigRuleVersions)
mux.HandleFunc("GET "+prefix+"/config/rules/{id}/versions/{version}", h.getConfigRuleVersion)
mux.HandleFunc("POST "+prefix+"/config/rules/{id}/versions/{version}/promote", h.promoteConfigRule)
mux.HandleFunc("POST "+prefix+"/config/rules/{id}/versions/{version}/fork", h.forkConfigRule)
}
// ── /me ─────────────────────────────────────────────────────────────────────
// getMe reports the caller's resolved identity, global access level, and group
// memberships. The frontend uses this to tailor the UI (hide edit affordances
// for read-only users, block no-access users). Always reachable, even for
// no-access users, so the UI can render an appropriate blocked state.
func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
user := access.UserFrom(r.Context())
groups := h.policy.GroupsOf(user)
if groups == nil {
groups = []string{}
}
defaultZoom := h.uiDefaultZoom
if defaultZoom <= 0 {
defaultZoom = 1.0
}
jsonOK(w, map[string]any{
"user": user,
"level": h.policy.Level(user).String(),
"groups": groups,
"canEditLogic": h.policy.CanEditLogic(user),
"canViewAudit": h.policy.CanViewAudit(user),
"canAdmin": h.policy.CanAdmin(user),
"defaultZoom": defaultZoom,
})
}
// ── /audit ────────────────────────────────────────────────────────────────────
// getAudit returns audit-log entries matching the query filters. Access is
// restricted to users permitted by CanViewAudit (the audit-readers allowlist).
// Filters: start, end (RFC3339), user, action, ds, signal, limit.
func (h *Handler) getAudit(w http.ResponseWriter, r *http.Request) {
if !h.policy.CanViewAudit(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to view the audit log")
return
}
q := r.URL.Query()
f := audit.Filter{
Actor: q.Get("user"),
Action: q.Get("action"),
DS: q.Get("ds"),
Signal: q.Get("signal"),
}
if v := q.Get("start"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid start time (want RFC3339): "+v)
return
}
f.Start = t
}
if v := q.Get("end"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid end time (want RFC3339): "+v)
return
}
f.End = t
}
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
f.Limit = n
}
}
events, err := h.audit.Query(f)
if err != nil {
h.log.Error("audit query", "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, events)
}
// ── access helpers ────────────────────────────────────────────────────────────
// caller returns the resolved end-user identity for the request (set by the
// access middleware).
func caller(r *http.Request) string { return access.UserFrom(r.Context()) }
// clientIP returns the best-effort client address for audit attribution,
// preferring the proxy-set X-Forwarded-For / X-Real-IP headers.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// recordMutation appends a successful configuration change to the audit log.
func (h *Handler) recordMutation(r *http.Request, action, detail string) {
h.audit.Record(audit.Event{
Actor: caller(r),
ActorType: audit.ActorUser,
Action: action,
Detail: detail,
IP: clientIP(r),
Outcome: audit.OutcomeOK,
})
}
// capByGlobal lowers a per-panel/folder permission to what the user's global
// access level allows: read-only users can never exceed read, no-access users
// get nothing.
func (h *Handler) capByGlobal(user string, p panelacl.Perm) panelacl.Perm {
switch h.policy.Level(user) {
case access.LevelNone:
return panelacl.PermNone
case access.LevelRead:
if p > panelacl.PermRead {
return panelacl.PermRead
}
}
return p
}
// panelPerm returns the caller's effective permission on a panel. A request with
// no resolved identity (no proxy header and no default_user) has no per-user ACL,
// so it is governed purely by the global level: full write on an unconfigured
// (open) policy, read-only once roles are configured.
func (h *Handler) panelPerm(r *http.Request, id string) panelacl.Perm {
user := caller(r)
if user == "" {
return h.capByGlobal("", panelacl.PermWrite)
}
return h.capByGlobal(user, h.acl.PanelPerm(id, user, h.policy.GroupsOf(user)))
}
// folderPerm returns the caller's effective permission on a folder. As with
// panelPerm, an unidentified caller is governed by the global level (full write
// on an unconfigured/open policy, read-only once roles are configured).
func (h *Handler) folderPerm(r *http.Request, id string) panelacl.Perm {
user := caller(r)
if user == "" {
return h.capByGlobal("", panelacl.PermWrite)
}
return h.capByGlobal(user, h.acl.FolderPerm(id, user, h.policy.GroupsOf(user)))
}
// ── /datasources ──────────────────────────────────────────────────────────────
type dsInfo struct {
Name string `json:"name"`
Status string `json:"status"`
}
func (h *Handler) listDataSources(w http.ResponseWriter, r *http.Request) {
sources := h.broker.DataSources()
out := make([]dsInfo, len(sources))
for i, ds := range sources {
out[i] = dsInfo{Name: ds.Name(), Status: "connected"}
}
jsonOK(w, out)
}
// ── /signals ──────────────────────────────────────────────────────────────────
type signalInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"`
DisplayLow float64 `json:"displayLow"`
DisplayHigh float64 `json:"displayHigh"`
Writable bool `json:"writable"`
EnumStrings []string `json:"enumStrings,omitempty"`
Properties map[string]string `json:"properties,omitempty"`
Tags []string `json:"tags,omitempty"`
}
func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
dsName := r.URL.Query().Get("ds")
if dsName == "" {
jsonError(w, http.StatusBadRequest, "query parameter 'ds' is required")
return
}
// Synthetic signals carry per-caller visibility rules, so build their list
// from the definitions (filtered) rather than the generic metadata path.
if dsName == "synthetic" && h.synthetic != nil {
user := caller(r)
panel := r.URL.Query().Get("panel")
userGroups := h.policy.GroupsOf(user)
metas := h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
return synVisible(d, user, panel, userGroups)
})
out := make([]signalInfo, len(metas))
for i, m := range metas {
out[i] = metaToSignalInfo(m)
}
jsonOK(w, out)
return
}
ds, ok := h.broker.Source(dsName)
if !ok {
jsonError(w, http.StatusNotFound, "unknown data source: "+dsName)
return
}
metas, err := ds.ListSignals(r.Context())
if err != nil {
h.log.Error("list signals", "ds", dsName, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]signalInfo, len(metas))
for i, m := range metas {
out[i] = metaToSignalInfo(m)
}
jsonOK(w, out)
}
// synVisible reports whether a synthetic signal should be listed for the given
// caller (a member of userGroups) while editing the given panel. An empty
// Visibility is treated as "global" so legacy definitions remain visible
// everywhere.
func synVisible(d synthetic.SignalDef, user, panel string, userGroups []string) bool {
switch d.Visibility {
case "user":
return user != "" && d.Owner == user
case "group":
return access.CanSee(user, d.Owner, access.ScopeGroup, d.Groups, userGroups)
case "panel":
return panel != "" && d.Panel == panel
default: // "global" or legacy empty
return true
}
}
// ── /signals/search ───────────────────────────────────────────────────────────
func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
q := strings.ToLower(r.URL.Query().Get("q"))
dsName := r.URL.Query().Get("ds")
var sources []datasource.DataSource
if dsName != "" {
ds, ok := h.broker.Source(dsName)
if !ok {
jsonError(w, http.StatusNotFound, "unknown data source: "+dsName)
return
}
sources = []datasource.DataSource{ds}
} else {
sources = h.broker.DataSources()
}
user := caller(r)
panel := r.URL.Query().Get("panel")
userGroups := h.policy.GroupsOf(user)
var out []signalInfo
for _, ds := range sources {
var metas []datasource.Metadata
if ds.Name() == "synthetic" && h.synthetic != nil {
metas = h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
return synVisible(d, user, panel, userGroups)
})
} else {
var err error
metas, err = ds.ListSignals(r.Context())
if err != nil {
continue
}
}
for _, m := range metas {
if q == "" || strings.Contains(strings.ToLower(m.Name), q) || strings.Contains(strings.ToLower(m.Description), q) {
si := metaToSignalInfo(m)
out = append(out, si)
}
}
}
if out == nil {
out = []signalInfo{}
}
jsonOK(w, out)
}
// ── /channel-finder ───────────────────────────────────────────────────────────
// channelFinder proxies a query to the EPICS Channel Finder service when one
// is configured, returning a flat list of PV names. Returns 501 otherwise.
func (h *Handler) channelFinder(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
// Empty-query availability probe (used by the signal tree to decide whether
// to surface Channel Finder UI). Always respond 200 so an unconfigured CFS
// is not logged as a failed request in the browser console.
if q == "" && (r.URL.RawQuery == "" || r.URL.RawQuery == "q=") {
jsonOK(w, map[string]bool{"available": h.channelFinderURL != ""})
return
}
if h.channelFinderURL == "" {
jsonError(w, http.StatusNotImplemented, "Channel Finder not configured (set channel_finder_url in config)")
return
}
cfURL := strings.TrimRight(h.channelFinderURL, "/") + "/resources/channels"
if q != "" {
cfURL += "?~name=*" + q + "*"
} else {
// Structured query (e.g. ~name=, ~tag=, property filters) passed through.
cfURL += "?" + r.URL.RawQuery
}
resp, err := http.Get(cfURL) //nolint:noctx
if err != nil {
jsonError(w, http.StatusBadGateway, "Channel Finder request failed: "+err.Error())
return
}
defer resp.Body.Close()
// Channel Finder returns JSON array of channel objects; extract names.
var channels []struct {
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&channels); err != nil {
jsonError(w, http.StatusBadGateway, "Channel Finder response parse error: "+err.Error())
return
}
names := make([]string, 0, len(channels))
for _, ch := range channels {
if ch.Name != "" {
names = append(names, ch.Name)
}
}
jsonOK(w, names)
}
// ── /archiver/search ──────────────────────────────────────────────────────────
// archiverSearch proxies a search query to the EPICS Archive Appliance
// management API when configured. Returns 501 otherwise.
func (h *Handler) archiverSearch(w http.ResponseWriter, r *http.Request) {
if h.archiverURL == "" {
jsonError(w, http.StatusNotImplemented, "Archiver not configured (set archive_url in config)")
return
}
pattern := r.URL.Query().Get("q")
if pattern == "" {
pattern = "*"
}
// AA expects glob patterns. If no glob characters, wrap in stars.
if !strings.ContainsAny(pattern, "*?[]") {
pattern = "*" + pattern + "*"
}
searchURL := strings.TrimRight(h.archiverURL, "/") + "/mgmt/bpl/getAllPVs?pv=" + url.QueryEscape(pattern)
resp, err := http.Get(searchURL) //nolint:noctx
if err != nil {
jsonError(w, http.StatusBadGateway, "Archiver request failed: "+err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
jsonError(w, http.StatusBadGateway, "Archiver returned status "+resp.Status)
return
}
var pvs []string
if err := json.NewDecoder(resp.Body).Decode(&pvs); err != nil {
jsonError(w, http.StatusBadGateway, "Archiver response parse error: "+err.Error())
return
}
jsonOK(w, pvs)
}
// ── /groups ───────────────────────────────────────────────────────────────────
// getGroups returns the workspace group tree as a JSON array.
func (h *Handler) getGroups(w http.ResponseWriter, r *http.Request) {
data, err := h.store.ReadGroups()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
}
// putGroups replaces the workspace group tree. The body must be a JSON array.
func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB max
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
// Validate it is a JSON array (basic sanity check).
var raw []json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
http.Error(w, "body must be a JSON array", http.StatusBadRequest)
return
}
if err := h.store.WriteGroups(body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── /interfaces ───────────────────────────────────────────────────────────────
// interfaceListItem enriches the stored metadata with the panel's owner,
// containing folder, and the caller's effective permission, so the frontend can
// render sharing affordances and filter the visible set.
type interfaceListItem struct {
storage.InterfaceMeta
Owner string `json:"owner,omitempty"`
Folder string `json:"folder,omitempty"`
Order float64 `json:"order,omitempty"`
Perm string `json:"perm"`
Scope string `json:"scope,omitempty"` // derived visibility bucket: private|group|global
Groups []string `json:"groups,omitempty"` // groups a group-scoped panel is shared with
}
// panelScope derives a uniform visibility token (and, for group scope, the
// shared group names) from a panel's ACL record so the selector tree can bucket
// it as Mine/Group/Global like the other scoped subsystems. An unmanaged panel
// (nil record) or any panel exposed publicly is global; otherwise a panel shared
// with one or more user-groups is group-scoped; everything else is private.
func panelScope(acl *panelacl.PanelACL) (string, []string) {
if acl == nil || acl.Public != "" {
return access.ScopeGlobal, nil
}
var groups []string
for _, g := range acl.Grants {
if g.Kind == "group" {
groups = append(groups, g.Name)
}
}
if len(groups) > 0 {
return access.ScopeGroup, groups
}
return access.ScopePrivate, nil
}
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
list, err := h.store.List()
if err != nil {
h.log.Error("list interfaces", "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]interfaceListItem, 0, len(list))
for _, m := range list {
perm := h.panelPerm(r, m.ID)
if perm < panelacl.PermRead {
continue // hide panels the caller cannot see
}
item := interfaceListItem{InterfaceMeta: m, Perm: perm.String()}
acl := h.acl.GetPanel(m.ID)
if acl != nil {
item.Owner = acl.Owner
item.Folder = acl.Folder
item.Order = acl.Order
}
item.Scope, item.Groups = panelScope(acl)
out = append(out, item)
}
h.log.Info("list interfaces", "count", len(out))
jsonOK(w, out)
}
func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
// Editing panel logic may be restricted to an allowlist. A brand-new panel
// carrying a non-empty <logic> block requires that permission.
if !h.policy.CanEditLogic(caller(r)) && extractLogicBlock(body) != "" {
jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic")
return
}
id, err := h.store.Create(body, r.URL.Query().Get("tag"))
if err != nil {
h.log.Error("create interface", "err", err)
jsonError(w, http.StatusBadRequest, err.Error())
return
}
// Record ownership so the new panel defaults to private (owner-only). Skip
// for unidentified callers (trusted LAN): an owner-less record would make the
// panel invisible to everyone.
if user := caller(r); user != "" {
if err := h.acl.CreatePanel(id, user); err != nil {
h.log.Error("record panel ownership", "id", id, "err", err)
}
}
h.recordMutation(r, "interface.create", id)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
}
// reorderInterfaces places a set of panels into a folder (or the root when folder
// is empty) in the given order, stamping each id with order=index. The caller must
// have write access to the destination folder (if any) and to each panel.
func (h *Handler) reorderInterfaces(w http.ResponseWriter, r *http.Request) {
var req struct {
Folder string `json:"folder"`
IDs []string `json:"ids"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "decode body: "+err.Error())
return
}
if req.Folder != "" {
if _, err := h.acl.GetFolder(req.Folder); err != nil {
jsonError(w, http.StatusNotFound, "folder not found: "+req.Folder)
return
}
if h.folderPerm(r, req.Folder) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this folder")
return
}
}
for _, id := range req.IDs {
if h.panelPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to panel: "+id)
return
}
}
for i, id := range req.IDs {
if err := h.acl.PlacePanel(id, req.Folder, float64(i)); err != nil {
h.log.Error("reorder interface", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermRead {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
return
}
data, err := h.store.Get(id)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write(data)
}
func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this panel")
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
// Editing panel logic may be restricted to an allowlist. Permit unrestricted
// edits to the rest of the panel as long as the <logic> block is unchanged.
if !h.policy.CanEditLogic(caller(r)) {
if cur, err := h.store.Get(id); err != nil || extractLogicBlock(body) != extractLogicBlock(cur) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic")
return
}
}
if err := h.store.Update(id, body, r.URL.Query().Get("tag")); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
h.log.Error("update interface", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
h.recordMutation(r, "interface.update", id)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermRead {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
return
}
versions, err := h.store.Versions(id)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
h.log.Error("list versions", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
jsonOK(w, versions)
}
func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermRead {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
return
}
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
data, err := h.store.GetVersion(id, version)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "version not found")
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write(data)
}
func (h *Handler) setVersionTag(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this panel")
return
}
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
if err := h.store.SetVersionTag(id, version, r.URL.Query().Get("tag")); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "version not found")
} else {
h.log.Error("set version tag", "id", id, "version", version, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) promoteVersion(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this panel")
return
}
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
if err := h.store.Promote(id, version); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "version not found")
} else {
h.log.Error("promote version", "id", id, "version", version, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) forkVersion(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermRead {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
return
}
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
newID, err := h.store.Fork(id, version)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "version not found")
} else {
h.log.Error("fork version", "id", id, "version", version, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
// The fork is a brand-new panel owned by the caller.
if user := caller(r); user != "" {
if err := h.acl.CreatePanel(newID, user); err != nil {
h.log.Error("record fork ownership", "id", newID, "err", err)
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": newID})
}
func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this panel")
return
}
if err := h.store.Delete(id); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
if err := h.acl.DeletePanel(id); err != nil {
h.log.Error("delete panel ACL", "id", id, "err", err)
}
h.recordMutation(r, "interface.delete", id)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermRead {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
return
}
newID, err := h.store.Clone(id)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
h.log.Error("clone interface", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
// The clone is a brand-new panel owned by the caller.
if user := caller(r); user != "" {
if err := h.acl.CreatePanel(newID, user); err != nil {
h.log.Error("record clone ownership", "id", newID, "err", err)
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": newID})
}
// ── /interfaces/{id}/acl (Phase 2) ──────────────────────────────────────────
// panelACLRequest is the body accepted by putPanelACL: the panel's folder,
// public level, and explicit user/group grants. Owner is immutable here.
type panelACLRequest struct {
Folder string `json:"folder"`
Public string `json:"public"`
Grants []panelacl.Grant `json:"grants"`
}
// panelACLResponse is the sharing record returned to the frontend, enriched
// with the caller's own effective permission.
type panelACLResponse struct {
Owner string `json:"owner"`
Folder string `json:"folder"`
Public string `json:"public"`
Grants []panelacl.Grant `json:"grants"`
Perm string `json:"perm"`
}
// getPanelACL returns the sharing settings for a panel. Any user who can see
// the panel (read) may inspect them.
func (h *Handler) getPanelACL(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
perm := h.panelPerm(r, id)
if perm < panelacl.PermRead {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
return
}
resp := panelACLResponse{Grants: []panelacl.Grant{}, Perm: perm.String()}
if acl := h.acl.GetPanel(id); acl != nil {
resp.Owner = acl.Owner
resp.Folder = acl.Folder
resp.Public = acl.Public
if acl.Grants != nil {
resp.Grants = acl.Grants
}
}
jsonOK(w, resp)
}
// putPanelACL replaces a panel's sharing settings. Only the panel owner may do
// this (an unmanaged legacy panel is adopted by the first writer to set an ACL).
func (h *Handler) putPanelACL(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
user := caller(r)
// The panel must exist in storage.
if _, err := h.store.Get(id); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
// Read-only / no-access users can never change sharing.
if h.policy.Level(user) < access.LevelWrite {
jsonError(w, http.StatusForbidden, "you do not have write access")
return
}
owner := user
if acl := h.acl.GetPanel(id); acl != nil && acl.Owner != "" {
if acl.Owner != user {
jsonError(w, http.StatusForbidden, "only the owner can change sharing")
return
}
owner = acl.Owner
}
var req panelACLRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
// A referenced folder must exist.
if req.Folder != "" {
if _, err := h.acl.GetFolder(req.Folder); err != nil {
jsonError(w, http.StatusBadRequest, "unknown folder: "+req.Folder)
return
}
}
if err := h.acl.SetPanel(id, owner, req.Folder, req.Public, req.Grants); err != nil {
h.log.Error("set panel ACL", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── /folders (Phase 3) ──────────────────────────────────────────────────────
// folderItem is a folder enriched with the caller's effective permission.
type folderItem struct {
panelacl.Folder
Perm string `json:"perm"`
}
// folderRequest is the body accepted by create/updateFolder.
type folderRequest struct {
Name string `json:"name"`
Parent string `json:"parent"`
Public string `json:"public"`
Grants []panelacl.Grant `json:"grants"`
}
// listFolders returns every folder the caller can at least read.
func (h *Handler) listFolders(w http.ResponseWriter, r *http.Request) {
folders := h.acl.Folders()
out := make([]folderItem, 0, len(folders))
for id, f := range folders {
perm := h.folderPerm(r, id)
if perm < panelacl.PermRead {
continue
}
if f.Grants == nil {
f.Grants = []panelacl.Grant{}
}
out = append(out, folderItem{Folder: f, Perm: perm.String()})
}
jsonOK(w, out)
}
// createFolder creates a new folder owned by the caller. Creating a subfolder
// requires write access on the parent.
func (h *Handler) createFolder(w http.ResponseWriter, r *http.Request) {
user := caller(r)
if h.policy.Level(user) < access.LevelWrite {
jsonError(w, http.StatusForbidden, "you do not have write access")
return
}
var req folderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if strings.TrimSpace(req.Name) == "" {
jsonError(w, http.StatusBadRequest, "folder name is required")
return
}
if req.Parent != "" && h.folderPerm(r, req.Parent) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to the parent folder")
return
}
f, err := h.acl.CreateFolder(genID("fld"), req.Name, req.Parent, user)
if err != nil {
if errors.Is(err, panelacl.ErrNotFound) {
jsonError(w, http.StatusBadRequest, "unknown parent folder: "+req.Parent)
} else {
h.log.Error("create folder", "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(f)
}
// updateFolder replaces a folder's mutable fields. Requires write access on the
// folder (owners always have it).
func (h *Handler) updateFolder(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.folderPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this folder")
return
}
var req folderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if strings.TrimSpace(req.Name) == "" {
jsonError(w, http.StatusBadRequest, "folder name is required")
return
}
if err := h.acl.UpdateFolder(id, req.Name, req.Parent, req.Public, req.Grants); err != nil {
switch {
case errors.Is(err, panelacl.ErrNotFound):
jsonError(w, http.StatusNotFound, "folder not found: "+id)
default:
jsonError(w, http.StatusBadRequest, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// deleteFolder removes a folder, reparenting its contents. Requires write
// access on the folder.
func (h *Handler) deleteFolder(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.folderPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this folder")
return
}
if err := h.acl.DeleteFolder(id); err != nil {
if errors.Is(err, panelacl.ErrNotFound) {
jsonError(w, http.StatusNotFound, "folder not found: "+id)
} else {
h.log.Error("delete folder", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── /usergroups ─────────────────────────────────────────────────────────────
// listUserGroups returns the names of the user-groups defined in config, for
// populating the sharing UI's group picker.
func (h *Handler) listUserGroups(w http.ResponseWriter, _ *http.Request) {
names := h.policy.GroupNames()
if names == nil {
names = []string{}
}
jsonOK(w, names)
}
// ── /admin ──────────────────────────────────────────────────────────────────
// requireAdmin writes a 403 and returns false unless the caller may use the
// admin pane (CanAdmin). Anonymous/trusted-LAN callers and, when no admins
// allowlist is configured, everyone, are permitted.
func (h *Handler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if h.policy.CanAdmin(caller(r)) {
return true
}
jsonError(w, http.StatusForbidden, "you are not permitted to administer this server")
return false
}
// getAdminAccess returns the full mutable access configuration (users, groups,
// allowlists) for the admin pane.
func (h *Handler) getAdminAccess(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
jsonOK(w, h.policy.Snapshot())
}
type adminUserReq struct {
// Roles maps each group the user should belong to → their role token. The
// user is removed from any group not listed. Unknown groups are created.
Roles map[string]string `json:"roles"`
}
// putAdminUser replaces a user's full set of (group → role) memberships.
func (h *Handler) putAdminUser(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
user := strings.TrimSpace(r.PathValue("user"))
if user == "" {
jsonError(w, http.StatusBadRequest, "empty user")
return
}
var req adminUserReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
roles := make(map[string]access.Role, len(req.Roles))
for g, token := range req.Roles {
if g = strings.TrimSpace(g); g != "" {
roles[g] = access.ParseRole(token)
}
}
if err := h.policy.SetUserRoles(user, roles); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
h.recordMutation(r, "admin.user", user)
jsonOK(w, h.policy.Snapshot())
}
type adminGroupReq struct {
Name string `json:"name"` // for update: the (possibly new) group name
Parent string `json:"parent"` // parent group for nesting ("" = root)
Members map[string]string `json:"members"`
}
// createAdminGroup creates an empty group with an optional parent.
func (h *Handler) createAdminGroup(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
var req adminGroupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if strings.TrimSpace(req.Name) == "" {
jsonError(w, http.StatusBadRequest, "empty group name")
return
}
if err := h.policy.CreateGroup(req.Name, req.Parent); err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
h.recordMutation(r, "admin.group.create", req.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, h.policy.Snapshot())
}
// updateAdminGroup renames a group (when the body name differs from the path)
// and replaces its parent and member roles.
func (h *Handler) updateAdminGroup(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
old := strings.TrimSpace(r.PathValue("name"))
var req adminGroupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = old
}
if name != old {
if err := h.policy.RenameGroup(old, name); err != nil {
if errors.Is(err, access.ErrNotFound) {
jsonError(w, http.StatusNotFound, "group not found: "+old)
return
}
jsonError(w, http.StatusBadRequest, err.Error())
return
}
}
members := make(map[string]access.Role, len(req.Members))
for u, token := range req.Members {
if u = strings.TrimSpace(u); u != "" {
members[u] = access.ParseRole(token)
}
}
if err := h.policy.SetGroup(name, req.Parent, members); err != nil {
if errors.Is(err, access.ErrNotFound) {
jsonError(w, http.StatusNotFound, "group not found: "+name)
return
}
jsonError(w, http.StatusBadRequest, err.Error())
return
}
h.recordMutation(r, "admin.group.update", name)
jsonOK(w, h.policy.Snapshot())
}
// deleteAdminGroup removes a group (members keep their other groups; children
// are reparented). The built-in public group cannot be deleted.
func (h *Handler) deleteAdminGroup(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
name := strings.TrimSpace(r.PathValue("name"))
if err := h.policy.DeleteGroup(name); err != nil {
if errors.Is(err, access.ErrNotFound) {
jsonError(w, http.StatusNotFound, "group not found: "+name)
return
}
jsonError(w, http.StatusBadRequest, err.Error())
return
}
h.recordMutation(r, "admin.group.delete", name)
jsonOK(w, h.policy.Snapshot())
}
// getAdminStats reports live server statistics: the in-process metrics counters,
// observed signals and data sources from the broker, Go runtime stats, and the
// system load average on Linux.
func (h *Handler) getAdminStats(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
m := metrics.Snapshot()
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
sources := h.broker.DataSources()
dsNames := make([]string, len(sources))
for i, ds := range sources {
dsNames[i] = ds.Name()
}
jsonOK(w, map[string]any{
"uptimeSeconds": m.UptimeSeconds,
"wsConnections": m.WsConnections,
"observedSignals": h.broker.ActiveSubscriptions(),
"msgIn": m.MsgIn,
"msgOut": m.MsgOut,
"writeOps": m.WriteOps,
"historyReqs": m.HistoryReqs,
"goroutines": runtime.NumGoroutine(),
"memAllocBytes": ms.Alloc,
"memSysBytes": ms.Sys,
"heapInuseBytes": ms.HeapInuse,
"loadAvg": readLoadAvg(),
"dataSources": dsNames,
})
}
// readLoadAvg returns the 1/5/15-minute system load averages from
// /proc/loadavg, or nil on non-Linux systems or any read/parse error.
func readLoadAvg() []float64 {
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
return nil
}
fields := strings.Fields(string(data))
if len(fields) < 3 {
return nil
}
out := make([]float64, 3)
for i := 0; i < 3; i++ {
v, err := strconv.ParseFloat(fields[i], 64)
if err != nil {
return nil
}
out[i] = v
}
return out
}
// genID returns a short unique id with the given prefix (e.g. "fld-1a2b3c4d").
func genID(prefix string) string {
var b [8]byte
_, _ = rand.Read(b[:])
return prefix + "-" + hex.EncodeToString(b[:])
}
// ── /synthetic ────────────────────────────────────────────────────────────────
func (h *Handler) listSynthetic(w http.ResponseWriter, _ *http.Request) {
if h.synthetic == nil {
jsonOK(w, []any{})
return
}
jsonOK(w, h.synthetic.GetDefs())
}
func (h *Handler) createSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
var def synthetic.SignalDef
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
// Stamp ownership from the trusted identity; clients cannot spoof it.
def.Owner = caller(r)
if def.Visibility == "" {
def.Visibility = "panel"
}
if err := h.synthetic.AddSignal(def); err != nil {
if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusConflict, err.Error())
} else {
jsonError(w, http.StatusBadRequest, err.Error())
}
return
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, def)
}
func (h *Handler) getSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
def, err := h.synthetic.GetSignal(name)
if err != nil {
if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
jsonOK(w, def)
}
func (h *Handler) updateSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
var def synthetic.SignalDef
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
// Ensure the name in the URL matches the body (or fill it in).
def.Name = name
// Preserve the original owner across edits; never trust a client-supplied one.
if existing, err := h.synthetic.GetSignal(name); err == nil {
def.Owner = existing.Owner
}
if err := h.synthetic.UpdateSignal(def); err != nil {
if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
} else {
jsonError(w, http.StatusBadRequest, err.Error())
}
return
}
jsonOK(w, def)
}
func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
if err := h.synthetic.RemoveSignal(name); err != nil {
if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// traceSynthetic evaluates an unsaved synthetic graph once against the current
// live value of each source signal and returns every node's computed value. It
// persists nothing; it powers the editor's live/debug overlay.
func (h *Handler) traceSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
var def synthetic.SignalDef
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
read := func(ds, name string) (any, error) {
v, err := h.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: name})
if err != nil {
return nil, err
}
return v.Data, nil
}
res, err := h.synthetic.Trace(def, read)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
jsonOK(w, res)
}
func (h *Handler) listSyntheticVersions(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
versions, err := h.synthetic.Versions(name)
if err != nil {
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
return
}
jsonOK(w, versions)
}
func (h *Handler) getSyntheticVersion(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
def, err := h.synthetic.GetVersion(name, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
jsonOK(w, def)
}
func (h *Handler) promoteSyntheticVersion(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
def, err := h.synthetic.PromoteVersion(name, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
h.recordMutation(r, "synthetic.promote", fmt.Sprintf("%s v%d", name, version))
jsonOK(w, def)
}
func (h *Handler) forkSyntheticVersion(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
def, err := h.synthetic.ForkVersion(name, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
h.recordMutation(r, "synthetic.fork", fmt.Sprintf("%s v%d -> %s", name, version, def.Name))
w.WriteHeader(http.StatusCreated)
jsonOK(w, map[string]string{"id": def.Name})
}
// ── Control logic ──────────────────────────────────────────────────────────────
func (h *Handler) listControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonOK(w, []any{})
return
}
user := caller(r)
graphs := []controllogic.Graph{}
for _, g := range h.ctrlLogic.List() {
if h.policy.CanSee(user, g.Owner, g.Scope, g.ScopeGroups) {
graphs = append(graphs, g)
}
}
jsonOK(w, graphs)
}
func (h *Handler) getControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
g, err := h.ctrlLogic.Get(r.PathValue("id"))
if err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
return
}
jsonOK(w, g)
}
func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
var g controllogic.Graph
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
g.ID = genID("cl")
g.Owner = caller(r) // stamp ownership from the trusted identity
if err := h.ctrlLogic.Save(g); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.create", g.ID+" "+g.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, g)
}
func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
id := r.PathValue("id")
prev, err := h.ctrlLogic.Get(id)
if err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
return
}
var g controllogic.Graph
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
g.ID = id
g.Owner = prev.Owner // owner is immutable across revisions
if err := h.ctrlLogic.Save(g); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.update", g.ID+" "+g.Name)
jsonOK(w, g)
}
func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
id := r.PathValue("id")
if err := h.ctrlLogic.Delete(id); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.delete", id)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) listControlLogicVersions(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
versions, err := h.ctrlLogic.Versions(r.PathValue("id"))
if err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
return
}
jsonOK(w, versions)
}
func (h *Handler) getControlLogicVersion(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
g, err := h.ctrlLogic.GetVersion(r.PathValue("id"), version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
jsonOK(w, g)
}
func (h *Handler) promoteControlLogicVersion(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
id := r.PathValue("id")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
g, err := h.ctrlLogic.Promote(id, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.promote", fmt.Sprintf("%s v%d", id, version))
jsonOK(w, g)
}
func (h *Handler) forkControlLogicVersion(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
id := r.PathValue("id")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
g, err := h.ctrlLogic.Fork(id, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.fork", fmt.Sprintf("%s v%d -> %s", id, version, g.ID))
w.WriteHeader(http.StatusCreated)
jsonOK(w, map[string]string{"id": g.ID})
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// extractLogicBlock returns the verbatim <logic>…</logic> section of an
// interface XML document, or "" when absent. The frontend serializes the block
// only when it holds nodes/wires and uses a stable format, so comparing the
// extracted substrings reliably detects whether the panel logic changed.
func extractLogicBlock(xml []byte) string {
s := string(xml)
start := strings.Index(s, "<logic>")
if start < 0 {
return ""
}
end := strings.Index(s[start:], "</logic>")
if end < 0 {
return ""
}
return s[start : start+end+len("</logic>")]
}
func metaToSignalInfo(m datasource.Metadata) signalInfo {
return signalInfo{
Name: m.Name,
Type: dataTypeName(m.Type),
Unit: m.Unit,
Description: m.Description,
DisplayLow: m.DisplayLow,
DisplayHigh: m.DisplayHigh,
Writable: m.Writable,
EnumStrings: m.EnumStrings,
Properties: m.Properties,
Tags: m.Tags,
}
}
func dataTypeName(t datasource.DataType) string {
switch t {
case datasource.TypeFloat64:
return "float64"
case datasource.TypeFloat64Array:
return "float64[]"
case datasource.TypeString:
return "string"
case datasource.TypeInt64:
return "int64"
case datasource.TypeBool:
return "bool"
case datasource.TypeEnum:
return "enum"
default:
return "unknown"
}
}
func jsonOK(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func jsonError(w http.ResponseWriter, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}