Major changes: logic add to panel, local variables, panel histor, users management...
This commit is contained in:
+559
-13
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user