diff --git a/cmd/uopi/main.go b/cmd/uopi/main.go
index 78469b5..b498a93 100644
--- a/cmd/uopi/main.go
+++ b/cmd/uopi/main.go
@@ -11,12 +11,14 @@ import (
"os/signal"
"syscall"
+ "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/config"
"github.com/uopi/uopi/internal/datasource/epics"
"github.com/uopi/uopi/internal/datasource/pva"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic"
+ "github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/server"
"github.com/uopi/uopi/internal/storage"
"github.com/uopi/uopi/web"
@@ -49,6 +51,12 @@ func main() {
os.Exit(1)
}
+ aclStore, err := panelacl.New(cfg.Server.StorageDir)
+ if err != nil {
+ log.Error("failed to open panel ACL store", "err", err)
+ os.Exit(1)
+ }
+
webFS, err := fs.Sub(web.FS, "dist")
if err != nil {
log.Error("failed to sub web dist", "err", err)
@@ -113,7 +121,20 @@ func main() {
}
}
- srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, log)
+ // Build the global access policy from config: every user is trusted with
+ // full write access unless downgraded by the blacklist; groups are named
+ // sets of users referenced by per-panel sharing.
+ blacklist := make(map[string]string, len(cfg.Server.Blacklist))
+ for _, e := range cfg.Server.Blacklist {
+ blacklist[e.User] = e.Level
+ }
+ groups := make(map[string][]string, len(cfg.Groups))
+ for _, g := range cfg.Groups {
+ groups[g.Name] = g.Members
+ }
+ policy := access.New(cfg.Server.DefaultUser, blacklist, groups)
+
+ srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
diff --git a/debug_api.go b/debug_api.go
deleted file mode 100644
index 3d50ac1..0000000
--- a/debug_api.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "os"
-
- "github.com/uopi/uopi/internal/api"
- "github.com/uopi/uopi/internal/broker"
- "github.com/uopi/uopi/internal/storage"
-)
-
-func main() {
- log := slog.New(slog.NewTextHandler(os.Stderr, nil))
- store, _ := storage.New("./interfaces")
- // broker.New(ctx, log, maxRate)
- brk := broker.New(context.Background(), log, 0)
-
- h := api.New(brk, nil, store, "", "", log)
- mux := http.NewServeMux()
- h.Register(mux, "/api/v1")
-
- req := httptest.NewRequest("GET", "/api/v1/interfaces", nil)
- rr := httptest.NewRecorder()
- mux.ServeHTTP(rr, req)
-
- fmt.Printf("Status: %d\n", rr.Code)
- fmt.Printf("Body: %s\n", rr.Body.String())
-}
diff --git a/internal/access/access.go b/internal/access/access.go
new file mode 100644
index 0000000..17ce3c1
--- /dev/null
+++ b/internal/access/access.go
@@ -0,0 +1,150 @@
+// Package access implements uopi's global user-access policy: every user is
+// trusted (full write) by default, while a configured blacklist can downgrade
+// specific users to read-only or no access. It also resolves the per-request
+// user identity and the user→group memberships defined in config.
+//
+// This is the foundation layer (Phase 1). Per-panel ownership/ACL evaluation is
+// layered on top of this in a later phase.
+package access
+
+import (
+ "context"
+ "sort"
+ "strings"
+)
+
+// Level is a global access level. Higher levels include lower ones.
+type Level int
+
+const (
+ // LevelNone denies all access.
+ LevelNone Level = iota
+ // LevelRead permits reads only (no create/update/delete/share, no signal writes).
+ LevelRead
+ // LevelWrite permits full access. This is the default for any user not blacklisted.
+ LevelWrite
+)
+
+// String renders the level using the same tokens accepted by ParseLevel and
+// surfaced to the frontend via /api/v1/me.
+func (l Level) String() string {
+ switch l {
+ case LevelNone:
+ return "none"
+ case LevelRead:
+ return "readonly"
+ default:
+ return "write"
+ }
+}
+
+// ParseLevel maps a config string to a Level. Unknown values restrict to
+// read-only, since the only reason to list a user is to limit them.
+func ParseLevel(s string) Level {
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case "noaccess", "none", "no":
+ return LevelNone
+ case "readonly", "read", "ro":
+ return LevelRead
+ case "write", "readwrite", "rw", "full":
+ return LevelWrite
+ default:
+ return LevelRead
+ }
+}
+
+// Policy holds the resolved global access configuration. It is immutable after
+// construction and safe for concurrent use.
+type Policy struct {
+ defaultUser string
+ blacklist map[string]Level // user → downgraded level
+ userGroups map[string][]string // user → groups they belong to
+ groupNames []string // all configured group names (sorted)
+}
+
+// New builds a Policy. blacklist maps a username to a config level string;
+// groups maps a group name to its member usernames.
+func New(defaultUser string, blacklist map[string]string, groups map[string][]string) *Policy {
+ p := &Policy{
+ defaultUser: strings.TrimSpace(defaultUser),
+ blacklist: make(map[string]Level),
+ userGroups: make(map[string][]string),
+ }
+ for user, lvl := range blacklist {
+ u := strings.TrimSpace(user)
+ if u == "" {
+ continue
+ }
+ p.blacklist[u] = ParseLevel(lvl)
+ }
+ for g, members := range groups {
+ g = strings.TrimSpace(g)
+ if g == "" {
+ continue
+ }
+ p.groupNames = append(p.groupNames, g)
+ for _, m := range members {
+ m = strings.TrimSpace(m)
+ if m == "" {
+ continue
+ }
+ p.userGroups[m] = append(p.userGroups[m], g)
+ }
+ }
+ sort.Strings(p.groupNames)
+ return p
+}
+
+// GroupNames returns a copy of every configured user-group name, sorted.
+func (p *Policy) GroupNames() []string {
+ out := make([]string, len(p.groupNames))
+ copy(out, p.groupNames)
+ return out
+}
+
+// ResolveUser trims the proxy-provided header value and falls back to the
+// configured default_user when it is empty (e.g. unproxied/dev deployments).
+func (p *Policy) ResolveUser(headerValue string) string {
+ u := strings.TrimSpace(headerValue)
+ if u == "" {
+ return p.defaultUser
+ }
+ return u
+}
+
+// Level returns the global access level for a user. Users that are neither
+// blacklisted nor anonymous get full write access.
+func (p *Policy) Level(user string) Level {
+ user = strings.TrimSpace(user)
+ if user == "" {
+ // No identity at all (no proxy header, no default_user): trusted LAN.
+ return LevelWrite
+ }
+ if lvl, ok := p.blacklist[user]; ok {
+ return lvl
+ }
+ return LevelWrite
+}
+
+// GroupsOf returns a copy of the groups a user belongs to.
+func (p *Policy) GroupsOf(user string) []string {
+ src := p.userGroups[strings.TrimSpace(user)]
+ out := make([]string, len(src))
+ copy(out, src)
+ return out
+}
+
+// ── request-scoped user identity ────────────────────────────────────────────
+
+type ctxKey struct{}
+
+// WithUser returns a copy of ctx carrying the resolved end-user identity.
+func WithUser(ctx context.Context, user string) context.Context {
+ return context.WithValue(ctx, ctxKey{}, user)
+}
+
+// UserFrom returns the identity stored by WithUser, or "" if none.
+func UserFrom(ctx context.Context) string {
+ u, _ := ctx.Value(ctxKey{}).(string)
+ return u
+}
diff --git a/internal/api/api.go b/internal/api/api.go
index cfd517f..e8a4dae 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -2,17 +2,22 @@
package api
import (
+ "crypto/rand"
+ "encoding/hex"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
+ "strconv"
"strings"
+ "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic"
+ "github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage"
)
@@ -21,17 +26,21 @@ type Handler struct {
broker *broker.Broker
synthetic *synthetic.Synthetic // nil if not enabled
store *storage.Store
+ policy *access.Policy
+ acl *panelacl.Store
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
log *slog.Logger
}
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
-func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
+func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
return &Handler{
broker: b,
synthetic: synth,
store: store,
+ policy: policy,
+ acl: acl,
channelFinderURL: channelFinderURL,
archiverURL: archiverURL,
log: log,
@@ -41,6 +50,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cha
// 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+"/datasources", h.listDataSources)
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
@@ -48,10 +58,26 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
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)
// Signal group tree
mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups)
@@ -63,6 +89,67 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
}
+// ── /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{}
+ }
+ jsonOK(w, map[string]any{
+ "user": user,
+ "level": h.policy.Level(user).String(),
+ "groups": groups,
+ })
+}
+
+// ── 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()) }
+
+// 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) is a trusted
+// LAN deployment with no per-user enforcement, so it gets full write.
+func (h *Handler) panelPerm(r *http.Request, id string) panelacl.Perm {
+ user := caller(r)
+ if user == "" {
+ return 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 trusted with full write.
+func (h *Handler) folderPerm(r *http.Request, id string) panelacl.Perm {
+ user := caller(r)
+ if user == "" {
+ return panelacl.PermWrite
+ }
+ return h.capByGlobal(user, h.acl.FolderPerm(id, user, h.policy.GroupsOf(user)))
+}
+
// ── /datasources ──────────────────────────────────────────────────────────────
type dsInfo struct {
@@ -101,6 +188,22 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
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")
+ metas := h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
+ return synVisible(d, user, panel)
+ })
+ 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)
@@ -121,6 +224,20 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
jsonOK(w, out)
}
+// synVisible reports whether a synthetic signal should be listed for the given
+// caller 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) bool {
+ switch d.Visibility {
+ case "user":
+ return user != "" && d.Owner == user
+ 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) {
@@ -139,11 +256,22 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
sources = h.broker.DataSources()
}
+ user := caller(r)
+ panel := r.URL.Query().Get("panel")
+
var out []signalInfo
for _, ds := range sources {
- metas, err := ds.ListSignals(r.Context())
- if err != nil {
- continue
+ 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)
+ })
+ } 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) {
@@ -163,21 +291,27 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
// 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
}
- q := r.URL.Query().Get("q")
cfURL := strings.TrimRight(h.channelFinderURL, "/") + "/resources/channels"
if q != "" {
cfURL += "?~name=*" + q + "*"
- } else if r.URL.RawQuery != "" && r.URL.RawQuery != "q=" {
- cfURL += "?" + r.URL.RawQuery
} else {
- // Empty query check: return empty results to signal CFS is configured.
- jsonOK(w, []struct{}{})
- return
+ // Structured query (e.g. ~name=, ~tag=, property filters) passed through.
+ cfURL += "?" + r.URL.RawQuery
}
resp, err := http.Get(cfURL) //nolint:noctx
@@ -281,6 +415,16 @@ func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
// ── /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"`
+ Perm string `json:"perm"`
+}
+
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
list, err := h.store.List()
if err != nil {
@@ -288,7 +432,21 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
- jsonOK(w, list)
+ 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()}
+ if acl := h.acl.GetPanel(m.ID); acl != nil {
+ item.Owner = acl.Owner
+ item.Folder = acl.Folder
+ }
+ 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) {
@@ -297,12 +455,20 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
- id, err := h.store.Create(body)
+ 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)
+ }
+ }
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
@@ -310,6 +476,10 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
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) {
@@ -325,12 +495,16 @@ func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
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
}
- if err := h.store.Update(id, body); err != nil {
+ 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 {
@@ -342,8 +516,133 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
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)
@@ -352,11 +651,18 @@ func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
}
return
}
+ if err := h.acl.DeletePanel(id); err != nil {
+ h.log.Error("delete panel ACL", "id", id, "err", err)
+ }
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) {
@@ -367,11 +673,242 @@ func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) {
}
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)
+}
+
+// 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) {
@@ -392,6 +929,11 @@ func (h *Handler) createSynthetic(w http.ResponseWriter, r *http.Request) {
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())
@@ -435,6 +977,10 @@ func (h *Handler) updateSynthetic(w http.ResponseWriter, r *http.Request) {
}
// 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)
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index b88d5c1..41094b3 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -12,9 +12,11 @@ import (
"strings"
"testing"
+ "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource/stub"
+ "github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage"
)
@@ -38,9 +40,13 @@ func setup(t *testing.T) (*httptest.Server, func()) {
if err != nil {
t.Fatal("storage.New:", err)
}
+ acl, err := panelacl.New(dir)
+ if err != nil {
+ t.Fatal("panelacl.New:", err)
+ }
mux := http.NewServeMux()
- api.New(brk, nil, store, "", "", log).Register(mux, "/api/v1")
+ api.New(brk, nil, store, access.New("", nil, nil), acl, "", "", log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() {
@@ -396,7 +402,7 @@ func TestStorageValidateID(t *testing.T) {
}
// Create a real interface first to get a valid ID.
- id, err := store.Create([]byte(sampleXML))
+ id, err := store.Create([]byte(sampleXML), "")
if err != nil {
t.Fatal("create:", err)
}
@@ -416,7 +422,7 @@ func TestStorageValidateID(t *testing.T) {
if _, err := store.Get(bad); err == nil {
t.Errorf("Get(%q) should have failed, got nil error", bad)
}
- if err := store.Update(bad, []byte(sampleXML)); err == nil {
+ if err := store.Update(bad, []byte(sampleXML), ""); err == nil {
t.Errorf("Update(%q) should have failed", bad)
}
if err := store.Delete(bad); err == nil {
diff --git a/internal/config/config.go b/internal/config/config.go
index 7b18489..13a8edc 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -12,12 +12,44 @@ import (
type Config struct {
Server ServerConfig `toml:"server"`
Datasource DatasourceConfig `toml:"datasource"`
+ // Groups are named sets of users, referenced by panel sharing rules.
+ Groups []GroupDef `toml:"groups"`
+}
+
+// GroupDef is a named set of users defined as [[groups]] in the config file.
+type GroupDef struct {
+ Name string `toml:"name"`
+ Members []string `toml:"members"`
+}
+
+// BlacklistEntry downgrades a specific user's global access level. Levels:
+// "readonly" (no writes) or "noaccess" (denied). Users not listed are trusted
+// with full access.
+type BlacklistEntry struct {
+ User string `toml:"user"`
+ Level string `toml:"level"`
}
type ServerConfig struct {
- Listen string `toml:"listen"`
- StorageDir string `toml:"storage_dir"`
- MaxUpdateRateHz float64 `toml:"max_update_rate_hz"` // 0 = unlimited
+ Listen string `toml:"listen"`
+ StorageDir string `toml:"storage_dir"`
+ MaxUpdateRateHz float64 `toml:"max_update_rate_hz"` // 0 = unlimited
+
+ // TrustedUserHeader is the HTTP header from which the end-user identity is
+ // read on each WebSocket connection (set by a trusted reverse proxy doing
+ // authentication). The identity is used for EPICS writes so each session
+ // can act as a different user. Empty disables it (writes use the server
+ // identity). MUST only be enabled when a proxy strips any client-supplied
+ // value, otherwise the header can be spoofed.
+ TrustedUserHeader string `toml:"trusted_user_header"`
+
+ // DefaultUser is the identity used when the trusted user header is absent or
+ // empty (e.g. unproxied/dev/LAN deployments). Empty leaves the user anonymous.
+ DefaultUser string `toml:"default_user"`
+
+ // Blacklist downgrades specific users' global access level. Everyone not
+ // listed is trusted with full write access.
+ Blacklist []BlacklistEntry `toml:"blacklist"`
}
type DatasourceConfig struct {
@@ -93,6 +125,12 @@ func applyEnv(cfg *Config) {
cfg.Server.MaxUpdateRateHz = hz
}
}
+ if v := env("UOPI_SERVER_TRUSTED_USER_HEADER"); v != "" {
+ cfg.Server.TrustedUserHeader = v
+ }
+ if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
+ cfg.Server.DefaultUser = v
+ }
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
cfg.Datasource.EPICS.CAAddrList = v
}
diff --git a/internal/datasource/epics/epics.go b/internal/datasource/epics/epics.go
index e8df9a3..393b9ae 100644
--- a/internal/datasource/epics/epics.go
+++ b/internal/datasource/epics/epics.go
@@ -508,6 +508,11 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
// Write puts a new value onto a CA channel.
// If the signal is not currently subscribed (e.g. a button in oneshot mode),
// a temporary CA channel is created, used for the put, then torn down.
+//
+// NOTE: per-session end-user identity (datasource.WithUser) is NOT honoured in
+// the CGo/libca build: libca uses a single process-wide CA context whose client
+// name is fixed at startup. Writes therefore use the server identity here. Use
+// the default pure-Go build for per-user write attribution.
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
e.attachCAContext()
diff --git a/internal/datasource/epics/noop.go b/internal/datasource/epics/noop.go
index f1b6a2d..69ebe54 100644
--- a/internal/datasource/epics/noop.go
+++ b/internal/datasource/epics/noop.go
@@ -39,8 +39,29 @@ type EPICS struct {
mu sync.RWMutex
metadata map[string]datasource.Metadata
+
+ // Per-user CA clients for writes. EPICS Channel Access carries the client
+ // username at the circuit (TCP) level, so attributing a write to a specific
+ // end-user requires a dedicated client whose ClientName is that user. These
+ // are created lazily on first write by each user and evicted when idle.
+ ctx context.Context // datasource lifetime; governs per-user clients
+ baseCfg ca.Config // template config for per-user clients
+ selfName string // the server's own CA client name
+ userMu sync.Mutex
+ userClients map[string]*userClient
}
+// userClient is a per-end-user CA client and its last-use time for idle eviction.
+type userClient struct {
+ cli *ca.Client
+ lastUsed time.Time
+}
+
+// userClientIdleTTL is how long an idle per-user CA client is kept before being
+// closed. Writes are bursty, so a few minutes avoids reconnecting on every put
+// without holding circuits open indefinitely.
+const userClientIdleTTL = 10 * time.Minute
+
// New creates a new EPICS Channel Access data source.
//
// caAddrList is the EPICS_CA_ADDR_LIST value (space-separated addresses).
@@ -79,7 +100,12 @@ func (e *EPICS) Connect(ctx context.Context) error {
return fmt.Errorf("epics: %w", err)
}
e.client = cli
- slog.Info("epics: CA client started", "addrs", cfg.AddrList)
+ e.ctx = ctx
+ e.baseCfg = cfg
+ e.selfName = cfg.ClientName
+ e.userClients = make(map[string]*userClient)
+ go e.evictIdleUserClients(ctx)
+ slog.Info("epics: CA client started", "addrs", cfg.AddrList, "client_name", cfg.ClientName)
// Perform background sync from Channel Finder if configured.
if e.cfURL != "" && e.autoSyncFilter != "" {
@@ -344,13 +370,70 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
// Write puts a new value onto the named CA channel.
// value must be one of: float64, float32, int64, int32, int, int16, string, bool.
+//
+// When the request context carries an end-user identity (see datasource.WithUser)
+// that differs from the server's own CA client name, the put is performed on a
+// dedicated CA client whose ClientName is that user, so the IOC's access-security
+// rules see the actual end-user rather than the server process.
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
- if err := e.client.Put(ctx, signal, value); err != nil {
+ cli := e.client
+ if user, ok := datasource.UserFrom(ctx); ok && user != e.selfName {
+ uc, err := e.clientForUser(user)
+ if err != nil {
+ return fmt.Errorf("epics: write %q as %q: %w", signal, user, err)
+ }
+ cli = uc
+ }
+ if err := cli.Put(ctx, signal, value); err != nil {
return fmt.Errorf("epics: write %q: %w", signal, err)
}
return nil
}
+// clientForUser returns a CA client whose ClientName is user, creating and
+// caching one on first use. The client is bound to the datasource lifetime ctx,
+// not the per-request ctx, so it survives across writes until evicted when idle.
+func (e *EPICS) clientForUser(user string) (*ca.Client, error) {
+ e.userMu.Lock()
+ defer e.userMu.Unlock()
+ if uc, ok := e.userClients[user]; ok {
+ uc.lastUsed = time.Now()
+ return uc.cli, nil
+ }
+ cfg := e.baseCfg
+ cfg.ClientName = user
+ cli, err := ca.NewClient(e.ctx, cfg)
+ if err != nil {
+ return nil, err
+ }
+ e.userClients[user] = &userClient{cli: cli, lastUsed: time.Now()}
+ slog.Info("epics: created per-user CA client", "user", user)
+ return cli, nil
+}
+
+// evictIdleUserClients closes per-user CA clients that have not been used within
+// userClientIdleTTL. It runs until ctx is cancelled.
+func (e *EPICS) evictIdleUserClients(ctx context.Context) {
+ t := time.NewTicker(time.Minute)
+ defer t.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case now := <-t.C:
+ e.userMu.Lock()
+ for user, uc := range e.userClients {
+ if now.Sub(uc.lastUsed) > userClientIdleTTL {
+ uc.cli.Close()
+ delete(e.userClients, user)
+ slog.Info("epics: evicted idle per-user CA client", "user", user)
+ }
+ }
+ e.userMu.Unlock()
+ }
+ }
+}
+
// -------------------------------------------------------------------------- //
// History //
// -------------------------------------------------------------------------- //
diff --git a/internal/datasource/synthetic/definition.go b/internal/datasource/synthetic/definition.go
index 45894f4..a5cf39f 100644
--- a/internal/datasource/synthetic/definition.go
+++ b/internal/datasource/synthetic/definition.go
@@ -10,6 +10,16 @@ type SignalDef struct {
Inputs []InputRef `json:"inputs"` // alternative multi-input format
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
Meta MetaOverride `json:"meta"` // optional metadata overrides
+
+ // Visibility controls who sees this signal in the signal tree:
+ // "global" — listed in every panel's edit mode
+ // "user" — listed in every panel owned by Owner
+ // "panel" — listed only when editing the bound Panel
+ // An empty value is treated as "global" for backward compatibility with
+ // definitions created before this field existed.
+ Visibility string `json:"visibility,omitempty"`
+ Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
+ Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
}
// InputRef names one upstream signal used as input to the pipeline.
diff --git a/internal/datasource/synthetic/dsp_bridge.go b/internal/datasource/synthetic/dsp_bridge.go
index e01ef8b..9156168 100644
--- a/internal/datasource/synthetic/dsp_bridge.go
+++ b/internal/datasource/synthetic/dsp_bridge.go
@@ -83,6 +83,9 @@ func buildNode(d NodeDef) (dsp.Node, error) {
case "derivative":
return &dsp.DerivativeNode{}, nil
+ case "integrate":
+ return &dsp.IntegrateNode{}, nil
+
case "clamp":
return &dsp.ClampNode{
Min: floatParam(p, "min"),
diff --git a/internal/datasource/synthetic/synthetic.go b/internal/datasource/synthetic/synthetic.go
index 0c0f77e..901d0e6 100644
--- a/internal/datasource/synthetic/synthetic.go
+++ b/internal/datasource/synthetic/synthetic.go
@@ -85,6 +85,22 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error
return out, nil
}
+// FilteredMetadata returns metadata for every defined synthetic signal for
+// which keep returns true. It lets the API layer apply per-caller visibility
+// rules (which depend on SignalDef fields not present in datasource.Metadata).
+func (s *Synthetic) FilteredMetadata(keep func(SignalDef) bool) []datasource.Metadata {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ out := make([]datasource.Metadata, 0, len(s.signals))
+ for _, st := range s.signals {
+ if keep(st.def) {
+ out = append(out, defToMetadata(st.def))
+ }
+ }
+ return out
+}
+
// GetMetadata returns metadata for a single named signal.
func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
s.mu.RLock()
diff --git a/internal/datasource/user.go b/internal/datasource/user.go
new file mode 100644
index 0000000..72159c4
--- /dev/null
+++ b/internal/datasource/user.go
@@ -0,0 +1,27 @@
+package datasource
+
+import "context"
+
+// userKey is the unexported context key under which the end-user identity is
+// stored. Using a private type prevents collisions with other packages.
+type userKey struct{}
+
+// WithUser returns a copy of ctx carrying the end-user identity associated with
+// the request (e.g. the authenticated web client). Data sources may use this to
+// attribute operations to the actual user rather than the server process — for
+// example EPICS Channel Access access-security rules match on the client
+// username. An empty user is treated as "no client identity".
+func WithUser(ctx context.Context, user string) context.Context {
+ if user == "" {
+ return ctx
+ }
+ return context.WithValue(ctx, userKey{}, user)
+}
+
+// UserFrom returns the end-user identity stored in ctx by WithUser. The boolean
+// is false when no (non-empty) identity is present, in which case callers
+// should fall back to the server's own identity.
+func UserFrom(ctx context.Context) (string, bool) {
+ u, ok := ctx.Value(userKey{}).(string)
+ return u, ok && u != ""
+}
diff --git a/internal/dsp/nodes.go b/internal/dsp/nodes.go
index 7181f86..4c22789 100644
--- a/internal/dsp/nodes.go
+++ b/internal/dsp/nodes.go
@@ -204,6 +204,34 @@ func (n *DerivativeNode) Process(inputs []float64, state map[string]any) (float6
return 0, nil
}
+// ── IntegrateNode ─────────────────────────────────────────────────────────────
+
+// IntegrateNode accumulates the time-integral of input[0] using the trapezoidal
+// rule, where dt is in seconds. On the first call it returns 0 (no interval yet).
+type IntegrateNode struct{}
+
+func (n *IntegrateNode) Type() string { return "integrate" }
+func (n *IntegrateNode) Process(inputs []float64, state map[string]any) (float64, error) {
+ if len(inputs) == 0 {
+ return 0, errors.New("integrate: no inputs")
+ }
+ now := time.Now()
+ acc, _ := state["acc"].(float64)
+ if prevVal, ok := state["prev_val"].(float64); ok {
+ if prevTime, ok := state["prev_time"].(time.Time); ok {
+ dt := now.Sub(prevTime).Seconds()
+ if dt < 0 {
+ dt = 0
+ }
+ acc += (inputs[0] + prevVal) * 0.5 * dt
+ }
+ }
+ state["acc"] = acc
+ state["prev_val"] = inputs[0]
+ state["prev_time"] = now
+ return acc, nil
+}
+
// ── ClampNode ─────────────────────────────────────────────────────────────────
// ClampNode clamps the output to [Min, Max].
diff --git a/internal/panelacl/panelacl.go b/internal/panelacl/panelacl.go
new file mode 100644
index 0000000..bb58ef4
--- /dev/null
+++ b/internal/panelacl/panelacl.go
@@ -0,0 +1,417 @@
+// Package panelacl implements per-panel ownership and sharing (Phase 2) plus a
+// nested folder hierarchy for organising panels (Phase 3). It persists a single
+// sidecar index, {storageDir}/acl.json, alongside the XML interface files.
+//
+// The effective permission a user has on a panel is the maximum of: ownership
+// (always write), the panel's public level, any direct user grant, any grant to
+// a user-group the user belongs to, and the permissions inherited from the
+// folder chain the panel lives in. Callers are expected to further cap this by
+// the user's global access level (see internal/access).
+//
+// Panels that have no record in the index are treated as fully open
+// (PermWrite). This preserves access to interfaces created before ACLs existed;
+// panels created through the ACL-aware API are recorded as private to their
+// owner.
+package panelacl
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "sync"
+)
+
+// ErrNotFound is returned when a folder does not exist.
+var ErrNotFound = errors.New("not found")
+
+// Perm is an effective access level on a panel or folder.
+type Perm int
+
+const (
+ // PermNone denies access.
+ PermNone Perm = iota
+ // PermRead permits viewing only.
+ PermRead
+ // PermWrite permits viewing and modification.
+ PermWrite
+)
+
+// String renders a Perm as the token used in the JSON index and HTTP API.
+func (p Perm) String() string {
+ switch p {
+ case PermRead:
+ return "read"
+ case PermWrite:
+ return "write"
+ default:
+ return "none"
+ }
+}
+
+// parsePerm maps a config/API token to a Perm. Unknown/empty → PermNone.
+func parsePerm(s string) Perm {
+ switch s {
+ case "read":
+ return PermRead
+ case "write", "readwrite", "rw":
+ return PermWrite
+ default:
+ return PermNone
+ }
+}
+
+// Grant shares a panel or folder with a single user or a named user-group.
+type Grant struct {
+ Kind string `json:"kind"` // "user" | "group"
+ Name string `json:"name"` // username or user-group name
+ Perm string `json:"perm"` // "read" | "write"
+}
+
+// PanelACL is the ownership + sharing record for one panel.
+type PanelACL struct {
+ Owner string `json:"owner"`
+ Folder string `json:"folder,omitempty"` // folder id the panel belongs to ("" = root)
+ Public string `json:"public,omitempty"` // "" | "read" | "write"
+ Grants []Grant `json:"grants,omitempty"`
+ Order float64 `json:"order,omitempty"` // sort position within its folder
+}
+
+// Folder is a node in the panel-organisation hierarchy. Permissions set on a
+// folder are inherited by every panel and subfolder beneath it.
+type Folder struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Parent string `json:"parent,omitempty"` // parent folder id ("" = root)
+ Owner string `json:"owner"`
+ Public string `json:"public,omitempty"`
+ Grants []Grant `json:"grants,omitempty"`
+}
+
+type index struct {
+ Panels map[string]*PanelACL `json:"panels"`
+ Folders map[string]*Folder `json:"folders"`
+}
+
+// Store persists and resolves the panel ACL index. It is safe for concurrent
+// use.
+type Store struct {
+ path string
+ mu sync.RWMutex
+ idx index
+}
+
+// New opens (loading if present) the acl.json index in storageDir.
+func New(storageDir string) (*Store, error) {
+ s := &Store{
+ path: filepath.Join(storageDir, "acl.json"),
+ idx: index{Panels: map[string]*PanelACL{}, Folders: map[string]*Folder{}},
+ }
+ data, err := os.ReadFile(s.path)
+ if errors.Is(err, os.ErrNotExist) {
+ return s, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal(data, &s.idx); err != nil {
+ return nil, err
+ }
+ if s.idx.Panels == nil {
+ s.idx.Panels = map[string]*PanelACL{}
+ }
+ if s.idx.Folders == nil {
+ s.idx.Folders = map[string]*Folder{}
+ }
+ return s, nil
+}
+
+// saveLocked atomically persists the index. The caller must hold s.mu.
+func (s *Store) saveLocked() error {
+ data, err := json.MarshalIndent(s.idx, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp := s.path + ".tmp"
+ if err := os.WriteFile(tmp, data, 0o644); err != nil {
+ return err
+ }
+ return os.Rename(tmp, s.path)
+}
+
+// ── Panel records ────────────────────────────────────────────────────────────
+
+// GetPanel returns a copy of a panel's ACL record, or nil if it is unmanaged.
+func (s *Store) GetPanel(id string) *PanelACL {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ acl, ok := s.idx.Panels[id]
+ if !ok {
+ return nil
+ }
+ return clonePanel(acl)
+}
+
+// CreatePanel records ownership for a newly created panel, defaulting to
+// private (owner-only) access. Existing records are left untouched.
+func (s *Store) CreatePanel(id, owner string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if _, ok := s.idx.Panels[id]; ok {
+ return nil
+ }
+ s.idx.Panels[id] = &PanelACL{Owner: owner}
+ return s.saveLocked()
+}
+
+// SetPanel replaces a panel's sharing settings (folder, public level, grants)
+// while preserving its existing owner (or adopting the supplied owner when the
+// panel was previously unmanaged).
+func (s *Store) SetPanel(id, owner, folder, public string, grants []Grant) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ acl := s.idx.Panels[id]
+ if acl == nil {
+ acl = &PanelACL{Owner: owner}
+ s.idx.Panels[id] = acl
+ }
+ acl.Folder = folder
+ acl.Public = public
+ acl.Grants = grants
+ return s.saveLocked()
+}
+
+// PlacePanel sets only a panel's organizational fields — its folder and sort
+// order — preserving ownership and sharing. A previously-unmanaged panel gets a
+// record with no owner, which PanelPerm still treats as fully open (so dragging
+// a legacy panel into a folder does not lock it).
+func (s *Store) PlacePanel(id, folder string, order float64) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ acl := s.idx.Panels[id]
+ if acl == nil {
+ acl = &PanelACL{}
+ s.idx.Panels[id] = acl
+ }
+ acl.Folder = folder
+ acl.Order = order
+ return s.saveLocked()
+}
+
+// DeletePanel removes a panel's ACL record (called when the panel is deleted).
+func (s *Store) DeletePanel(id string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if _, ok := s.idx.Panels[id]; !ok {
+ return nil
+ }
+ delete(s.idx.Panels, id)
+ return s.saveLocked()
+}
+
+// ── Folders ──────────────────────────────────────────────────────────────────
+
+// Folders returns a copy of every folder, keyed by id.
+func (s *Store) Folders() map[string]Folder {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ out := make(map[string]Folder, len(s.idx.Folders))
+ for id, f := range s.idx.Folders {
+ out[id] = *cloneFolder(f)
+ }
+ return out
+}
+
+// GetFolder returns a copy of a folder, or ErrNotFound.
+func (s *Store) GetFolder(id string) (Folder, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ f, ok := s.idx.Folders[id]
+ if !ok {
+ return Folder{}, ErrNotFound
+ }
+ return *cloneFolder(f), nil
+}
+
+// CreateFolder adds a new folder owned by owner and returns it.
+func (s *Store) CreateFolder(id, name, parent, owner string) (Folder, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if parent != "" {
+ if _, ok := s.idx.Folders[parent]; !ok {
+ return Folder{}, ErrNotFound
+ }
+ }
+ f := &Folder{ID: id, Name: name, Parent: parent, Owner: owner}
+ s.idx.Folders[id] = f
+ if err := s.saveLocked(); err != nil {
+ return Folder{}, err
+ }
+ return *cloneFolder(f), nil
+}
+
+// UpdateFolder replaces a folder's mutable fields (name, parent, public,
+// grants). It rejects parent changes that would create a cycle.
+func (s *Store) UpdateFolder(id, name, parent, public string, grants []Grant) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ f, ok := s.idx.Folders[id]
+ if !ok {
+ return ErrNotFound
+ }
+ if parent != "" {
+ if _, ok := s.idx.Folders[parent]; !ok {
+ return ErrNotFound
+ }
+ if s.createsCycleLocked(id, parent) {
+ return errors.New("folder parent change would create a cycle")
+ }
+ }
+ f.Name = name
+ f.Parent = parent
+ f.Public = public
+ f.Grants = grants
+ return s.saveLocked()
+}
+
+// DeleteFolder removes a folder, reparenting its child folders and panels to the
+// deleted folder's parent (so nothing is orphaned).
+func (s *Store) DeleteFolder(id string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ f, ok := s.idx.Folders[id]
+ if !ok {
+ return ErrNotFound
+ }
+ newParent := f.Parent
+ for _, child := range s.idx.Folders {
+ if child.Parent == id {
+ child.Parent = newParent
+ }
+ }
+ for _, acl := range s.idx.Panels {
+ if acl.Folder == id {
+ acl.Folder = newParent
+ }
+ }
+ delete(s.idx.Folders, id)
+ return s.saveLocked()
+}
+
+// createsCycleLocked reports whether setting folder id's parent to newParent
+// would introduce a cycle. The caller must hold s.mu.
+func (s *Store) createsCycleLocked(id, newParent string) bool {
+ for cur := newParent; cur != ""; {
+ if cur == id {
+ return true
+ }
+ f, ok := s.idx.Folders[cur]
+ if !ok {
+ return false
+ }
+ cur = f.Parent
+ }
+ return false
+}
+
+// ── Permission resolution ────────────────────────────────────────────────────
+
+// PanelPerm returns the effective permission user has on panel id, given the
+// user-groups the user belongs to. Unmanaged panels are fully open.
+func (s *Store) PanelPerm(id, user string, groups []string) Perm {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ acl, ok := s.idx.Panels[id]
+ if !ok {
+ return PermWrite // legacy/unmanaged panel: fully open
+ }
+ // A record carrying only organizational metadata (folder/order) with no
+ // owner, public level, or grants is still open — keeps an unmanaged panel
+ // accessible after it has merely been moved into a folder or reordered.
+ if acl.Owner == "" && acl.Public == "" && len(acl.Grants) == 0 {
+ return PermWrite
+ }
+ best := PermNone
+ if user != "" && acl.Owner == user {
+ return PermWrite
+ }
+ best = maxPerm(best, parsePerm(acl.Public))
+ best = maxPerm(best, grantsPerm(acl.Grants, user, groups))
+ if acl.Folder != "" {
+ best = maxPerm(best, s.folderPermLocked(acl.Folder, user, groups, map[string]bool{}))
+ }
+ return best
+}
+
+// FolderPerm returns the effective permission user has on folder id.
+func (s *Store) FolderPerm(id, user string, groups []string) Perm {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.folderPermLocked(id, user, groups, map[string]bool{})
+}
+
+func (s *Store) folderPermLocked(id, user string, groups []string, seen map[string]bool) Perm {
+ if id == "" || seen[id] {
+ return PermNone
+ }
+ seen[id] = true
+ f, ok := s.idx.Folders[id]
+ if !ok {
+ return PermNone
+ }
+ if user != "" && f.Owner == user {
+ return PermWrite
+ }
+ best := PermNone
+ best = maxPerm(best, parsePerm(f.Public))
+ best = maxPerm(best, grantsPerm(f.Grants, user, groups))
+ best = maxPerm(best, s.folderPermLocked(f.Parent, user, groups, seen))
+ return best
+}
+
+func grantsPerm(grants []Grant, user string, groups []string) Perm {
+ best := PermNone
+ for _, g := range grants {
+ switch g.Kind {
+ case "user":
+ if user != "" && g.Name == user {
+ best = maxPerm(best, parsePerm(g.Perm))
+ }
+ case "group":
+ if contains(groups, g.Name) {
+ best = maxPerm(best, parsePerm(g.Perm))
+ }
+ }
+ }
+ return best
+}
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+func maxPerm(a, b Perm) Perm {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+func contains(xs []string, v string) bool {
+ for _, x := range xs {
+ if x == v {
+ return true
+ }
+ }
+ return false
+}
+
+func clonePanel(a *PanelACL) *PanelACL {
+ c := *a
+ c.Grants = append([]Grant(nil), a.Grants...)
+ return &c
+}
+
+func cloneFolder(f *Folder) *Folder {
+ c := *f
+ c.Grants = append([]Grant(nil), f.Grants...)
+ return &c
+}
diff --git a/internal/panelacl/panelacl_test.go b/internal/panelacl/panelacl_test.go
new file mode 100644
index 0000000..af5bfff
--- /dev/null
+++ b/internal/panelacl/panelacl_test.go
@@ -0,0 +1,109 @@
+package panelacl
+
+import "testing"
+
+func newStore(t *testing.T) *Store {
+ t.Helper()
+ s, err := New(t.TempDir())
+ if err != nil {
+ t.Fatal("New:", err)
+ }
+ return s
+}
+
+func TestUnmanagedPanelIsOpen(t *testing.T) {
+ s := newStore(t)
+ if got := s.PanelPerm("legacy", "alice", nil); got != PermWrite {
+ t.Fatalf("unmanaged panel perm = %v, want write", got)
+ }
+}
+
+func TestOwnerAlwaysWrites(t *testing.T) {
+ s := newStore(t)
+ if err := s.CreatePanel("p1", "alice"); err != nil {
+ t.Fatal(err)
+ }
+ if got := s.PanelPerm("p1", "alice", nil); got != PermWrite {
+ t.Fatalf("owner perm = %v, want write", got)
+ }
+ // A freshly created panel is private to its owner.
+ if got := s.PanelPerm("p1", "bob", nil); got != PermNone {
+ t.Fatalf("non-owner perm on private panel = %v, want none", got)
+ }
+}
+
+func TestPublicAndGrants(t *testing.T) {
+ s := newStore(t)
+ if err := s.CreatePanel("p1", "alice"); err != nil {
+ t.Fatal(err)
+ }
+ grants := []Grant{
+ {Kind: "user", Name: "bob", Perm: "write"},
+ {Kind: "group", Name: "ops", Perm: "read"},
+ }
+ if err := s.SetPanel("p1", "alice", "", "read", grants); err != nil {
+ t.Fatal(err)
+ }
+ // Public read applies to anyone.
+ if got := s.PanelPerm("p1", "carol", nil); got != PermRead {
+ t.Fatalf("public perm = %v, want read", got)
+ }
+ // Direct user grant raises bob to write.
+ if got := s.PanelPerm("p1", "bob", nil); got != PermWrite {
+ t.Fatalf("granted user perm = %v, want write", got)
+ }
+ // Group membership grants read.
+ if got := s.PanelPerm("p1", "dave", []string{"ops"}); got != PermRead {
+ t.Fatalf("group perm = %v, want read", got)
+ }
+}
+
+func TestFolderInheritance(t *testing.T) {
+ s := newStore(t)
+ f, err := s.CreateFolder("f1", "Shared", "", "alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.UpdateFolder(f.ID, f.Name, "", "read", nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.CreatePanel("p1", "alice"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetPanel("p1", "alice", f.ID, "", nil); err != nil {
+ t.Fatal(err)
+ }
+ // The panel inherits the folder's public-read level.
+ if got := s.PanelPerm("p1", "bob", nil); got != PermRead {
+ t.Fatalf("inherited perm = %v, want read", got)
+ }
+}
+
+func TestFolderCycleRejected(t *testing.T) {
+ s := newStore(t)
+ a, _ := s.CreateFolder("a", "A", "", "alice")
+ b, _ := s.CreateFolder("b", "B", a.ID, "alice")
+ // Re-parenting A under its own descendant B must be rejected.
+ if err := s.UpdateFolder(a.ID, a.Name, b.ID, "", nil); err == nil {
+ t.Fatal("expected cycle rejection, got nil")
+ }
+}
+
+func TestDeleteFolderReparents(t *testing.T) {
+ s := newStore(t)
+ a, _ := s.CreateFolder("a", "A", "", "alice")
+ b, _ := s.CreateFolder("b", "B", a.ID, "alice")
+ if err := s.CreatePanel("p1", "alice"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetPanel("p1", "alice", b.ID, "", nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.DeleteFolder(b.ID); err != nil {
+ t.Fatal(err)
+ }
+ // B's panel should reparent to A (B's former parent), not orphan.
+ if acl := s.GetPanel("p1"); acl == nil || acl.Folder != a.ID {
+ t.Fatalf("panel folder after delete = %+v, want %s", acl, a.ID)
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index a8375ed..e8cfca3 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -5,12 +5,15 @@ import (
"io/fs"
"log/slog"
"net/http"
+ "strings"
"time"
+ "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker"
"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"
)
@@ -23,7 +26,7 @@ type Server struct {
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
// synth may be nil if the synthetic data source is not enabled.
-func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Server {
+func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
mux := http.NewServeMux()
// Health check
@@ -33,13 +36,16 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
})
// WebSocket endpoint
- mux.Handle("/ws", &wsHandler{broker: brk, log: log})
+ mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy})
// Prometheus-format metrics
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
- // REST API
- api.New(brk, synth, store, channelFinderURL, archiverURL, log).Register(mux, apiPrefix)
+ // REST API — registered on a dedicated mux so it can be wrapped with the
+ // access-control middleware (identity resolution + global level enforcement).
+ apiMux := http.NewServeMux()
+ api.New(brk, synth, store, policy, acl, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
+ mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
// Embedded frontend — must be last (catch-all)
mux.Handle("/", http.FileServerFS(webFS))
@@ -56,6 +62,44 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
}
}
+// accessMiddleware resolves the end-user identity from the trusted proxy header
+// (falling back to the configured default_user), stores it on the request
+// context, and enforces the user's global access level on every API request:
+// - LevelNone → 403 for all requests.
+// - LevelRead → 403 for any mutating request (non GET/HEAD).
+// - LevelWrite → no restriction.
+//
+// The /me endpoint is always reachable so the frontend can discover the
+// caller's identity and level even when otherwise restricted.
+func accessMiddleware(policy *access.Policy, userHeader string, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var raw string
+ if userHeader != "" {
+ raw = r.Header.Get(userHeader)
+ }
+ user := policy.ResolveUser(raw)
+ r = r.WithContext(access.WithUser(r.Context(), user))
+
+ // Always allow identity discovery.
+ if strings.HasSuffix(r.URL.Path, apiPrefix+"/me") {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ switch policy.Level(user) {
+ case access.LevelNone:
+ http.Error(w, "access denied", http.StatusForbidden)
+ return
+ case access.LevelRead:
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ http.Error(w, "read-only access", http.StatusForbidden)
+ return
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
// Start listens and serves until ctx is cancelled.
func (s *Server) Start(ctx context.Context) error {
s.log.Info("listening", "addr", s.httpServer.Addr)
diff --git a/internal/server/ws.go b/internal/server/ws.go
index 0717b6e..ff71e9e 100644
--- a/internal/server/ws.go
+++ b/internal/server/ws.go
@@ -6,11 +6,13 @@ import (
"errors"
"log/slog"
"net/http"
+ "strings"
"sync"
"time"
"github.com/coder/websocket"
+ "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/metrics"
@@ -87,9 +89,21 @@ type histPoint struct {
type wsHandler struct {
broker *broker.Broker
log *slog.Logger
+ // userHeader is the HTTP header from which the per-session end-user identity
+ // is read (set by a trusted auth proxy). Empty disables per-user identity.
+ userHeader string
+ // policy enforces the global access level (blacklist) on signal writes.
+ policy *access.Policy
}
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // Capture the end-user identity from the trusted proxy header (if enabled)
+ // before the connection is upgraded, while the original headers are present.
+ var user string
+ if h.userHeader != "" {
+ user = strings.TrimSpace(r.Header.Get(h.userHeader))
+ }
+
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
InsecureSkipVerify: true,
@@ -107,6 +121,8 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c := &wsClient{
conn: conn,
broker: h.broker,
+ user: user,
+ policy: h.policy,
outCh: make(chan []byte, 512),
updateCh: make(chan broker.Update, 1024),
subs: make(map[broker.SignalRef]func()),
@@ -136,6 +152,8 @@ type wsClient struct {
conn *websocket.Conn
broker *broker.Broker
log *slog.Logger
+ user string // end-user identity from the trusted proxy header ("" if none)
+ policy *access.Policy // global access-level enforcement
outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker
@@ -277,6 +295,14 @@ func (c *wsClient) handleUnsubscribe(refs []sigRef) {
}
func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
+ // Enforce the user's global access level: blacklisted read-only / no-access
+ // users may not write signals, regardless of the channel's own writability.
+ if c.policy != nil && c.policy.Level(c.policy.ResolveUser(c.user)) < access.LevelWrite {
+ c.log.Warn("write: access denied", "ds", msg.DS, "name", msg.Name, "user", c.user)
+ c.sendError(ctx, "ACCESS_DENIED", "you do not have write access")
+ return
+ }
+
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
@@ -305,8 +331,11 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
return
}
- c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value)
+ c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value, "user", c.user)
metrics.IncWrites()
+ // Attribute the write to the connecting end-user so data sources (EPICS) can
+ // act under that identity rather than the server's.
+ ctx = datasource.WithUser(ctx, c.user)
if err := ds.Write(ctx, msg.Name, value); err != nil {
c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err)
c.sendError(ctx, "WRITE_ERROR", err.Error())
diff --git a/internal/storage/store.go b/internal/storage/store.go
index c09ca26..1b67282 100644
--- a/internal/storage/store.go
+++ b/internal/storage/store.go
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "sort"
"strconv"
"strings"
"time"
@@ -23,6 +24,15 @@ type InterfaceMeta struct {
Version int `json:"version"`
}
+// VersionMeta describes a single persisted revision of an interface.
+type VersionMeta struct {
+ Version int `json:"version"`
+ Name string `json:"name"`
+ Tag string `json:"tag,omitempty"`
+ Current bool `json:"current"`
+ SavedAt time.Time `json:"savedAt"`
+}
+
// Store persists interface definitions as XML files under {storageDir}/interfaces/
// and workspace-level JSON blobs directly in {storageDir}.
type Store struct {
@@ -123,6 +133,7 @@ type rootAttrs struct {
ID string `xml:"id,attr"`
Name string `xml:"name,attr"`
Version int `xml:"version,attr"`
+ Tag string `xml:"tag,attr"`
}
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
@@ -151,8 +162,8 @@ func (s *Store) Get(id string) ([]byte, error) {
// Create stores new interface XML and returns the generated ID.
// The ID is derived from the name attribute; a timestamp suffix is added to
-// ensure uniqueness.
-func (s *Store) Create(xmlData []byte) (string, error) {
+// ensure uniqueness. If tag is non-empty it is stamped as the revision label.
+func (s *Store) Create(xmlData []byte, tag string) (string, error) {
var root rootAttrs
if err := xml.Unmarshal(xmlData, &root); err != nil {
return "", fmt.Errorf("invalid XML: %w", err)
@@ -167,18 +178,30 @@ func (s *Store) Create(xmlData []byte) (string, error) {
id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli())
}
- // Initialize version to 1.
+ // Initialize version to 1 and stamp the generated ID so the persisted XML
+ // is self-consistent (the client sends an empty id for new interfaces).
updatedData, err := setXMLAttribute(xmlData, "version", "1")
if err != nil {
return "", fmt.Errorf("initialize version attribute: %w", err)
}
+ updatedData, err = setXMLAttribute(updatedData, "id", id)
+ if err != nil {
+ return "", fmt.Errorf("stamp id attribute: %w", err)
+ }
+ if tag != "" {
+ updatedData, err = setXMLAttribute(updatedData, "tag", tag)
+ if err != nil {
+ return "", fmt.Errorf("set tag attribute: %w", err)
+ }
+ }
return id, os.WriteFile(s.filePath(id), updatedData, 0o644)
}
// Update replaces the XML for an existing interface, preserving the previous
-// version as a separate file.
-func (s *Store) Update(id string, xmlData []byte) error {
+// version as a separate file. If tag is non-empty it is stamped as the label
+// for the new revision.
+func (s *Store) Update(id string, xmlData []byte, tag string) error {
if err := validateID(id); err != nil {
return ErrNotFound
}
@@ -213,21 +236,223 @@ func (s *Store) Update(id string, xmlData []byte) error {
if err != nil {
return fmt.Errorf("update version attribute: %w", err)
}
+ if tag != "" {
+ updatedData, err = setXMLAttribute(updatedData, "tag", tag)
+ if err != nil {
+ return fmt.Errorf("set tag attribute: %w", err)
+ }
+ }
return os.WriteFile(oldPath, updatedData, 0o644)
}
+// Versions returns metadata for every persisted revision of the interface,
+// ordered newest-first. The current file is flagged with Current=true.
+func (s *Store) Versions(id string) ([]VersionMeta, error) {
+ if err := validateID(id); err != nil {
+ return nil, ErrNotFound
+ }
+
+ // Current revision.
+ curInfo, err := os.Stat(s.filePath(id))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, ErrNotFound
+ } else if err != nil {
+ return nil, err
+ }
+ curRoot, err := s.readRoot(s.filePath(id))
+ if err != nil {
+ return nil, err
+ }
+
+ out := []VersionMeta{{
+ Version: curRoot.Version,
+ Name: curRoot.Name,
+ Tag: curRoot.Tag,
+ Current: true,
+ SavedAt: curInfo.ModTime(),
+ }}
+
+ // Backup revisions: id.vN.xml
+ entries, err := os.ReadDir(s.dir)
+ if err != nil {
+ return nil, err
+ }
+ prefix := id + ".v"
+ for _, e := range entries {
+ name := e.Name()
+ if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".xml") {
+ continue
+ }
+ if !isVersioned(name) {
+ continue
+ }
+ p := filepath.Join(s.dir, name)
+ info, err := e.Info()
+ if err != nil {
+ continue
+ }
+ root, err := s.readRoot(p)
+ if err != nil {
+ continue
+ }
+ out = append(out, VersionMeta{
+ Version: root.Version,
+ Name: root.Name,
+ Tag: root.Tag,
+ SavedAt: info.ModTime(),
+ })
+ }
+
+ sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
+ return out, nil
+}
+
+// GetVersion returns the raw XML bytes for a specific revision of an interface.
+func (s *Store) GetVersion(id string, version int) ([]byte, error) {
+ if err := validateID(id); err != nil {
+ return nil, ErrNotFound
+ }
+
+ // The current file may hold the requested version.
+ curRoot, err := s.readRoot(s.filePath(id))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, ErrNotFound
+ }
+ return nil, err
+ }
+ if curRoot.Version == version {
+ return os.ReadFile(s.filePath(id))
+ }
+
+ backupPath := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version))
+ data, err := os.ReadFile(backupPath)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, ErrNotFound
+ }
+ return data, err
+}
+
+// versionPath returns the on-disk path for a specific revision, resolving the
+// current file when version matches the live revision.
+func (s *Store) versionPath(id string, version int) (string, error) {
+ cur := s.filePath(id)
+ curRoot, err := s.readRoot(cur)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return "", ErrNotFound
+ }
+ return "", err
+ }
+ if curRoot.Version == version {
+ return cur, nil
+ }
+ backup := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version))
+ if _, err := os.Stat(backup); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return "", ErrNotFound
+ }
+ return "", err
+ }
+ return backup, nil
+}
+
+// SetVersionTag sets (or clears, when tag is empty) the label of a specific
+// revision in place, without creating a new revision.
+func (s *Store) SetVersionTag(id string, version int, tag string) error {
+ if err := validateID(id); err != nil {
+ return ErrNotFound
+ }
+ path, err := s.versionPath(id, version)
+ if err != nil {
+ return err
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ updated, err := setXMLAttribute(data, "tag", tag)
+ if err != nil {
+ return fmt.Errorf("set tag attribute: %w", err)
+ }
+ return os.WriteFile(path, updated, 0o644)
+}
+
+// Promote makes a past revision the current one by re-saving its content as a
+// new revision on top of history. The existing current revision is preserved
+// as a backup, so promotion is non-destructive.
+func (s *Store) Promote(id string, version int) error {
+ data, err := s.GetVersion(id, version)
+ if err != nil {
+ return err
+ }
+ return s.Update(id, data, fmt.Sprintf("restored from v%d", version))
+}
+
+// Fork creates a brand-new interface from the content of a specific revision,
+// assigning a fresh unique ID and resetting its version to 1.
+func (s *Store) Fork(id string, version int) (string, error) {
+ data, err := s.GetVersion(id, version)
+ if err != nil {
+ return "", err
+ }
+ newID := id + "-fork-" + fmt.Sprintf("%d", time.Now().UnixMilli())
+ updated, err := setXMLAttribute(data, "version", "1")
+ if err != nil {
+ return "", fmt.Errorf("reset version attribute: %w", err)
+ }
+ updated, err = setXMLAttribute(updated, "id", newID)
+ if err != nil {
+ return "", fmt.Errorf("stamp id attribute: %w", err)
+ }
+ updated, err = setXMLAttribute(updated, "tag", "")
+ if err != nil {
+ return "", fmt.Errorf("clear tag attribute: %w", err)
+ }
+ return newID, os.WriteFile(s.filePath(newID), updated, 0o644)
+}
+
+// readRoot parses the top-level interface attributes from the file at path.
+func (s *Store) readRoot(path string) (rootAttrs, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return rootAttrs{}, err
+ }
+ var root rootAttrs
+ if err := xml.Unmarshal(data, &root); err != nil {
+ return rootAttrs{}, err
+ }
+ return root, nil
+}
+
// setXMLAttribute is a primitive helper to update a top-level attribute in an
// XML blob without full unmarshal/marshal (which might mess up formatting).
func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
// Very basic implementation: look for the attribute in the first 512 bytes.
// A proper implementation would use an XML encoder or a better regex.
s := string(data)
- tagEnd := strings.Index(s, ">")
- if tagEnd == -1 {
+
+ // Locate the root element's opening tag, skipping any XML declaration
+ // (), comments, or processing instructions. Operating on the
+ // first '>' in the document would match the '?>' of the XML declaration and
+ // corrupt its version attribute (producing invalid XML).
+ tagStart := -1
+ for i := 0; i+1 < len(s); i++ {
+ if s[i] == '<' && s[i+1] != '?' && s[i+1] != '!' {
+ tagStart = i
+ break
+ }
+ }
+ if tagStart == -1 {
+ return nil, errors.New("malformed XML: no root element")
+ }
+ rel := strings.Index(s[tagStart:], ">")
+ if rel == -1 {
return nil, errors.New("malformed XML: no root tag end")
}
- rootTag := s[:tagEnd]
+ tagEnd := tagStart + rel
+ rootTag := s[tagStart:tagEnd]
attrSearch := " " + attr + "=\""
idx := strings.Index(rootTag, attrSearch)
@@ -240,8 +465,8 @@ func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
return []byte(s[:insertIdx] + fmt.Sprintf(" %s=\"%s\"", attr, value) + s[insertIdx:]), nil
}
- // Attribute found, replace its value.
- valStart := idx + len(attrSearch)
+ // Attribute found, replace its value (idx is relative to rootTag).
+ valStart := tagStart + idx + len(attrSearch)
valEnd := strings.Index(s[valStart:], "\"")
if valEnd == -1 {
return nil, errors.New("malformed XML: attribute value not closed")
diff --git a/internal/storage/store_test.go b/internal/storage/store_test.go
index 6f7a594..bf9a08f 100644
--- a/internal/storage/store_test.go
+++ b/internal/storage/store_test.go
@@ -35,7 +35,7 @@ func TestListEmpty(t *testing.T) {
func TestListAfterCreate(t *testing.T) {
s := newStore(t)
- if _, err := s.Create([]byte(sampleXML)); err != nil {
+ if _, err := s.Create([]byte(sampleXML), ""); err != nil {
t.Fatalf("Create: %v", err)
}
list, err := s.List()
@@ -59,7 +59,7 @@ func TestListAfterCreate(t *testing.T) {
func TestCreateAndGet(t *testing.T) {
s := newStore(t)
- id, err := s.Create([]byte(sampleXML))
+ id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
@@ -78,7 +78,7 @@ func TestCreateAndGet(t *testing.T) {
func TestCreateUsesEmbeddedID(t *testing.T) {
s := newStore(t)
- id, err := s.Create([]byte(sampleXML))
+ id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
@@ -90,11 +90,11 @@ func TestCreateUsesEmbeddedID(t *testing.T) {
func TestCreateDuplicateGetsUniqueID(t *testing.T) {
s := newStore(t)
- id1, err := s.Create([]byte(sampleXML))
+ id1, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("first Create: %v", err)
}
- id2, err := s.Create([]byte(sampleXML))
+ id2, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("second Create: %v", err)
}
@@ -105,7 +105,7 @@ func TestCreateDuplicateGetsUniqueID(t *testing.T) {
func TestCreateInvalidXML(t *testing.T) {
s := newStore(t)
- _, err := s.Create([]byte("not xml"))
+ _, err := s.Create([]byte("not xml"), "")
if err == nil {
t.Error("expected error for invalid XML")
}
@@ -145,13 +145,13 @@ func TestGetEmptyID(t *testing.T) {
func TestUpdate(t *testing.T) {
s := newStore(t)
- id, err := s.Create([]byte(sampleXML))
+ id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
updated := ``
- if err := s.Update(id, []byte(updated)); err != nil {
+ if err := s.Update(id, []byte(updated), ""); err != nil {
t.Fatalf("Update: %v", err)
}
@@ -166,7 +166,7 @@ func TestUpdate(t *testing.T) {
func TestUpdateNotFound(t *testing.T) {
s := newStore(t)
- err := s.Update("no-such-id", []byte(sampleXML))
+ err := s.Update("no-such-id", []byte(sampleXML), "")
if !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Update missing: want ErrNotFound, got %v", err)
}
@@ -178,7 +178,7 @@ func TestUpdateNotFound(t *testing.T) {
func TestDelete(t *testing.T) {
s := newStore(t)
- id, err := s.Create([]byte(sampleXML))
+ id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
@@ -205,7 +205,7 @@ func TestDeleteNotFound(t *testing.T) {
func TestClone(t *testing.T) {
s := newStore(t)
- id, err := s.Create([]byte(sampleXML))
+ id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
diff --git a/uopi.example.toml b/uopi.example.toml
index 5e03ea8..64a31bb 100644
--- a/uopi.example.toml
+++ b/uopi.example.toml
@@ -2,6 +2,27 @@
listen = ":8080"
storage_dir = "./interfaces"
+# ── Identity & access control ───────────────────────────────────────────────
+# The end-user identity is read from a header set by a trusted authenticating
+# reverse proxy. Leave trusted_user_header empty for unproxied/dev/LAN use.
+# SECURITY: only enable this when the proxy strips any client-supplied value of
+# this header, otherwise it can be spoofed.
+trusted_user_header = "" # e.g. "X-Forwarded-User"
+# Identity used when the trusted header is absent/empty. Empty = anonymous.
+default_user = ""
+
+# Every user is trusted with full write access by default. The blacklist
+# downgrades specific users. Levels: "readonly" (view only, no writes) or
+# "noaccess" (denied entirely).
+# [[server.blacklist]]
+# user = "guest"
+# level = "readonly"
+
+# Named sets of users, referenced by per-panel sharing rules (future phases).
+# [[groups]]
+# name = "operators"
+# members = ["alice", "bob"]
+
[datasource.epics]
enabled = true
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 583993b..b76e0fc 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -5,7 +5,8 @@ import ViewMode from './ViewMode';
import EditMode from './EditMode';
import FullscreenMode from './FullscreenMode';
import { applyZoom, getStoredZoom } from './ZoomControl';
-import type { Interface } from './lib/types';
+import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
+import type { Interface, Me } from './lib/types';
type AppMode = 'view' | 'edit';
@@ -18,6 +19,8 @@ export default function App() {
const [editTarget, setEditTarget] = useState(null);
// Interface to show when returning to view mode after editing
const [viewTarget, setViewTarget] = useState(null);
+ // Resolved identity + global access level for the current user.
+ const [me, setMe] = useState(DEFAULT_ME);
useEffect(() => {
const unsub = wsClient.status.subscribe(setWsStatus);
@@ -29,6 +32,7 @@ export default function App() {
document.documentElement.style.setProperty('--dpr', String(dpr));
applyZoom(getStoredZoom());
if (!fsParam) wsClient.connect('/ws');
+ fetchMe().then(setMe);
}, []);
if (fsParam) {
@@ -40,25 +44,40 @@ export default function App() {
setMode('edit');
}
- function exitEdit(iface: Interface) {
- setViewTarget(iface);
+ // `iface === null` means the editor discarded an unsaved/new panel; keep
+ // showing whatever was previously open (held in viewTarget).
+ function exitEdit(iface: Interface | null) {
+ if (iface) setViewTarget(iface);
setMode('view');
setEditTarget(null);
}
- return (
-
- {wsStatus === 'disconnected' && (
-
WebSocket disconnected — reconnecting…
- )}
- {/* ViewMode is always mounted so PlotPanel ring-buffers are never destroyed.
- EditMode is conditionally rendered; it resets from props each time anyway. */}
-
-
+ if (!canRead(me.level)) {
+ return (
+
+
+
Access denied
+
+ Your account{me.user ? ` (${me.user})` : ''} does not have permission
+ to access this application. Contact an administrator.
+
+
- {mode === 'edit' && (
-
- )}
-
+ );
+ }
+
+ return (
+
+
+ {wsStatus === 'disconnected' && (
+
WebSocket disconnected — reconnecting…
+ )}
+ {mode === 'view' ? (
+
+ ) : (
+
+ )}
+
+
);
}
diff --git a/web/src/Canvas.tsx b/web/src/Canvas.tsx
index 3baae30..892a561 100644
--- a/web/src/Canvas.tsx
+++ b/web/src/Canvas.tsx
@@ -1,5 +1,7 @@
import { h } from 'preact';
-import { useState } from 'preact/hooks';
+import { useState, useEffect } from 'preact/hooks';
+import { initLocalState } from './lib/localstate';
+import { logicEngine } from './lib/logic';
import type { Interface, Widget, SignalRef } from './lib/types';
import TextView from './widgets/TextView';
import TextLabel from './widgets/TextLabel';
@@ -15,6 +17,7 @@ import ImageWidget from './widgets/ImageWidget';
import LinkWidget from './widgets/LinkWidget';
import ContextMenu from './ContextMenu';
import InfoPanel from './InfoPanel';
+import SplitLayout from './SplitLayout';
const COMPONENTS: Record = {
textview: TextView,
@@ -42,15 +45,25 @@ interface Props {
iface: Interface | null;
onNavigate?: (interfaceId: string) => void;
timeRange?: { start: string; end: string } | null;
- onPlot?: (signal: SignalRef) => void;
}
-export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props) {
+export default function Canvas({ iface, onNavigate, timeRange }: Props) {
const [ctxMenu, setCtxMenu] = useState({
visible: false, x: 0, y: 0, signal: null,
});
const [infoSignal, setInfoSignal] = useState(null);
+ // Instantiate this panel's local state variables from their initial values.
+ useEffect(() => {
+ initLocalState(iface?.statevars);
+ }, [iface?.id, iface?.statevars]);
+
+ // Activate panel logic for the live view; tear it down on unmount/panel switch.
+ useEffect(() => {
+ logicEngine.load(iface?.logic);
+ return () => logicEngine.clear();
+ }, [iface?.id, iface?.logic]);
+
function onCtxMenu(e: MouseEvent, widget: Widget) {
e.preventDefault();
e.stopPropagation();
@@ -65,10 +78,6 @@ export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props)
if (ctxMenu.signal) setInfoSignal(ctxMenu.signal);
}
- function handlePlot() {
- if (ctxMenu.signal && onPlot) onPlot(ctxMenu.signal);
- }
-
if (!iface) {
return (
@@ -79,6 +88,41 @@ export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props)
);
}
+ // Plot panel: plots fill the viewport arranged in a recursive split layout.
+ if (iface.kind === 'plot' && iface.layout) {
+ const byId = new Map(iface.widgets.map(w => [w.id, w]));
+ return (
+