Initial commit: PilotVault multi-service project
Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App (Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment configs. Design assets and build artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// adminClient authenticates to PocketBase as a superuser service account and is
|
||||
// used only for admin user-management (list/create/delete users). It caches the
|
||||
// superuser token and transparently re-authenticates when PocketBase rejects it.
|
||||
//
|
||||
// This is the one place the server holds elevated PocketBase credentials; every
|
||||
// admin endpoint that uses it first verifies the *caller* is an app admin.
|
||||
type adminClient struct {
|
||||
baseURL string
|
||||
email string
|
||||
password string
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
}
|
||||
|
||||
func newAdminClient(baseURL, email, password string) *adminClient {
|
||||
return &adminClient{
|
||||
baseURL: baseURL,
|
||||
email: email,
|
||||
password: password,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *adminClient) configured() bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
_, email, password := a.creds()
|
||||
return email != "" && password != ""
|
||||
}
|
||||
|
||||
// creds snapshots the current base URL + service-account credentials under lock,
|
||||
// so a concurrent reconfigure() can't tear them mid-request.
|
||||
func (a *adminClient) creds() (baseURL, email, password string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.baseURL, a.email, a.password
|
||||
}
|
||||
|
||||
// reconfigure retargets the service account at a new PocketBase and/or new
|
||||
// credentials, invalidating any cached superuser token.
|
||||
func (a *adminClient) reconfigure(baseURL, email, password string) {
|
||||
a.mu.Lock()
|
||||
a.baseURL = baseURL
|
||||
a.email = email
|
||||
a.password = password
|
||||
a.token = "" // force re-auth against the new target
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *adminClient) authenticate(ctx context.Context) (string, error) {
|
||||
baseURL, email, password := a.creds()
|
||||
tok, _, err := superuserAuth(ctx, a.client, baseURL, email, password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.token = tok
|
||||
a.mu.Unlock()
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// superuserAuth performs a PocketBase superuser auth-with-password and returns
|
||||
// the token and HTTP status. Shared by the live client and the settings
|
||||
// connection-test so both classify failures identically.
|
||||
func superuserAuth(ctx context.Context, client *http.Client, baseURL, email, password string) (string, int, error) {
|
||||
body, _ := json.Marshal(map[string]string{"identity": email, "password": password})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
baseURL+"/api/collections/_superusers/auth-with-password", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", resp.StatusCode, errors.New("superuser auth failed: " + string(data))
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil || out.Token == "" {
|
||||
return "", resp.StatusCode, errors.New("superuser auth: no token")
|
||||
}
|
||||
return out.Token, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (a *adminClient) cachedToken() string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.token
|
||||
}
|
||||
|
||||
// do performs an admin request, (re)authenticating as needed. It returns the
|
||||
// upstream response body and status. On a 401 it re-authenticates once and
|
||||
// retries, so an expired cached token is self-healing.
|
||||
func (a *adminClient) do(ctx context.Context, method, path string, payload any) ([]byte, int, error) {
|
||||
token := a.cachedToken()
|
||||
if token == "" {
|
||||
var err error
|
||||
if token, err = a.authenticate(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
baseURL, _, _ := a.creds()
|
||||
send := func(tok string) ([]byte, int, error) {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
b, _ := json.Marshal(payload)
|
||||
body = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, method, baseURL+path, body)
|
||||
req.Header.Set("Authorization", tok)
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
data, status, err := send(token)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if status == http.StatusUnauthorized {
|
||||
if token, err = a.authenticate(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return send(token)
|
||||
}
|
||||
return data, status, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// authProxy forwards login / token-validation to the PocketBase kept behind the
|
||||
// API Server. PocketBase's address lives only here — it is never exposed to or
|
||||
// configurable by clients. The base URL is guarded by a mutex so it can be
|
||||
// retargeted at runtime from the panel's PocketBase settings.
|
||||
type authProxy struct {
|
||||
mu sync.RWMutex
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newAuthProxy(baseURL string) *authProxy {
|
||||
return &authProxy{
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// url returns the current PocketBase base URL.
|
||||
func (a *authProxy) url() string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.baseURL
|
||||
}
|
||||
|
||||
// setBaseURL retargets the proxy at a new PocketBase address.
|
||||
func (a *authProxy) setBaseURL(u string) {
|
||||
a.mu.Lock()
|
||||
a.baseURL = u
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// POST /api/auth/login
|
||||
// Body: {"email"|"identity":"...","password":"..."}
|
||||
// Proxies to PocketBase users auth-with-password and returns its response verbatim.
|
||||
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Identity string `json:"identity"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
identity := body.Identity
|
||||
if identity == "" {
|
||||
identity = body.Email
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(map[string]string{"identity": identity, "password": body.Password})
|
||||
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost,
|
||||
s.auth.url()+"/api/collections/users/auth-with-password", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.auth.client.Do(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
relay(w, resp)
|
||||
}
|
||||
|
||||
// GET /api/auth/validate (Authorization: <pb token>)
|
||||
// Proxies to PocketBase auth-refresh to confirm a token is still valid.
|
||||
func (s *Server) handleAuthValidate(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"valid": false})
|
||||
return
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost,
|
||||
s.auth.url()+"/api/collections/users/auth-refresh", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
|
||||
resp, err := s.auth.client.Do(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"valid": false, "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
relay(w, resp)
|
||||
}
|
||||
|
||||
// relay copies an upstream PocketBase response (status + JSON body) to the client.
|
||||
func relay(w http.ResponseWriter, resp *http.Response) {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// GET /api/devices — list all known device states.
|
||||
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.hub.Snapshot())
|
||||
}
|
||||
|
||||
// GET /api/devices/{id}/track — GPS track for the map trail.
|
||||
func (s *Server) handleTrack(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.hub.Track(r.PathValue("id")))
|
||||
}
|
||||
|
||||
// POST /api/devices/{id}/command — push a command down to a device.
|
||||
// Body: {"command":"...","payload":{...}}
|
||||
func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
Command string `json:"command"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Command == "" {
|
||||
writeError(w, http.StatusBadRequest, "command required")
|
||||
return
|
||||
}
|
||||
if !s.hub.SendCommand(id, body.Command, body.Payload) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "device not connected", "deviceId": id})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sent": true, "deviceId": id, "command": body.Command})
|
||||
}
|
||||
|
||||
// DELETE /api/devices/{id} — forget a device's stored state (clears stale entries).
|
||||
func (s *Server) handleForget(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
existed := s.hub.Forget(id)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"removed": existed, "deviceId": id})
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
@@ -0,0 +1,7 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="11" fill="#0F1E3D" />
|
||||
<g stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<polyline points="10,30 21,17 32,30" stroke="#3D7BF0" />
|
||||
<polyline points="16,33 27,20 38,33" stroke="#F4F7FC" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 371 B |
+15
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0F1E3D" />
|
||||
<title>PilotVault · API Server</title>
|
||||
<script type="module" crossorigin src="/assets/index-DKDpmK_V.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BwP7TTth.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
// handleHealth reports server readiness plus device counts. "devices" is the
|
||||
// number of devices connected right now; "known" also includes offline devices
|
||||
// whose last-known state is still cached.
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"service": "pilotvault-api",
|
||||
"devices": s.hub.OnlineCount(),
|
||||
"known": len(s.hub.Snapshot()),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Integrations exposes the OpenSky plugin's settings to end users under a
|
||||
// three-layer cascade (superadmin/global → organization → user). Each of the
|
||||
// four settings resolves independently, top wins, and a blank field falls
|
||||
// through to the layer below:
|
||||
//
|
||||
// - global (L1): the plugin's config in plugins.json, set in the API Server panel.
|
||||
// - org (L2): pluginSettings on the caller's organization record (org admins).
|
||||
// - user (L3): pluginSettings on the caller's own user record.
|
||||
//
|
||||
// The OAuth2 client id + secret resolve as a *pair* from the highest layer that
|
||||
// supplies a client id, so credential halves are never mixed across layers.
|
||||
// Enablement is strictly per-user (L3) and gated by the global master switch.
|
||||
//
|
||||
// Secrets (and inherited client ids) are never returned to a lower-privileged
|
||||
// client: the effective config is resolved server-side and only masked values
|
||||
// leave the API. Live probes run server-side against the resolved config.
|
||||
|
||||
const (
|
||||
openSkyPlugin = "opensky"
|
||||
openSkySecretMask = "••••••••"
|
||||
)
|
||||
|
||||
// osConfig is one layer's OpenSky settings.
|
||||
type osConfig struct {
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
Plan string `json:"plan"`
|
||||
Bbox string `json:"bbox"`
|
||||
}
|
||||
|
||||
// osStored is what we persist per user/org under pluginSettings.opensky.
|
||||
type osStored struct {
|
||||
Config osConfig `json:"config"`
|
||||
// Enabled is the personal per-user opt-in (user layer). Default false.
|
||||
Enabled bool `json:"enabled"`
|
||||
// Disabled is the organization layer's off switch, stored inverted so that
|
||||
// absent == enabled: existing org records (which carry a legacy enabled:false
|
||||
// from earlier config saves) therefore read as enabled, avoiding a regression.
|
||||
// Only meaningful on the org record; ignored on user records.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// osSettingsDoc is the pluginSettings JSON shape (only opensky today).
|
||||
type osSettingsDoc struct {
|
||||
OpenSky osStored `json:"opensky"`
|
||||
}
|
||||
|
||||
// osFieldView is one field's resolved state for the UI.
|
||||
type osFieldView struct {
|
||||
Effective string `json:"effective"` // resolved value in force (secrets/inherited creds masked)
|
||||
Own string `json:"own"` // the caller's own editable-layer value (secret masked)
|
||||
Source string `json:"source"` // global | org | user | unset
|
||||
Locked bool `json:"locked"` // set above the caller's editable layer
|
||||
}
|
||||
|
||||
// osResolution is the fully-resolved OpenSky state for one caller. It captures the
|
||||
// effective cascade plus both editable layers (personal + organization), so an org
|
||||
// admin can manage each independently — their own settings as a user, and the
|
||||
// organization-wide settings that override every user's.
|
||||
type osResolution struct {
|
||||
eff osConfig // effective (unmasked) — used only server-side (probes)
|
||||
userOwn osConfig // caller's personal (L3) values (unmasked)
|
||||
orgOwn osConfig // organization (L2) values (unmasked)
|
||||
source map[string]string // field -> layer name (global|org|user|unset)
|
||||
isSuper bool // superadmin: manages the global layer in the panel
|
||||
canOrg bool // caller may edit the organization layer (org admin)
|
||||
available bool // global master switch
|
||||
orgEnabled bool // org master switch (default true; gates the org's users)
|
||||
allowAnon bool // global anonymous policy
|
||||
enabled bool // caller's personal enable flag
|
||||
}
|
||||
|
||||
// resolveOpenSky computes the cascade for a caller. userRaw is the caller's
|
||||
// pluginSettings blob (from their auth-refresh record).
|
||||
func (s *Server) resolveOpenSky(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) osResolution {
|
||||
g, masterEnabled, _ := s.plugins.RawConfig(openSkyPlugin)
|
||||
gc := osConfig{ClientID: g["clientId"], ClientSecret: g["clientSecret"], Plan: g["plan"], Bbox: g["bbox"]}
|
||||
allowAnon := !strings.EqualFold(strings.TrimSpace(g["allowAnonymous"]), "false")
|
||||
|
||||
var oStored osStored
|
||||
if who.OrgID != "" {
|
||||
oStored, _ = s.orgOpenSky(ctx, who.OrgID)
|
||||
}
|
||||
oc := oStored.Config
|
||||
|
||||
var uStored osStored
|
||||
if len(userRaw) > 0 {
|
||||
var d osSettingsDoc
|
||||
_ = json.Unmarshal(userRaw, &d)
|
||||
uStored = d.OpenSky
|
||||
}
|
||||
uc := uStored.Config
|
||||
|
||||
res := osResolution{
|
||||
source: map[string]string{},
|
||||
userOwn: uc,
|
||||
orgOwn: oc,
|
||||
isSuper: who.isSuperadmin(),
|
||||
// An org admin may edit the organization layer in addition to their own
|
||||
// personal layer. Requires the service account (org writes go through it);
|
||||
// without it the org layer is invisible to the cascade anyway.
|
||||
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(),
|
||||
available: masterEnabled,
|
||||
// Org gate: enabled by default, off only when the org explicitly disabled it.
|
||||
orgEnabled: !oStored.Disabled,
|
||||
allowAnon: allowAnon,
|
||||
enabled: uStored.Enabled,
|
||||
}
|
||||
|
||||
// Ordered layers, top (highest priority) first.
|
||||
type layer struct {
|
||||
name string
|
||||
c osConfig
|
||||
}
|
||||
layers := []layer{{"global", gc}}
|
||||
if who.OrgID != "" {
|
||||
layers = append(layers, layer{"org", oc})
|
||||
}
|
||||
layers = append(layers, layer{"user", uc})
|
||||
|
||||
pick := func(get func(osConfig) string) (val, src string) {
|
||||
for _, l := range layers {
|
||||
if v := strings.TrimSpace(get(l.c)); v != "" {
|
||||
return v, l.name
|
||||
}
|
||||
}
|
||||
return "", "unset"
|
||||
}
|
||||
res.eff.Plan, res.source["plan"] = pick(func(c osConfig) string { return c.Plan })
|
||||
res.eff.Bbox, res.source["bbox"] = pick(func(c osConfig) string { return c.Bbox })
|
||||
|
||||
// Credentials resolve as a pair from the highest layer with a client id.
|
||||
credSrc := "unset"
|
||||
for _, l := range layers {
|
||||
if id := strings.TrimSpace(l.c.ClientID); id != "" {
|
||||
res.eff.ClientID, res.eff.ClientSecret, credSrc = id, l.c.ClientSecret, l.name
|
||||
break
|
||||
}
|
||||
}
|
||||
res.source["clientId"] = credSrc
|
||||
res.source["clientSecret"] = credSrc
|
||||
return res
|
||||
}
|
||||
|
||||
// layerRank orders the cascade layers; a higher number is lower priority.
|
||||
var layerRank = map[string]int{"global": 1, "org": 2, "user": 3}
|
||||
|
||||
// lockedFor reports whether a field whose value comes from source is locked for a
|
||||
// caller whose editable layer is editable (i.e. the value is set above them).
|
||||
func lockedFor(source, editable string) bool {
|
||||
if editable == "none" {
|
||||
return true // superadmin edits the global layer in the panel, not here
|
||||
}
|
||||
sr, ok := layerRank[source]
|
||||
if !ok {
|
||||
return false // unset — the caller may be the first to set it
|
||||
}
|
||||
return sr < layerRank[editable]
|
||||
}
|
||||
|
||||
// maskPresent returns the secret mask when v is non-empty, else "".
|
||||
func maskPresent(v string) string {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return openSkySecretMask
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// orgOpenSky reads an organization's stored OpenSky settings (config + the org
|
||||
// gate) and its raw pluginSettings blob via the service account. Best effort: zero
|
||||
// values on any miss so callers can proceed as if the org layer were empty.
|
||||
func (s *Server) orgOpenSky(ctx context.Context, orgID string) (osStored, json.RawMessage) {
|
||||
if orgID == "" || !s.admin.configured() {
|
||||
return osStored{}, nil
|
||||
}
|
||||
data, status, err := s.admin.do(ctx, http.MethodGet,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return osStored{}, nil
|
||||
}
|
||||
var rec struct {
|
||||
PluginSettings json.RawMessage `json:"pluginSettings"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
var doc osSettingsDoc
|
||||
if len(rec.PluginSettings) > 0 {
|
||||
_ = json.Unmarshal(rec.PluginSettings, &doc)
|
||||
}
|
||||
return doc.OpenSky, rec.PluginSettings
|
||||
}
|
||||
|
||||
// mergeOpenSky applies a mutation to the opensky entry of a pluginSettings blob,
|
||||
// preserving any other plugin keys, and returns the new blob.
|
||||
func mergeOpenSky(existing json.RawMessage, apply func(*osStored)) json.RawMessage {
|
||||
doc := map[string]json.RawMessage{}
|
||||
if len(existing) > 0 {
|
||||
_ = json.Unmarshal(existing, &doc)
|
||||
}
|
||||
if doc == nil {
|
||||
doc = map[string]json.RawMessage{} // existing was JSON null
|
||||
}
|
||||
var os osStored
|
||||
if raw, ok := doc["opensky"]; ok {
|
||||
_ = json.Unmarshal(raw, &os)
|
||||
}
|
||||
apply(&os)
|
||||
b, _ := json.Marshal(os)
|
||||
doc["opensky"] = b
|
||||
out, _ := json.Marshal(doc)
|
||||
return out
|
||||
}
|
||||
|
||||
// callerFromRecord builds an identity from an auth-refresh record.
|
||||
func callerFromRecord(rec *pbAuthResp) *callerIdentity {
|
||||
role := unquote(rec.Record["role"])
|
||||
if role == "" {
|
||||
role = roleUser
|
||||
}
|
||||
return &callerIdentity{
|
||||
ID: unquote(rec.Record["id"]),
|
||||
Email: unquote(rec.Record["email"]),
|
||||
Role: role,
|
||||
OrgID: unquote(rec.Record["organization"]),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/integrations/opensky — resolved OpenSky view for the caller.
|
||||
func (s *Server) handleGetOpenSky(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing token")
|
||||
return
|
||||
}
|
||||
rec, status, err := s.pbAuthRefresh(r.Context(), token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK || rec == nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
who := callerFromRecord(rec)
|
||||
res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"])
|
||||
writeJSON(w, http.StatusOK, s.openSkyView(who, res))
|
||||
}
|
||||
|
||||
// osScopeView builds the masked field set for one editable scope. editable is the
|
||||
// layer the caller edits in this scope ("user" | "org" | "none"); a field is locked
|
||||
// when its effective value is set above that layer.
|
||||
func (s *Server) osScopeView(res osResolution, editable string) map[string]any {
|
||||
own := res.userOwn
|
||||
if editable == "org" {
|
||||
own = res.orgOwn
|
||||
}
|
||||
field := func(key, eff, ownv string, secret bool) osFieldView {
|
||||
src := res.source[key]
|
||||
locked := lockedFor(src, editable)
|
||||
fv := osFieldView{Source: src, Locked: locked}
|
||||
switch {
|
||||
case secret:
|
||||
// Never expose a secret; show only presence.
|
||||
fv.Effective, fv.Own = maskPresent(eff), maskPresent(ownv)
|
||||
case key == "clientId" && locked:
|
||||
// Inherited client id — hide the concrete value from a lower layer.
|
||||
fv.Effective, fv.Own = maskPresent(eff), maskPresent(ownv)
|
||||
default:
|
||||
fv.Effective, fv.Own = eff, ownv
|
||||
}
|
||||
return fv
|
||||
}
|
||||
return map[string]any{
|
||||
"editableLayer": editable,
|
||||
"fields": map[string]osFieldView{
|
||||
"clientId": field("clientId", res.eff.ClientID, own.ClientID, false),
|
||||
"clientSecret": field("clientSecret", res.eff.ClientSecret, own.ClientSecret, true),
|
||||
"plan": field("plan", res.eff.Plan, own.Plan, false),
|
||||
"bbox": field("bbox", res.eff.Bbox, own.Bbox, false),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// openSkyView builds the masked, client-safe response body from a resolution. It
|
||||
// exposes a "user" scope for everyone plus, for org admins, an "org" scope — each
|
||||
// with its own locked-field state — so the UI can present the two independently.
|
||||
func (s *Server) openSkyView(who *callerIdentity, res osResolution) map[string]any {
|
||||
out := map[string]any{
|
||||
"available": res.available,
|
||||
"orgEnabled": res.orgEnabled,
|
||||
"allowAnonymous": res.allowAnon,
|
||||
"enabled": res.enabled,
|
||||
"role": who.Role,
|
||||
"orgId": who.OrgID,
|
||||
"canEditOrg": res.canOrg,
|
||||
"isSuperadmin": res.isSuper,
|
||||
}
|
||||
if res.isSuper {
|
||||
// Superadmin manages the global layer in the panel; here it is read-only.
|
||||
out["editableLayer"] = "none"
|
||||
out["scopes"] = map[string]any{"user": s.osScopeView(res, "none")}
|
||||
return out
|
||||
}
|
||||
scopes := map[string]any{"user": s.osScopeView(res, "user")}
|
||||
if res.canOrg {
|
||||
scopes["org"] = s.osScopeView(res, "org")
|
||||
}
|
||||
out["scopes"] = scopes
|
||||
return out
|
||||
}
|
||||
|
||||
// PUT /api/integrations/opensky — save the caller's editable layer. Body:
|
||||
// {enabled?: bool, config?: {clientId, clientSecret, plan, bbox}}. Fields locked
|
||||
// above the caller are ignored; a client secret left at the mask is preserved.
|
||||
func (s *Server) handlePutOpenSky(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing token")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Scope string `json:"scope"` // "user" (default) | "org" (admins only)
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
|
||||
rec, status, err := s.pbAuthRefresh(r.Context(), token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK || rec == nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
who := callerFromRecord(rec)
|
||||
userRaw := rec.Record["pluginSettings"]
|
||||
res := s.resolveOpenSky(r.Context(), who, userRaw)
|
||||
|
||||
// Resolve which layer this write targets. Everyone edits their own personal
|
||||
// (user) layer by default; an org admin may target the organization layer by
|
||||
// asking for scope "org". Superadmins are read-only here (they manage global
|
||||
// in the panel) and may only toggle their personal enable flag.
|
||||
editable := "user"
|
||||
switch {
|
||||
case res.isSuper:
|
||||
editable = "none"
|
||||
case strings.EqualFold(strings.TrimSpace(body.Scope), "org"):
|
||||
if !res.canOrg {
|
||||
writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings")
|
||||
return
|
||||
}
|
||||
editable = "org"
|
||||
}
|
||||
|
||||
// Build the new target-layer config from its current own values, overlaying
|
||||
// only fields the caller is allowed to change in this scope.
|
||||
newOwn := res.userOwn
|
||||
if editable == "org" {
|
||||
newOwn = res.orgOwn
|
||||
}
|
||||
applyField := func(key string, set func(*osConfig, string)) {
|
||||
v, ok := body.Config[key]
|
||||
if !ok || lockedFor(res.source[key], editable) {
|
||||
return
|
||||
}
|
||||
if key == "clientSecret" && v == openSkySecretMask {
|
||||
return // keep current secret
|
||||
}
|
||||
set(&newOwn, strings.TrimSpace(v))
|
||||
}
|
||||
applyField("plan", func(c *osConfig, v string) { c.Plan = v })
|
||||
applyField("bbox", func(c *osConfig, v string) { c.Bbox = v })
|
||||
applyField("clientId", func(c *osConfig, v string) { c.ClientID = v })
|
||||
// Secret is not trimmed (may legitimately contain edge whitespace? no — trim
|
||||
// for consistency with the panel's Upsert, which TrimSpaces all values).
|
||||
applyField("clientSecret", func(c *osConfig, v string) { c.ClientSecret = v })
|
||||
|
||||
// Persist the organization layer (admins) via the service account.
|
||||
if editable == "org" {
|
||||
if who.OrgID == "" {
|
||||
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
|
||||
return
|
||||
}
|
||||
if !s.admin.configured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server")
|
||||
return
|
||||
}
|
||||
_, orgRaw := s.orgOpenSky(r.Context(), who.OrgID)
|
||||
newDoc := mergeOpenSky(orgRaw, func(os *osStored) {
|
||||
os.Config = newOwn
|
||||
// In the org scope the enable flag is the org master switch, stored
|
||||
// inverted (disabled) so absent means enabled.
|
||||
if body.Enabled != nil {
|
||||
os.Disabled = !*body.Enabled
|
||||
}
|
||||
})
|
||||
_, st, err := s.admin.do(r.Context(), http.MethodPatch,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(who.OrgID),
|
||||
map[string]json.RawMessage{"pluginSettings": newDoc})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if st != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not save organization settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the user record: the personal enable flag lives here (user/superadmin
|
||||
// scope — in the org scope it targets the org gate instead), and so does the
|
||||
// personal config layer when this write targets the user scope.
|
||||
personalEnable := body.Enabled != nil && editable != "org"
|
||||
if personalEnable || editable == "user" {
|
||||
newDoc := mergeOpenSky(userRaw, func(os *osStored) {
|
||||
if personalEnable {
|
||||
os.Enabled = *body.Enabled
|
||||
}
|
||||
if editable == "user" {
|
||||
os.Config = newOwn
|
||||
}
|
||||
})
|
||||
if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
} else if code != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not save user settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve and return the fresh view.
|
||||
fresh, st, err := s.pbAuthRefresh(r.Context(), token)
|
||||
if err != nil || st != http.StatusOK || fresh == nil {
|
||||
// The writes succeeded; just report success minimally.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
res2 := s.resolveOpenSky(r.Context(), who, fresh.Record["pluginSettings"])
|
||||
writeJSON(w, http.StatusOK, s.openSkyView(who, res2))
|
||||
}
|
||||
|
||||
// patchUserPluginSettings writes the pluginSettings blob onto the caller's own
|
||||
// user record using their token (PocketBase authorises self-writes).
|
||||
func (s *Server) patchUserPluginSettings(ctx context.Context, token, id string, doc json.RawMessage) (int, error) {
|
||||
if id == "" {
|
||||
return 0, io.EOF // treated as a transport-ish failure by the caller
|
||||
}
|
||||
patch, _ := json.Marshal(map[string]json.RawMessage{"pluginSettings": doc})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPatch,
|
||||
s.auth.url()+"/api/collections/users/records/"+url.PathEscape(id), bytes.NewReader(patch))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := s.auth.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// POST /api/integrations/opensky/health — live probe using the caller's resolved
|
||||
// config. Never returns secrets.
|
||||
func (s *Server) handleOpenSkyHealth(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing token")
|
||||
return
|
||||
}
|
||||
rec, status, err := s.pbAuthRefresh(r.Context(), token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK || rec == nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
who := callerFromRecord(rec)
|
||||
if !who.isSuperadmin() {
|
||||
if _, _, ok := s.plugins.RawConfig(openSkyPlugin); !ok {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
}
|
||||
res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"])
|
||||
if !res.available {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "OpenSky is disabled by the administrator"}})
|
||||
return
|
||||
}
|
||||
if !res.orgEnabled {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "OpenSky is disabled for your organization"}})
|
||||
return
|
||||
}
|
||||
cfg := map[string]string{
|
||||
"clientId": res.eff.ClientID,
|
||||
"clientSecret": res.eff.ClientSecret,
|
||||
"plan": res.eff.Plan,
|
||||
"bbox": res.eff.Bbox,
|
||||
"allowAnonymous": boolStr(res.allowAnon),
|
||||
}
|
||||
h, err := s.plugins.HealthCheckWith(r.Context(), openSkyPlugin, cfg)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": h})
|
||||
}
|
||||
|
||||
func boolStr(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file exposes the "filetransfer" (FTP/FTPS/SFTP) plugin's settings to end
|
||||
// users through the exact same three-layer cascade OpenSky uses
|
||||
// (superadmin/global → organization → user); see integrations.go for the shared
|
||||
// helpers (lockedFor, layerRank, maskPresent, callerFromRecord, boolStr).
|
||||
//
|
||||
// - global (L1): the plugin's config in plugins.json, set in the API Server panel.
|
||||
// - org (L2): pluginSettings.filetransfer on the caller's organization record.
|
||||
// - user (L3): pluginSettings.filetransfer on the caller's own user record.
|
||||
//
|
||||
// Unlike OpenSky's independently-resolved tunables, a file-server connection is
|
||||
// only meaningful as a whole: you cannot take the host from one layer and the
|
||||
// credentials from another. So every connection field (protocol, host, port,
|
||||
// username, password, private key + passphrase, host-key fingerprint, TLS
|
||||
// verification) resolves as a *group* from the highest layer that supplies a
|
||||
// host — mirroring how OpenSky resolves its client id + secret as a pair, just
|
||||
// widened to the whole connection. Only basePath cascades independently, so a
|
||||
// user can point at their own working directory on an org-provided server.
|
||||
//
|
||||
// Secrets are never returned to a lower-privileged client: the effective config
|
||||
// is resolved server-side and only masked values leave the API. Live probes run
|
||||
// server-side against the resolved config.
|
||||
|
||||
const fileTransferPlugin = "filetransfer"
|
||||
|
||||
// ftConfig is one layer's filetransfer settings. Values are strings to match the
|
||||
// plugin's ConfigField keys 1:1 (they are handed straight to plugins.Init).
|
||||
type ftConfig struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
PrivateKey string `json:"privateKey"`
|
||||
KeyPassphrase string `json:"keyPassphrase"`
|
||||
HostKeyFingerprint string `json:"hostKeyFingerprint"`
|
||||
InsecureSkipVerify string `json:"insecureSkipVerify"`
|
||||
BasePath string `json:"basePath"`
|
||||
}
|
||||
|
||||
// ftConnKeys are the fields that resolve together as one connection (everything
|
||||
// host-specific). basePath is deliberately excluded — it cascades on its own.
|
||||
var ftConnKeys = []string{
|
||||
"protocol", "host", "port", "username", "password",
|
||||
"privateKey", "keyPassphrase", "hostKeyFingerprint", "insecureSkipVerify",
|
||||
}
|
||||
|
||||
// ftSecretKeys are masked in every view and preserved on save when left at the mask.
|
||||
var ftSecretKeys = map[string]bool{"password": true, "privateKey": true, "keyPassphrase": true}
|
||||
|
||||
// ftStored is what we persist per user/org under pluginSettings.filetransfer.
|
||||
type ftStored struct {
|
||||
Config ftConfig `json:"config"`
|
||||
// Enabled is the personal per-user opt-in (user layer). Default false.
|
||||
Enabled bool `json:"enabled"`
|
||||
// Disabled is the organization layer's off switch, stored inverted so that
|
||||
// absent == enabled (mirrors OpenSky). Only meaningful on the org record.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// ftSettingsDoc is the filetransfer slice of the shared pluginSettings JSON.
|
||||
type ftSettingsDoc struct {
|
||||
FileTransfer ftStored `json:"filetransfer"`
|
||||
}
|
||||
|
||||
// ftResolution is the fully-resolved filetransfer state for one caller.
|
||||
type ftResolution struct {
|
||||
eff ftConfig // effective (unmasked) — used only server-side (probes)
|
||||
userOwn ftConfig // caller's personal (L3) values (unmasked)
|
||||
orgOwn ftConfig // organization (L2) values (unmasked)
|
||||
source map[string]string // field -> layer name (global|org|user|unset)
|
||||
isSuper bool // superadmin: manages the global layer in the panel
|
||||
canOrg bool // caller may edit the organization layer (org admin)
|
||||
available bool // global master switch (plugin enabled in the panel)
|
||||
orgEnabled bool // org master switch (default true; gates the org's users)
|
||||
enabled bool // caller's personal enable flag
|
||||
}
|
||||
|
||||
// ftConfigFromMap builds an ftConfig from a flat string map (global plugin config).
|
||||
func ftConfigFromMap(m map[string]string) ftConfig {
|
||||
return ftConfig{
|
||||
Protocol: m["protocol"],
|
||||
Host: m["host"],
|
||||
Port: m["port"],
|
||||
Username: m["username"],
|
||||
Password: m["password"],
|
||||
PrivateKey: m["privateKey"],
|
||||
KeyPassphrase: m["keyPassphrase"],
|
||||
HostKeyFingerprint: m["hostKeyFingerprint"],
|
||||
InsecureSkipVerify: m["insecureSkipVerify"],
|
||||
BasePath: m["basePath"],
|
||||
}
|
||||
}
|
||||
|
||||
// ftGet returns a config field by the plugin's key name.
|
||||
func ftGet(c ftConfig, key string) string {
|
||||
switch key {
|
||||
case "protocol":
|
||||
return c.Protocol
|
||||
case "host":
|
||||
return c.Host
|
||||
case "port":
|
||||
return c.Port
|
||||
case "username":
|
||||
return c.Username
|
||||
case "password":
|
||||
return c.Password
|
||||
case "privateKey":
|
||||
return c.PrivateKey
|
||||
case "keyPassphrase":
|
||||
return c.KeyPassphrase
|
||||
case "hostKeyFingerprint":
|
||||
return c.HostKeyFingerprint
|
||||
case "insecureSkipVerify":
|
||||
return c.InsecureSkipVerify
|
||||
case "basePath":
|
||||
return c.BasePath
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ftSet writes a config field by the plugin's key name.
|
||||
func ftSet(c *ftConfig, key, v string) {
|
||||
switch key {
|
||||
case "protocol":
|
||||
c.Protocol = v
|
||||
case "host":
|
||||
c.Host = v
|
||||
case "port":
|
||||
c.Port = v
|
||||
case "username":
|
||||
c.Username = v
|
||||
case "password":
|
||||
c.Password = v
|
||||
case "privateKey":
|
||||
c.PrivateKey = v
|
||||
case "keyPassphrase":
|
||||
c.KeyPassphrase = v
|
||||
case "hostKeyFingerprint":
|
||||
c.HostKeyFingerprint = v
|
||||
case "insecureSkipVerify":
|
||||
c.InsecureSkipVerify = v
|
||||
case "basePath":
|
||||
c.BasePath = v
|
||||
}
|
||||
}
|
||||
|
||||
// resolveFileTransfer computes the cascade for a caller. userRaw is the caller's
|
||||
// pluginSettings blob (from their auth-refresh record).
|
||||
func (s *Server) resolveFileTransfer(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) ftResolution {
|
||||
g, masterEnabled, _ := s.plugins.RawConfig(fileTransferPlugin)
|
||||
gc := ftConfigFromMap(g)
|
||||
|
||||
var oStored ftStored
|
||||
if who.OrgID != "" {
|
||||
oStored, _ = s.orgFileTransfer(ctx, who.OrgID)
|
||||
}
|
||||
oc := oStored.Config
|
||||
|
||||
var uStored ftStored
|
||||
if len(userRaw) > 0 {
|
||||
var d ftSettingsDoc
|
||||
_ = json.Unmarshal(userRaw, &d)
|
||||
uStored = d.FileTransfer
|
||||
}
|
||||
uc := uStored.Config
|
||||
|
||||
res := ftResolution{
|
||||
source: map[string]string{},
|
||||
userOwn: uc,
|
||||
orgOwn: oc,
|
||||
isSuper: who.isSuperadmin(),
|
||||
// An org admin may edit the organization layer in addition to their own.
|
||||
// Requires the service account (org writes go through it).
|
||||
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(),
|
||||
available: masterEnabled,
|
||||
orgEnabled: !oStored.Disabled,
|
||||
enabled: uStored.Enabled,
|
||||
}
|
||||
|
||||
// Ordered layers, top (highest priority) first.
|
||||
type layer struct {
|
||||
name string
|
||||
c ftConfig
|
||||
}
|
||||
layers := []layer{{"global", gc}}
|
||||
if who.OrgID != "" {
|
||||
layers = append(layers, layer{"org", oc})
|
||||
}
|
||||
layers = append(layers, layer{"user", uc})
|
||||
|
||||
// Connection group: the whole connection comes from the highest layer that
|
||||
// supplies a host, so credential halves are never mixed across layers.
|
||||
connSrc := "unset"
|
||||
for _, l := range layers {
|
||||
if strings.TrimSpace(l.c.Host) != "" {
|
||||
for _, k := range ftConnKeys {
|
||||
ftSet(&res.eff, k, ftGet(l.c, k))
|
||||
}
|
||||
connSrc = l.name
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, k := range ftConnKeys {
|
||||
res.source[k] = connSrc
|
||||
}
|
||||
|
||||
// basePath cascades independently, top wins, blanks fall through.
|
||||
baseSrc := "unset"
|
||||
for _, l := range layers {
|
||||
if v := strings.TrimSpace(l.c.BasePath); v != "" {
|
||||
res.eff.BasePath, baseSrc = v, l.name
|
||||
break
|
||||
}
|
||||
}
|
||||
res.source["basePath"] = baseSrc
|
||||
return res
|
||||
}
|
||||
|
||||
// orgFileTransfer reads an organization's stored filetransfer settings (config +
|
||||
// the org gate) and its raw pluginSettings blob via the service account. Best
|
||||
// effort: zero values on any miss so callers proceed as if the org layer were empty.
|
||||
func (s *Server) orgFileTransfer(ctx context.Context, orgID string) (ftStored, json.RawMessage) {
|
||||
if orgID == "" || !s.admin.configured() {
|
||||
return ftStored{}, nil
|
||||
}
|
||||
data, status, err := s.admin.do(ctx, http.MethodGet,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return ftStored{}, nil
|
||||
}
|
||||
var rec struct {
|
||||
PluginSettings json.RawMessage `json:"pluginSettings"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
var doc ftSettingsDoc
|
||||
if len(rec.PluginSettings) > 0 {
|
||||
_ = json.Unmarshal(rec.PluginSettings, &doc)
|
||||
}
|
||||
return doc.FileTransfer, rec.PluginSettings
|
||||
}
|
||||
|
||||
// mergeFileTransfer applies a mutation to the filetransfer entry of a
|
||||
// pluginSettings blob, preserving any other plugin keys (e.g. opensky), and
|
||||
// returns the new blob.
|
||||
func mergeFileTransfer(existing json.RawMessage, apply func(*ftStored)) json.RawMessage {
|
||||
doc := map[string]json.RawMessage{}
|
||||
if len(existing) > 0 {
|
||||
_ = json.Unmarshal(existing, &doc)
|
||||
}
|
||||
if doc == nil {
|
||||
doc = map[string]json.RawMessage{} // existing was JSON null
|
||||
}
|
||||
var ft ftStored
|
||||
if raw, ok := doc["filetransfer"]; ok {
|
||||
_ = json.Unmarshal(raw, &ft)
|
||||
}
|
||||
apply(&ft)
|
||||
b, _ := json.Marshal(ft)
|
||||
doc["filetransfer"] = b
|
||||
out, _ := json.Marshal(doc)
|
||||
return out
|
||||
}
|
||||
|
||||
// GET /api/integrations/filetransfer — resolved view for the caller.
|
||||
func (s *Server) handleGetFileTransfer(w http.ResponseWriter, r *http.Request) {
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := s.resolveFileTransfer(r.Context(), who, userRaw)
|
||||
writeJSON(w, http.StatusOK, s.fileTransferView(who, res))
|
||||
}
|
||||
|
||||
// ftScopeView builds the masked field set for one editable scope. editable is the
|
||||
// layer the caller edits ("user" | "org" | "none"); a field is locked when its
|
||||
// effective value is set above that layer.
|
||||
func (s *Server) ftScopeView(res ftResolution, editable string) map[string]any {
|
||||
own := res.userOwn
|
||||
if editable == "org" {
|
||||
own = res.orgOwn
|
||||
}
|
||||
fields := map[string]osFieldView{}
|
||||
for _, key := range append(append([]string{}, ftConnKeys...), "basePath") {
|
||||
src := res.source[key]
|
||||
fv := osFieldView{Source: src, Locked: lockedFor(src, editable)}
|
||||
if ftSecretKeys[key] {
|
||||
fv.Effective, fv.Own = maskPresent(ftGet(res.eff, key)), maskPresent(ftGet(own, key))
|
||||
} else {
|
||||
fv.Effective, fv.Own = ftGet(res.eff, key), ftGet(own, key)
|
||||
}
|
||||
fields[key] = fv
|
||||
}
|
||||
return map[string]any{"editableLayer": editable, "fields": fields}
|
||||
}
|
||||
|
||||
// fileTransferView builds the masked, client-safe response body. It exposes a
|
||||
// "user" scope for everyone plus, for org admins, an "org" scope.
|
||||
func (s *Server) fileTransferView(who *callerIdentity, res ftResolution) map[string]any {
|
||||
out := map[string]any{
|
||||
"available": res.available,
|
||||
"orgEnabled": res.orgEnabled,
|
||||
"enabled": res.enabled,
|
||||
"role": who.Role,
|
||||
"orgId": who.OrgID,
|
||||
"canEditOrg": res.canOrg,
|
||||
"isSuperadmin": res.isSuper,
|
||||
}
|
||||
if res.isSuper {
|
||||
out["editableLayer"] = "none"
|
||||
out["scopes"] = map[string]any{"user": s.ftScopeView(res, "none")}
|
||||
return out
|
||||
}
|
||||
scopes := map[string]any{"user": s.ftScopeView(res, "user")}
|
||||
if res.canOrg {
|
||||
scopes["org"] = s.ftScopeView(res, "org")
|
||||
}
|
||||
out["scopes"] = scopes
|
||||
return out
|
||||
}
|
||||
|
||||
// PUT /api/integrations/filetransfer — save the caller's editable layer. Body:
|
||||
// {enabled?, scope?, config?}. Fields locked above the caller are ignored; a
|
||||
// secret left at the mask is preserved.
|
||||
func (s *Server) handlePutFileTransfer(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Scope string `json:"scope"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := s.resolveFileTransfer(r.Context(), who, userRaw)
|
||||
|
||||
// Resolve which layer this write targets.
|
||||
editable := "user"
|
||||
switch {
|
||||
case res.isSuper:
|
||||
editable = "none"
|
||||
case strings.EqualFold(strings.TrimSpace(body.Scope), "org"):
|
||||
if !res.canOrg {
|
||||
writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings")
|
||||
return
|
||||
}
|
||||
editable = "org"
|
||||
}
|
||||
|
||||
// Overlay the fields the caller may change in this scope onto its own values.
|
||||
newOwn := res.userOwn
|
||||
if editable == "org" {
|
||||
newOwn = res.orgOwn
|
||||
}
|
||||
for _, key := range append(append([]string{}, ftConnKeys...), "basePath") {
|
||||
v, present := body.Config[key]
|
||||
if !present || lockedFor(res.source[key], editable) {
|
||||
continue
|
||||
}
|
||||
if ftSecretKeys[key] && v == openSkySecretMask {
|
||||
continue // keep current secret
|
||||
}
|
||||
ftSet(&newOwn, key, strings.TrimSpace(v))
|
||||
}
|
||||
|
||||
// Persist the organization layer (admins) via the service account.
|
||||
if editable == "org" {
|
||||
if who.OrgID == "" {
|
||||
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
|
||||
return
|
||||
}
|
||||
if !s.admin.configured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server")
|
||||
return
|
||||
}
|
||||
_, orgRaw := s.orgFileTransfer(r.Context(), who.OrgID)
|
||||
newDoc := mergeFileTransfer(orgRaw, func(ft *ftStored) {
|
||||
ft.Config = newOwn
|
||||
if body.Enabled != nil {
|
||||
ft.Disabled = !*body.Enabled // org master switch, stored inverted
|
||||
}
|
||||
})
|
||||
_, st, err := s.admin.do(r.Context(), http.MethodPatch,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(who.OrgID),
|
||||
map[string]json.RawMessage{"pluginSettings": newDoc})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if st != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not save organization settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the user record: the personal enable flag lives here (user/superadmin
|
||||
// scope), and so does the personal config layer when this write targets user.
|
||||
personalEnable := body.Enabled != nil && editable != "org"
|
||||
if personalEnable || editable == "user" {
|
||||
newDoc := mergeFileTransfer(userRaw, func(ft *ftStored) {
|
||||
if personalEnable {
|
||||
ft.Enabled = *body.Enabled
|
||||
}
|
||||
if editable == "user" {
|
||||
ft.Config = newOwn
|
||||
}
|
||||
})
|
||||
if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
} else if code != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not save user settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve and return the fresh view.
|
||||
fresh, st, err := s.pbAuthRefresh(r.Context(), token)
|
||||
if err != nil || st != http.StatusOK || fresh == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
res2 := s.resolveFileTransfer(r.Context(), who, fresh.Record["pluginSettings"])
|
||||
writeJSON(w, http.StatusOK, s.fileTransferView(who, res2))
|
||||
}
|
||||
|
||||
// POST /api/integrations/filetransfer/health — live probe using the caller's
|
||||
// resolved config. Never returns secrets.
|
||||
func (s *Server) handleFileTransferHealth(w http.ResponseWriter, r *http.Request) {
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !who.isSuperadmin() {
|
||||
if _, _, ok := s.plugins.RawConfig(fileTransferPlugin); !ok {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
}
|
||||
res := s.resolveFileTransfer(r.Context(), who, userRaw)
|
||||
if !res.available {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "File transfer is disabled by the administrator"}})
|
||||
return
|
||||
}
|
||||
if !res.orgEnabled {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "File transfer is disabled for your organization"}})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(res.eff.Host) == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "No server configured — set a host to connect"}})
|
||||
return
|
||||
}
|
||||
cfg := map[string]string{}
|
||||
for _, k := range append(append([]string{}, ftConnKeys...), "basePath") {
|
||||
cfg[k] = ftGet(res.eff, k)
|
||||
}
|
||||
h, err := s.plugins.HealthCheckWith(r.Context(), fileTransferPlugin, cfg)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": h})
|
||||
}
|
||||
|
||||
// integrationCaller authenticates the request via a PocketBase auth-refresh and
|
||||
// returns the caller identity plus their pluginSettings blob. It writes the error
|
||||
// response and returns ok=false on any failure. Shared by the filetransfer
|
||||
// integration endpoints (the OpenSky handlers predate it and inline the same steps).
|
||||
func (s *Server) integrationCaller(w http.ResponseWriter, r *http.Request) (*callerIdentity, json.RawMessage, bool) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing token")
|
||||
return nil, nil, false
|
||||
}
|
||||
rec, status, err := s.pbAuthRefresh(r.Context(), token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return nil, nil, false
|
||||
}
|
||||
if status != http.StatusOK || rec == nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return nil, nil, false
|
||||
}
|
||||
return callerFromRecord(rec), rec.Record["pluginSettings"], true
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file exposes the "localstorage" (host local filesystem) plugin's settings
|
||||
// to end users through the same three-layer cascade OpenSky and filetransfer use
|
||||
// (superadmin/global → organization → user); see integrations.go for the shared
|
||||
// helpers (lockedFor, layerRank, maskPresent, callerFromRecord, boolStr).
|
||||
//
|
||||
// - global (L1): the plugin's config in plugins.json, set in the API Server panel
|
||||
// (the storage root basePath + the master switch + a global read-only default).
|
||||
// - org (L2): pluginSettings.localstorage on the caller's organization record.
|
||||
// - user (L3): pluginSettings.localstorage on the caller's own user record.
|
||||
//
|
||||
// Unlike filetransfer, a local drive gives each tenant *isolated* folders rather
|
||||
// than a freely-chosen path. Folders are derived from identity, never taken from a
|
||||
// client, and laid out so that "private" is genuinely private:
|
||||
//
|
||||
// <root>/orgs/<orgId>/shared — the organization folder (all members)
|
||||
// <root>/orgs/<orgId>/private/<userId>— a member's private folder (opt-in)
|
||||
// <root>/users/<userId> — an org-less user's private folder
|
||||
//
|
||||
// A member on the shared folder is confined to .../shared and cannot traverse into
|
||||
// anyone's .../private subtree; each private folder is confined to its own
|
||||
// .../private/<userId>. So an org member can hold BOTH the shared org folder and a
|
||||
// private folder nested inside the org folder, reachable only by them. The plugin
|
||||
// confines every operation within the folder it is handed, so isolation is enforced
|
||||
// end-to-end.
|
||||
//
|
||||
// A member opts into their private folder personally (user layer); an org admin may
|
||||
// gate the feature for the whole organization (org layer, default allowed). The one
|
||||
// other cascading tunable is readOnly (blank falls through, top wins, a set value
|
||||
// locks lower layers).
|
||||
|
||||
const localStoragePlugin = "localstorage"
|
||||
|
||||
// lsConfig is one layer's editable localstorage settings. Folders are not here:
|
||||
// they are computed from identity, never stored or taken from a client.
|
||||
type lsConfig struct {
|
||||
ReadOnly string `json:"readOnly"` // "" (inherit) | "true" | "false"
|
||||
}
|
||||
|
||||
// lsStored is what we persist per user/org under pluginSettings.localstorage.
|
||||
type lsStored struct {
|
||||
Config lsConfig `json:"config"`
|
||||
// Enabled is the personal per-user opt-in (user layer). Default false.
|
||||
Enabled bool `json:"enabled"`
|
||||
// Disabled is the organization layer's off switch, stored inverted so that
|
||||
// absent == enabled (mirrors OpenSky). Only meaningful on the org record.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
// PrivateFolder is the user layer's opt-in for a private folder inside the org
|
||||
// folder. Only meaningful for a caller who belongs to an organization.
|
||||
PrivateFolder bool `json:"privateFolder,omitempty"`
|
||||
// DisallowPrivate is the org layer's gate on private folders, stored inverted so
|
||||
// that absent == allowed. Only meaningful on the org record.
|
||||
DisallowPrivate bool `json:"disallowPrivate,omitempty"`
|
||||
}
|
||||
|
||||
// lsSettingsDoc is the localstorage slice of the shared pluginSettings JSON.
|
||||
type lsSettingsDoc struct {
|
||||
LocalStorage lsStored `json:"localstorage"`
|
||||
}
|
||||
|
||||
// lsMount is one isolated folder a caller can reach.
|
||||
type lsMount struct {
|
||||
ID string `json:"id"` // "shared" | "private" | "personal"
|
||||
Label string `json:"label"` // human label for the UI
|
||||
Path string `json:"path"` // absolute folder path
|
||||
Kind string `json:"kind"` // "shared" | "private"
|
||||
}
|
||||
|
||||
// lsResolution is the fully-resolved localstorage state for one caller.
|
||||
type lsResolution struct {
|
||||
readOnly string // effective read-only ("" | "true" | "false")
|
||||
userReadOnly string // user (L3) read-only value
|
||||
orgReadOnly string // organization (L2) read-only value
|
||||
root string // global storage root
|
||||
mounts []lsMount // isolated folders in force for this caller
|
||||
source map[string]string // field -> layer name (global|org|user|unset)
|
||||
isSuper bool
|
||||
canOrg bool
|
||||
available bool // global master switch
|
||||
orgEnabled bool // org master switch (default true)
|
||||
enabled bool // personal opt-in to use the plugin
|
||||
isOrgUser bool // caller belongs to an organization
|
||||
allowPrivate bool // org policy: private folders permitted (default true)
|
||||
wantsPrivate bool // user's raw private-folder opt-in
|
||||
privateOn bool // effective: org user + allowed + opted in
|
||||
}
|
||||
|
||||
// Folder builders. Forward-slash joins (path, not filepath) since the target host
|
||||
// is Linux; the plugin re-resolves against the OS filesystem and confines within.
|
||||
func orgSharedFolder(root, orgID string) string {
|
||||
return path.Join(root, "orgs", orgID, "shared")
|
||||
}
|
||||
func orgPrivateFolder(root, orgID, userID string) string {
|
||||
return path.Join(root, "orgs", orgID, "private", userID)
|
||||
}
|
||||
func userPersonalFolder(root, userID string) string {
|
||||
return path.Join(root, "users", userID)
|
||||
}
|
||||
|
||||
// tenantMounts computes the isolated folders for a caller. An org member always
|
||||
// gets the shared org folder and, when privateOn, an additional private folder
|
||||
// nested inside the org folder; an org-less user gets a single private folder.
|
||||
func tenantMounts(root string, who *callerIdentity, privateOn bool) []lsMount {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return []lsMount{}
|
||||
}
|
||||
if who.OrgID != "" {
|
||||
mounts := []lsMount{{
|
||||
ID: "shared", Label: "Organization folder", Kind: "shared",
|
||||
Path: orgSharedFolder(root, who.OrgID),
|
||||
}}
|
||||
if privateOn && who.ID != "" {
|
||||
mounts = append(mounts, lsMount{
|
||||
ID: "private", Label: "Your private folder", Kind: "private",
|
||||
Path: orgPrivateFolder(root, who.OrgID, who.ID),
|
||||
})
|
||||
}
|
||||
return mounts
|
||||
}
|
||||
if who.ID != "" {
|
||||
return []lsMount{{
|
||||
ID: "personal", Label: "Your private folder", Kind: "private",
|
||||
Path: userPersonalFolder(root, who.ID),
|
||||
}}
|
||||
}
|
||||
return []lsMount{}
|
||||
}
|
||||
|
||||
// resolveLocalStorage computes the cascade for a caller. userRaw is the caller's
|
||||
// pluginSettings blob (from their auth-refresh record).
|
||||
func (s *Server) resolveLocalStorage(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) lsResolution {
|
||||
g, masterEnabled, _ := s.plugins.RawConfig(localStoragePlugin)
|
||||
root := strings.TrimSpace(g["basePath"])
|
||||
// The global panel's readOnly select defaults to a concrete "false"; treat that
|
||||
// as unset so the global default does not permanently lock lower layers. Only an
|
||||
// explicit global "true" freezes every folder.
|
||||
globalRO := strings.TrimSpace(g["readOnly"])
|
||||
if strings.EqualFold(globalRO, "false") {
|
||||
globalRO = ""
|
||||
}
|
||||
|
||||
var oStored lsStored
|
||||
if who.OrgID != "" {
|
||||
oStored, _ = s.orgLocalStorage(ctx, who.OrgID)
|
||||
}
|
||||
var uStored lsStored
|
||||
if len(userRaw) > 0 {
|
||||
var d lsSettingsDoc
|
||||
_ = json.Unmarshal(userRaw, &d)
|
||||
uStored = d.LocalStorage
|
||||
}
|
||||
|
||||
res := lsResolution{
|
||||
source: map[string]string{},
|
||||
userReadOnly: uStored.Config.ReadOnly,
|
||||
orgReadOnly: oStored.Config.ReadOnly,
|
||||
isSuper: who.isSuperadmin(),
|
||||
// An org admin may edit the organization layer in addition to their own.
|
||||
// Requires the service account (org writes go through it).
|
||||
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(),
|
||||
available: masterEnabled,
|
||||
orgEnabled: !oStored.Disabled,
|
||||
enabled: uStored.Enabled,
|
||||
root: root,
|
||||
isOrgUser: who.OrgID != "",
|
||||
allowPrivate: !oStored.DisallowPrivate, // default allowed
|
||||
wantsPrivate: uStored.PrivateFolder,
|
||||
}
|
||||
|
||||
// readOnly cascades top-wins, blanks fall through (same mechanism as OpenSky).
|
||||
type layer struct{ name, ro string }
|
||||
layers := []layer{{"global", globalRO}}
|
||||
if who.OrgID != "" {
|
||||
layers = append(layers, layer{"org", oStored.Config.ReadOnly})
|
||||
}
|
||||
layers = append(layers, layer{"user", uStored.Config.ReadOnly})
|
||||
roSrc := "unset"
|
||||
for _, l := range layers {
|
||||
if v := strings.TrimSpace(l.ro); v != "" {
|
||||
res.readOnly, roSrc = v, l.name
|
||||
break
|
||||
}
|
||||
}
|
||||
res.source["readOnly"] = roSrc
|
||||
|
||||
// Effective private-folder state and the isolated folders in force.
|
||||
res.privateOn = res.isOrgUser && res.allowPrivate && res.wantsPrivate
|
||||
res.mounts = tenantMounts(root, who, res.privateOn)
|
||||
return res
|
||||
}
|
||||
|
||||
// orgLocalStorage reads an organization's stored localstorage settings (config +
|
||||
// the org gate) and its raw pluginSettings blob via the service account. Best
|
||||
// effort: zero values on any miss so callers proceed as if the org layer were empty.
|
||||
func (s *Server) orgLocalStorage(ctx context.Context, orgID string) (lsStored, json.RawMessage) {
|
||||
if orgID == "" || !s.admin.configured() {
|
||||
return lsStored{}, nil
|
||||
}
|
||||
data, status, err := s.admin.do(ctx, http.MethodGet,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return lsStored{}, nil
|
||||
}
|
||||
var rec struct {
|
||||
PluginSettings json.RawMessage `json:"pluginSettings"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
var doc lsSettingsDoc
|
||||
if len(rec.PluginSettings) > 0 {
|
||||
_ = json.Unmarshal(rec.PluginSettings, &doc)
|
||||
}
|
||||
return doc.LocalStorage, rec.PluginSettings
|
||||
}
|
||||
|
||||
// mergeLocalStorage applies a mutation to the localstorage entry of a
|
||||
// pluginSettings blob, preserving any other plugin keys, and returns the new blob.
|
||||
func mergeLocalStorage(existing json.RawMessage, apply func(*lsStored)) json.RawMessage {
|
||||
doc := map[string]json.RawMessage{}
|
||||
if len(existing) > 0 {
|
||||
_ = json.Unmarshal(existing, &doc)
|
||||
}
|
||||
if doc == nil {
|
||||
doc = map[string]json.RawMessage{} // existing was JSON null
|
||||
}
|
||||
var ls lsStored
|
||||
if raw, ok := doc["localstorage"]; ok {
|
||||
_ = json.Unmarshal(raw, &ls)
|
||||
}
|
||||
apply(&ls)
|
||||
b, _ := json.Marshal(ls)
|
||||
doc["localstorage"] = b
|
||||
out, _ := json.Marshal(doc)
|
||||
return out
|
||||
}
|
||||
|
||||
// GET /api/integrations/localstorage — resolved view for the caller.
|
||||
func (s *Server) handleGetLocalStorage(w http.ResponseWriter, r *http.Request) {
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := s.resolveLocalStorage(r.Context(), who, userRaw)
|
||||
writeJSON(w, http.StatusOK, s.localStorageView(who, res))
|
||||
}
|
||||
|
||||
// lsScopeView builds the field set for one editable scope. editable is the layer
|
||||
// the caller edits ("user" | "org" | "none"); readOnly is locked when its effective
|
||||
// value is set above that layer.
|
||||
func (s *Server) lsScopeView(res lsResolution, editable string) map[string]any {
|
||||
own := res.userReadOnly
|
||||
if editable == "org" {
|
||||
own = res.orgReadOnly
|
||||
}
|
||||
src := res.source["readOnly"]
|
||||
field := osFieldView{
|
||||
Source: src,
|
||||
Locked: lockedFor(src, editable),
|
||||
Effective: res.readOnly,
|
||||
Own: own,
|
||||
}
|
||||
return map[string]any{
|
||||
"editableLayer": editable,
|
||||
"fields": map[string]osFieldView{"readOnly": field},
|
||||
}
|
||||
}
|
||||
|
||||
// localStorageView builds the client-safe response body. It exposes a "user" scope
|
||||
// for everyone plus, for org admins, an "org" scope. The effective isolated folders
|
||||
// are reported at the top level (derived, not editable).
|
||||
func (s *Server) localStorageView(who *callerIdentity, res lsResolution) map[string]any {
|
||||
out := map[string]any{
|
||||
"available": res.available,
|
||||
"orgEnabled": res.orgEnabled,
|
||||
"enabled": res.enabled,
|
||||
"role": who.Role,
|
||||
"orgId": who.OrgID,
|
||||
"canEditOrg": res.canOrg,
|
||||
"isSuperadmin": res.isSuper,
|
||||
"isOrgUser": res.isOrgUser,
|
||||
"rootConfigured": strings.TrimSpace(res.root) != "",
|
||||
"mounts": res.mounts,
|
||||
"privateFolder": res.wantsPrivate, // the user's own opt-in
|
||||
"privateEnabled": res.privateOn, // effective (may be gated off by the org)
|
||||
"allowPrivate": res.allowPrivate, // org policy
|
||||
}
|
||||
if res.isSuper {
|
||||
out["editableLayer"] = "none"
|
||||
out["scopes"] = map[string]any{"user": s.lsScopeView(res, "none")}
|
||||
return out
|
||||
}
|
||||
scopes := map[string]any{"user": s.lsScopeView(res, "user")}
|
||||
if res.canOrg {
|
||||
scopes["org"] = s.lsScopeView(res, "org")
|
||||
}
|
||||
out["scopes"] = scopes
|
||||
return out
|
||||
}
|
||||
|
||||
// PUT /api/integrations/localstorage — save the caller's editable layer. Body:
|
||||
// {enabled?, privateFolder?, allowPrivate?, scope?, config?}. Folders are derived,
|
||||
// so only readOnly, the enable flags, and the private-folder settings are writable.
|
||||
func (s *Server) handlePutLocalStorage(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
PrivateFolder *bool `json:"privateFolder"` // user: opt into a private folder
|
||||
AllowPrivate *bool `json:"allowPrivate"` // org: permit private folders
|
||||
Scope string `json:"scope"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := s.resolveLocalStorage(r.Context(), who, userRaw)
|
||||
|
||||
// Resolve which layer this write targets.
|
||||
editable := "user"
|
||||
switch {
|
||||
case res.isSuper:
|
||||
editable = "none"
|
||||
case strings.EqualFold(strings.TrimSpace(body.Scope), "org"):
|
||||
if !res.canOrg {
|
||||
writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings")
|
||||
return
|
||||
}
|
||||
editable = "org"
|
||||
}
|
||||
|
||||
// Overlay readOnly onto the target scope's own value, unless it is locked above.
|
||||
newRO := res.userReadOnly
|
||||
if editable == "org" {
|
||||
newRO = res.orgReadOnly
|
||||
}
|
||||
if v, present := body.Config["readOnly"]; present && !lockedFor(res.source["readOnly"], editable) {
|
||||
newRO = normalizeReadOnly(v)
|
||||
}
|
||||
|
||||
// Persist the organization layer (admins) via the service account.
|
||||
if editable == "org" {
|
||||
if who.OrgID == "" {
|
||||
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
|
||||
return
|
||||
}
|
||||
if !s.admin.configured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server")
|
||||
return
|
||||
}
|
||||
_, orgRaw := s.orgLocalStorage(r.Context(), who.OrgID)
|
||||
newDoc := mergeLocalStorage(orgRaw, func(ls *lsStored) {
|
||||
ls.Config.ReadOnly = newRO
|
||||
if body.Enabled != nil {
|
||||
ls.Disabled = !*body.Enabled // org master switch, stored inverted
|
||||
}
|
||||
if body.AllowPrivate != nil {
|
||||
ls.DisallowPrivate = !*body.AllowPrivate // stored inverted (absent = allowed)
|
||||
}
|
||||
})
|
||||
_, st, err := s.admin.do(r.Context(), http.MethodPatch,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(who.OrgID),
|
||||
map[string]json.RawMessage{"pluginSettings": newDoc})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if st != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not save organization settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the user record: the personal enable flag and private-folder opt-in
|
||||
// live here (user/superadmin scope), and so does the personal readOnly when this
|
||||
// write targets the user scope.
|
||||
personalEnable := body.Enabled != nil && editable != "org"
|
||||
personalPrivate := body.PrivateFolder != nil && editable != "org"
|
||||
if personalEnable || personalPrivate || editable == "user" {
|
||||
newDoc := mergeLocalStorage(userRaw, func(ls *lsStored) {
|
||||
if personalEnable {
|
||||
ls.Enabled = *body.Enabled
|
||||
}
|
||||
if personalPrivate {
|
||||
ls.PrivateFolder = *body.PrivateFolder
|
||||
}
|
||||
if editable == "user" {
|
||||
ls.Config.ReadOnly = newRO
|
||||
}
|
||||
})
|
||||
if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
} else if code != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not save user settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve and return the fresh view.
|
||||
fresh, st, err := s.pbAuthRefresh(r.Context(), token)
|
||||
if err != nil || st != http.StatusOK || fresh == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
res2 := s.resolveLocalStorage(r.Context(), who, fresh.Record["pluginSettings"])
|
||||
writeJSON(w, http.StatusOK, s.localStorageView(who, res2))
|
||||
}
|
||||
|
||||
// normalizeReadOnly coerces a submitted readOnly value to the stored vocabulary.
|
||||
func normalizeReadOnly(v string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "true":
|
||||
return "true"
|
||||
case "false":
|
||||
return "false"
|
||||
default:
|
||||
return "" // inherit
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/integrations/localstorage/health — live probe against every isolated
|
||||
// folder the caller holds (each auto-created), honouring the resolved read-only flag.
|
||||
func (s *Server) handleLocalStorageHealth(w http.ResponseWriter, r *http.Request) {
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !who.isSuperadmin() {
|
||||
if _, _, ok := s.plugins.RawConfig(localStoragePlugin); !ok {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
}
|
||||
res := s.resolveLocalStorage(r.Context(), who, userRaw)
|
||||
if !res.available {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "Local storage is disabled by the administrator"}})
|
||||
return
|
||||
}
|
||||
if !res.orgEnabled {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "Local storage is disabled for your organization"}})
|
||||
return
|
||||
}
|
||||
if len(res.mounts) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "No storage root configured by the administrator"}})
|
||||
return
|
||||
}
|
||||
|
||||
// Probe each folder; aggregate to the worst status for the summary badge and
|
||||
// return per-folder results so the UI can annotate each mount.
|
||||
perMount := make([]map[string]any, 0, len(res.mounts))
|
||||
worst := "ok"
|
||||
for _, m := range res.mounts {
|
||||
cfg := map[string]string{
|
||||
"basePath": m.Path,
|
||||
"createMissing": "true", // each folder is provisioned on demand
|
||||
"readOnly": res.readOnly,
|
||||
}
|
||||
h, err := s.plugins.HealthCheckWith(r.Context(), localStoragePlugin, cfg)
|
||||
status, detail := "down", ""
|
||||
if err != nil {
|
||||
detail = err.Error()
|
||||
} else {
|
||||
status, detail = h.Status, h.Detail
|
||||
}
|
||||
worst = worseStatus(worst, status)
|
||||
perMount = append(perMount, map[string]any{
|
||||
"id": m.ID, "label": m.Label, "path": m.Path, "status": status, "detail": detail,
|
||||
})
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("%d folder%s reachable", len(res.mounts), plural2(len(res.mounts)))
|
||||
if worst != "ok" {
|
||||
// Surface the first non-ok detail so the badge is actionable.
|
||||
for _, m := range perMount {
|
||||
if m["status"] != "ok" {
|
||||
summary = fmt.Sprintf("%s: %v", m["label"], m["detail"])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"health": map[string]any{"status": worst, "detail": summary},
|
||||
"mounts": perMount,
|
||||
})
|
||||
}
|
||||
|
||||
// worseStatus returns the more severe of two health statuses (ok < degraded < down).
|
||||
func worseStatus(a, b string) string {
|
||||
rank := map[string]int{"ok": 0, "degraded": 1, "down": 2}
|
||||
if rank[b] > rank[a] {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func plural2(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mountByID(mounts []lsMount, id string) (lsMount, bool) {
|
||||
for _, m := range mounts {
|
||||
if m.ID == id {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return lsMount{}, false
|
||||
}
|
||||
|
||||
func TestTenantMountsOrgUser(t *testing.T) {
|
||||
who := &callerIdentity{ID: "u1", OrgID: "org9"}
|
||||
|
||||
// Private off: only the shared org folder.
|
||||
off := tenantMounts("/data", who, false)
|
||||
if len(off) != 1 {
|
||||
t.Fatalf("private off: got %d mounts, want 1", len(off))
|
||||
}
|
||||
if off[0].ID != "shared" || off[0].Path != "/data/orgs/org9/shared" {
|
||||
t.Errorf("shared mount = %+v", off[0])
|
||||
}
|
||||
|
||||
// Private on: shared + a private folder nested inside the org folder.
|
||||
on := tenantMounts("/data", who, true)
|
||||
if len(on) != 2 {
|
||||
t.Fatalf("private on: got %d mounts, want 2", len(on))
|
||||
}
|
||||
priv, ok := mountByID(on, "private")
|
||||
if !ok || priv.Path != "/data/orgs/org9/private/u1" || priv.Kind != "private" {
|
||||
t.Errorf("private mount = %+v", priv)
|
||||
}
|
||||
// The private folder must sit under the org folder but NOT under the shared
|
||||
// subtree, so shared-folder members cannot traverse into it.
|
||||
shared, _ := mountByID(on, "shared")
|
||||
if !strings.HasPrefix(priv.Path, "/data/orgs/org9/") {
|
||||
t.Errorf("private folder %q is not inside the org folder", priv.Path)
|
||||
}
|
||||
if strings.HasPrefix(priv.Path, shared.Path+"/") {
|
||||
t.Errorf("private folder %q is reachable from the shared folder %q", priv.Path, shared.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantMountsOrgLessUser(t *testing.T) {
|
||||
m := tenantMounts("/data", &callerIdentity{ID: "solo"}, true)
|
||||
if len(m) != 1 || m[0].ID != "personal" || m[0].Path != "/data/users/solo" || m[0].Kind != "private" {
|
||||
t.Fatalf("org-less mounts = %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantMountsNoRoot(t *testing.T) {
|
||||
if m := tenantMounts("", &callerIdentity{ID: "u1", OrgID: "o"}, true); len(m) != 0 {
|
||||
t.Fatalf("no root: got %d mounts, want 0", len(m))
|
||||
}
|
||||
if m := tenantMounts(" ", &callerIdentity{ID: "u1"}, true); len(m) != 0 {
|
||||
t.Fatalf("blank root: got %d mounts, want 0", len(m))
|
||||
}
|
||||
}
|
||||
|
||||
// Two members' private folders must never collide (isolation).
|
||||
func TestPrivateFolderIsolation(t *testing.T) {
|
||||
a := orgPrivateFolder("/data", "org1", "alice")
|
||||
b := orgPrivateFolder("/data", "org1", "bob")
|
||||
if a == b {
|
||||
t.Fatalf("distinct members share a private folder: %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeReadOnly(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"true": "true", "TRUE": "true", " true ": "true",
|
||||
"false": "false", "False": "false",
|
||||
"": "", "inherit": "", "garbage": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeReadOnly(in); got != want {
|
||||
t.Errorf("normalizeReadOnly(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorseStatus(t *testing.T) {
|
||||
cases := []struct{ a, b, want string }{
|
||||
{"ok", "ok", "ok"},
|
||||
{"ok", "degraded", "degraded"},
|
||||
{"degraded", "down", "down"},
|
||||
{"down", "ok", "down"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := worseStatus(c.a, c.b); got != c.want {
|
||||
t.Errorf("worseStatus(%q,%q) = %q, want %q", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file exposes the "webdav" plugin's settings to end users through the exact
|
||||
// same three-layer cascade OpenSky uses (superadmin/global → organization →
|
||||
// user); see integrations.go for the shared helpers (lockedFor, layerRank,
|
||||
// maskPresent, callerFromRecord). It mirrors integrations_filetransfer.go — a
|
||||
// WebDAV endpoint is likewise a connection that is only meaningful as a whole.
|
||||
//
|
||||
// - global (L1): the plugin's config in plugins.json, set in the API Server panel.
|
||||
// - org (L2): pluginSettings.webdav on the caller's organization record.
|
||||
// - user (L3): pluginSettings.webdav on the caller's own user record.
|
||||
//
|
||||
// Every connection field (server URL, username, password, TLS verification)
|
||||
// resolves as a *group* from the highest layer that supplies a server URL, so
|
||||
// credential halves are never mixed across layers — exactly like filetransfer's
|
||||
// host-group and OpenSky's client id + secret pair. Only basePath cascades
|
||||
// independently, so a user can point at their own working directory on an
|
||||
// org-provided server.
|
||||
//
|
||||
// Secrets are never returned to a lower-privileged client: the effective config
|
||||
// is resolved server-side and only masked values leave the API. Live probes run
|
||||
// server-side against the resolved config.
|
||||
|
||||
const webDavPlugin = "webdav"
|
||||
|
||||
// wdConfig is one layer's webdav settings. Values are strings to match the
|
||||
// plugin's ConfigField keys 1:1 (they are handed straight to plugins.Init).
|
||||
type wdConfig struct {
|
||||
BaseURL string `json:"baseURL"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
InsecureSkipVerify string `json:"insecureSkipVerify"`
|
||||
BasePath string `json:"basePath"`
|
||||
}
|
||||
|
||||
// wdConnKeys are the fields that resolve together as one connection (everything
|
||||
// server-specific). basePath is deliberately excluded — it cascades on its own.
|
||||
var wdConnKeys = []string{"baseURL", "username", "password", "insecureSkipVerify"}
|
||||
|
||||
// wdSecretKeys are masked in every view and preserved on save when left at the mask.
|
||||
var wdSecretKeys = map[string]bool{"password": true}
|
||||
|
||||
// wdStored is what we persist per user/org under pluginSettings.webdav.
|
||||
type wdStored struct {
|
||||
Config wdConfig `json:"config"`
|
||||
// Enabled is the personal per-user opt-in (user layer). Default false.
|
||||
Enabled bool `json:"enabled"`
|
||||
// Disabled is the organization layer's off switch, stored inverted so that
|
||||
// absent == enabled (mirrors OpenSky). Only meaningful on the org record.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// wdSettingsDoc is the webdav slice of the shared pluginSettings JSON.
|
||||
type wdSettingsDoc struct {
|
||||
WebDav wdStored `json:"webdav"`
|
||||
}
|
||||
|
||||
// wdResolution is the fully-resolved webdav state for one caller.
|
||||
type wdResolution struct {
|
||||
eff wdConfig // effective (unmasked) — used only server-side (probes)
|
||||
userOwn wdConfig // caller's personal (L3) values (unmasked)
|
||||
orgOwn wdConfig // organization (L2) values (unmasked)
|
||||
source map[string]string // field -> layer name (global|org|user|unset)
|
||||
isSuper bool // superadmin: manages the global layer in the panel
|
||||
canOrg bool // caller may edit the organization layer (org admin)
|
||||
available bool // global master switch (plugin enabled in the panel)
|
||||
orgEnabled bool // org master switch (default true; gates the org's users)
|
||||
enabled bool // caller's personal enable flag
|
||||
}
|
||||
|
||||
// wdConfigFromMap builds a wdConfig from a flat string map (global plugin config).
|
||||
func wdConfigFromMap(m map[string]string) wdConfig {
|
||||
return wdConfig{
|
||||
BaseURL: m["baseURL"],
|
||||
Username: m["username"],
|
||||
Password: m["password"],
|
||||
InsecureSkipVerify: m["insecureSkipVerify"],
|
||||
BasePath: m["basePath"],
|
||||
}
|
||||
}
|
||||
|
||||
// wdGet returns a config field by the plugin's key name.
|
||||
func wdGet(c wdConfig, key string) string {
|
||||
switch key {
|
||||
case "baseURL":
|
||||
return c.BaseURL
|
||||
case "username":
|
||||
return c.Username
|
||||
case "password":
|
||||
return c.Password
|
||||
case "insecureSkipVerify":
|
||||
return c.InsecureSkipVerify
|
||||
case "basePath":
|
||||
return c.BasePath
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// wdSet writes a config field by the plugin's key name.
|
||||
func wdSet(c *wdConfig, key, v string) {
|
||||
switch key {
|
||||
case "baseURL":
|
||||
c.BaseURL = v
|
||||
case "username":
|
||||
c.Username = v
|
||||
case "password":
|
||||
c.Password = v
|
||||
case "insecureSkipVerify":
|
||||
c.InsecureSkipVerify = v
|
||||
case "basePath":
|
||||
c.BasePath = v
|
||||
}
|
||||
}
|
||||
|
||||
// resolveWebDav computes the cascade for a caller. userRaw is the caller's
|
||||
// pluginSettings blob (from their auth-refresh record).
|
||||
func (s *Server) resolveWebDav(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) wdResolution {
|
||||
g, masterEnabled, _ := s.plugins.RawConfig(webDavPlugin)
|
||||
gc := wdConfigFromMap(g)
|
||||
|
||||
var oStored wdStored
|
||||
if who.OrgID != "" {
|
||||
oStored, _ = s.orgWebDav(ctx, who.OrgID)
|
||||
}
|
||||
oc := oStored.Config
|
||||
|
||||
var uStored wdStored
|
||||
if len(userRaw) > 0 {
|
||||
var d wdSettingsDoc
|
||||
_ = json.Unmarshal(userRaw, &d)
|
||||
uStored = d.WebDav
|
||||
}
|
||||
uc := uStored.Config
|
||||
|
||||
res := wdResolution{
|
||||
source: map[string]string{},
|
||||
userOwn: uc,
|
||||
orgOwn: oc,
|
||||
isSuper: who.isSuperadmin(),
|
||||
// An org admin may edit the organization layer in addition to their own.
|
||||
// Requires the service account (org writes go through it).
|
||||
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(),
|
||||
available: masterEnabled,
|
||||
orgEnabled: !oStored.Disabled,
|
||||
enabled: uStored.Enabled,
|
||||
}
|
||||
|
||||
// Ordered layers, top (highest priority) first.
|
||||
type layer struct {
|
||||
name string
|
||||
c wdConfig
|
||||
}
|
||||
layers := []layer{{"global", gc}}
|
||||
if who.OrgID != "" {
|
||||
layers = append(layers, layer{"org", oc})
|
||||
}
|
||||
layers = append(layers, layer{"user", uc})
|
||||
|
||||
// Connection group: the whole connection comes from the highest layer that
|
||||
// supplies a server URL, so credential halves are never mixed across layers.
|
||||
connSrc := "unset"
|
||||
for _, l := range layers {
|
||||
if strings.TrimSpace(l.c.BaseURL) != "" {
|
||||
for _, k := range wdConnKeys {
|
||||
wdSet(&res.eff, k, wdGet(l.c, k))
|
||||
}
|
||||
connSrc = l.name
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, k := range wdConnKeys {
|
||||
res.source[k] = connSrc
|
||||
}
|
||||
|
||||
// basePath cascades independently, top wins, blanks fall through.
|
||||
baseSrc := "unset"
|
||||
for _, l := range layers {
|
||||
if v := strings.TrimSpace(l.c.BasePath); v != "" {
|
||||
res.eff.BasePath, baseSrc = v, l.name
|
||||
break
|
||||
}
|
||||
}
|
||||
res.source["basePath"] = baseSrc
|
||||
return res
|
||||
}
|
||||
|
||||
// orgWebDav reads an organization's stored webdav settings (config + the org
|
||||
// gate) and its raw pluginSettings blob via the service account. Best effort:
|
||||
// zero values on any miss so callers proceed as if the org layer were empty.
|
||||
func (s *Server) orgWebDav(ctx context.Context, orgID string) (wdStored, json.RawMessage) {
|
||||
if orgID == "" || !s.admin.configured() {
|
||||
return wdStored{}, nil
|
||||
}
|
||||
data, status, err := s.admin.do(ctx, http.MethodGet,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return wdStored{}, nil
|
||||
}
|
||||
var rec struct {
|
||||
PluginSettings json.RawMessage `json:"pluginSettings"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
var doc wdSettingsDoc
|
||||
if len(rec.PluginSettings) > 0 {
|
||||
_ = json.Unmarshal(rec.PluginSettings, &doc)
|
||||
}
|
||||
return doc.WebDav, rec.PluginSettings
|
||||
}
|
||||
|
||||
// mergeWebDav applies a mutation to the webdav entry of a pluginSettings blob,
|
||||
// preserving any other plugin keys (e.g. opensky, filetransfer), and returns the
|
||||
// new blob.
|
||||
func mergeWebDav(existing json.RawMessage, apply func(*wdStored)) json.RawMessage {
|
||||
doc := map[string]json.RawMessage{}
|
||||
if len(existing) > 0 {
|
||||
_ = json.Unmarshal(existing, &doc)
|
||||
}
|
||||
if doc == nil {
|
||||
doc = map[string]json.RawMessage{} // existing was JSON null
|
||||
}
|
||||
var wd wdStored
|
||||
if raw, ok := doc["webdav"]; ok {
|
||||
_ = json.Unmarshal(raw, &wd)
|
||||
}
|
||||
apply(&wd)
|
||||
b, _ := json.Marshal(wd)
|
||||
doc["webdav"] = b
|
||||
out, _ := json.Marshal(doc)
|
||||
return out
|
||||
}
|
||||
|
||||
// GET /api/integrations/webdav — resolved view for the caller.
|
||||
func (s *Server) handleGetWebDav(w http.ResponseWriter, r *http.Request) {
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := s.resolveWebDav(r.Context(), who, userRaw)
|
||||
writeJSON(w, http.StatusOK, s.webDavView(who, res))
|
||||
}
|
||||
|
||||
// wdScopeView builds the masked field set for one editable scope. editable is the
|
||||
// layer the caller edits ("user" | "org" | "none"); a field is locked when its
|
||||
// effective value is set above that layer.
|
||||
func (s *Server) wdScopeView(res wdResolution, editable string) map[string]any {
|
||||
own := res.userOwn
|
||||
if editable == "org" {
|
||||
own = res.orgOwn
|
||||
}
|
||||
fields := map[string]osFieldView{}
|
||||
for _, key := range append(append([]string{}, wdConnKeys...), "basePath") {
|
||||
src := res.source[key]
|
||||
fv := osFieldView{Source: src, Locked: lockedFor(src, editable)}
|
||||
if wdSecretKeys[key] {
|
||||
fv.Effective, fv.Own = maskPresent(wdGet(res.eff, key)), maskPresent(wdGet(own, key))
|
||||
} else {
|
||||
fv.Effective, fv.Own = wdGet(res.eff, key), wdGet(own, key)
|
||||
}
|
||||
fields[key] = fv
|
||||
}
|
||||
return map[string]any{"editableLayer": editable, "fields": fields}
|
||||
}
|
||||
|
||||
// webDavView builds the masked, client-safe response body. It exposes a "user"
|
||||
// scope for everyone plus, for org admins, an "org" scope.
|
||||
func (s *Server) webDavView(who *callerIdentity, res wdResolution) map[string]any {
|
||||
out := map[string]any{
|
||||
"available": res.available,
|
||||
"orgEnabled": res.orgEnabled,
|
||||
"enabled": res.enabled,
|
||||
"role": who.Role,
|
||||
"orgId": who.OrgID,
|
||||
"canEditOrg": res.canOrg,
|
||||
"isSuperadmin": res.isSuper,
|
||||
}
|
||||
if res.isSuper {
|
||||
out["editableLayer"] = "none"
|
||||
out["scopes"] = map[string]any{"user": s.wdScopeView(res, "none")}
|
||||
return out
|
||||
}
|
||||
scopes := map[string]any{"user": s.wdScopeView(res, "user")}
|
||||
if res.canOrg {
|
||||
scopes["org"] = s.wdScopeView(res, "org")
|
||||
}
|
||||
out["scopes"] = scopes
|
||||
return out
|
||||
}
|
||||
|
||||
// PUT /api/integrations/webdav — save the caller's editable layer. Body:
|
||||
// {enabled?, scope?, config?}. Fields locked above the caller are ignored; a
|
||||
// secret left at the mask is preserved.
|
||||
func (s *Server) handlePutWebDav(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Scope string `json:"scope"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := s.resolveWebDav(r.Context(), who, userRaw)
|
||||
|
||||
// Resolve which layer this write targets.
|
||||
editable := "user"
|
||||
switch {
|
||||
case res.isSuper:
|
||||
editable = "none"
|
||||
case strings.EqualFold(strings.TrimSpace(body.Scope), "org"):
|
||||
if !res.canOrg {
|
||||
writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings")
|
||||
return
|
||||
}
|
||||
editable = "org"
|
||||
}
|
||||
|
||||
// Overlay the fields the caller may change in this scope onto its own values.
|
||||
newOwn := res.userOwn
|
||||
if editable == "org" {
|
||||
newOwn = res.orgOwn
|
||||
}
|
||||
for _, key := range append(append([]string{}, wdConnKeys...), "basePath") {
|
||||
v, present := body.Config[key]
|
||||
if !present || lockedFor(res.source[key], editable) {
|
||||
continue
|
||||
}
|
||||
if wdSecretKeys[key] && v == openSkySecretMask {
|
||||
continue // keep current secret
|
||||
}
|
||||
wdSet(&newOwn, key, strings.TrimSpace(v))
|
||||
}
|
||||
|
||||
// Persist the organization layer (admins) via the service account.
|
||||
if editable == "org" {
|
||||
if who.OrgID == "" {
|
||||
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
|
||||
return
|
||||
}
|
||||
if !s.admin.configured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server")
|
||||
return
|
||||
}
|
||||
_, orgRaw := s.orgWebDav(r.Context(), who.OrgID)
|
||||
newDoc := mergeWebDav(orgRaw, func(wd *wdStored) {
|
||||
wd.Config = newOwn
|
||||
if body.Enabled != nil {
|
||||
wd.Disabled = !*body.Enabled // org master switch, stored inverted
|
||||
}
|
||||
})
|
||||
_, st, err := s.admin.do(r.Context(), http.MethodPatch,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(who.OrgID),
|
||||
map[string]json.RawMessage{"pluginSettings": newDoc})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if st != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not save organization settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the user record: the personal enable flag lives here (user/superadmin
|
||||
// scope), and so does the personal config layer when this write targets user.
|
||||
personalEnable := body.Enabled != nil && editable != "org"
|
||||
if personalEnable || editable == "user" {
|
||||
newDoc := mergeWebDav(userRaw, func(wd *wdStored) {
|
||||
if personalEnable {
|
||||
wd.Enabled = *body.Enabled
|
||||
}
|
||||
if editable == "user" {
|
||||
wd.Config = newOwn
|
||||
}
|
||||
})
|
||||
if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
} else if code != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not save user settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve and return the fresh view.
|
||||
fresh, st, err := s.pbAuthRefresh(r.Context(), token)
|
||||
if err != nil || st != http.StatusOK || fresh == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
res2 := s.resolveWebDav(r.Context(), who, fresh.Record["pluginSettings"])
|
||||
writeJSON(w, http.StatusOK, s.webDavView(who, res2))
|
||||
}
|
||||
|
||||
// POST /api/integrations/webdav/health — live probe using the caller's resolved
|
||||
// config. Never returns secrets.
|
||||
func (s *Server) handleWebDavHealth(w http.ResponseWriter, r *http.Request) {
|
||||
who, userRaw, ok := s.integrationCaller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !who.isSuperadmin() {
|
||||
if _, _, ok := s.plugins.RawConfig(webDavPlugin); !ok {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
}
|
||||
res := s.resolveWebDav(r.Context(), who, userRaw)
|
||||
if !res.available {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "WebDAV is disabled by the administrator"}})
|
||||
return
|
||||
}
|
||||
if !res.orgEnabled {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "WebDAV is disabled for your organization"}})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(res.eff.BaseURL) == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
||||
"status": "down", "detail": "No server configured — set a server URL to connect"}})
|
||||
return
|
||||
}
|
||||
cfg := map[string]string{}
|
||||
for _, k := range append(append([]string{}, wdConnKeys...), "basePath") {
|
||||
cfg[k] = wdGet(res.eff, k)
|
||||
}
|
||||
h, err := s.plugins.HealthCheckWith(r.Context(), webDavPlugin, cfg)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": h})
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// orgView is the trimmed organization shape returned to clients.
|
||||
type orgView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
// orgNameMap returns an id→name map of all organizations via the service
|
||||
// account. On any error it returns an empty (non-nil) map so callers can index
|
||||
// it safely.
|
||||
func (s *Server) orgNameMap(ctx context.Context) map[string]string {
|
||||
out := map[string]string{}
|
||||
if !s.admin.configured() {
|
||||
return out
|
||||
}
|
||||
data, status, err := s.admin.do(ctx, http.MethodGet,
|
||||
"/api/collections/organizations/records?perPage=500&fields=id,name", nil)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return out
|
||||
}
|
||||
var list struct {
|
||||
Items []orgView `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &list)
|
||||
for _, o := range list.Items {
|
||||
out[o.ID] = o.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// orgName resolves a single organization's name (best effort; "" on miss).
|
||||
func (s *Server) orgName(ctx context.Context, id string) string {
|
||||
if id == "" || !s.admin.configured() {
|
||||
return ""
|
||||
}
|
||||
data, status, err := s.admin.do(ctx, http.MethodGet,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(id)+"?fields=id,name", nil)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
var o orgView
|
||||
_ = json.Unmarshal(data, &o)
|
||||
return o.Name
|
||||
}
|
||||
|
||||
// GET /api/orgs — list organizations (manager only). Superadmins see all;
|
||||
// admins see only their own organization.
|
||||
func (s *Server) handleListOrgs(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
path := "/api/collections/organizations/records?perPage=500&sort=name&fields=id,name,created"
|
||||
if who != nil && !who.isSuperadmin() {
|
||||
if who.OrgID == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"organizations": []orgView{}})
|
||||
return
|
||||
}
|
||||
path += "&filter=" + url.QueryEscape("id = \""+who.OrgID+"\"")
|
||||
}
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
var list struct {
|
||||
Items []orgView `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &list)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"organizations": list.Items})
|
||||
}
|
||||
|
||||
// POST /api/orgs — create an organization (superadmin only). Body: {name}.
|
||||
func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
|
||||
name, ok := decodeOrgName(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodPost,
|
||||
"/api/collections/organizations/records", map[string]any{"name": name})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
// Relay PocketBase's error (e.g. duplicate name violates the unique index).
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
var org orgView
|
||||
_ = json.Unmarshal(data, &org)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"organization": org})
|
||||
}
|
||||
|
||||
// PATCH /api/orgs/{id} — rename an organization (superadmin only). Body: {name}.
|
||||
func (s *Server) handleUpdateOrg(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing organization id")
|
||||
return
|
||||
}
|
||||
name, ok := decodeOrgName(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodPatch,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(id), map[string]any{"name": name})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
var org orgView
|
||||
_ = json.Unmarshal(data, &org)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"organization": org})
|
||||
}
|
||||
|
||||
// DELETE /api/orgs/{id} — delete an organization (superadmin only). Refused
|
||||
// while the org still has members, to avoid silently orphaning users.
|
||||
func (s *Server) handleDeleteOrg(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing organization id")
|
||||
return
|
||||
}
|
||||
|
||||
// Guard: block deletion if any user still belongs to this org.
|
||||
countPath := "/api/collections/users/records?perPage=1&fields=id&filter=" +
|
||||
url.QueryEscape("organization = \""+id+"\"")
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodGet, countPath, nil)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
var page struct {
|
||||
TotalItems int `json:"totalItems"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &page)
|
||||
if page.TotalItems > 0 {
|
||||
writeError(w, http.StatusConflict, "organization still has members; reassign or remove them first")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, status, err = s.admin.do(r.Context(), http.MethodDelete,
|
||||
"/api/collections/organizations/records/"+url.PathEscape(id), nil)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK && status != http.StatusNoContent {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// decodeOrgName parses and validates a {name} body, writing an error response
|
||||
// and returning ok=false on failure.
|
||||
func decodeOrgName(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return "", false
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "organization name is required")
|
||||
return "", false
|
||||
}
|
||||
if len(name) > 120 {
|
||||
writeError(w, http.StatusBadRequest, "organization name is too long (max 120)")
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// The PilotVault web panel: a Vue 3 + Tailwind app (source in panel/, built
|
||||
// with `npm run build` into dist/) embedded at compile time and served at the
|
||||
// server root.
|
||||
//
|
||||
//go:embed all:dist
|
||||
var panelFS embed.FS
|
||||
|
||||
// panelHandler serves the built panel assets.
|
||||
func panelHandler() http.Handler {
|
||||
sub, err := fs.Sub(panelFS, "dist")
|
||||
if err != nil {
|
||||
panic(err) // embedded dist is malformed; unreachable in a valid build
|
||||
}
|
||||
return http.FileServerFS(sub)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
// GET /api/admin/plugins — every known plugin (registry ∪ persisted), secrets masked.
|
||||
func (s *Server) handleListPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"plugins": s.plugins.List()})
|
||||
}
|
||||
|
||||
// GET /api/admin/plugins/{name} — one plugin's view.
|
||||
func (s *Server) handleGetPlugin(w http.ResponseWriter, r *http.Request) {
|
||||
v, ok := s.plugins.Get(r.PathValue("name"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"plugin": v})
|
||||
}
|
||||
|
||||
// PUT /api/admin/plugins/{name} — enable/disable + merge config. Body:
|
||||
// {enabled?, config?}. A secret left at the mask keeps its stored value.
|
||||
func (s *Server) handleUpdatePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
current, ok := s.plugins.Get(name)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
enabled := current.Enabled
|
||||
if body.Enabled != nil {
|
||||
enabled = *body.Enabled
|
||||
}
|
||||
|
||||
v, err := s.plugins.Upsert(r.Context(), name, enabled, body.Config)
|
||||
if err != nil {
|
||||
if plugins.IsUnknown(err) {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
// A failed init (e.g. bad credentials) is reported but the state was saved.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"plugin": v, "warning": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"plugin": v})
|
||||
}
|
||||
|
||||
// POST /api/admin/plugins — register an external (remote HTTP) plugin. Body:
|
||||
// {name, baseURL, provider?}. This is the "add a plugin without a rebuild" path.
|
||||
func (s *Server) handleRegisterPlugin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
if body.Name == "" || body.BaseURL == "" {
|
||||
writeError(w, http.StatusBadRequest, "name and baseURL are required")
|
||||
return
|
||||
}
|
||||
if err := s.plugins.RegisterExternal(body.Name, body.BaseURL, body.Provider); err != nil {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
v, _ := s.plugins.Get(body.Name)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"plugin": v})
|
||||
}
|
||||
|
||||
// DELETE /api/admin/plugins/{name} — remove an external plugin (builtins can only
|
||||
// be disabled).
|
||||
func (s *Server) handleDeletePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.plugins.Remove(r.Context(), r.PathValue("name")); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/admin/plugins/{name}/health — run a health check now.
|
||||
func (s *Server) handlePluginHealth(w http.ResponseWriter, r *http.Request) {
|
||||
h, err := s.plugins.HealthCheck(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
if plugins.IsUnknown(err) {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": h})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// User preferences are stored as a JSON field named "preferences" on the
|
||||
// PocketBase `users` auth record. Because every request carries the caller's own
|
||||
// auth token, PocketBase enforces that a user can only read and write their own
|
||||
// record — the API Server never needs admin credentials for this.
|
||||
|
||||
// pbAuthResp is the subset of PocketBase's auth-refresh response we care about.
|
||||
type pbAuthResp struct {
|
||||
Token string `json:"token"`
|
||||
Record map[string]json.RawMessage `json:"record"`
|
||||
}
|
||||
|
||||
// pbAuthRefresh resolves the caller's user record (id + fields incl. preferences)
|
||||
// from their token. Returns the parsed record, the upstream status, and any
|
||||
// transport error.
|
||||
func (s *Server) pbAuthRefresh(ctx context.Context, token string) (*pbAuthResp, int, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
s.auth.url()+"/api/collections/users/auth-refresh", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
|
||||
resp, err := s.auth.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, resp.StatusCode, nil
|
||||
}
|
||||
var out pbAuthResp
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return &out, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// GET /api/preferences (Authorization: <pb token>)
|
||||
// Returns {"preferences": <json|null>} for the authenticated user.
|
||||
func (s *Server) handleGetPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing token")
|
||||
return
|
||||
}
|
||||
rec, status, err := s.pbAuthRefresh(r.Context(), token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK || rec == nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
prefs := rec.Record["preferences"]
|
||||
if len(prefs) == 0 {
|
||||
prefs = json.RawMessage("null")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]json.RawMessage{"preferences": prefs})
|
||||
}
|
||||
|
||||
// PUT /api/preferences (Authorization: <pb token>)
|
||||
// Body: {"preferences": {...}} — persists the blob onto the user's record.
|
||||
func (s *Server) handlePutPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing token")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Preferences json.RawMessage `json:"preferences"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if len(body.Preferences) == 0 {
|
||||
body.Preferences = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
// Resolve the caller's record id (PocketBase authorises the PATCH against it).
|
||||
rec, status, err := s.pbAuthRefresh(r.Context(), token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK || rec == nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
var id string
|
||||
_ = json.Unmarshal(rec.Record["id"], &id)
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadGateway, "could not resolve user id")
|
||||
return
|
||||
}
|
||||
|
||||
patch, _ := json.Marshal(map[string]json.RawMessage{"preferences": body.Preferences})
|
||||
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPatch,
|
||||
s.auth.url()+"/api/collections/users/records/"+id, bytes.NewReader(patch))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.auth.client.Do(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Relay PocketBase's error (e.g. missing "preferences" field on schema).
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Return just the saved preferences blob.
|
||||
var saved struct {
|
||||
Preferences json.RawMessage `json:"preferences"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &saved)
|
||||
if len(saved.Preferences) == 0 {
|
||||
saved.Preferences = json.RawMessage("null")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]json.RawMessage{"preferences": saved.Preferences})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// writeJSON writes v as a JSON response with the given status code.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("writeJSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// errorBody is the standard error envelope.
|
||||
type errorBody struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// writeError writes a JSON error response.
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, errorBody{Error: msg})
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pilotvault/apiserver/internal/config"
|
||||
"pilotvault/apiserver/internal/hub"
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin" // register built-in plugins
|
||||
)
|
||||
|
||||
// Server wires together the HTTP handlers and their dependencies.
|
||||
type Server struct {
|
||||
mu sync.RWMutex // guards the mutable PocketBase connection in cfg
|
||||
cfg config.Config
|
||||
hub *hub.Hub
|
||||
auth *authProxy
|
||||
admin *adminClient
|
||||
plugins *plugins.Manager
|
||||
}
|
||||
|
||||
// pbURL returns the current PocketBase base URL.
|
||||
func (s *Server) pbURL() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg.PocketBaseURL
|
||||
}
|
||||
|
||||
// pbSettings snapshots the PocketBase connection for the settings endpoints.
|
||||
func (s *Server) pbSettings() (url, adminEmail, adminPassword string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg.PocketBaseURL, s.cfg.PocketBaseAdminEmail, s.cfg.PocketBaseAdminPassword
|
||||
}
|
||||
|
||||
// setPBConfig retargets the PocketBase connection at runtime: it updates the
|
||||
// cached config and repoints both the auth proxy and the admin service account.
|
||||
func (s *Server) setPBConfig(url, adminEmail, adminPassword string) {
|
||||
s.mu.Lock()
|
||||
s.cfg.PocketBaseURL = url
|
||||
s.cfg.PocketBaseAdminEmail = adminEmail
|
||||
s.cfg.PocketBaseAdminPassword = adminPassword
|
||||
s.mu.Unlock()
|
||||
|
||||
s.auth.setBaseURL(url)
|
||||
s.admin.reconfigure(url, adminEmail, adminPassword)
|
||||
}
|
||||
|
||||
// New constructs a Server.
|
||||
func New(cfg config.Config, h *hub.Hub) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
hub: h,
|
||||
auth: newAuthProxy(cfg.PocketBaseURL),
|
||||
admin: newAdminClient(cfg.PocketBaseURL, cfg.PocketBaseAdminEmail, cfg.PocketBaseAdminPassword),
|
||||
plugins: plugins.NewManager(cfg.PluginsFile),
|
||||
}
|
||||
}
|
||||
|
||||
// StartPlugins loads persisted plugin state and initialises enabled plugins.
|
||||
func (s *Server) StartPlugins() error { return s.plugins.Load() }
|
||||
|
||||
// Stop releases server-held resources (currently: plugin instances).
|
||||
func (s *Server) Stop(ctx context.Context) { s.plugins.Shutdown(ctx) }
|
||||
|
||||
// Handler returns the root HTTP handler with all routes registered.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Web panel (public) — embedded Vue + Tailwind app. Only the explicit panel
|
||||
// paths are routed to it so unknown /api/* paths still 404 as JSON.
|
||||
panel := panelHandler()
|
||||
mux.Handle("GET /{$}", panel)
|
||||
mux.Handle("GET /assets/", panel)
|
||||
mux.Handle("GET /favicon.svg", panel)
|
||||
|
||||
// Health (public)
|
||||
mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||
mux.HandleFunc("GET /api/health", s.handleHealth)
|
||||
mux.HandleFunc("GET /api/status", s.handleStatus)
|
||||
|
||||
// Auth — proxied to the PocketBase kept behind this server.
|
||||
mux.HandleFunc("POST /api/auth/login", s.handleAuthLogin)
|
||||
mux.HandleFunc("GET /api/auth/validate", s.handleAuthValidate)
|
||||
|
||||
// Current user (id, email, role) resolved from the caller's token.
|
||||
mux.HandleFunc("GET /api/me", s.handleMe)
|
||||
|
||||
// User preferences — persisted on the caller's own PocketBase user record.
|
||||
mux.HandleFunc("GET /api/preferences", s.handleGetPreferences)
|
||||
mux.HandleFunc("PUT /api/preferences", s.handlePutPreferences)
|
||||
|
||||
// Plugin integrations for end users — per-user/per-org settings resolved
|
||||
// through the superadmin→org→user cascade. Role logic lives inside the
|
||||
// handlers (org users must reach them too), so no requireManager wrapper.
|
||||
mux.HandleFunc("GET /api/integrations/opensky", s.handleGetOpenSky)
|
||||
mux.HandleFunc("PUT /api/integrations/opensky", s.handlePutOpenSky)
|
||||
mux.HandleFunc("POST /api/integrations/opensky/health", s.handleOpenSkyHealth)
|
||||
mux.HandleFunc("GET /api/integrations/filetransfer", s.handleGetFileTransfer)
|
||||
mux.HandleFunc("PUT /api/integrations/filetransfer", s.handlePutFileTransfer)
|
||||
mux.HandleFunc("POST /api/integrations/filetransfer/health", s.handleFileTransferHealth)
|
||||
mux.HandleFunc("GET /api/integrations/localstorage", s.handleGetLocalStorage)
|
||||
mux.HandleFunc("PUT /api/integrations/localstorage", s.handlePutLocalStorage)
|
||||
mux.HandleFunc("POST /api/integrations/localstorage/health", s.handleLocalStorageHealth)
|
||||
mux.HandleFunc("GET /api/integrations/webdav", s.handleGetWebDav)
|
||||
mux.HandleFunc("PUT /api/integrations/webdav", s.handlePutWebDav)
|
||||
mux.HandleFunc("POST /api/integrations/webdav/health", s.handleWebDavHealth)
|
||||
|
||||
// User-management — gated on the caller being a manager (admin or superadmin).
|
||||
// Admins are scoped to their own organization inside each handler.
|
||||
mux.HandleFunc("GET /api/users", s.requireManager(s.handleListUsers))
|
||||
mux.HandleFunc("POST /api/users", s.requireManager(s.handleCreateUser))
|
||||
mux.HandleFunc("PATCH /api/users/{id}", s.requireManager(s.handleUpdateUser))
|
||||
mux.HandleFunc("DELETE /api/users/{id}", s.requireManager(s.handleDeleteUser))
|
||||
|
||||
// Organizations — listing is manager-scoped; create/edit/delete are
|
||||
// superadmin-only (a superadmin spans all organizations).
|
||||
mux.HandleFunc("GET /api/orgs", s.requireManager(s.handleListOrgs))
|
||||
mux.HandleFunc("POST /api/orgs", s.requireSuperadmin(s.handleCreateOrg))
|
||||
mux.HandleFunc("PATCH /api/orgs/{id}", s.requireSuperadmin(s.handleUpdateOrg))
|
||||
mux.HandleFunc("DELETE /api/orgs/{id}", s.requireSuperadmin(s.handleDeleteOrg))
|
||||
|
||||
// PocketBase connection settings — superadmin only. These do NOT require the
|
||||
// service account to already be configured (they exist to configure it).
|
||||
mux.HandleFunc("GET /api/admin/pb-config", s.requireSuperadminAuth(s.handleGetPBConfig))
|
||||
mux.HandleFunc("POST /api/admin/pb-config/test", s.requireSuperadminAuth(s.handleTestPBConfig))
|
||||
mux.HandleFunc("PUT /api/admin/pb-config", s.requireSuperadminAuth(s.handleUpdatePBConfig))
|
||||
|
||||
// Plugins — external-service integrations, managed by a superadmin.
|
||||
mux.HandleFunc("GET /api/admin/plugins", s.requireSuperadminAuth(s.handleListPlugins))
|
||||
mux.HandleFunc("POST /api/admin/plugins", s.requireSuperadminAuth(s.handleRegisterPlugin))
|
||||
mux.HandleFunc("GET /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleGetPlugin))
|
||||
mux.HandleFunc("PUT /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleUpdatePlugin))
|
||||
mux.HandleFunc("DELETE /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleDeletePlugin))
|
||||
mux.HandleFunc("POST /api/admin/plugins/{name}/health", s.requireSuperadminAuth(s.handlePluginHealth))
|
||||
|
||||
// Device / dashboard API.
|
||||
mux.HandleFunc("GET /api/devices", s.handleListDevices)
|
||||
mux.HandleFunc("GET /api/devices/{id}/track", s.handleTrack)
|
||||
mux.HandleFunc("POST /api/devices/{id}/command", s.handleCommand)
|
||||
mux.HandleFunc("DELETE /api/devices/{id}", s.handleForget)
|
||||
mux.HandleFunc("POST /api/telemetry", s.handleTelemetryPost)
|
||||
|
||||
// Websockets: device uplink (Fly App) and dashboard stream (Web App/panel).
|
||||
mux.HandleFunc("GET /ws/device", s.handleDeviceWS)
|
||||
mux.HandleFunc("GET /ws/ui", s.handleUIWS)
|
||||
|
||||
return s.withMiddleware(mux)
|
||||
}
|
||||
|
||||
// withMiddleware applies panic recovery, CORS, and request logging globally.
|
||||
func (s *Server) withMiddleware(next http.Handler) http.Handler {
|
||||
return s.recoverer(s.cors(s.logger(next)))
|
||||
}
|
||||
|
||||
func (s *Server) logger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Printf("panic: %v", rec)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) cors(next http.Handler) http.Handler {
|
||||
allowed := map[string]bool{}
|
||||
wildcard := false
|
||||
for _, o := range s.cfg.AllowOrigins {
|
||||
if o == "*" {
|
||||
wildcard = true
|
||||
}
|
||||
allowed[o] = true
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && (wildcard || allowed[origin]) {
|
||||
if wildcard {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
} else {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Add("Vary", "Origin")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// statusWriter captures the response status code for logging.
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wrote bool
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
if !w.wrote {
|
||||
w.status = code
|
||||
w.wrote = true
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||
w.wrote = true
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Hijack lets the websocket upgrader take over the underlying connection even
|
||||
// though the logger has wrapped the ResponseWriter.
|
||||
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
h, ok := w.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, http.ErrNotSupported
|
||||
}
|
||||
return h.Hijack()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"pilotvault/apiserver/internal/config"
|
||||
)
|
||||
|
||||
// pbProbe is the outcome of testing a PocketBase connection: whether the base
|
||||
// URL answers its health check and whether the service-account credentials
|
||||
// authenticate as a superuser.
|
||||
type pbProbe struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
HTTPStatus int `json:"httpStatus,omitempty"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
Superuser bool `json:"superuser"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// pbConfigView is the PocketBase-connection shape returned to the panel. The
|
||||
// password itself is never sent back — only whether one is set.
|
||||
type pbConfigView struct {
|
||||
URL string `json:"url"`
|
||||
AdminEmail string `json:"adminEmail"`
|
||||
AdminConfigured bool `json:"adminConfigured"`
|
||||
Probe pbProbe `json:"probe"`
|
||||
}
|
||||
|
||||
// probePB checks a PocketBase base URL's health and, when credentials are given,
|
||||
// whether they authenticate as a superuser. It uses the short-timeout
|
||||
// healthClient so a hung PocketBase cannot stall the request.
|
||||
func (s *Server) probePB(ctx context.Context, url, email, password string) pbProbe {
|
||||
h := probe(ctx, url+"/api/health")
|
||||
p := pbProbe{Reachable: h.Status == "ok", HTTPStatus: h.HTTPStatus, LatencyMs: h.LatencyMs}
|
||||
if h.Error != "" {
|
||||
p.Detail = h.Error
|
||||
}
|
||||
if email != "" && password != "" {
|
||||
_, st, err := superuserAuth(ctx, healthClient, url, email, password)
|
||||
if err == nil {
|
||||
p.Superuser = true
|
||||
} else if p.Reachable {
|
||||
p.Detail = "superuser auth failed"
|
||||
if st > 0 {
|
||||
p.Detail += " (HTTP " + strconv.Itoa(st) + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// normalizePBURL trims, defaults the scheme to http, and drops a trailing slash.
|
||||
func normalizePBURL(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||
u = "http://" + u
|
||||
}
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
|
||||
// GET /api/admin/pb-config — current PocketBase connection + a live probe.
|
||||
func (s *Server) handleGetPBConfig(w http.ResponseWriter, r *http.Request) {
|
||||
url, email, password := s.pbSettings()
|
||||
writeJSON(w, http.StatusOK, pbConfigView{
|
||||
URL: url,
|
||||
AdminEmail: email,
|
||||
AdminConfigured: email != "" && password != "",
|
||||
Probe: s.probePB(r.Context(), url, email, password),
|
||||
})
|
||||
}
|
||||
|
||||
// pbConfigBody is the editable connection payload. A blank adminPassword means
|
||||
// "keep the current one"; a blank adminEmail/url means "keep current".
|
||||
type pbConfigBody struct {
|
||||
URL string `json:"url"`
|
||||
AdminEmail string `json:"adminEmail"`
|
||||
AdminPassword string `json:"adminPassword"`
|
||||
}
|
||||
|
||||
// resolve merges a request body onto the current settings, applying the
|
||||
// keep-current semantics for blank fields.
|
||||
func (s *Server) resolve(b pbConfigBody) (url, email, password string) {
|
||||
curURL, curEmail, curPassword := s.pbSettings()
|
||||
url = normalizePBURL(b.URL)
|
||||
if url == "" {
|
||||
url = curURL
|
||||
}
|
||||
email = strings.TrimSpace(b.AdminEmail)
|
||||
if email == "" {
|
||||
email = curEmail
|
||||
}
|
||||
password = b.AdminPassword
|
||||
if password == "" {
|
||||
password = curPassword
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// POST /api/admin/pb-config/test — probe a candidate connection WITHOUT applying
|
||||
// it, so a superadmin can verify before saving.
|
||||
func (s *Server) handleTestPBConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var b pbConfigBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
url, email, password := s.resolve(b)
|
||||
writeJSON(w, http.StatusOK, s.probePB(r.Context(), url, email, password))
|
||||
}
|
||||
|
||||
// PUT /api/admin/pb-config — apply a new PocketBase connection at runtime and
|
||||
// persist it to .env. Returns the new config plus a fresh probe.
|
||||
func (s *Server) handleUpdatePBConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var b pbConfigBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if normalizePBURL(b.URL) == "" {
|
||||
writeError(w, http.StatusBadRequest, "a PocketBase URL is required")
|
||||
return
|
||||
}
|
||||
url, email, password := s.resolve(b)
|
||||
|
||||
// Apply at runtime, then persist so the change survives a restart.
|
||||
s.setPBConfig(url, email, password)
|
||||
if err := config.UpdateEnvFile(config.EnvFile, map[string]string{
|
||||
"POCKETBASE_URL": url,
|
||||
"POCKETBASE_ADMIN_EMAIL": email,
|
||||
"POCKETBASE_ADMIN_PASSWORD": password,
|
||||
}); err != nil {
|
||||
// The runtime change already took effect; report that persistence failed.
|
||||
log.Printf("pb-config: persist to %s failed: %v", config.EnvFile, err)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"config": pbConfigView{
|
||||
URL: url, AdminEmail: email, AdminConfigured: email != "" && password != "",
|
||||
Probe: s.probePB(r.Context(), url, email, password),
|
||||
},
|
||||
"warning": "applied for this session, but could not be saved to .env: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("pb-config: PocketBase connection updated to %s (by superadmin)", url)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"config": pbConfigView{
|
||||
URL: url, AdminEmail: email, AdminConfigured: email != "" && password != "",
|
||||
Probe: s.probePB(r.Context(), url, email, password),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// svcHealth is the health of one upstream service, as shown on the panel.
|
||||
type svcHealth struct {
|
||||
Status string `json:"status"` // "ok" | "down"
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
HTTPStatus int `json:"httpStatus,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// healthClient is a short-timeout client for probing upstreams so a hung
|
||||
// dependency can't stall the status endpoint.
|
||||
var healthClient = &http.Client{Timeout: 4 * time.Second}
|
||||
|
||||
// probe does a GET against url and classifies the result.
|
||||
func probe(ctx context.Context, url string) svcHealth {
|
||||
start := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return svcHealth{Status: "down", URL: url, Error: err.Error()}
|
||||
}
|
||||
resp, err := healthClient.Do(req)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return svcHealth{Status: "down", URL: url, LatencyMs: lat, Error: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
status := "ok"
|
||||
if resp.StatusCode >= 400 {
|
||||
status = "down"
|
||||
}
|
||||
return svcHealth{Status: status, LatencyMs: lat, HTTPStatus: resp.StatusCode, URL: url}
|
||||
}
|
||||
|
||||
// GET /api/status — aggregate health of the API Server and its neighbours
|
||||
// (PocketBase and the Web App), probed server-side. The panel polls this so the
|
||||
// browser never has to reach PocketBase or the Web App directly.
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
var pb, web svcHealth
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); pb = probe(r.Context(), s.pbURL()+"/api/health") }()
|
||||
go func() { defer wg.Done(); web = probe(r.Context(), s.cfg.WebAppURL+"/healthz") }()
|
||||
wg.Wait()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"apiServer": map[string]any{
|
||||
"status": "ok",
|
||||
"devices": s.hub.OnlineCount(),
|
||||
"known": len(s.hub.Snapshot()),
|
||||
},
|
||||
"pocketBase": pb,
|
||||
"webApp": web,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// POST /api/telemetry?id=<deviceId> — HTTP alternative to the websocket for
|
||||
// pushing a single event (handy for testing with curl).
|
||||
func (s *Server) handleTelemetryPost(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
id = "default"
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
s.hub.Ingest(id, raw)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Role names as stored in the PocketBase users.role select field. Missing/empty
|
||||
// is treated as roleUser.
|
||||
const (
|
||||
roleUser = "user"
|
||||
roleAdmin = "admin"
|
||||
roleSuperadmin = "superadmin"
|
||||
)
|
||||
|
||||
// callerIdentity is who the request token belongs to.
|
||||
type callerIdentity struct {
|
||||
ID string
|
||||
Email string
|
||||
Role string
|
||||
OrgID string // organization record id ("" when the user belongs to no org)
|
||||
}
|
||||
|
||||
func (c *callerIdentity) isSuperadmin() bool { return c != nil && c.Role == roleSuperadmin }
|
||||
func (c *callerIdentity) isManager() bool {
|
||||
return c != nil && (c.Role == roleAdmin || c.Role == roleSuperadmin)
|
||||
}
|
||||
|
||||
// identify resolves the caller's id/email/role/org from their PocketBase token.
|
||||
// Role defaults to "user" when the field is empty/absent.
|
||||
func (s *Server) identify(ctx context.Context, token string) (*callerIdentity, int, error) {
|
||||
rec, status, err := s.pbAuthRefresh(ctx, token)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if status != http.StatusOK || rec == nil {
|
||||
return nil, status, nil
|
||||
}
|
||||
id := unquote(rec.Record["id"])
|
||||
email := unquote(rec.Record["email"])
|
||||
role := unquote(rec.Record["role"])
|
||||
if role == "" {
|
||||
role = roleUser
|
||||
}
|
||||
org := unquote(rec.Record["organization"])
|
||||
return &callerIdentity{ID: id, Email: email, Role: role, OrgID: org}, http.StatusOK, nil
|
||||
}
|
||||
|
||||
func unquote(raw json.RawMessage) string {
|
||||
var s string
|
||||
_ = json.Unmarshal(raw, &s)
|
||||
return s
|
||||
}
|
||||
|
||||
// GET /api/me — the authenticated caller's identity, including organization.
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing token")
|
||||
return
|
||||
}
|
||||
who, status, err := s.identify(r.Context(), token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK || who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
orgName := ""
|
||||
if who.OrgID != "" {
|
||||
orgName = s.orgName(r.Context(), who.OrgID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": who.ID,
|
||||
"email": who.Email,
|
||||
"role": who.Role,
|
||||
"organization": who.OrgID,
|
||||
"organizationName": orgName,
|
||||
})
|
||||
}
|
||||
|
||||
// requireManager wraps a handler so only managers (admin or superadmin) may
|
||||
// proceed. The caller's identity is stashed on the request context for reuse.
|
||||
func (s *Server) requireManager(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.requireRole(next, func(c *callerIdentity) bool { return c.isManager() }, "admin role required")
|
||||
}
|
||||
|
||||
// requireSuperadmin wraps a handler so only superadmins may proceed.
|
||||
func (s *Server) requireSuperadmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.requireRole(next, func(c *callerIdentity) bool { return c.isSuperadmin() }, "superadmin role required")
|
||||
}
|
||||
|
||||
// requireSuperadminAuth gates a handler on a valid superadmin token WITHOUT
|
||||
// requiring the service account to be configured. Used by the PocketBase
|
||||
// settings endpoints, whose whole purpose is to configure that service account.
|
||||
func (s *Server) requireSuperadminAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing token")
|
||||
return
|
||||
}
|
||||
who, status, err := s.identify(r.Context(), token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK || who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
if !who.isSuperadmin() {
|
||||
writeError(w, http.StatusForbidden, "superadmin role required")
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), ctxCaller, who)))
|
||||
}
|
||||
}
|
||||
|
||||
// requireRole is the shared gate: it needs the service account (all privileged
|
||||
// management flows through it), a valid token, and a caller that satisfies ok.
|
||||
func (s *Server) requireRole(next http.HandlerFunc, ok func(*callerIdentity) bool, denied string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.admin.configured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "user management not configured on the server")
|
||||
return
|
||||
}
|
||||
token := r.Header.Get("Authorization")
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing token")
|
||||
return
|
||||
}
|
||||
who, status, err := s.identify(r.Context(), token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK || who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
if !ok(who) {
|
||||
writeError(w, http.StatusForbidden, denied)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), ctxCaller, who)))
|
||||
}
|
||||
}
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const ctxCaller ctxKey = iota
|
||||
|
||||
func caller(r *http.Request) *callerIdentity {
|
||||
if v, ok := r.Context().Value(ctxCaller).(*callerIdentity); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// userView is the trimmed user shape returned to managers.
|
||||
type userView struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Verified bool `json:"verified"`
|
||||
Created string `json:"created"`
|
||||
Organization string `json:"organization"` // org record id ("" = none)
|
||||
OrganizationName string `json:"organizationName"` // resolved name ("" = none)
|
||||
}
|
||||
|
||||
// getUserRecord fetches a single user's id/email/role/organization via the
|
||||
// service account. Returns nil (not an error) when the user does not exist.
|
||||
func (s *Server) getUserRecord(ctx context.Context, id string) (*userView, error) {
|
||||
path := "/api/collections/users/records/" + url.PathEscape(id) + "?fields=id,email,role,verified,organization"
|
||||
data, status, err := s.admin.do(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, nil
|
||||
}
|
||||
var v userView
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v.Role == "" {
|
||||
v.Role = roleUser
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// GET /api/users — list users (manager only). Superadmins see everyone;
|
||||
// admins see only their own organization's members.
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
path := "/api/collections/users/records?perPage=500&sort=email&fields=id,email,role,verified,created,organization"
|
||||
if who != nil && !who.isSuperadmin() {
|
||||
// Admin: scope to their own organization.
|
||||
if who.OrgID == "" {
|
||||
// An org-less admin manages nobody.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": []userView{}})
|
||||
return
|
||||
}
|
||||
path += "&filter=" + url.QueryEscape("organization = \""+who.OrgID+"\"")
|
||||
}
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
var list struct {
|
||||
Items []userView `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &list)
|
||||
names := s.orgNameMap(r.Context())
|
||||
for i := range list.Items {
|
||||
if list.Items[i].Role == "" {
|
||||
list.Items[i].Role = roleUser
|
||||
}
|
||||
list.Items[i].OrganizationName = names[list.Items[i].Organization]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": list.Items})
|
||||
}
|
||||
|
||||
// POST /api/users — create a user (manager only). Body: {email, password, role,
|
||||
// organization?}. Admins may only create within their own org and may not mint
|
||||
// superadmins; superadmins may target any org (or none) and any role.
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Organization string `json:"organization"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.Email = strings.TrimSpace(strings.ToLower(body.Email))
|
||||
if body.Email == "" || !strings.Contains(body.Email, "@") {
|
||||
writeError(w, http.StatusBadRequest, "a valid email is required")
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
|
||||
role, ok := normalizeRole(body.Role)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'")
|
||||
return
|
||||
}
|
||||
org := strings.TrimSpace(body.Organization)
|
||||
|
||||
if !who.isSuperadmin() {
|
||||
// Admin: no superadmins, and members are forced into the admin's own org.
|
||||
if role == roleSuperadmin {
|
||||
writeError(w, http.StatusForbidden, "only a superadmin can create superadmins")
|
||||
return
|
||||
}
|
||||
if who.OrgID == "" {
|
||||
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
|
||||
return
|
||||
}
|
||||
org = who.OrgID
|
||||
}
|
||||
|
||||
create := map[string]any{
|
||||
"email": body.Email,
|
||||
"password": body.Password,
|
||||
"passwordConfirm": body.Password,
|
||||
"role": role,
|
||||
"verified": true,
|
||||
"emailVisibility": false,
|
||||
}
|
||||
// Only send organization when set; superadmins may deliberately omit it to
|
||||
// create an org-less account.
|
||||
if org != "" {
|
||||
create["organization"] = org
|
||||
}
|
||||
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodPost, "/api/collections/users/records", create)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
// Relay PocketBase's validation error (e.g. duplicate email, bad org id).
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
var rec userView
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
if rec.Role == "" {
|
||||
rec.Role = role
|
||||
}
|
||||
rec.OrganizationName = s.orgName(r.Context(), rec.Organization)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"user": rec})
|
||||
}
|
||||
|
||||
// PATCH /api/users/{id} — edit a user (manager only). Any subset of
|
||||
// {email, role, password, verified, organization} may be supplied. Admins are
|
||||
// scoped to their own org and cannot touch superadmins or grant the superadmin
|
||||
// role; nobody can demote their own role (avoids self-lockout).
|
||||
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing user id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
Verified *bool `json:"verified"`
|
||||
Organization *string `json:"organization"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the target so we can enforce org/role scoping.
|
||||
target, err := s.getUserRecord(r.Context(), id)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if target == nil {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
if !who.isSuperadmin() {
|
||||
// Admin scoping: target must be inside the admin's org and not a superadmin.
|
||||
if who.OrgID == "" || target.Organization != who.OrgID {
|
||||
writeError(w, http.StatusForbidden, "user is outside your organization")
|
||||
return
|
||||
}
|
||||
if target.Role == roleSuperadmin {
|
||||
writeError(w, http.StatusForbidden, "you cannot edit a superadmin")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
patch := map[string]any{}
|
||||
|
||||
if email := strings.TrimSpace(strings.ToLower(body.Email)); email != "" {
|
||||
if !strings.Contains(email, "@") {
|
||||
writeError(w, http.StatusBadRequest, "a valid email is required")
|
||||
return
|
||||
}
|
||||
patch["email"] = email
|
||||
}
|
||||
if body.Role != "" {
|
||||
role, ok := normalizeRole(body.Role)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'")
|
||||
return
|
||||
}
|
||||
if !who.isSuperadmin() && role == roleSuperadmin {
|
||||
writeError(w, http.StatusForbidden, "only a superadmin can grant the superadmin role")
|
||||
return
|
||||
}
|
||||
if who != nil && who.ID == id && role != who.Role {
|
||||
writeError(w, http.StatusBadRequest, "you cannot change your own role")
|
||||
return
|
||||
}
|
||||
patch["role"] = role
|
||||
}
|
||||
if body.Password != "" {
|
||||
if len(body.Password) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
patch["password"] = body.Password
|
||||
patch["passwordConfirm"] = body.Password
|
||||
}
|
||||
if body.Verified != nil {
|
||||
patch["verified"] = *body.Verified
|
||||
}
|
||||
// Organization moves are superadmin-only; admins cannot reassign membership.
|
||||
if body.Organization != nil {
|
||||
if !who.isSuperadmin() {
|
||||
if *body.Organization != who.OrgID {
|
||||
writeError(w, http.StatusForbidden, "you cannot move users to another organization")
|
||||
return
|
||||
}
|
||||
// no-op for admins staying in their own org
|
||||
} else {
|
||||
patch["organization"] = *body.Organization // "" clears membership
|
||||
}
|
||||
}
|
||||
if len(patch) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "no changes provided")
|
||||
return
|
||||
}
|
||||
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodPatch, "/api/collections/users/records/"+url.PathEscape(id), patch)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
// Relay PocketBase's validation error (e.g. duplicate email, bad org id).
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
var rec userView
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
if rec.Role == "" {
|
||||
rec.Role = roleUser
|
||||
}
|
||||
rec.OrganizationName = s.orgName(r.Context(), rec.Organization)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": rec})
|
||||
}
|
||||
|
||||
// DELETE /api/users/{id} — delete a user (manager only). Admins may delete only
|
||||
// non-superadmin members of their own org; nobody can delete their own account.
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing user id")
|
||||
return
|
||||
}
|
||||
if who != nil && who.ID == id {
|
||||
writeError(w, http.StatusBadRequest, "you cannot delete your own account")
|
||||
return
|
||||
}
|
||||
|
||||
if who != nil && !who.isSuperadmin() {
|
||||
target, err := s.getUserRecord(r.Context(), id)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if target == nil {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if who.OrgID == "" || target.Organization != who.OrgID {
|
||||
writeError(w, http.StatusForbidden, "user is outside your organization")
|
||||
return
|
||||
}
|
||||
if target.Role == roleSuperadmin {
|
||||
writeError(w, http.StatusForbidden, "you cannot delete a superadmin")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodDelete, "/api/collections/users/records/"+url.PathEscape(id), nil)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK && status != http.StatusNoContent {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// normalizeRole validates a client-supplied role. Returns the canonical value
|
||||
// and whether it was recognised.
|
||||
func normalizeRole(role string) (string, bool) {
|
||||
switch strings.TrimSpace(strings.ToLower(role)) {
|
||||
case "", roleUser:
|
||||
return roleUser, true
|
||||
case roleAdmin:
|
||||
return roleAdmin, true
|
||||
case roleSuperadmin:
|
||||
return roleSuperadmin, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
// GET /ws/device?id=<deviceId> — the Fly App connects here to stream telemetry.
|
||||
func (s *Server) handleDeviceWS(w http.ResponseWriter, r *http.Request) {
|
||||
s.hub.ServeDevice(w, r, r.URL.Query().Get("id"))
|
||||
}
|
||||
|
||||
// GET /ws/ui — the web dashboard / panel connects here for the live stream.
|
||||
func (s *Server) handleUIWS(w http.ResponseWriter, r *http.Request) {
|
||||
s.hub.ServeUI(w, r)
|
||||
}
|
||||
Reference in New Issue
Block a user