// Package api implements the REST API handlers for uopi. 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" ) // 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 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, 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, } } // 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) 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) // 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) } // ── /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 { 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") 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) 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 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) { 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") 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) }) } 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"` Perm string `json:"perm"` } 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()} 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) { body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20)) if err != nil { jsonError(w, http.StatusBadRequest, "read body: "+err.Error()) 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) } } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) } 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 } 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 } 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) } 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) } // 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) } // ── Helpers ─────────────────────────────────────────────────────────────────── 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}) }