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)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds all runtime configuration for the API Server.
|
||||
type Config struct {
|
||||
Addr string
|
||||
PocketBaseURL string
|
||||
WebAppURL string
|
||||
AllowOrigins []string
|
||||
|
||||
// PluginsFile is the local JSON store for plugin enable-state + config.
|
||||
PluginsFile string
|
||||
|
||||
// Superuser service account used ONLY for admin user-management
|
||||
// (list/create/delete users). Optional: when unset, those endpoints return
|
||||
// 503 and the rest of the server is unaffected.
|
||||
PocketBaseAdminEmail string
|
||||
PocketBaseAdminPassword string
|
||||
}
|
||||
|
||||
// EnvFile is the .env path (relative to the working directory) that Load reads
|
||||
// and that runtime settings changes persist back into.
|
||||
const EnvFile = ".env"
|
||||
|
||||
// Load reads configuration from environment variables, applying sensible
|
||||
// defaults. A .env file, if present in the working directory, is loaded first.
|
||||
func Load() Config {
|
||||
loadDotEnv(EnvFile)
|
||||
|
||||
cfg := Config{
|
||||
Addr: getenv("API_ADDR", ":8080"),
|
||||
PocketBaseURL: strings.TrimRight(pocketBaseURL(), "/"),
|
||||
WebAppURL: strings.TrimRight(getenv("WEBAPP_URL", "http://localhost:8090"), "/"),
|
||||
AllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "*")),
|
||||
PluginsFile: getenv("PLUGINS_FILE", "plugins.json"),
|
||||
PocketBaseAdminEmail: getenv("POCKETBASE_ADMIN_EMAIL", os.Getenv("PB_ADMIN_EMAIL")),
|
||||
PocketBaseAdminPassword: getenv("POCKETBASE_ADMIN_PASSWORD", os.Getenv("PB_ADMIN_PASSWORD")),
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// pocketBaseURL resolves the PocketBase base URL, honouring the legacy PB_URL
|
||||
// variable for backward compatibility with older deployments.
|
||||
func pocketBaseURL() string {
|
||||
if v := os.Getenv("POCKETBASE_URL"); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := os.Getenv("PB_URL"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "http://10.2.1.10:8026"
|
||||
}
|
||||
|
||||
// UpdateEnvFile persists the given KEY=VALUE pairs into the .env file at path,
|
||||
// replacing existing keys in place and appending new ones, while preserving all
|
||||
// other lines (comments, ordering, unrelated keys). The file is created if it
|
||||
// does not exist. Written with 0600 perms since it holds secrets.
|
||||
func UpdateEnvFile(path string, updates map[string]string) error {
|
||||
existing, _ := os.ReadFile(path) // missing file → start empty
|
||||
|
||||
remaining := make(map[string]string, len(updates))
|
||||
for k, v := range updates {
|
||||
remaining[k] = v
|
||||
}
|
||||
|
||||
var out []string
|
||||
for _, line := range strings.Split(string(existing), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
out = append(out, line)
|
||||
continue
|
||||
}
|
||||
key, _, ok := strings.Cut(trimmed, "=")
|
||||
key = strings.TrimSpace(key)
|
||||
if ok {
|
||||
if v, found := remaining[key]; found {
|
||||
out = append(out, key+"="+v)
|
||||
delete(remaining, key)
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
// Append any keys that weren't already present.
|
||||
for k, v := range remaining {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
|
||||
content := strings.Join(out, "\n")
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
return os.WriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// loadDotEnv loads KEY=VALUE pairs from a .env file into the process env if they
|
||||
// are not already set. It is intentionally minimal (no quoting rules beyond
|
||||
// trimming surrounding quotes).
|
||||
func loadDotEnv(path string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.Trim(strings.TrimSpace(val), `"'`)
|
||||
if _, exists := os.LookupEnv(key); !exists {
|
||||
_ = os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// Package hub keeps the live, in-memory view of every connected device and
|
||||
// fans telemetry out to dashboards over websockets. It is the drone-domain core
|
||||
// of the API Server; the api package exposes it over HTTP.
|
||||
package hub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
maxMessageSize = 1 << 20
|
||||
sendBuffer = 256
|
||||
maxTrackPoints = 1000
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
// Dev default: accept any origin. Lock this down for production.
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
type clientKind int
|
||||
|
||||
const (
|
||||
kindDevice clientKind = iota
|
||||
kindUI
|
||||
)
|
||||
|
||||
// Client is a single websocket connection (either a device/app or a dashboard).
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
kind clientKind
|
||||
deviceID string
|
||||
}
|
||||
|
||||
// Hub keeps track of all connections and the latest state per device.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
uis map[*Client]bool
|
||||
devices map[string]*Client // currently-online device connections
|
||||
states map[string]*DeviceState // last-known state, persists across reconnects
|
||||
tracks map[string][]TrackPoint
|
||||
}
|
||||
|
||||
// New constructs an empty Hub.
|
||||
func New() *Hub {
|
||||
return &Hub{
|
||||
uis: make(map[*Client]bool),
|
||||
devices: make(map[string]*Client),
|
||||
states: make(map[string]*DeviceState),
|
||||
tracks: make(map[string][]TrackPoint),
|
||||
}
|
||||
}
|
||||
|
||||
func nowMs() int64 { return time.Now().UnixMilli() }
|
||||
|
||||
// ── Websocket entry points ───────────────────────────────────────────────────
|
||||
|
||||
// ServeDevice upgrades an incoming request into a device connection (the Fly
|
||||
// App's telemetry uplink) bound to deviceID.
|
||||
func (h *Hub) ServeDevice(w http.ResponseWriter, r *http.Request, deviceID string) {
|
||||
if deviceID == "" {
|
||||
deviceID = "default"
|
||||
}
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c := &Client{hub: h, conn: conn, send: make(chan []byte, sendBuffer), kind: kindDevice, deviceID: deviceID}
|
||||
h.addDevice(c)
|
||||
log.Printf("device connected: %s", deviceID)
|
||||
go c.writePump()
|
||||
go c.readPump()
|
||||
}
|
||||
|
||||
// ServeUI upgrades an incoming request into a dashboard connection.
|
||||
func (h *Hub) ServeUI(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c := &Client{hub: h, conn: conn, send: make(chan []byte, sendBuffer), kind: kindUI}
|
||||
h.addUI(c)
|
||||
go c.writePump()
|
||||
go c.readPump()
|
||||
}
|
||||
|
||||
// ── UI client lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
func (h *Hub) addUI(c *Client) {
|
||||
h.mu.Lock()
|
||||
h.uis[c] = true
|
||||
devices := make([]*DeviceState, 0, len(h.states))
|
||||
for _, s := range h.states {
|
||||
cp := *s
|
||||
devices = append(devices, &cp)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if msg, err := json.Marshal(ServerToUI{Type: "snapshot", Devices: devices, TS: nowMs()}); err == nil {
|
||||
c.send <- msg
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) removeUI(c *Client) {
|
||||
h.mu.Lock()
|
||||
delete(h.uis, c)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// ── Device client lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
func (h *Hub) addDevice(c *Client) {
|
||||
h.mu.Lock()
|
||||
h.devices[c.deviceID] = c
|
||||
s := h.states[c.deviceID]
|
||||
if s == nil {
|
||||
s = &DeviceState{DeviceID: c.deviceID}
|
||||
h.states[c.deviceID] = s
|
||||
}
|
||||
s.Online = true
|
||||
s.LastSeenMs = nowMs()
|
||||
snap := *s
|
||||
h.mu.Unlock()
|
||||
|
||||
h.broadcastUI(ServerToUI{Type: "update", Device: &snap, TS: nowMs()})
|
||||
}
|
||||
|
||||
func (h *Hub) removeDevice(c *Client) {
|
||||
h.mu.Lock()
|
||||
if h.devices[c.deviceID] == c {
|
||||
delete(h.devices, c.deviceID)
|
||||
}
|
||||
var snap *DeviceState
|
||||
if s := h.states[c.deviceID]; s != nil {
|
||||
s.Online = false
|
||||
s.Connected = false
|
||||
s.Telemetry = Telemetry{} // app stopped streaming: drop stale live telemetry
|
||||
s.LastSeenMs = nowMs()
|
||||
cp := *s
|
||||
snap = &cp
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if snap != nil {
|
||||
h.broadcastUI(ServerToUI{Type: "update", Device: snap, TS: nowMs()})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data flow ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Ingest applies a raw event from a device and fans it out to the dashboards.
|
||||
func (h *Hub) Ingest(deviceID string, raw map[string]any) {
|
||||
h.mu.Lock()
|
||||
s := h.states[deviceID]
|
||||
if s == nil {
|
||||
s = &DeviceState{DeviceID: deviceID}
|
||||
h.states[deviceID] = s
|
||||
}
|
||||
s.Online = true
|
||||
s.LastSeenMs = nowMs()
|
||||
|
||||
switch raw["type"] {
|
||||
case "registration":
|
||||
if st, ok := raw["state"].(string); ok {
|
||||
s.Registration = st
|
||||
}
|
||||
case "connection":
|
||||
if c, ok := raw["connected"].(bool); ok {
|
||||
s.Connected = c
|
||||
if !c {
|
||||
s.Telemetry = Telemetry{} // drone unlinked: live telemetry is no longer valid
|
||||
}
|
||||
}
|
||||
if m, ok := raw["model"].(string); ok {
|
||||
s.Model = m
|
||||
}
|
||||
case "battery":
|
||||
if p, ok := toInt(raw["percent"]); ok {
|
||||
s.Telemetry.BatteryPercent = &p
|
||||
}
|
||||
case "telemetry":
|
||||
applyTelemetry(&s.Telemetry, raw)
|
||||
lat, okLat := toFloat(raw["latitude"])
|
||||
lng, okLng := toFloat(raw["longitude"])
|
||||
if okLat && okLng && (lat != 0 || lng != 0) {
|
||||
alt, _ := toFloat(raw["altitude"])
|
||||
h.appendTrackLocked(deviceID, TrackPoint{Lat: lat, Lng: lng, Alt: alt, TS: nowMs()})
|
||||
}
|
||||
}
|
||||
snap := *s
|
||||
h.mu.Unlock()
|
||||
|
||||
h.broadcastUI(ServerToUI{Type: "update", Device: &snap, Event: raw, TS: nowMs()})
|
||||
}
|
||||
|
||||
// appendTrackLocked must be called with h.mu held.
|
||||
func (h *Hub) appendTrackLocked(deviceID string, p TrackPoint) {
|
||||
t := append(h.tracks[deviceID], p)
|
||||
if len(t) > maxTrackPoints {
|
||||
t = t[len(t)-maxTrackPoints:]
|
||||
}
|
||||
h.tracks[deviceID] = t
|
||||
}
|
||||
|
||||
// SendCommand routes a command from the server (or a dashboard) to a device.
|
||||
// It returns false if the device is not currently connected.
|
||||
func (h *Hub) SendCommand(deviceID, command string, payload map[string]any) bool {
|
||||
cmd := Command{Type: "command", Command: command, Payload: payload, TS: nowMs()}
|
||||
msg, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
h.mu.RLock()
|
||||
c := h.devices[deviceID]
|
||||
h.mu.RUnlock()
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case c.send <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) broadcastUI(m ServerToUI) {
|
||||
msg, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.mu.RLock()
|
||||
for c := range h.uis {
|
||||
select {
|
||||
case c.send <- msg:
|
||||
default: // drop messages for a slow/stuck dashboard rather than block
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
|
||||
// Forget drops a device's stored state and track. Intended for clearing
|
||||
// stale/offline entries; a still-online device will simply repopulate.
|
||||
func (h *Hub) Forget(deviceID string) bool {
|
||||
h.mu.Lock()
|
||||
_, existed := h.states[deviceID]
|
||||
delete(h.states, deviceID)
|
||||
delete(h.tracks, deviceID)
|
||||
h.mu.Unlock()
|
||||
if existed {
|
||||
h.broadcastUI(ServerToUI{Type: "removed", DeviceID: deviceID, TS: nowMs()})
|
||||
}
|
||||
return existed
|
||||
}
|
||||
|
||||
// OnlineCount returns the number of devices with a live websocket connection
|
||||
// right now (offline/last-known states are not counted).
|
||||
func (h *Hub) OnlineCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.devices)
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of every known device's last state.
|
||||
func (h *Hub) Snapshot() []*DeviceState {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
out := make([]*DeviceState, 0, len(h.states))
|
||||
for _, s := range h.states {
|
||||
cp := *s
|
||||
out = append(out, &cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Track returns a copy of a device's GPS track.
|
||||
func (h *Hub) Track(deviceID string) []TrackPoint {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
src := h.tracks[deviceID]
|
||||
out := make([]TrackPoint, len(src))
|
||||
copy(out, src)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Pumps ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
if c.kind == kindDevice {
|
||||
c.hub.removeDevice(c)
|
||||
} else {
|
||||
c.hub.removeUI(c)
|
||||
}
|
||||
c.conn.Close()
|
||||
}()
|
||||
|
||||
c.conn.SetReadLimit(maxMessageSize)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
return c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
})
|
||||
|
||||
for {
|
||||
_, data, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
log.Printf("ws read error (%s): %v", c.deviceID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if c.kind == kindDevice {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
c.hub.Ingest(c.deviceID, raw)
|
||||
continue
|
||||
}
|
||||
|
||||
// UI -> server: command requests
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
Command string `json:"command"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &req); err != nil {
|
||||
continue
|
||||
}
|
||||
if req.Action == "command" && req.Command != "" {
|
||||
c.hub.SendCommand(req.DeviceID, req.Command, req.Payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writePump() {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-c.send:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if !ok {
|
||||
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package hub
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Telemetry holds the latest flight-controller / battery values for a device.
|
||||
// Pointers distinguish "not yet reported" (nil) from a genuine zero value.
|
||||
type Telemetry struct {
|
||||
SatelliteCount *int `json:"satelliteCount,omitempty"`
|
||||
IsFlying *bool `json:"isFlying,omitempty"`
|
||||
FlightMode *string `json:"flightMode,omitempty"`
|
||||
Altitude *float64 `json:"altitude,omitempty"`
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
VelocityX *float64 `json:"velocityX,omitempty"`
|
||||
VelocityY *float64 `json:"velocityY,omitempty"`
|
||||
VelocityZ *float64 `json:"velocityZ,omitempty"`
|
||||
BatteryPercent *int `json:"batteryPercent,omitempty"`
|
||||
}
|
||||
|
||||
// DeviceState is the server's aggregated view of one app/drone.
|
||||
type DeviceState struct {
|
||||
DeviceID string `json:"deviceId"`
|
||||
Online bool `json:"online"` // app's websocket is connected to the server
|
||||
Connected bool `json:"connected"` // a drone is connected to the app
|
||||
Model string `json:"model"`
|
||||
Registration string `json:"registration"`
|
||||
Telemetry Telemetry `json:"telemetry"`
|
||||
LastSeenMs int64 `json:"lastSeenMs"`
|
||||
}
|
||||
|
||||
// TrackPoint is one sample of the drone's GPS track (for the map trail).
|
||||
type TrackPoint struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Alt float64 `json:"alt"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
// ServerToUI is the message a dashboard receives over /ws/ui.
|
||||
type ServerToUI struct {
|
||||
Type string `json:"type"` // "snapshot" | "update" | "removed"
|
||||
Device *DeviceState `json:"device,omitempty"`
|
||||
Devices []*DeviceState `json:"devices,omitempty"`
|
||||
DeviceID string `json:"deviceId,omitempty"` // for "removed"
|
||||
Event map[string]any `json:"event,omitempty"` // the raw device event that triggered this
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
// Command is what the server pushes down to a device over /ws/device.
|
||||
type Command struct {
|
||||
Type string `json:"type"` // always "command"
|
||||
Command string `json:"command"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
// toFloat coerces a JSON-decoded value into a float64.
|
||||
func toFloat(v any) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case float32:
|
||||
return float64(n), true
|
||||
case int:
|
||||
return float64(n), true
|
||||
case int64:
|
||||
return float64(n), true
|
||||
case json.Number:
|
||||
f, err := n.Float64()
|
||||
return f, err == nil
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// toInt coerces a JSON-decoded value into an int.
|
||||
func toInt(v any) (int, bool) {
|
||||
if f, ok := toFloat(v); ok {
|
||||
return int(f), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// applyTelemetry copies any present telemetry fields from a raw event map.
|
||||
func applyTelemetry(t *Telemetry, raw map[string]any) {
|
||||
if v, ok := toInt(raw["satelliteCount"]); ok {
|
||||
t.SatelliteCount = &v
|
||||
}
|
||||
if v, ok := raw["isFlying"].(bool); ok {
|
||||
t.IsFlying = &v
|
||||
}
|
||||
if v, ok := raw["flightMode"].(string); ok {
|
||||
t.FlightMode = &v
|
||||
}
|
||||
if v, ok := toFloat(raw["altitude"]); ok {
|
||||
t.Altitude = &v
|
||||
}
|
||||
if v, ok := toFloat(raw["latitude"]); ok {
|
||||
t.Latitude = &v
|
||||
}
|
||||
if v, ok := toFloat(raw["longitude"]); ok {
|
||||
t.Longitude = &v
|
||||
}
|
||||
if v, ok := toFloat(raw["velocityX"]); ok {
|
||||
t.VelocityX = &v
|
||||
}
|
||||
if v, ok := toFloat(raw["velocityY"]); ok {
|
||||
t.VelocityY = &v
|
||||
}
|
||||
if v, ok := toFloat(raw["velocityZ"]); ok {
|
||||
t.VelocityZ = &v
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
# Building PilotVault Plugins
|
||||
|
||||
A **plugin** integrates an external third-party service (flight data,
|
||||
notifications, …) behind one uniform contract. There are two kinds:
|
||||
|
||||
| Kind | Written as | Added by | Rebuild? | Use when |
|
||||
|---|---|---|---|---|
|
||||
| **built-in** | Go code in this repo | a rebuild | yes | first-party, high-trust, type-safe connectors |
|
||||
| **external** | any HTTP service | registering a URL at runtime | **no** | third-party / less-trusted / independently deployed |
|
||||
|
||||
Both implement the same behaviour; the server treats them identically. Enable
|
||||
state and per-plugin config persist to `plugins.json` and load on boot. Every
|
||||
plugin is managed by a **superadmin** from the panel (`/`) or the
|
||||
`/api/admin/plugins*` API.
|
||||
|
||||
---
|
||||
|
||||
## The contract
|
||||
|
||||
All plugins satisfy the Go interface in [`plugin.go`](plugin.go):
|
||||
|
||||
```go
|
||||
type Plugin interface {
|
||||
Descriptor() Descriptor
|
||||
Init(ctx context.Context, config map[string]string) error
|
||||
HealthCheck(ctx context.Context) Health
|
||||
Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error)
|
||||
Shutdown(ctx context.Context) error
|
||||
}
|
||||
```
|
||||
|
||||
- **`Descriptor`** — static metadata (name, provider, version, capabilities,
|
||||
auth type, config fields). Drives the panel UI.
|
||||
- **`Init`** — called with the resolved config (secrets included) whenever the
|
||||
plugin is enabled or its config changes. Prepare clients/tokens here.
|
||||
- **`HealthCheck`** — probe the upstream and classify: `Health{Status, LatencyMs, Detail}`
|
||||
where `Status` is `StatusOK` / `StatusDegraded` / `StatusDown`.
|
||||
- **`Invoke`** — run a named capability. **Part of the contract for the future;
|
||||
no HTTP endpoint exposes it in v1.** Implement it anyway so the connector is
|
||||
ready.
|
||||
- **`Shutdown`** — release resources.
|
||||
|
||||
### Descriptor & config fields
|
||||
|
||||
```go
|
||||
Descriptor{
|
||||
Name: "acme", // unique id, [a-z0-9-]
|
||||
Provider: "ACME Corp", // human label
|
||||
Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin, // or KindExternal
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
|
||||
},
|
||||
AuthType: plugins.AuthAPIKey, // None | APIKey | Basic | OAuth2 | Webhook (metadata only)
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
{Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true,
|
||||
Help: "Found under ACME → Settings → API."},
|
||||
{Key: "region", Label: "Region", Type: "text", Help: "e.g. eu-west-1"},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`ConfigField.Type` is `"text"`, `"password"`, or `"number"` (form input hint).
|
||||
Set **`Secret: true`** for credentials — the server never echoes them back in
|
||||
clear; the panel shows a mask (`••••••••`), and on save a field left at the mask
|
||||
keeps its stored value (so operators don't retype secrets). **`Required: true`**
|
||||
fields must be non-empty before the plugin can be enabled.
|
||||
|
||||
---
|
||||
|
||||
## Building a built-in plugin
|
||||
|
||||
1. **Create a package** under `internal/plugins/builtin/<name>/`.
|
||||
2. **Implement `Plugin`** and **register it in `init()`**.
|
||||
3. **Blank-import** your package from [`builtin/builtin.go`](builtin/builtin.go).
|
||||
4. **Rebuild** the server.
|
||||
|
||||
### Minimal example — `internal/plugins/builtin/acme/acme.go`
|
||||
|
||||
```go
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugins.Register("acme", func() plugins.Plugin { return &Plugin{} })
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
apiKey string
|
||||
region string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "acme", Provider: "ACME Corp", Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin, AuthType: plugins.AuthAPIKey,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
|
||||
},
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
{Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true},
|
||||
{Key: "region", Label: "Region", Type: "text"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
p.apiKey = strings.TrimSpace(config["apiKey"])
|
||||
p.region = strings.TrimSpace(config["region"])
|
||||
p.client = &http.Client{Timeout: 10 * time.Second}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
||||
start := time.Now()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.acme.example/ping", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
resp, err := p.client.Do(req)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: "reachable"}
|
||||
}
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: "HTTP " + resp.Status}
|
||||
}
|
||||
|
||||
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
// Implement your capabilities; return normalized JSON. (Not yet called in v1.)
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
||||
```
|
||||
|
||||
### Register it for compilation — `internal/plugins/builtin/builtin.go`
|
||||
|
||||
```go
|
||||
import (
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/acme"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/opensky"
|
||||
)
|
||||
```
|
||||
|
||||
### Rebuild
|
||||
|
||||
```powershell
|
||||
cd "API Server"
|
||||
go build -o api-server.exe ./cmd/server
|
||||
```
|
||||
|
||||
Restart the server. The plugin appears in the panel's **Plugins** card,
|
||||
**disabled** by default. See [`builtin/opensky/opensky.go`](builtin/opensky/opensky.go)
|
||||
for a fuller example with an **OAuth2 client-credentials** auth provider and an
|
||||
anonymous fallback.
|
||||
|
||||
---
|
||||
|
||||
## Building an external plugin (no rebuild)
|
||||
|
||||
An external plugin is **any HTTP service** you host (Go recommended, but any
|
||||
language works). You register its base URL at runtime; the server drives it over
|
||||
a tiny JSON contract.
|
||||
|
||||
### The HTTP contract
|
||||
|
||||
| Method & path | Purpose | Response |
|
||||
|---|---|---|
|
||||
| `GET {base}/manifest` | describe the plugin (optional) | `{provider, version, capabilities, authType, configFields}` |
|
||||
| `GET {base}/health` | health probe (required) | `2xx` = healthy; optional body `{status, detail}` |
|
||||
| `POST {base}/invoke` | run a capability (optional; unused in v1) | `{action, params}` in → arbitrary JSON out |
|
||||
|
||||
Health rules the server applies: transport error or `5xx` → `down`; `2xx` → `ok`;
|
||||
anything else → `degraded`. An explicit `{"status":"ok|degraded|down","detail":"…"}`
|
||||
body overrides the status-code heuristic. Bodies are size-limited (health 64 KiB,
|
||||
manifest 1 MiB).
|
||||
|
||||
### Minimal example — a Go plugin service
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/manifest", func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"provider": "ACME Cloud",
|
||||
"version": "2.1.0",
|
||||
"authType": "apikey",
|
||||
"capabilities": []map[string]any{
|
||||
{"id": "widgets.list", "method": "GET", "endpoint": "/widgets", "description": "List widgets."},
|
||||
}, // a plain []string{"widgets.list"} is also accepted
|
||||
"configFields": []map[string]any{
|
||||
{"key": "apiKey", "label": "API key", "type": "password", "required": true, "secret": true},
|
||||
},
|
||||
})
|
||||
})
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "detail": "acme cloud reachable"})
|
||||
})
|
||||
http.HandleFunc("/invoke", func(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Action string `json:"action"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&in)
|
||||
json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": in.Action})
|
||||
})
|
||||
http.ListenAndServe(":9100", nil)
|
||||
}
|
||||
```
|
||||
|
||||
### Register it
|
||||
|
||||
From the panel's **Plugins** card → *Register external plugin* (name + base URL),
|
||||
or via the API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/admin/plugins \
|
||||
-H "Authorization: $SUPERADMIN_TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"name":"acme-cloud","baseURL":"http://127.0.0.1:9100","provider":"ACME Cloud"}'
|
||||
```
|
||||
|
||||
It starts **disabled**; enable it and run a health check from the panel. Because
|
||||
it runs as its own process/container, an external plugin is also the
|
||||
**sandboxing** path for less-trusted integrations.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle, config & secrets
|
||||
|
||||
- **Enable/disable** and **config** persist to `plugins.json` (gitignored; override
|
||||
the path with `PLUGINS_FILE`). Enabling calls `Init`; disabling calls `Shutdown`.
|
||||
- **Secrets** (`Secret: true` fields) are returned masked. On save, a field still
|
||||
equal to the mask keeps its stored value; send a new value to change it, or an
|
||||
empty string to clear it.
|
||||
- **Required** fields are validated when enabling — enabling fails with a clear
|
||||
error if one is blank.
|
||||
- If `Init` fails (e.g. bad credentials), the state is still saved and the API
|
||||
returns the plugin plus a `warning`; fix the config and re-save.
|
||||
|
||||
---
|
||||
|
||||
## Managing plugins (superadmin API)
|
||||
|
||||
All endpoints require a superadmin bearer token (`Authorization: <token>` from
|
||||
`POST /api/auth/login`). See the panel's **Management API** reference too.
|
||||
|
||||
| Method | Path | Body | Purpose |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/admin/plugins` | — | list all plugins + state + last health |
|
||||
| `GET` | `/api/admin/plugins/{name}` | — | one plugin |
|
||||
| `PUT` | `/api/admin/plugins/{name}` | `{enabled?, config?}` | enable/disable + configure |
|
||||
| `POST` | `/api/admin/plugins` | `{name, baseURL, provider?}` | register an external plugin |
|
||||
| `DELETE` | `/api/admin/plugins/{name}` | — | remove an external plugin (built-ins only disable) |
|
||||
| `POST` | `/api/admin/plugins/{name}/health` | — | run a health check now |
|
||||
|
||||
---
|
||||
|
||||
## Testing your plugin
|
||||
|
||||
1. Build + restart (built-in) or start your service (external) and register it.
|
||||
2. `GET /api/admin/plugins` → confirm your descriptor, config fields, capabilities.
|
||||
3. `PUT /api/admin/plugins/{name} {"enabled":true, "config":{…}}` → enable with config.
|
||||
4. `POST /api/admin/plugins/{name}/health` → confirm the live probe classifies correctly.
|
||||
5. Restart the server → confirm state reloads from `plugins.json`.
|
||||
|
||||
A Go unit test can exercise a built-in directly:
|
||||
|
||||
```go
|
||||
p := &acme.Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"apiKey": "test"})
|
||||
if h := p.HealthCheck(context.Background()); h.Status == "" {
|
||||
t.Fatal("expected a health status")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Not yet implemented (roadmap)
|
||||
|
||||
The contract is shaped for these; see [`doc.go`](doc.go):
|
||||
|
||||
- **Invocation API** — an endpoint to call `Invoke` from clients, with a normalized
|
||||
request/response envelope and a provider→internal mapper.
|
||||
- **Resilience** — retry/backoff, circuit breaker, per-plugin latency/error metrics.
|
||||
- **Per-tenant credentials** — config keyed by org/user so users connect their own accounts.
|
||||
- **Audit logging** of plugin access.
|
||||
|
||||
Until the invocation API lands, `Invoke` is dormant — plugins are discoverable,
|
||||
configurable, and health-checked, but not yet callable over HTTP.
|
||||
@@ -0,0 +1,11 @@
|
||||
// Package builtin blank-imports every built-in plugin so their init() functions
|
||||
// register them with the plugin registry. Import this package once (from the api
|
||||
// package) to make all built-in connectors available.
|
||||
package builtin
|
||||
|
||||
import (
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/filetransfer"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/localstorage"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/opensky"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/webdav"
|
||||
)
|
||||
@@ -0,0 +1,610 @@
|
||||
// Package filetransfer is a built-in plugin that connects to a file-transfer
|
||||
// server over FTP, FTPS (explicit TLS), or SFTP (SSH). It demonstrates a
|
||||
// stateful third-party integration behind the plugin contract: one descriptor
|
||||
// with a protocol switch, and a small protocol-agnostic `conn` abstraction that
|
||||
// HealthCheck and Invoke drive without caring which wire protocol is in use.
|
||||
//
|
||||
// Connections are opened per operation rather than pooled: FTP/SFTP sessions are
|
||||
// stateful and idle-timeout aggressively, so dialling on demand is both simpler
|
||||
// and more robust than keeping a long-lived connection healthy. Init only stores
|
||||
// the resolved config; nothing connects until HealthCheck or Invoke runs.
|
||||
//
|
||||
// - FTP : github.com/jlaffaye/ftp
|
||||
// - FTPS : github.com/jlaffaye/ftp with explicit TLS (AUTH TLS)
|
||||
// - SFTP : golang.org/x/crypto/ssh + github.com/pkg/sftp
|
||||
package filetransfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jlaffaye/ftp"
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
const (
|
||||
protoSFTP = "sftp"
|
||||
protoFTP = "ftp"
|
||||
protoFTPS = "ftps"
|
||||
|
||||
dialTimeout = 12 * time.Second
|
||||
// maxReadBytes caps a download so a huge remote file can't exhaust memory;
|
||||
// the health probe and Invoke both honour it.
|
||||
maxReadBytes = 32 << 20 // 32 MiB
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugins.Register("filetransfer", func() plugins.Plugin { return &Plugin{} })
|
||||
}
|
||||
|
||||
// Plugin is the FTP/FTPS/SFTP connector. All fields are guarded by mu because
|
||||
// Init may run concurrently with a HealthCheck/Invoke from another request.
|
||||
type Plugin struct {
|
||||
mu sync.Mutex
|
||||
protocol string
|
||||
host string
|
||||
port int
|
||||
username string
|
||||
password string
|
||||
privateKey string // PEM-encoded SSH private key (sftp only)
|
||||
keyPass string // passphrase for the private key
|
||||
basePath string
|
||||
// hostKeyFP, when set, pins the SFTP server's SHA256 host-key fingerprint
|
||||
// ("SHA256:…"); empty means accept any host key (trust-on-first-use, no
|
||||
// verification — flagged as degraded by the health probe).
|
||||
hostKeyFP string
|
||||
// insecureTLS skips FTPS certificate verification when true.
|
||||
insecureTLS bool
|
||||
}
|
||||
|
||||
func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "filetransfer",
|
||||
Provider: "FTP / SFTP",
|
||||
Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin,
|
||||
Category: plugins.CategoryDrivesExternal,
|
||||
AuthType: plugins.AuthBasic,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "list", Method: "GET", Endpoint: "/", Description: "List a remote directory. params: {path}"},
|
||||
{ID: "stat", Method: "GET", Endpoint: "/", Description: "Stat one remote path. params: {path}"},
|
||||
{ID: "download", Method: "GET", Endpoint: "/", Description: "Read a remote file (base64, ≤32 MiB). params: {path}"},
|
||||
{ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a remote file. params: {path, contentBase64}"},
|
||||
{ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a remote file. params: {path}"},
|
||||
{ID: "mkdir", Method: "PUT", Endpoint: "/", Description: "Create a remote directory. params: {path}"},
|
||||
},
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
// No field is Required: the plugin can be enabled as a master switch with
|
||||
// an empty global config, leaving each organization or user to supply
|
||||
// their own connection through the cascade (mirrors OpenSky). A missing
|
||||
// host is reported gracefully by the health probe.
|
||||
{Key: "protocol", Label: "Protocol", Type: "select", Default: protoSFTP,
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: protoSFTP, Label: "SFTP — file transfer over SSH (recommended)"},
|
||||
{Value: protoFTPS, Label: "FTPS — FTP with explicit TLS (AUTH TLS)"},
|
||||
{Value: protoFTP, Label: "FTP — plaintext (insecure)"},
|
||||
},
|
||||
Help: "SFTP runs over SSH (port 22); FTP/FTPS use port 21 by default."},
|
||||
{Key: "host", Label: "Host", Type: "text", Help: "Server hostname or IP, e.g. files.example.com"},
|
||||
{Key: "port", Label: "Port", Type: "number", Help: "Leave blank for the protocol default (22 for SFTP, 21 for FTP/FTPS)."},
|
||||
{Key: "username", Label: "Username", Type: "text"},
|
||||
{Key: "password", Label: "Password", Type: "password", Secret: true,
|
||||
Help: "Password for FTP/FTPS, or SFTP password auth. Leave blank to use an SFTP private key."},
|
||||
{Key: "privateKey", Label: "SSH private key (SFTP)", Type: "password", Secret: true,
|
||||
Help: "PEM-encoded private key for SFTP key auth. Used instead of, or alongside, a password."},
|
||||
{Key: "keyPassphrase", Label: "Private key passphrase", Type: "password", Secret: true,
|
||||
Help: "Passphrase protecting the SSH private key, if any."},
|
||||
{Key: "basePath", Label: "Base path", Type: "text", Default: ".",
|
||||
Help: "Directory used as the working root and probed by the health check, e.g. /uploads. Relative capability paths are resolved under it."},
|
||||
{Key: "hostKeyFingerprint", Label: "SFTP host key fingerprint", Type: "text",
|
||||
Help: "Optional SHA256:… fingerprint to pin the SFTP server's host key. Leave blank to accept any key (no verification)."},
|
||||
{Key: "insecureSkipVerify", Label: "FTPS TLS verification", Type: "select", Default: "false",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "false", Label: "Verify certificate (recommended)"},
|
||||
{Value: "true", Label: "Skip verification — accept any certificate"},
|
||||
},
|
||||
Help: "Only affects FTPS. Skip verification only for self-signed test servers."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.protocol = strings.ToLower(strings.TrimSpace(config["protocol"]))
|
||||
if p.protocol == "" {
|
||||
p.protocol = protoSFTP
|
||||
}
|
||||
p.host = strings.TrimSpace(config["host"])
|
||||
p.port = 0
|
||||
if raw := strings.TrimSpace(config["port"]); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
p.port = n
|
||||
}
|
||||
}
|
||||
p.username = strings.TrimSpace(config["username"])
|
||||
p.password = config["password"]
|
||||
p.privateKey = config["privateKey"]
|
||||
p.keyPass = config["keyPassphrase"]
|
||||
p.basePath = strings.TrimSpace(config["basePath"])
|
||||
if p.basePath == "" {
|
||||
p.basePath = "."
|
||||
}
|
||||
p.hostKeyFP = strings.TrimSpace(config["hostKeyFingerprint"])
|
||||
p.insecureTLS = strings.EqualFold(strings.TrimSpace(config["insecureSkipVerify"]), "true")
|
||||
return nil
|
||||
}
|
||||
|
||||
// effectivePort returns the configured port or the protocol default.
|
||||
func (p *Plugin) effectivePort() int {
|
||||
if p.port > 0 {
|
||||
return p.port
|
||||
}
|
||||
if p.protocol == protoSFTP {
|
||||
return 22
|
||||
}
|
||||
return 21
|
||||
}
|
||||
|
||||
// resolve joins a caller-supplied path against the base path. An absolute path
|
||||
// is used as-is; an empty path becomes the base path itself.
|
||||
func (p *Plugin) resolve(rel string) string {
|
||||
rel = strings.TrimSpace(rel)
|
||||
if rel == "" {
|
||||
return p.basePath
|
||||
}
|
||||
if strings.HasPrefix(rel, "/") || p.basePath == "" || p.basePath == "." {
|
||||
return rel
|
||||
}
|
||||
return path.Join(p.basePath, rel)
|
||||
}
|
||||
|
||||
// HealthCheck dials, authenticates, and lists the base path, classifying the
|
||||
// outcome. A missing/unverified SFTP host key downgrades OK to degraded.
|
||||
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
||||
start := time.Now()
|
||||
|
||||
p.mu.Lock()
|
||||
proto, host, hostKeyFP := p.protocol, p.host, p.hostKeyFP
|
||||
base := p.basePath
|
||||
p.mu.Unlock()
|
||||
|
||||
if host == "" {
|
||||
return plugins.Health{Status: plugins.StatusDown, Detail: "no host configured"}
|
||||
}
|
||||
|
||||
c, err := p.dial(ctx)
|
||||
if err != nil {
|
||||
lat := time.Since(start).Milliseconds()
|
||||
return plugins.Health{Status: classifyDialErr(err), LatencyMs: lat, Detail: err.Error()}
|
||||
}
|
||||
defer c.close()
|
||||
|
||||
entries, err := c.list(base)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: lat,
|
||||
Detail: fmt.Sprintf("connected (%s) but listing %q failed: %v", proto, base, err)}
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("%s reachable — %d entr%s under %q", strings.ToUpper(proto), len(entries), plural(len(entries)), base)
|
||||
status := plugins.StatusOK
|
||||
if proto == protoSFTP && hostKeyFP == "" {
|
||||
status = plugins.StatusDegraded
|
||||
detail += " · host key not verified (no fingerprint pinned)"
|
||||
}
|
||||
if proto == protoFTP {
|
||||
detail += " · plaintext (no encryption)"
|
||||
}
|
||||
return plugins.Health{Status: status, LatencyMs: lat, Detail: detail}
|
||||
}
|
||||
|
||||
// Invoke runs one capability against a freshly-dialled connection.
|
||||
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
c, err := p.dial(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.close()
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
entries, err := c.list(p.resolve(in.Path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "entries": entries})
|
||||
|
||||
case "stat":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
fi, err := c.stat(p.resolve(in.Path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(fi)
|
||||
|
||||
case "download":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
data, err := c.read(p.resolve(in.Path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"path": p.resolve(in.Path),
|
||||
"size": len(data),
|
||||
"contentBase64": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
|
||||
case "upload":
|
||||
var in writeParams
|
||||
if err := json.Unmarshal(params, &in); err != nil {
|
||||
return nil, fmt.Errorf("invalid params: %w", err)
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(in.ContentBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err)
|
||||
}
|
||||
if err := c.write(p.resolve(in.Path), data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "size": len(data), "ok": true})
|
||||
|
||||
case "delete":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
if err := c.remove(p.resolve(in.Path)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "ok": true})
|
||||
|
||||
case "mkdir":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
if err := c.mkdir(p.resolve(in.Path)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "ok": true})
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
||||
|
||||
// pathParams / writeParams are the Invoke request shapes.
|
||||
type pathParams struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
type writeParams struct {
|
||||
Path string `json:"path"`
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
|
||||
// fileInfo is the normalized directory-entry shape returned by list/stat.
|
||||
type fileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
ModTime string `json:"modTime,omitempty"`
|
||||
}
|
||||
|
||||
// conn is the protocol-agnostic surface HealthCheck and Invoke drive. Both the
|
||||
// FTP and SFTP implementations satisfy it.
|
||||
type conn interface {
|
||||
list(path string) ([]fileInfo, error)
|
||||
stat(path string) (fileInfo, error)
|
||||
read(path string) ([]byte, error)
|
||||
write(path string, data []byte) error
|
||||
remove(path string) error
|
||||
mkdir(path string) error
|
||||
close() error
|
||||
}
|
||||
|
||||
// dial builds an authenticated connection for the configured protocol.
|
||||
func (p *Plugin) dial(ctx context.Context) (conn, error) {
|
||||
p.mu.Lock()
|
||||
proto := p.protocol
|
||||
p.mu.Unlock()
|
||||
|
||||
switch proto {
|
||||
case protoSFTP:
|
||||
return p.dialSFTP(ctx)
|
||||
case protoFTP, protoFTPS:
|
||||
return p.dialFTP(ctx)
|
||||
default:
|
||||
return nil, errors.New("unsupported protocol: " + proto)
|
||||
}
|
||||
}
|
||||
|
||||
// classifyDialErr maps a dial/auth failure to a health status: an auth rejection
|
||||
// is degraded (server reachable, credentials wrong); anything else is down.
|
||||
func classifyDialErr(err error) string {
|
||||
msg := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(msg, "unable to authenticate"),
|
||||
strings.Contains(msg, "auth"),
|
||||
strings.Contains(msg, "password"),
|
||||
strings.Contains(msg, "login"),
|
||||
strings.Contains(msg, "530"), // FTP: not logged in
|
||||
strings.Contains(msg, "permission denied"):
|
||||
return plugins.StatusDegraded
|
||||
default:
|
||||
return plugins.StatusDown
|
||||
}
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return "y"
|
||||
}
|
||||
return "ies"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SFTP implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type sftpConn struct {
|
||||
ssh *ssh.Client
|
||||
cli *sftp.Client
|
||||
}
|
||||
|
||||
func (p *Plugin) dialSFTP(ctx context.Context) (conn, error) {
|
||||
p.mu.Lock()
|
||||
host, user, pass := p.host, p.username, p.password
|
||||
key, keyPass, hostKeyFP := p.privateKey, p.keyPass, p.hostKeyFP
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(p.effectivePort()))
|
||||
p.mu.Unlock()
|
||||
|
||||
var auth []ssh.AuthMethod
|
||||
if strings.TrimSpace(key) != "" {
|
||||
signer, err := parseSigner(key, keyPass)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("private key: %w", err)
|
||||
}
|
||||
auth = append(auth, ssh.PublicKeys(signer))
|
||||
}
|
||||
if pass != "" {
|
||||
auth = append(auth, ssh.Password(pass))
|
||||
}
|
||||
if len(auth) == 0 {
|
||||
return nil, errors.New("SFTP requires a password or a private key")
|
||||
}
|
||||
|
||||
hostKeyCallback, err := hostKeyChecker(hostKeyFP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: auth,
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: dialTimeout,
|
||||
}
|
||||
|
||||
// ssh.Dial has no context form; dial the TCP conn with the context, then
|
||||
// run the SSH handshake over it.
|
||||
d := net.Dialer{Timeout: dialTimeout}
|
||||
tcp, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(tcp, addr, cfg)
|
||||
if err != nil {
|
||||
_ = tcp.Close()
|
||||
return nil, err
|
||||
}
|
||||
client := ssh.NewClient(sshConn, chans, reqs)
|
||||
sc, err := sftp.NewClient(client)
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &sftpConn{ssh: client, cli: sc}, nil
|
||||
}
|
||||
|
||||
// parseSigner parses a PEM private key, with or without a passphrase.
|
||||
func parseSigner(pem, passphrase string) (ssh.Signer, error) {
|
||||
if strings.TrimSpace(passphrase) != "" {
|
||||
return ssh.ParsePrivateKeyWithPassphrase([]byte(pem), []byte(passphrase))
|
||||
}
|
||||
return ssh.ParsePrivateKey([]byte(pem))
|
||||
}
|
||||
|
||||
// hostKeyChecker returns a HostKeyCallback that pins the given SHA256:…
|
||||
// fingerprint, or accepts any key when the fingerprint is empty.
|
||||
func hostKeyChecker(fingerprint string) (ssh.HostKeyCallback, error) {
|
||||
if fingerprint == "" {
|
||||
return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec // opt-in: no fingerprint pinned
|
||||
}
|
||||
want := strings.TrimSpace(fingerprint)
|
||||
return func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||||
got := ssh.FingerprintSHA256(key)
|
||||
if got != want {
|
||||
return fmt.Errorf("host key mismatch: server presented %s, expected %s", got, want)
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *sftpConn) list(p string) ([]fileInfo, error) {
|
||||
infos, err := c.cli.ReadDir(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]fileInfo, 0, len(infos))
|
||||
for _, fi := range infos {
|
||||
out = append(out, fileInfo{
|
||||
Name: fi.Name(),
|
||||
Size: fi.Size(),
|
||||
IsDir: fi.IsDir(),
|
||||
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *sftpConn) stat(p string) (fileInfo, error) {
|
||||
fi, err := c.cli.Stat(p)
|
||||
if err != nil {
|
||||
return fileInfo{}, err
|
||||
}
|
||||
return fileInfo{
|
||||
Name: fi.Name(),
|
||||
Size: fi.Size(),
|
||||
IsDir: fi.IsDir(),
|
||||
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *sftpConn) read(p string) ([]byte, error) {
|
||||
f, err := c.cli.Open(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, maxReadBytes))
|
||||
}
|
||||
|
||||
func (c *sftpConn) write(p string, data []byte) error {
|
||||
f, err := c.cli.Create(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *sftpConn) remove(p string) error { return c.cli.Remove(p) }
|
||||
func (c *sftpConn) mkdir(p string) error { return c.cli.MkdirAll(p) }
|
||||
|
||||
func (c *sftpConn) close() error {
|
||||
err := c.cli.Close()
|
||||
if c.ssh != nil {
|
||||
_ = c.ssh.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FTP / FTPS implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ftpConn struct {
|
||||
c *ftp.ServerConn
|
||||
}
|
||||
|
||||
func (p *Plugin) dialFTP(ctx context.Context) (conn, error) {
|
||||
p.mu.Lock()
|
||||
host, user, pass, proto := p.host, p.username, p.password, p.protocol
|
||||
insecure := p.insecureTLS
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(p.effectivePort()))
|
||||
p.mu.Unlock()
|
||||
|
||||
opts := []ftp.DialOption{ftp.DialWithContext(ctx), ftp.DialWithTimeout(dialTimeout)}
|
||||
if proto == protoFTPS {
|
||||
opts = append(opts, ftp.DialWithExplicitTLS(&tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: insecure, //nolint:gosec // opt-in for self-signed test servers
|
||||
}))
|
||||
}
|
||||
|
||||
sc, err := ftp.Dial(addr, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := sc.Login(user, pass); err != nil {
|
||||
_ = sc.Quit()
|
||||
return nil, err
|
||||
}
|
||||
return &ftpConn{c: sc}, nil
|
||||
}
|
||||
|
||||
func (c *ftpConn) list(p string) ([]fileInfo, error) {
|
||||
entries, err := c.c.List(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]fileInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.Name == "." || e.Name == ".." {
|
||||
continue
|
||||
}
|
||||
out = append(out, entryToInfo(e))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *ftpConn) stat(p string) (fileInfo, error) {
|
||||
// FTP has no portable stat; MLST via GetEntry works on servers that support
|
||||
// it, otherwise fall back to listing the parent and matching the name.
|
||||
if e, err := c.c.GetEntry(p); err == nil && e != nil {
|
||||
return entryToInfo(e), nil
|
||||
}
|
||||
dir, base := path.Split(strings.TrimRight(p, "/"))
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
entries, err := c.c.List(dir)
|
||||
if err != nil {
|
||||
return fileInfo{}, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name == base {
|
||||
return entryToInfo(e), nil
|
||||
}
|
||||
}
|
||||
return fileInfo{}, fmt.Errorf("not found: %s", p)
|
||||
}
|
||||
|
||||
func (c *ftpConn) read(p string) ([]byte, error) {
|
||||
resp, err := c.c.Retr(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Close()
|
||||
return io.ReadAll(io.LimitReader(resp, maxReadBytes))
|
||||
}
|
||||
|
||||
func (c *ftpConn) write(p string, data []byte) error {
|
||||
return c.c.Stor(p, strings.NewReader(string(data)))
|
||||
}
|
||||
|
||||
func (c *ftpConn) remove(p string) error { return c.c.Delete(p) }
|
||||
func (c *ftpConn) mkdir(p string) error { return c.c.MakeDir(p) }
|
||||
|
||||
func (c *ftpConn) close() error { return c.c.Quit() }
|
||||
|
||||
// entryToInfo normalizes a jlaffaye/ftp entry.
|
||||
func entryToInfo(e *ftp.Entry) fileInfo {
|
||||
fi := fileInfo{
|
||||
Name: e.Name,
|
||||
Size: int64(e.Size),
|
||||
IsDir: e.Type == ftp.EntryTypeFolder,
|
||||
}
|
||||
if !e.Time.IsZero() {
|
||||
fi.ModTime = e.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return fi
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package filetransfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
func TestDescriptor(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
d := p.Descriptor()
|
||||
if d.Name != "filetransfer" {
|
||||
t.Fatalf("name = %q, want filetransfer", d.Name)
|
||||
}
|
||||
if d.Kind != plugins.KindBuiltin {
|
||||
t.Fatalf("kind = %q, want builtin", d.Kind)
|
||||
}
|
||||
if len(d.Capabilities) == 0 {
|
||||
t.Fatal("expected capabilities")
|
||||
}
|
||||
// Every secret field must be flagged so the manager masks it.
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Key == "password" || f.Key == "privateKey" || f.Key == "keyPassphrase" {
|
||||
if !f.Secret {
|
||||
t.Errorf("config field %q must be Secret", f.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDefaults(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
if err := p.Init(context.Background(), map[string]string{"host": "h", "username": "u"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.protocol != protoSFTP {
|
||||
t.Errorf("default protocol = %q, want sftp", p.protocol)
|
||||
}
|
||||
if p.effectivePort() != 22 {
|
||||
t.Errorf("default sftp port = %d, want 22", p.effectivePort())
|
||||
}
|
||||
p.protocol = protoFTP
|
||||
if p.effectivePort() != 21 {
|
||||
t.Errorf("default ftp port = %d, want 21", p.effectivePort())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
p := &Plugin{basePath: "/uploads"}
|
||||
cases := map[string]string{
|
||||
"": "/uploads",
|
||||
"a/b.txt": "/uploads/a/b.txt",
|
||||
"/etc/abs": "/etc/abs",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := p.resolve(in); got != want {
|
||||
t.Errorf("resolve(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthCheckUnreachable confirms an unreachable host is classified as down
|
||||
// (not a panic) — the graceful-failure path Init/HealthCheck must guarantee.
|
||||
func TestHealthCheckUnreachable(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
// Port 1 is reserved and refuses connections quickly.
|
||||
if err := p.Init(context.Background(), map[string]string{
|
||||
"protocol": protoSFTP, "host": "127.0.0.1", "port": "1",
|
||||
"username": "u", "password": "pw",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := p.HealthCheck(context.Background())
|
||||
if h.Status != plugins.StatusDown {
|
||||
t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostKeyFingerprintMismatch(t *testing.T) {
|
||||
cb, err := hostKeyChecker("SHA256:doesnotmatch")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cb == nil {
|
||||
t.Fatal("expected a callback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistered confirms the plugin registered itself with the shared registry
|
||||
// via init(), so the manager will surface it.
|
||||
func TestRegistered(t *testing.T) {
|
||||
m := plugins.NewManager(t.TempDir() + "/plugins.json")
|
||||
if _, ok := m.Get("filetransfer"); !ok {
|
||||
t.Fatal("filetransfer not registered in the plugin manager")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// Package localstorage is a built-in plugin that exposes a directory on the host
|
||||
// machine's own filesystem as a storage "drive", behind the same capability
|
||||
// surface (list/stat/download/upload/delete/mkdir) as the remote filetransfer
|
||||
// connector. Where filetransfer dials FTP/SFTP, this one just calls the os
|
||||
// package — there is no network, no auth, and nothing to dial.
|
||||
//
|
||||
// Every caller-supplied path is confined under the configured base path: paths
|
||||
// are treated as relative to the base and cleaned so that ".." or a leading
|
||||
// separator can never escape the storage root. This is the one piece of extra
|
||||
// care a local-filesystem connector needs that a remote one gets from the remote
|
||||
// server's own chroot/permissions.
|
||||
package localstorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
// maxReadBytes caps a download so a huge file can't exhaust memory; the health
|
||||
// probe and Invoke both honour it (mirrors filetransfer).
|
||||
const maxReadBytes = 32 << 20 // 32 MiB
|
||||
|
||||
func init() {
|
||||
plugins.Register("localstorage", func() plugins.Plugin { return &Plugin{} })
|
||||
}
|
||||
|
||||
// Plugin is the local-filesystem connector. Fields are guarded by mu because
|
||||
// Init may run concurrently with a HealthCheck/Invoke from another request.
|
||||
type Plugin struct {
|
||||
mu sync.Mutex
|
||||
basePath string
|
||||
createMissing bool // create the base path (and upload/mkdir parents) if absent
|
||||
readOnly bool // reject upload/delete/mkdir when true
|
||||
}
|
||||
|
||||
func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "localstorage",
|
||||
Provider: "Local Filesystem",
|
||||
Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin,
|
||||
Category: plugins.CategoryDrivesLocal,
|
||||
AuthType: plugins.AuthNone,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "list", Method: "GET", Endpoint: "/", Description: "List a directory under the base path. params: {path}"},
|
||||
{ID: "stat", Method: "GET", Endpoint: "/", Description: "Stat one path under the base path. params: {path}"},
|
||||
{ID: "download", Method: "GET", Endpoint: "/", Description: "Read a file (base64, ≤32 MiB). params: {path}"},
|
||||
{ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a file (creates parent dirs). params: {path, contentBase64}"},
|
||||
{ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a file or empty directory. params: {path}"},
|
||||
{ID: "mkdir", Method: "PUT", Endpoint: "/", Description: "Create a directory. params: {path}"},
|
||||
},
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
// No field is Required: like the other drive plugins, this can be enabled
|
||||
// as a master switch with an empty config; a missing base path is reported
|
||||
// gracefully by the health probe rather than blocking the switch.
|
||||
{Key: "basePath", Label: "Base path", Type: "text",
|
||||
Help: `Absolute directory used as the storage root, e.g. /data or /var/lib/pilotvault. In Docker this should be a mounted volume so data survives redeploys, and the container user must own it. Every operation is confined within it — ".." and absolute paths cannot escape.`},
|
||||
{Key: "createMissing", Label: "Create base path", Type: "select", Default: "false",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "false", Label: "Require the directory to already exist"},
|
||||
{Value: "true", Label: "Create it if missing (also creates upload/mkdir parents)"},
|
||||
},
|
||||
Help: "When on, the base path is created by the health check and parent directories are created on upload/mkdir."},
|
||||
{Key: "readOnly", Label: "Access mode", Type: "select", Default: "false",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "false", Label: "Read-write"},
|
||||
{Value: "true", Label: "Read-only — reject upload, delete and mkdir"},
|
||||
},
|
||||
Help: "Read-only is a safety guard for pointing at a directory you only want to serve from."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.basePath = strings.TrimSpace(config["basePath"])
|
||||
p.createMissing = strings.EqualFold(strings.TrimSpace(config["createMissing"]), "true")
|
||||
p.readOnly = strings.EqualFold(strings.TrimSpace(config["readOnly"]), "true")
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve confines a caller-supplied path under the base path. The path is always
|
||||
// treated as relative to the base; a leading separator or ".." segments are
|
||||
// neutralized by cleaning against a virtual root, so the result can never escape.
|
||||
func (p *Plugin) resolve(rel string) (string, error) {
|
||||
base := strings.TrimSpace(p.basePath)
|
||||
if base == "" {
|
||||
return "", errors.New("no base path configured")
|
||||
}
|
||||
absBase, err := filepath.Abs(base)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Clean against a virtual root so "..", ".", and leading separators collapse to
|
||||
// a path that stays at or below "/", then strip the root and join under base.
|
||||
virtual := filepath.ToSlash(strings.TrimSpace(rel))
|
||||
cleaned := filepath.Clean("/" + strings.TrimLeft(virtual, "/"))
|
||||
sub := filepath.FromSlash(strings.TrimPrefix(cleaned, "/"))
|
||||
joined := filepath.Join(absBase, sub)
|
||||
|
||||
// Belt-and-braces containment check after joining.
|
||||
if joined != absBase && !strings.HasPrefix(joined, absBase+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("path %q escapes the base directory", rel)
|
||||
}
|
||||
return joined, nil
|
||||
}
|
||||
|
||||
// HealthCheck verifies the base path exists, is a directory, is readable, and
|
||||
// (unless read-only) is writable. Missing-but-creatable resolves to OK.
|
||||
func (p *Plugin) HealthCheck(_ context.Context) plugins.Health {
|
||||
start := time.Now()
|
||||
|
||||
p.mu.Lock()
|
||||
base, create, readOnly := p.basePath, p.createMissing, p.readOnly
|
||||
p.mu.Unlock()
|
||||
|
||||
if strings.TrimSpace(base) == "" {
|
||||
return plugins.Health{Status: plugins.StatusDown, Detail: "no base path configured"}
|
||||
}
|
||||
absBase, err := filepath.Abs(base)
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: err.Error()}
|
||||
}
|
||||
|
||||
info, err := os.Stat(absBase)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && create {
|
||||
if mkErr := os.MkdirAll(absBase, 0o755); mkErr != nil {
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start),
|
||||
Detail: fmt.Sprintf("base path %q does not exist and could not be created: %v", absBase, mkErr)}
|
||||
}
|
||||
info, err = os.Stat(absBase)
|
||||
}
|
||||
if err != nil {
|
||||
detail := fmt.Sprintf("base path %q not accessible: %v", absBase, err)
|
||||
if os.IsNotExist(err) {
|
||||
detail = fmt.Sprintf("base path %q does not exist (enable \"Create base path\" to create it)", absBase)
|
||||
}
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: detail}
|
||||
}
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start),
|
||||
Detail: fmt.Sprintf("base path %q is not a directory", absBase)}
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(absBase)
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: ms(start),
|
||||
Detail: fmt.Sprintf("base path %q is not readable: %v", absBase, err)}
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("%q reachable — %d entr%s", absBase, len(entries), plural(len(entries)))
|
||||
status := plugins.StatusOK
|
||||
if readOnly {
|
||||
detail += " · read-only"
|
||||
} else if werr := probeWritable(absBase); werr != nil {
|
||||
status = plugins.StatusDegraded
|
||||
detail += fmt.Sprintf(" · not writable: %v", werr)
|
||||
} else {
|
||||
detail += " · read-write"
|
||||
}
|
||||
return plugins.Health{Status: status, LatencyMs: ms(start), Detail: detail}
|
||||
}
|
||||
|
||||
// Invoke runs one capability against the local filesystem.
|
||||
func (p *Plugin) Invoke(_ context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
p.mu.Lock()
|
||||
create, readOnly := p.createMissing, p.readOnly
|
||||
p.mu.Unlock()
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
target, err := p.resolve(in.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]fileInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, dirEntryToInfo(e))
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": target, "entries": out})
|
||||
|
||||
case "stat":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
target, err := p.resolve(in.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fi, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(statToInfo(fi))
|
||||
|
||||
case "download":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
target, err := p.resolve(in.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := readCapped(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"path": target,
|
||||
"size": len(data),
|
||||
"contentBase64": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
|
||||
case "upload":
|
||||
if readOnly {
|
||||
return nil, errReadOnly
|
||||
}
|
||||
var in writeParams
|
||||
if err := json.Unmarshal(params, &in); err != nil {
|
||||
return nil, fmt.Errorf("invalid params: %w", err)
|
||||
}
|
||||
target, err := p.resolve(in.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(in.ContentBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err)
|
||||
}
|
||||
if create {
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": target, "size": len(data), "ok": true})
|
||||
|
||||
case "delete":
|
||||
if readOnly {
|
||||
return nil, errReadOnly
|
||||
}
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
target, err := p.resolve(in.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Refuse to delete the base path itself.
|
||||
absBase, _ := filepath.Abs(strings.TrimSpace(p.basePath))
|
||||
if target == absBase {
|
||||
return nil, errors.New("refusing to delete the base directory")
|
||||
}
|
||||
if err := os.Remove(target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": target, "ok": true})
|
||||
|
||||
case "mkdir":
|
||||
if readOnly {
|
||||
return nil, errReadOnly
|
||||
}
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
target, err := p.resolve(in.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": target, "ok": true})
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
||||
|
||||
var errReadOnly = errors.New("plugin is configured read-only")
|
||||
|
||||
// pathParams / writeParams are the Invoke request shapes (mirrors filetransfer).
|
||||
type pathParams struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
type writeParams struct {
|
||||
Path string `json:"path"`
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
|
||||
// fileInfo is the normalized directory-entry shape returned by list/stat.
|
||||
type fileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
ModTime string `json:"modTime,omitempty"`
|
||||
}
|
||||
|
||||
func statToInfo(fi os.FileInfo) fileInfo {
|
||||
return fileInfo{
|
||||
Name: fi.Name(),
|
||||
Size: fi.Size(),
|
||||
IsDir: fi.IsDir(),
|
||||
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// dirEntryToInfo normalizes an os.DirEntry, tolerating a stat failure on a single
|
||||
// entry (e.g. a broken symlink) by reporting name/isDir without size/modtime.
|
||||
func dirEntryToInfo(e os.DirEntry) fileInfo {
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
return fileInfo{Name: e.Name(), IsDir: e.IsDir()}
|
||||
}
|
||||
return statToInfo(fi)
|
||||
}
|
||||
|
||||
// readCapped reads a file up to maxReadBytes.
|
||||
func readCapped(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, maxReadBytes))
|
||||
}
|
||||
|
||||
// probeWritable confirms the directory accepts a write by creating and removing a
|
||||
// short-lived temp file.
|
||||
func probeWritable(dir string) error {
|
||||
f, err := os.CreateTemp(dir, ".pilotvault-health-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := f.Name()
|
||||
_ = f.Close()
|
||||
return os.Remove(name)
|
||||
}
|
||||
|
||||
func ms(start time.Time) int64 { return time.Since(start).Milliseconds() }
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return "y"
|
||||
}
|
||||
return "ies"
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package localstorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
func TestDescriptor(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
d := p.Descriptor()
|
||||
if d.Name != "localstorage" {
|
||||
t.Fatalf("name = %q, want localstorage", d.Name)
|
||||
}
|
||||
if d.Kind != plugins.KindBuiltin {
|
||||
t.Fatalf("kind = %q, want builtin", d.Kind)
|
||||
}
|
||||
if d.Category != plugins.CategoryDrivesLocal {
|
||||
t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryDrivesLocal)
|
||||
}
|
||||
if len(d.Capabilities) == 0 {
|
||||
t.Fatal("expected capabilities")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistered confirms the plugin registered itself with the shared registry
|
||||
// via init(), so the manager will surface it.
|
||||
func TestRegistered(t *testing.T) {
|
||||
m := plugins.NewManager(t.TempDir() + "/plugins.json")
|
||||
if _, ok := m.Get("localstorage"); !ok {
|
||||
t.Fatal("localstorage not registered in the plugin manager")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveConfinement verifies that traversal, absolute-looking, and
|
||||
// backslash paths all stay under the base directory.
|
||||
func TestResolveConfinement(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
p := &Plugin{basePath: base}
|
||||
absBase, _ := filepath.Abs(base)
|
||||
|
||||
contained := []string{"a/b.txt", "/etc/passwd", "../../../etc/passwd", "a\\b", "./x", ""}
|
||||
for _, in := range contained {
|
||||
got, err := p.resolve(in)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve(%q) errored: %v", in, err)
|
||||
}
|
||||
if got != absBase && !strings.HasPrefix(got, absBase+string(os.PathSeparator)) {
|
||||
t.Errorf("resolve(%q) = %q escaped base %q", in, got, absBase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNoBase(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
if _, err := p.resolve("x"); err == nil {
|
||||
t.Fatal("expected error when base path unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckMissing(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
// Base path unset -> down.
|
||||
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
|
||||
t.Errorf("unset base: status = %q, want down", h.Status)
|
||||
}
|
||||
// Nonexistent path without createMissing -> down.
|
||||
_ = p.Init(context.Background(), map[string]string{"basePath": filepath.Join(t.TempDir(), "nope")})
|
||||
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
|
||||
t.Errorf("missing base: status = %q, want down (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckCreateMissing(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "created")
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"basePath": dir, "createMissing": "true"})
|
||||
h := p.HealthCheck(context.Background())
|
||||
if h.Status != plugins.StatusOK {
|
||||
t.Fatalf("status = %q, want ok (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
|
||||
t.Fatalf("base path was not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoundTrip exercises upload -> list -> download -> delete end to end.
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"basePath": base, "createMissing": "true"})
|
||||
|
||||
payload := []byte("hello pilotvault")
|
||||
up, _ := json.Marshal(writeParams{Path: "sub/dir/file.txt", ContentBase64: base64.StdEncoding.EncodeToString(payload)})
|
||||
if _, err := p.Invoke(context.Background(), "upload", up); err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
|
||||
dl, _ := json.Marshal(pathParams{Path: "sub/dir/file.txt"})
|
||||
raw, err := p.Invoke(context.Background(), "download", dl)
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &got)
|
||||
if decoded, _ := base64.StdEncoding.DecodeString(got.ContentBase64); string(decoded) != string(payload) {
|
||||
t.Fatalf("download content = %q, want %q", decoded, payload)
|
||||
}
|
||||
|
||||
if _, err := p.Invoke(context.Background(), "delete", dl); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, "sub", "dir", "file.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("file still present after delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyRejectsWrites(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"basePath": base, "readOnly": "true"})
|
||||
|
||||
up, _ := json.Marshal(writeParams{Path: "x.txt", ContentBase64: ""})
|
||||
if _, err := p.Invoke(context.Background(), "upload", up); err == nil {
|
||||
t.Error("upload should be rejected in read-only mode")
|
||||
}
|
||||
del, _ := json.Marshal(pathParams{Path: "x.txt"})
|
||||
if _, err := p.Invoke(context.Background(), "delete", del); err == nil {
|
||||
t.Error("delete should be rejected in read-only mode")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// Package opensky is a built-in plugin connecting the OpenSky Network REST API
|
||||
// (live ADS-B aircraft state vectors). It demonstrates a real third-party
|
||||
// integration behind the plugin contract, including an OAuth2 client-credentials
|
||||
// AuthProvider with an anonymous fallback.
|
||||
//
|
||||
// Docs: https://openskynetwork.github.io/opensky-api/rest.html
|
||||
package opensky
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBase = "https://opensky-network.org/api"
|
||||
tokenURL = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token"
|
||||
// Small default bounding box (Netherlands) keeps the health probe cheap.
|
||||
defaultBBox = "50.5,3.2,53.7,7.3" // lamin,lomin,lamax,lomax
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugins.Register("opensky", func() plugins.Plugin { return &Plugin{} })
|
||||
}
|
||||
|
||||
// Plugin is the OpenSky connector.
|
||||
type Plugin struct {
|
||||
mu sync.Mutex
|
||||
clientID string
|
||||
clientSecret string
|
||||
bbox string
|
||||
plan string
|
||||
allowAnonymous bool
|
||||
client *http.Client
|
||||
|
||||
token string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
// errAnonDisabled is returned when a probe/call has no resolved credentials and
|
||||
// the operator has disabled anonymous access.
|
||||
var errAnonDisabled = errors.New("OpenSky credentials required — anonymous access is disabled")
|
||||
|
||||
func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "opensky",
|
||||
Provider: "OpenSky Network",
|
||||
Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin,
|
||||
Category: plugins.CategoryAPIsExternal,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "states.all", Method: "GET", Endpoint: "/states/all",
|
||||
Description: "All current aircraft state vectors, world-wide (costs 4 credits/call)."},
|
||||
{ID: "states.bbox", Method: "GET", Endpoint: "/states/all?lamin&lomin&lamax&lomax",
|
||||
Description: "State vectors within the configured bounding box (1–4 credits by area)."},
|
||||
},
|
||||
AuthType: plugins.AuthOAuth2,
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
{Key: "plan", Label: "OpenSky plan", Type: "select",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "", Label: "Not set — let organizations and users choose"},
|
||||
{Value: "anonymous", Label: "Anonymous — 400 credits/day"},
|
||||
{Value: "standard", Label: "Standard (registered) — 4000 credits/day"},
|
||||
{Value: "contributor", Label: "Contributor — 8000 credits/day"},
|
||||
},
|
||||
Help: "Global account tier. Leave it unset to let each organization or user pick their own plan; set a value only to force one plan for everyone. Determines the daily credit allowance shown next to remaining credits."},
|
||||
{Key: "clientId", Label: "OAuth2 client ID", Type: "text", Help: "Optional — leave blank for anonymous access (lower rate limits)."},
|
||||
{Key: "clientSecret", Label: "OAuth2 client secret", Type: "password", Secret: true, Help: "Paired with the client ID for authenticated access."},
|
||||
{Key: "bbox", Label: "Default bounding box", Type: "text", Default: defaultBBox, Help: "lamin,lomin,lamax,lomax — used by the health probe and states.bbox."},
|
||||
{Key: "allowAnonymous", Label: "Anonymous access", Type: "select", Default: "true",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "true", Label: "Enabled — allow use without credentials"},
|
||||
{Value: "false", Label: "Disabled — require OAuth2 credentials"},
|
||||
},
|
||||
Help: "Global policy: when disabled, the plugin can only be used once OAuth2 credentials resolve from some layer (superadmin, organization, or user)."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// planDailyCredits maps an OpenSky plan to its daily credit allowance.
|
||||
// See https://openskynetwork.github.io/opensky-api/rest.html#api-credits
|
||||
func planDailyCredits(plan string) int {
|
||||
switch plan {
|
||||
case "anonymous":
|
||||
return 400
|
||||
case "contributor":
|
||||
return 8000
|
||||
default: // "standard"
|
||||
return 4000
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.clientID = strings.TrimSpace(config["clientId"])
|
||||
p.clientSecret = config["clientSecret"]
|
||||
p.bbox = strings.TrimSpace(config["bbox"])
|
||||
if p.bbox == "" {
|
||||
p.bbox = defaultBBox
|
||||
}
|
||||
p.plan = strings.TrimSpace(config["plan"])
|
||||
if p.plan == "" {
|
||||
p.plan = "standard" // OpenSky registered-user default
|
||||
}
|
||||
// Anonymous access defaults to enabled; only an explicit "false" turns it off.
|
||||
p.allowAnonymous = !strings.EqualFold(strings.TrimSpace(config["allowAnonymous"]), "false")
|
||||
p.client = &http.Client{Timeout: 10 * time.Second}
|
||||
p.token, p.tokenExp = "", time.Time{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bearer returns a valid OAuth2 token, fetching/refreshing via client-credentials
|
||||
// when configured. Returns "" (no error) when running anonymously.
|
||||
func (p *Plugin) bearer(ctx context.Context) (string, error) {
|
||||
p.mu.Lock()
|
||||
id, secret, allowAnon := p.clientID, p.clientSecret, p.allowAnonymous
|
||||
if p.token != "" && time.Now().Before(p.tokenExp) {
|
||||
tok := p.token
|
||||
p.mu.Unlock()
|
||||
return tok, nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if id == "" || secret == "" {
|
||||
if !allowAnon {
|
||||
return "", errAnonDisabled
|
||||
}
|
||||
return "", nil // anonymous
|
||||
}
|
||||
|
||||
form := url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {id},
|
||||
"client_secret": {secret},
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errors.New("token endpoint returned HTTP " + resp.Status)
|
||||
}
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil || out.AccessToken == "" {
|
||||
return "", errors.New("no access_token in token response")
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.token = out.AccessToken
|
||||
ttl := out.ExpiresIn
|
||||
if ttl <= 0 {
|
||||
ttl = 1800
|
||||
}
|
||||
p.tokenExp = time.Now().Add(time.Duration(ttl-30) * time.Second)
|
||||
p.mu.Unlock()
|
||||
return out.AccessToken, nil
|
||||
}
|
||||
|
||||
// statesURLBBox builds the /states/all request URL constrained to the configured
|
||||
// bounding box. Falls back to the whole world if the bbox is malformed.
|
||||
func (p *Plugin) statesURLBBox() string {
|
||||
p.mu.Lock()
|
||||
bbox := p.bbox
|
||||
p.mu.Unlock()
|
||||
parts := strings.Split(bbox, ",")
|
||||
if len(parts) != 4 {
|
||||
return apiBase + "/states/all"
|
||||
}
|
||||
q := url.Values{
|
||||
"lamin": {strings.TrimSpace(parts[0])},
|
||||
"lomin": {strings.TrimSpace(parts[1])},
|
||||
"lamax": {strings.TrimSpace(parts[2])},
|
||||
"lomax": {strings.TrimSpace(parts[3])},
|
||||
}
|
||||
return apiBase + "/states/all?" + q.Encode()
|
||||
}
|
||||
|
||||
// statesURLAll returns the world-wide /states/all URL (no bounding box).
|
||||
func (p *Plugin) statesURLAll() string { return apiBase + "/states/all" }
|
||||
|
||||
// creditCost returns the OpenSky credit cost of a /states/all call over the given
|
||||
// bounding box, per https://openskynetwork.github.io/opensky-api/rest.html#api-credits:
|
||||
// 1 credit ≤ 25 sq°, 2 ≤ 100, 3 ≤ 400, 4 for larger or the whole world.
|
||||
func creditCost(bbox string) int {
|
||||
parts := strings.Split(bbox, ",")
|
||||
if len(parts) != 4 {
|
||||
return 4 // no/invalid box → whole world
|
||||
}
|
||||
lamin, e1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
||||
lomin, e2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
lamax, e3 := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
|
||||
lomax, e4 := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64)
|
||||
if e1 != nil || e2 != nil || e3 != nil || e4 != nil {
|
||||
return 4
|
||||
}
|
||||
area := math.Abs(lamax-lamin) * math.Abs(lomax-lomin)
|
||||
switch {
|
||||
case area <= 25:
|
||||
return 1
|
||||
case area <= 100:
|
||||
return 2
|
||||
case area <= 400:
|
||||
return 3
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
// creditWord renders a credit count with correct pluralisation.
|
||||
func creditWord(n int) string {
|
||||
if n == 1 {
|
||||
return "1 credit"
|
||||
}
|
||||
return strconv.Itoa(n) + " credits"
|
||||
}
|
||||
|
||||
// HealthCheck performs a live states query (authenticated when configured, else
|
||||
// anonymous) and classifies the outcome.
|
||||
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
||||
start := time.Now()
|
||||
token, err := p.bearer(ctx)
|
||||
if errors.Is(err, errAnonDisabled) {
|
||||
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
|
||||
Detail: err.Error()}
|
||||
}
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
|
||||
Detail: "auth failed: " + err.Error()}
|
||||
}
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, p.statesURLBBox(), nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||||
|
||||
mode := "anonymous"
|
||||
if token != "" {
|
||||
mode = "authenticated"
|
||||
}
|
||||
|
||||
h := plugins.Health{LatencyMs: lat}
|
||||
switch {
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
h.Status, h.Detail = plugins.StatusOK, "OpenSky reachable ("+mode+")"
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
h.Status, h.Detail = plugins.StatusDegraded, "rate limited (HTTP 429)"
|
||||
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
|
||||
h.Status, h.Detail = plugins.StatusDegraded, "auth rejected (HTTP "+resp.Status+")"
|
||||
default:
|
||||
h.Status, h.Detail = plugins.StatusDown, "HTTP "+resp.Status
|
||||
}
|
||||
// Surface live credit usage from the rate-limit header, the plan's daily
|
||||
// allowance, and this probe's cost (e.g. "3996/4000 credits left today · 1 credit/probe").
|
||||
// The same figures are also exposed structurally (h.Credits) so the UI can
|
||||
// render a dedicated usage meter without parsing this string.
|
||||
p.mu.Lock()
|
||||
bbox, plan := p.bbox, p.plan
|
||||
p.mu.Unlock()
|
||||
|
||||
cost := creditCost(bbox)
|
||||
credits := &plugins.HealthCredits{Daily: planDailyCredits(plan), ProbeCost: cost, Mode: mode}
|
||||
if rem := strings.TrimSpace(resp.Header.Get("X-Rate-Limit-Remaining")); rem != "" {
|
||||
if n, err := strconv.Atoi(rem); err == nil {
|
||||
credits.Remaining = &n
|
||||
}
|
||||
h.Detail += " · " + p.creditsText(rem)
|
||||
}
|
||||
h.Detail += " · " + creditWord(cost) + "/probe"
|
||||
h.Credits = credits
|
||||
return h
|
||||
}
|
||||
|
||||
// creditsText formats the remaining-credit header against the plan's daily
|
||||
// allowance. Empty when the header is absent.
|
||||
func (p *Plugin) creditsText(remaining string) string {
|
||||
remaining = strings.TrimSpace(remaining)
|
||||
if remaining == "" {
|
||||
return ""
|
||||
}
|
||||
p.mu.Lock()
|
||||
daily := planDailyCredits(p.plan)
|
||||
p.mu.Unlock()
|
||||
return remaining + "/" + strconv.Itoa(daily) + " credits left today"
|
||||
}
|
||||
|
||||
// Invoke exposes states.all / states.bbox. Part of the contract; no HTTP endpoint
|
||||
// surfaces it in v1, but it keeps the connector functional for future use.
|
||||
func (p *Plugin) Invoke(ctx context.Context, action string, _ json.RawMessage) (json.RawMessage, error) {
|
||||
switch action {
|
||||
case "states.all", "states.bbox":
|
||||
token, err := p.bearer(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := p.statesURLBBox()
|
||||
if action == "states.all" {
|
||||
target = p.statesURLAll() // world-wide (4 credits)
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
return data, nil
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
||||
@@ -0,0 +1,551 @@
|
||||
// Package webdav is a built-in plugin that connects to a WebDAV server over
|
||||
// HTTP(S). It offers the same capability surface as the filetransfer plugin
|
||||
// (list/stat/download/upload/delete/mkdir) but speaks WebDAV verbs — PROPFIND,
|
||||
// GET, PUT, DELETE, MKCOL — directly over net/http, so it needs no third-party
|
||||
// client library and cross-compiles cleanly for the Linux container.
|
||||
//
|
||||
// Like filetransfer, nothing connects during Init; each capability (and the
|
||||
// health probe) issues its own HTTP request against the configured base URL,
|
||||
// authenticating with HTTP Basic auth. This suits WebDAV, which is stateless
|
||||
// per request, and keeps the plugin free of long-lived connection state.
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
const (
|
||||
dialTimeout = 12 * time.Second
|
||||
// maxReadBytes caps a download so a huge remote file can't exhaust memory;
|
||||
// the health probe and Invoke both honour it.
|
||||
maxReadBytes = 32 << 20 // 32 MiB
|
||||
|
||||
// propfindBody requests just the properties we normalize into fileInfo.
|
||||
propfindBody = `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<d:propfind xmlns:d="DAV:"><d:prop>` +
|
||||
`<d:displayname/><d:getcontentlength/><d:getlastmodified/><d:resourcetype/>` +
|
||||
`</d:prop></d:propfind>`
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugins.Register("webdav", func() plugins.Plugin { return &Plugin{} })
|
||||
}
|
||||
|
||||
// Plugin is the WebDAV connector. All fields are guarded by mu because Init may
|
||||
// run concurrently with a HealthCheck/Invoke from another request.
|
||||
type Plugin struct {
|
||||
mu sync.Mutex
|
||||
baseURL string // e.g. https://cloud.example.com/remote.php/dav/files/alice/
|
||||
username string
|
||||
password string
|
||||
basePath string // working root, resolved under the base URL's path
|
||||
// insecureTLS skips HTTPS certificate verification when true.
|
||||
insecureTLS bool
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "webdav",
|
||||
Provider: "WebDAV",
|
||||
Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin,
|
||||
Category: plugins.CategoryDrivesExternal,
|
||||
AuthType: plugins.AuthBasic,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "list", Method: "PROPFIND", Endpoint: "/", Description: "List a remote directory. params: {path}"},
|
||||
{ID: "stat", Method: "PROPFIND", Endpoint: "/", Description: "Stat one remote path. params: {path}"},
|
||||
{ID: "download", Method: "GET", Endpoint: "/", Description: "Read a remote file (base64, ≤32 MiB). params: {path}"},
|
||||
{ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a remote file. params: {path, contentBase64}"},
|
||||
{ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a remote file or directory. params: {path}"},
|
||||
{ID: "mkdir", Method: "MKCOL", Endpoint: "/", Description: "Create a remote directory. params: {path}"},
|
||||
},
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
// No field is Required: the plugin can be enabled as a master switch with
|
||||
// an empty global config, leaving each organization or user to supply
|
||||
// their own connection through the cascade (mirrors filetransfer). A
|
||||
// missing base URL is reported gracefully by the health probe.
|
||||
{Key: "baseURL", Label: "Server URL", Type: "text",
|
||||
Help: "WebDAV endpoint, e.g. https://cloud.example.com/remote.php/dav/files/alice/ — must include the scheme."},
|
||||
{Key: "username", Label: "Username", Type: "text"},
|
||||
{Key: "password", Label: "Password", Type: "password", Secret: true,
|
||||
Help: "Password or app-specific token for HTTP Basic auth. Leave blank for an anonymous/public share."},
|
||||
{Key: "basePath", Label: "Base path", Type: "text", Default: ".",
|
||||
Help: "Directory under the server URL used as the working root and probed by the health check, e.g. /Documents. Relative capability paths resolve under it."},
|
||||
{Key: "insecureSkipVerify", Label: "TLS verification", Type: "select", Default: "false",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "false", Label: "Verify certificate (recommended)"},
|
||||
{Value: "true", Label: "Skip verification — accept any certificate"},
|
||||
},
|
||||
Help: "Only affects HTTPS. Skip verification only for self-signed test servers."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.baseURL = strings.TrimSpace(config["baseURL"])
|
||||
p.username = strings.TrimSpace(config["username"])
|
||||
p.password = config["password"]
|
||||
p.basePath = strings.TrimSpace(config["basePath"])
|
||||
if p.basePath == "" {
|
||||
p.basePath = "."
|
||||
}
|
||||
p.insecureTLS = strings.EqualFold(strings.TrimSpace(config["insecureSkipVerify"]), "true")
|
||||
|
||||
p.client = &http.Client{
|
||||
// No client-level timeout: request lifetime is bounded by the caller's
|
||||
// context so large downloads aren't cut off mid-stream.
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: dialTimeout}).DialContext,
|
||||
TLSHandshakeTimeout: dialTimeout,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: p.insecureTLS, //nolint:gosec // opt-in for self-signed test servers
|
||||
},
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve joins a caller-supplied path against the base path. A leading "/" is
|
||||
// treated as relative to the server URL's own path root; an empty path becomes
|
||||
// the base path itself. It never allows escaping above that root: the joined
|
||||
// path is cleaned against a virtual "/" so "..", stray separators, and
|
||||
// backslashes can't climb out.
|
||||
func (p *Plugin) resolve(rel string) string {
|
||||
rel = strings.TrimSpace(rel)
|
||||
rel = strings.ReplaceAll(rel, "\\", "/")
|
||||
base := p.basePath
|
||||
if base == "." {
|
||||
base = ""
|
||||
}
|
||||
var joined string
|
||||
switch {
|
||||
case rel == "":
|
||||
joined = base
|
||||
case strings.HasPrefix(rel, "/"):
|
||||
joined = rel // relative to the server URL root, not the base path
|
||||
case base == "":
|
||||
joined = rel
|
||||
default:
|
||||
joined = base + "/" + rel
|
||||
}
|
||||
// Clean against a virtual root so nothing escapes above it.
|
||||
return strings.TrimPrefix(path.Clean("/"+joined), "/")
|
||||
}
|
||||
|
||||
// requestURL builds the absolute request URL for a resolved path. When dir is
|
||||
// true a trailing slash is kept, which WebDAV servers expect for collection
|
||||
// operations (PROPFIND/MKCOL). url.URL.String() percent-escapes the path, so
|
||||
// callers pass unescaped segments.
|
||||
func (p *Plugin) requestURL(resolved string, dir bool) (string, error) {
|
||||
base, err := url.Parse(p.baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid server URL: %w", err)
|
||||
}
|
||||
if base.Scheme == "" || base.Host == "" {
|
||||
return "", errors.New("server URL must include scheme and host")
|
||||
}
|
||||
full := *base
|
||||
full.Path = path.Join("/"+strings.Trim(base.Path, "/"), resolved)
|
||||
full.RawPath = "" // force re-escaping from Path
|
||||
if dir && !strings.HasSuffix(full.Path, "/") {
|
||||
full.Path += "/"
|
||||
}
|
||||
return full.String(), nil
|
||||
}
|
||||
|
||||
// do issues one authenticated WebDAV request and returns the response. The
|
||||
// caller is responsible for closing the body.
|
||||
func (p *Plugin) do(ctx context.Context, method, rawURL string, body io.Reader, headers map[string]string) (*http.Response, error) {
|
||||
p.mu.Lock()
|
||||
client, user, pass := p.client, p.username, p.password
|
||||
p.mu.Unlock()
|
||||
if client == nil {
|
||||
return nil, errors.New("plugin not initialized")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user != "" || pass != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
// HealthCheck issues a PROPFIND against the base path and classifies the
|
||||
// outcome. A 401/403 means the server is reachable but auth failed (degraded);
|
||||
// a transport error is down.
|
||||
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
||||
start := time.Now()
|
||||
|
||||
p.mu.Lock()
|
||||
base, baseURL := p.basePath, p.baseURL
|
||||
p.mu.Unlock()
|
||||
|
||||
if baseURL == "" {
|
||||
return plugins.Health{Status: plugins.StatusDown, Detail: "no server URL configured"}
|
||||
}
|
||||
|
||||
entries, err := p.propfind(ctx, p.resolve(""), 1)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
var he *httpError
|
||||
if errors.As(err, &he) {
|
||||
return plugins.Health{Status: classifyStatus(he.code), LatencyMs: lat,
|
||||
Detail: fmt.Sprintf("connected but PROPFIND %q returned %d %s", base, he.code, http.StatusText(he.code))}
|
||||
}
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("WebDAV reachable — %d entr%s under %q", len(entries), plural(len(entries)), base)
|
||||
status := plugins.StatusOK
|
||||
if strings.HasPrefix(strings.ToLower(baseURL), "http://") {
|
||||
status = plugins.StatusDegraded
|
||||
detail += " · plaintext HTTP (no encryption)"
|
||||
}
|
||||
return plugins.Health{Status: status, LatencyMs: lat, Detail: detail}
|
||||
}
|
||||
|
||||
// Invoke runs one capability against the WebDAV server.
|
||||
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
switch action {
|
||||
case "list":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
rp := p.resolve(in.Path)
|
||||
entries, err := p.propfind(ctx, rp, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": rp, "entries": entries})
|
||||
|
||||
case "stat":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
rp := p.resolve(in.Path)
|
||||
entries, err := p.propfind(ctx, rp, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("not found: %s", rp)
|
||||
}
|
||||
return json.Marshal(entries[0])
|
||||
|
||||
case "download":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
rp := p.resolve(in.Path)
|
||||
data, err := p.read(ctx, rp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"path": rp,
|
||||
"size": len(data),
|
||||
"contentBase64": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
|
||||
case "upload":
|
||||
var in writeParams
|
||||
if err := json.Unmarshal(params, &in); err != nil {
|
||||
return nil, fmt.Errorf("invalid params: %w", err)
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(in.ContentBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err)
|
||||
}
|
||||
rp := p.resolve(in.Path)
|
||||
if err := p.write(ctx, rp, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": rp, "size": len(data), "ok": true})
|
||||
|
||||
case "delete":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
rp := p.resolve(in.Path)
|
||||
if err := p.remove(ctx, rp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": rp, "ok": true})
|
||||
|
||||
case "mkdir":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
rp := p.resolve(in.Path)
|
||||
if err := p.mkdir(ctx, rp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": rp, "ok": true})
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
||||
|
||||
// pathParams / writeParams are the Invoke request shapes.
|
||||
type pathParams struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
type writeParams struct {
|
||||
Path string `json:"path"`
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
|
||||
// fileInfo is the normalized directory-entry shape returned by list/stat. It
|
||||
// matches filetransfer's shape so callers can treat the drives uniformly.
|
||||
type fileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
ModTime string `json:"modTime,omitempty"`
|
||||
}
|
||||
|
||||
// httpError carries a non-2xx status so HealthCheck can classify it.
|
||||
type httpError struct {
|
||||
code int
|
||||
method string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string {
|
||||
return fmt.Sprintf("%s: %d %s", e.method, e.code, http.StatusText(e.code))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebDAV operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// propfind lists (depth 1) or stats (depth 0) a path. For depth 1 the entry
|
||||
// describing the collection itself is dropped so only children are returned.
|
||||
func (p *Plugin) propfind(ctx context.Context, resolved string, depth int) ([]fileInfo, error) {
|
||||
u, err := p.requestURL(resolved, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := p.do(ctx, "PROPFIND", u, strings.NewReader(propfindBody), map[string]string{
|
||||
"Depth": strconv.Itoa(depth),
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer drainClose(resp.Body)
|
||||
|
||||
// 207 Multi-Status is the success case; 200 is tolerated for lenient servers.
|
||||
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
|
||||
return nil, &httpError{code: resp.StatusCode, method: "PROPFIND"}
|
||||
}
|
||||
|
||||
var ms davMultistatus
|
||||
if err := xml.NewDecoder(io.LimitReader(resp.Body, maxReadBytes)).Decode(&ms); err != nil {
|
||||
return nil, fmt.Errorf("parse PROPFIND response: %w", err)
|
||||
}
|
||||
|
||||
// The request path, cleaned, is used to recognise and drop the self entry.
|
||||
self := strings.Trim(resolved, "/")
|
||||
out := make([]fileInfo, 0, len(ms.Responses))
|
||||
for _, r := range ms.Responses {
|
||||
hrefPath := hrefToPath(r.Href)
|
||||
if depth == 1 && strings.Trim(hrefPath, "/") == self {
|
||||
continue // the collection itself
|
||||
}
|
||||
out = append(out, r.toFileInfo())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) read(ctx context.Context, resolved string) ([]byte, error) {
|
||||
u, err := p.requestURL(resolved, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := p.do(ctx, http.MethodGet, u, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer drainClose(resp.Body)
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, &httpError{code: resp.StatusCode, method: "GET"}
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, maxReadBytes))
|
||||
}
|
||||
|
||||
func (p *Plugin) write(ctx context.Context, resolved string, data []byte) error {
|
||||
u, err := p.requestURL(resolved, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := p.do(ctx, http.MethodPut, u, strings.NewReader(string(data)),
|
||||
map[string]string{"Content-Type": "application/octet-stream"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer drainClose(resp.Body)
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return &httpError{code: resp.StatusCode, method: "PUT"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) remove(ctx context.Context, resolved string) error {
|
||||
u, err := p.requestURL(resolved, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := p.do(ctx, http.MethodDelete, u, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer drainClose(resp.Body)
|
||||
// 404 is tolerated as already-gone.
|
||||
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
|
||||
return &httpError{code: resp.StatusCode, method: "DELETE"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) mkdir(ctx context.Context, resolved string) error {
|
||||
u, err := p.requestURL(resolved, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := p.do(ctx, "MKCOL", u, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer drainClose(resp.Body)
|
||||
// 405 Method Not Allowed is what most servers return when the collection
|
||||
// already exists — treat it as success (idempotent mkdir).
|
||||
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
return &httpError{code: resp.StatusCode, method: "MKCOL"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PROPFIND XML shapes and helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type davMultistatus struct {
|
||||
XMLName xml.Name `xml:"DAV: multistatus"`
|
||||
Responses []davResponse `xml:"DAV: response"`
|
||||
}
|
||||
|
||||
type davResponse struct {
|
||||
Href string `xml:"DAV: href"`
|
||||
Propstats []davPropstat `xml:"DAV: propstat"`
|
||||
}
|
||||
|
||||
type davPropstat struct {
|
||||
Status string `xml:"DAV: status"`
|
||||
Prop davProp `xml:"DAV: prop"`
|
||||
}
|
||||
|
||||
type davProp struct {
|
||||
DisplayName string `xml:"DAV: displayname"`
|
||||
ContentLen string `xml:"DAV: getcontentlength"`
|
||||
LastModified string `xml:"DAV: getlastmodified"`
|
||||
ResourceType davResourceType `xml:"DAV: resourcetype"`
|
||||
}
|
||||
|
||||
type davResourceType struct {
|
||||
Collection *xml.Name `xml:"DAV: collection"`
|
||||
}
|
||||
|
||||
// toFileInfo normalizes a PROPFIND <response>, preferring the 2xx propstat.
|
||||
func (r davResponse) toFileInfo() fileInfo {
|
||||
fi := fileInfo{Name: nameFromHref(r.Href)}
|
||||
for _, ps := range r.Propstats {
|
||||
if !strings.Contains(ps.Status, " 2") { // "HTTP/1.1 200 OK"
|
||||
continue
|
||||
}
|
||||
if ps.Prop.ResourceType.Collection != nil {
|
||||
fi.IsDir = true
|
||||
}
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(ps.Prop.ContentLen), 10, 64); err == nil {
|
||||
fi.Size = n
|
||||
}
|
||||
if lm := strings.TrimSpace(ps.Prop.LastModified); lm != "" {
|
||||
if t, err := http.ParseTime(lm); err == nil {
|
||||
fi.ModTime = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
if fi.Name == "" && strings.TrimSpace(ps.Prop.DisplayName) != "" {
|
||||
fi.Name = ps.Prop.DisplayName
|
||||
}
|
||||
}
|
||||
return fi
|
||||
}
|
||||
|
||||
// hrefToPath extracts the URL path from an href, which may be absolute
|
||||
// (http://host/a/b) or path-only (/a/b), and percent-decodes it.
|
||||
func hrefToPath(href string) string {
|
||||
if u, err := url.Parse(href); err == nil && u.Path != "" {
|
||||
return u.Path
|
||||
}
|
||||
if dec, err := url.PathUnescape(href); err == nil {
|
||||
return dec
|
||||
}
|
||||
return href
|
||||
}
|
||||
|
||||
// nameFromHref returns the last path segment of an href, percent-decoded.
|
||||
func nameFromHref(href string) string {
|
||||
p := strings.TrimRight(hrefToPath(href), "/")
|
||||
if i := strings.LastIndex(p, "/"); i >= 0 {
|
||||
p = p[i+1:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// classifyStatus maps an HTTP status to a health status: an auth rejection means
|
||||
// the server is reachable but credentials are wrong (degraded); anything else is
|
||||
// down.
|
||||
func classifyStatus(code int) string {
|
||||
switch code {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return plugins.StatusDegraded
|
||||
default:
|
||||
return plugins.StatusDown
|
||||
}
|
||||
}
|
||||
|
||||
// drainClose drains and closes a response body so the connection can be reused.
|
||||
func drainClose(body io.ReadCloser) {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(body, 4<<10))
|
||||
_ = body.Close()
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return "y"
|
||||
}
|
||||
return "ies"
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
func TestDescriptor(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
d := p.Descriptor()
|
||||
if d.Name != "webdav" {
|
||||
t.Fatalf("name = %q, want webdav", d.Name)
|
||||
}
|
||||
if d.Kind != plugins.KindBuiltin {
|
||||
t.Fatalf("kind = %q, want builtin", d.Kind)
|
||||
}
|
||||
if d.Category != plugins.CategoryDrivesExternal {
|
||||
t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryDrivesExternal)
|
||||
}
|
||||
if len(d.Capabilities) == 0 {
|
||||
t.Fatal("expected capabilities")
|
||||
}
|
||||
// The password field must be flagged so the manager masks it, and no field
|
||||
// may be Required (so the plugin can be enabled as an empty master switch).
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Key == "password" && !f.Secret {
|
||||
t.Errorf("config field %q must be Secret", f.Key)
|
||||
}
|
||||
if f.Required {
|
||||
t.Errorf("config field %q must not be Required", f.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDefaults(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
if err := p.Init(context.Background(), map[string]string{"baseURL": "https://h/dav"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.basePath != "." {
|
||||
t.Errorf("default basePath = %q, want .", p.basePath)
|
||||
}
|
||||
if p.client == nil {
|
||||
t.Error("Init must build an http client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfinement(t *testing.T) {
|
||||
p := &Plugin{basePath: "Documents"}
|
||||
cases := map[string]string{
|
||||
"": "Documents",
|
||||
"a/b.txt": "Documents/a/b.txt",
|
||||
"/etc/abs": "etc/abs", // leading slash → relative to dav root, not base
|
||||
"../../escape": "escape", // cannot climb above the root
|
||||
"a/../../escape": "escape", // nor via traversal
|
||||
"a\\b": "Documents/a/b", // backslashes normalized
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := p.resolve(in); got != want {
|
||||
t.Errorf("resolve(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestURL(t *testing.T) {
|
||||
p := &Plugin{baseURL: "https://cloud.example.com/remote.php/dav/files/alice/"}
|
||||
got, err := p.requestURL("Documents/report 1.txt", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "https://cloud.example.com/remote.php/dav/files/alice/Documents/report%201.txt"
|
||||
if got != want {
|
||||
t.Errorf("requestURL = %q, want %q", got, want)
|
||||
}
|
||||
// A directory op keeps the trailing slash servers expect for collections.
|
||||
dir, _ := p.requestURL("Documents", true)
|
||||
if !strings.HasSuffix(dir, "/") {
|
||||
t.Errorf("dir URL %q should end with /", dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestURLRejectsBadBase(t *testing.T) {
|
||||
p := &Plugin{baseURL: "not-a-url"}
|
||||
if _, err := p.requestURL("x", false); err == nil {
|
||||
t.Fatal("expected error for base URL without scheme/host")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthCheckNoURL confirms an empty base URL is reported as down, not a panic.
|
||||
func TestHealthCheckNoURL(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
if err := p.Init(context.Background(), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
|
||||
t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameFromHref(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/dav/files/alice/report%201.txt": "report 1.txt",
|
||||
"http://host/dav/Photos/": "Photos",
|
||||
"/dav/": "dav",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := nameFromHref(in); got != want {
|
||||
t.Errorf("nameFromHref(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistered(t *testing.T) {
|
||||
m := plugins.NewManager(t.TempDir() + "/plugins.json")
|
||||
if _, ok := m.Get("webdav"); !ok {
|
||||
t.Fatal("webdav not registered in the plugin manager")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeDAV is a minimal in-memory WebDAV server exercising the verbs the plugin
|
||||
// uses. It is not spec-complete — just enough to drive the round-trip test.
|
||||
type fakeDAV struct {
|
||||
files map[string][]byte // path (no leading slash) → contents; dirs end in "/"
|
||||
}
|
||||
|
||||
func newFakeDAV() *fakeDAV {
|
||||
return &fakeDAV{files: map[string][]byte{
|
||||
"": nil, // root collection
|
||||
"docs/": nil, // a subdirectory
|
||||
"hello.txt": []byte("hi"), // a file
|
||||
}}
|
||||
}
|
||||
|
||||
func (f *fakeDAV) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if u, _, ok := r.BasicAuth(); !ok || u != "alice" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
key := strings.Trim(r.URL.Path, "/")
|
||||
switch r.Method {
|
||||
case "PROPFIND":
|
||||
f.propfind(w, r, key)
|
||||
case http.MethodGet:
|
||||
if data, ok := f.files[key]; ok && data != nil {
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case http.MethodPut:
|
||||
body := make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(body)
|
||||
f.files[key] = body
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
case http.MethodDelete:
|
||||
delete(f.files, key)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case "MKCOL":
|
||||
f.files[key+"/"] = nil
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeDAV) propfind(w http.ResponseWriter, r *http.Request, key string) {
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">`)
|
||||
writeResp := func(href string, isDir bool, size int) {
|
||||
rt := ""
|
||||
if isDir {
|
||||
rt = "<d:collection/>"
|
||||
}
|
||||
fmt.Fprintf(&b, `<d:response><d:href>%s</d:href><d:propstat>`+
|
||||
`<d:prop><d:getcontentlength>%d</d:getcontentlength>`+
|
||||
`<d:getlastmodified>Wed, 08 Jul 2026 10:00:00 GMT</d:getlastmodified>`+
|
||||
`<d:resourcetype>%s</d:resourcetype></d:prop>`+
|
||||
`<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`,
|
||||
href, size, rt)
|
||||
}
|
||||
// Self entry first.
|
||||
writeResp("/"+key, true, 0)
|
||||
if r.Header.Get("Depth") == "1" && key == "" {
|
||||
writeResp("/docs/", true, 0)
|
||||
writeResp("/hello.txt", false, 2)
|
||||
}
|
||||
b.WriteString(`</d:multistatus>`)
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}
|
||||
|
||||
// TestRoundTrip drives list/stat/download/upload/delete/mkdir against the fake
|
||||
// server and checks the plugin's normalized responses.
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
srv := httptest.NewServer(newFakeDAV())
|
||||
defer srv.Close()
|
||||
|
||||
p := &Plugin{}
|
||||
if err := p.Init(context.Background(), map[string]string{
|
||||
"baseURL": srv.URL, "username": "alice", "password": "pw",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// list: the self entry is dropped, leaving docs/ and hello.txt.
|
||||
raw, err := p.Invoke(ctx, "list", json.RawMessage(`{"path":""}`))
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
var listed struct {
|
||||
Entries []fileInfo `json:"entries"`
|
||||
}
|
||||
mustJSON(t, raw, &listed)
|
||||
if len(listed.Entries) != 2 {
|
||||
t.Fatalf("list returned %d entries, want 2: %+v", len(listed.Entries), listed.Entries)
|
||||
}
|
||||
var sawDir, sawFile bool
|
||||
for _, e := range listed.Entries {
|
||||
if e.Name == "docs" && e.IsDir {
|
||||
sawDir = true
|
||||
}
|
||||
if e.Name == "hello.txt" && !e.IsDir && e.Size == 2 {
|
||||
sawFile = true
|
||||
}
|
||||
}
|
||||
if !sawDir || !sawFile {
|
||||
t.Errorf("unexpected entries: %+v", listed.Entries)
|
||||
}
|
||||
|
||||
// download
|
||||
raw, err = p.Invoke(ctx, "download", json.RawMessage(`{"path":"hello.txt"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
var dl struct {
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
mustJSON(t, raw, &dl)
|
||||
if got, _ := base64.StdEncoding.DecodeString(dl.ContentBase64); string(got) != "hi" {
|
||||
t.Errorf("download content = %q, want hi", got)
|
||||
}
|
||||
|
||||
// upload → then download it back
|
||||
body := base64.StdEncoding.EncodeToString([]byte("new-file"))
|
||||
if _, err := p.Invoke(ctx, "upload", json.RawMessage(fmt.Sprintf(`{"path":"new.txt","contentBase64":%q}`, body))); err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
raw, err = p.Invoke(ctx, "download", json.RawMessage(`{"path":"new.txt"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("download after upload: %v", err)
|
||||
}
|
||||
mustJSON(t, raw, &dl)
|
||||
if got, _ := base64.StdEncoding.DecodeString(dl.ContentBase64); string(got) != "new-file" {
|
||||
t.Errorf("round-tripped content = %q, want new-file", got)
|
||||
}
|
||||
|
||||
// mkdir and delete should succeed without error
|
||||
if _, err := p.Invoke(ctx, "mkdir", json.RawMessage(`{"path":"newdir"}`)); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if _, err := p.Invoke(ctx, "delete", json.RawMessage(`{"path":"new.txt"}`)); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
// health check is OK against a live (http) server, but degraded because it's plaintext
|
||||
if h := p.HealthCheck(ctx); h.Status != plugins.StatusDegraded {
|
||||
t.Errorf("health status = %q, want degraded (plaintext http); detail=%q", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthCheckAuthFailure confirms a 401 is classified as degraded, not down.
|
||||
func TestHealthCheckAuthFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(newFakeDAV())
|
||||
defer srv.Close()
|
||||
p := &Plugin{}
|
||||
if err := p.Init(context.Background(), map[string]string{
|
||||
"baseURL": srv.URL, "username": "wrong", "password": "pw",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDegraded {
|
||||
t.Errorf("status = %q, want degraded on 401 (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, raw json.RawMessage, v any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(raw, v); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", raw, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package plugins
|
||||
|
||||
// Deferred extension points (deliberately NOT in v1 — the "Management MVP").
|
||||
// The contract and manager are shaped so these can be added without a redesign:
|
||||
//
|
||||
// - Invocation API: the Plugin.Invoke method already exists; a
|
||||
// POST /api/admin/plugins/{name}/action endpoint + a normalized request/
|
||||
// response envelope would expose it. Add a mapper layer so core logic never
|
||||
// depends on a provider's schema.
|
||||
// - Resilience: wrap plugin calls with retry/backoff + a circuit breaker, and
|
||||
// record per-plugin latency/error/quota metrics for the panel.
|
||||
// - Per-tenant credentials: today config is a single global blob per plugin.
|
||||
// A (pluginName, orgID/userID) → config store would let users connect their
|
||||
// own third-party accounts.
|
||||
// - Audit logging: record which plugin accessed what and when.
|
||||
// - Sandboxing: the "external" plugin kind is the isolation story — run less
|
||||
// trusted plugins as separate processes/containers behind the HTTP contract.
|
||||
// - Hot-adding builtin Go code without a rebuild is intentionally unsupported
|
||||
// (Go .so plugins are Linux-only and toolchain-fragile); use the external
|
||||
// HTTP kind to add plugins at runtime instead.
|
||||
@@ -0,0 +1,149 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// externalPlugin adapts a remote HTTP service to the Plugin contract. The remote
|
||||
// side implements a tiny JSON contract:
|
||||
//
|
||||
// GET {baseURL}/manifest → { provider, version, capabilities, authType, configFields }
|
||||
// GET {baseURL}/health → 2xx, optionally { status, detail }
|
||||
// POST {baseURL}/invoke → { action, params } → arbitrary JSON (v1: unused)
|
||||
//
|
||||
// This is the "add a plugin without a rebuild" path: register a base URL at
|
||||
// runtime and the server drives it over HTTP. It is also the sandboxing story —
|
||||
// a less-trusted plugin runs as its own process/container.
|
||||
type externalPlugin struct {
|
||||
name string
|
||||
baseURL string
|
||||
desc Descriptor
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newExternalPlugin(name, baseURL, provider string) *externalPlugin {
|
||||
if provider == "" {
|
||||
provider = "External"
|
||||
}
|
||||
return &externalPlugin{
|
||||
name: name,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{Timeout: 8 * time.Second},
|
||||
desc: Descriptor{
|
||||
Name: name,
|
||||
Provider: provider,
|
||||
Version: "external",
|
||||
Kind: KindExternal,
|
||||
Category: CategoryAPIsExternal, // remote HTTP service; a manifest may override
|
||||
AuthType: AuthNone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalPlugin) Descriptor() Descriptor { return e.desc }
|
||||
|
||||
// Init best-effort fetches the remote manifest to enrich the descriptor. A
|
||||
// missing/broken manifest is non-fatal — the basic descriptor stands.
|
||||
func (e *externalPlugin) Init(ctx context.Context, _ map[string]string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.baseURL+"/manifest", nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
resp, err := e.client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var man struct {
|
||||
Provider string `json:"provider"`
|
||||
Version string `json:"version"`
|
||||
Category string `json:"category"`
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
AuthType AuthType `json:"authType"`
|
||||
ConfigFields []ConfigField `json:"configFields"`
|
||||
}
|
||||
if json.Unmarshal(data, &man) == nil {
|
||||
if man.Provider != "" {
|
||||
e.desc.Provider = man.Provider
|
||||
}
|
||||
if man.Version != "" {
|
||||
e.desc.Version = man.Version
|
||||
}
|
||||
if man.AuthType != "" {
|
||||
e.desc.AuthType = man.AuthType
|
||||
}
|
||||
if man.Category != "" {
|
||||
e.desc.Category = man.Category
|
||||
}
|
||||
e.desc.Capabilities = man.Capabilities
|
||||
e.desc.ConfigFields = man.ConfigFields
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *externalPlugin) HealthCheck(ctx context.Context) Health {
|
||||
start := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
return Health{Status: StatusDown, Detail: err.Error()}
|
||||
}
|
||||
resp, err := e.client.Do(req)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return Health{Status: StatusDown, LatencyMs: lat, Detail: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
|
||||
// Honour an explicit {status, detail} body when present.
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &body)
|
||||
|
||||
h := Health{LatencyMs: lat, Detail: body.Detail}
|
||||
switch {
|
||||
case body.Status != "":
|
||||
h.Status = body.Status
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
h.Status = StatusOK
|
||||
case resp.StatusCode >= 500:
|
||||
h.Status = StatusDown
|
||||
default:
|
||||
h.Status = StatusDegraded
|
||||
}
|
||||
if h.Detail == "" && h.Status != StatusOK {
|
||||
h.Detail = "HTTP " + resp.Status
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Invoke proxies to the remote /invoke endpoint. Part of the contract; no HTTP
|
||||
// endpoint exposes it in v1.
|
||||
func (e *externalPlugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
payload, _ := json.Marshal(map[string]any{"action": action, "params": params})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+"/invoke", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := e.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (e *externalPlugin) Shutdown(context.Context) error { return nil }
|
||||
@@ -0,0 +1,387 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// secretMask is what a set secret value is echoed back as. On save, a field that
|
||||
// still equals the mask is left unchanged (mirrors the pb-config password flow).
|
||||
const secretMask = "••••••••"
|
||||
|
||||
// record is the persisted state for one plugin. For builtins, Kind/BaseURL are
|
||||
// omitted (the descriptor comes from the registry); external plugins set them.
|
||||
type record struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
BaseURL string `json:"baseURL,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config map[string]string `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// View is the plugin shape returned to the panel (secrets masked).
|
||||
type View struct {
|
||||
Descriptor
|
||||
Enabled bool `json:"enabled"`
|
||||
Config map[string]string `json:"config"`
|
||||
BaseURL string `json:"baseURL,omitempty"`
|
||||
Health *Health `json:"health,omitempty"`
|
||||
}
|
||||
|
||||
// Manager owns the plugin registry, persisted state, and live instances.
|
||||
type Manager struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
factories map[string]Factory
|
||||
records map[string]*record
|
||||
live map[string]Plugin
|
||||
health map[string]*Health
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewManager builds a Manager backed by the JSON state file at path.
|
||||
func NewManager(path string) *Manager {
|
||||
return &Manager{
|
||||
path: path,
|
||||
factories: builtinFactories(),
|
||||
records: map[string]*record{},
|
||||
live: map[string]Plugin{},
|
||||
health: map[string]*Health{},
|
||||
client: &http.Client{Timeout: 12 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the state file and initialises every enabled plugin. A missing file
|
||||
// is fine (no plugins configured yet).
|
||||
func (m *Manager) Load() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if data, err := os.ReadFile(m.path); err == nil {
|
||||
var recs map[string]*record
|
||||
if err := json.Unmarshal(data, &recs); err != nil {
|
||||
return err
|
||||
}
|
||||
m.records = recs
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for name, rec := range m.records {
|
||||
if !rec.Enabled {
|
||||
continue
|
||||
}
|
||||
p := construct(name, m.factories[name], rec)
|
||||
if p == nil {
|
||||
log.Printf("plugins: cannot construct %q (unknown builtin?)", name)
|
||||
continue
|
||||
}
|
||||
if err := p.Init(ctx, rec.Config); err != nil {
|
||||
log.Printf("plugins: init %q failed: %v", name, err)
|
||||
continue
|
||||
}
|
||||
m.live[name] = p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// construct builds a plugin instance from a builtin factory or an external record.
|
||||
func construct(name string, f Factory, rec *record) Plugin {
|
||||
if f != nil {
|
||||
return f()
|
||||
}
|
||||
if rec != nil && rec.Kind == KindExternal {
|
||||
return newExternalPlugin(name, rec.BaseURL, rec.Provider)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// descriptorFor returns a plugin's descriptor without needing a live instance.
|
||||
func (m *Manager) descriptorFor(name string, rec *record) Descriptor {
|
||||
if p := m.live[name]; p != nil {
|
||||
return p.Descriptor()
|
||||
}
|
||||
if f := m.factories[name]; f != nil {
|
||||
return f().Descriptor()
|
||||
}
|
||||
if rec != nil && rec.Kind == KindExternal {
|
||||
return newExternalPlugin(name, rec.BaseURL, rec.Provider).Descriptor()
|
||||
}
|
||||
return Descriptor{Name: name}
|
||||
}
|
||||
|
||||
// maskConfig echoes config back with secret fields masked when set.
|
||||
func maskConfig(d Descriptor, cfg map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Secret && out[f.Key] != "" {
|
||||
out[f.Key] = secretMask
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// List returns every known plugin (registry ∪ persisted), sorted by name.
|
||||
func (m *Manager) List() []View {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
names := map[string]bool{}
|
||||
for n := range m.factories {
|
||||
names[n] = true
|
||||
}
|
||||
for n := range m.records {
|
||||
names[n] = true
|
||||
}
|
||||
|
||||
out := make([]View, 0, len(names))
|
||||
for name := range names {
|
||||
rec := m.records[name]
|
||||
d := m.descriptorFor(name, rec)
|
||||
v := View{Descriptor: d, Health: m.health[name]}
|
||||
if rec != nil {
|
||||
v.Enabled = rec.Enabled
|
||||
v.BaseURL = rec.BaseURL
|
||||
v.Config = maskConfig(d, rec.Config)
|
||||
} else {
|
||||
v.Config = map[string]string{}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns a single plugin view (ok=false when unknown).
|
||||
func (m *Manager) Get(name string) (View, bool) {
|
||||
for _, v := range m.List() {
|
||||
if v.Name == name {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return View{}, false
|
||||
}
|
||||
|
||||
// Upsert enables/disables a plugin and merges its config, then (re)initialises or
|
||||
// shuts down the live instance to match. Secrets left at the mask are preserved.
|
||||
func (m *Manager) Upsert(ctx context.Context, name string, enabled bool, incoming map[string]string) (View, error) {
|
||||
m.mu.Lock()
|
||||
|
||||
_, isBuiltin := m.factories[name]
|
||||
rec := m.records[name]
|
||||
if !isBuiltin && (rec == nil || rec.Kind != KindExternal) {
|
||||
m.mu.Unlock()
|
||||
return View{}, errUnknown
|
||||
}
|
||||
if rec == nil {
|
||||
rec = &record{}
|
||||
m.records[name] = rec
|
||||
}
|
||||
|
||||
d := m.descriptorFor(name, rec)
|
||||
merged := map[string]string{}
|
||||
for k, v := range rec.Config {
|
||||
merged[k] = v
|
||||
}
|
||||
// Apply incoming values, honouring the secret-mask keep-current rule.
|
||||
secretKeys := map[string]bool{}
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Secret {
|
||||
secretKeys[f.Key] = true
|
||||
}
|
||||
}
|
||||
for k, v := range incoming {
|
||||
if secretKeys[k] && v == secretMask {
|
||||
continue // keep existing secret
|
||||
}
|
||||
merged[k] = strings.TrimSpace(v)
|
||||
}
|
||||
// Validate required fields when enabling.
|
||||
if enabled {
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Required && merged[f.Key] == "" {
|
||||
m.mu.Unlock()
|
||||
return View{}, errors.New("missing required setting: " + f.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rec.Enabled = enabled
|
||||
rec.Config = merged
|
||||
if err := m.persistLocked(); err != nil {
|
||||
m.mu.Unlock()
|
||||
return View{}, err
|
||||
}
|
||||
|
||||
// Reconcile the live instance.
|
||||
if old := m.live[name]; old != nil {
|
||||
_ = old.Shutdown(ctx)
|
||||
delete(m.live, name)
|
||||
}
|
||||
var initErr error
|
||||
if enabled {
|
||||
p := construct(name, m.factories[name], rec)
|
||||
if p != nil {
|
||||
if err := p.Init(ctx, merged); err != nil {
|
||||
initErr = err
|
||||
} else {
|
||||
m.live[name] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
v, _ := m.Get(name)
|
||||
return v, initErr
|
||||
}
|
||||
|
||||
// RegisterExternal adds a new external (remote HTTP) plugin at runtime — the
|
||||
// "add a plugin without a rebuild" path. It starts disabled.
|
||||
func (m *Manager) RegisterExternal(name, baseURL, provider string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if name == "" || baseURL == "" {
|
||||
return errors.New("name and baseURL are required")
|
||||
}
|
||||
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||
baseURL = "http://" + baseURL
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, dup := m.factories[name]; dup {
|
||||
return errors.New("a builtin plugin already uses that name")
|
||||
}
|
||||
if _, dup := m.records[name]; dup {
|
||||
return errors.New("a plugin with that name already exists")
|
||||
}
|
||||
m.records[name] = &record{Kind: KindExternal, BaseURL: baseURL, Provider: provider}
|
||||
return m.persistLocked()
|
||||
}
|
||||
|
||||
// Remove deletes an external plugin registration. Builtins can only be disabled.
|
||||
func (m *Manager) Remove(ctx context.Context, name string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
rec := m.records[name]
|
||||
if rec == nil || rec.Kind != KindExternal {
|
||||
return errors.New("only external plugins can be removed")
|
||||
}
|
||||
if p := m.live[name]; p != nil {
|
||||
_ = p.Shutdown(ctx)
|
||||
delete(m.live, name)
|
||||
}
|
||||
delete(m.records, name)
|
||||
delete(m.health, name)
|
||||
return m.persistLocked()
|
||||
}
|
||||
|
||||
// HealthCheck probes a plugin now, building a transient instance if it is not
|
||||
// currently live (so disabled plugins can still be tested). Result is cached.
|
||||
func (m *Manager) HealthCheck(ctx context.Context, name string) (Health, error) {
|
||||
m.mu.Lock()
|
||||
p := m.live[name]
|
||||
transient := false
|
||||
var cfg map[string]string
|
||||
if p == nil {
|
||||
rec := m.records[name]
|
||||
if rec != nil {
|
||||
cfg = rec.Config
|
||||
}
|
||||
p = construct(name, m.factories[name], rec)
|
||||
transient = true
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if p == nil {
|
||||
return Health{}, errUnknown
|
||||
}
|
||||
if transient {
|
||||
_ = p.Init(ctx, cfg)
|
||||
defer func() { _ = p.Shutdown(context.Background()) }()
|
||||
}
|
||||
h := p.HealthCheck(ctx)
|
||||
|
||||
m.mu.Lock()
|
||||
hc := h
|
||||
m.health[name] = &hc
|
||||
m.mu.Unlock()
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// HealthCheckWith probes a plugin using a caller-supplied config instead of the
|
||||
// stored record. It always builds a transient instance, so it never disturbs the
|
||||
// live instance or the cached global health. Used by per-user integration flows
|
||||
// that resolve their own effective config (e.g. the OpenSky settings cascade).
|
||||
func (m *Manager) HealthCheckWith(ctx context.Context, name string, cfg map[string]string) (Health, error) {
|
||||
m.mu.Lock()
|
||||
rec := m.records[name]
|
||||
p := construct(name, m.factories[name], rec)
|
||||
m.mu.Unlock()
|
||||
|
||||
if p == nil {
|
||||
return Health{}, errUnknown
|
||||
}
|
||||
_ = p.Init(ctx, cfg)
|
||||
defer func() { _ = p.Shutdown(context.Background()) }()
|
||||
return p.HealthCheck(ctx), nil
|
||||
}
|
||||
|
||||
// RawConfig returns a plugin's stored config UNMASKED, together with its enabled
|
||||
// flag and whether the plugin is known. Server-side callers use it to resolve a
|
||||
// layered effective config (which needs the real secret values); it must never be
|
||||
// returned to a client. ok is false for an unknown plugin.
|
||||
func (m *Manager) RawConfig(name string) (cfg map[string]string, enabled, ok bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
_, isBuiltin := m.factories[name]
|
||||
rec := m.records[name]
|
||||
if !isBuiltin && rec == nil {
|
||||
return nil, false, false
|
||||
}
|
||||
out := map[string]string{}
|
||||
if rec != nil {
|
||||
for k, v := range rec.Config {
|
||||
out[k] = v
|
||||
}
|
||||
enabled = rec.Enabled
|
||||
}
|
||||
return out, enabled, true
|
||||
}
|
||||
|
||||
// Shutdown tears down every live plugin instance. Wire into graceful shutdown.
|
||||
func (m *Manager) Shutdown(ctx context.Context) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for name, p := range m.live {
|
||||
_ = p.Shutdown(ctx)
|
||||
delete(m.live, name)
|
||||
}
|
||||
}
|
||||
|
||||
// persistLocked writes the state file. Caller must hold m.mu.
|
||||
func (m *Manager) persistLocked() error {
|
||||
data, err := json.MarshalIndent(m.records, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(m.path, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
var errUnknown = errors.New("unknown plugin")
|
||||
|
||||
// IsUnknown reports whether err came from addressing a plugin that doesn't exist.
|
||||
func IsUnknown(err error) bool { return errors.Is(err, errUnknown) }
|
||||
@@ -0,0 +1,167 @@
|
||||
// Package plugins is the API Server's plugin system: a uniform contract for
|
||||
// integrating external third-party services (flight data, notifications, …).
|
||||
//
|
||||
// Two plugin kinds share one contract:
|
||||
// - "builtin" — a Go connector compiled into the server (type-safe, first-party).
|
||||
// Adding a new builtin requires a rebuild. See builtin/opensky for an example.
|
||||
// - "external" — a remote service registered at runtime (no rebuild) that speaks
|
||||
// a small JSON contract over HTTP. See external.go.
|
||||
//
|
||||
// Enable-state and per-plugin config (including secrets) are persisted to a local
|
||||
// plugins.json by the Manager, mirroring how the PocketBase connection persists to
|
||||
// .env. See doc.go for the deliberately-deferred extension points.
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Plugin kinds.
|
||||
const (
|
||||
KindBuiltin = "builtin"
|
||||
KindExternal = "external"
|
||||
)
|
||||
|
||||
// AuthType describes how a plugin authenticates to its upstream. It is metadata
|
||||
// for the UI/operators; each plugin implements the mechanics itself.
|
||||
type AuthType string
|
||||
|
||||
const (
|
||||
AuthNone AuthType = "none"
|
||||
AuthAPIKey AuthType = "apikey"
|
||||
AuthBasic AuthType = "basic"
|
||||
AuthOAuth2 AuthType = "oauth2"
|
||||
AuthWebhook AuthType = "webhook"
|
||||
)
|
||||
|
||||
// Health status values.
|
||||
const (
|
||||
StatusOK = "ok"
|
||||
StatusDegraded = "degraded"
|
||||
StatusDown = "down"
|
||||
)
|
||||
|
||||
// SelectOption is one choice for a ConfigField of Type "select".
|
||||
type SelectOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// ConfigField declares one configurable setting a plugin accepts. It drives the
|
||||
// panel's generated config form and controls secret masking.
|
||||
type ConfigField struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"` // "text" | "password" | "number" | "select"
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"` // never echoed back to clients in clear
|
||||
Help string `json:"help,omitempty"`
|
||||
Default string `json:"default,omitempty"` // effective default when unset
|
||||
Options []SelectOption `json:"options,omitempty"` // for Type "select"
|
||||
}
|
||||
|
||||
// Capability is one operation a plugin exposes. It maps a stable id to the
|
||||
// upstream endpoint it calls and a human description shown in the panel.
|
||||
type Capability struct {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method,omitempty"` // e.g. "GET"
|
||||
Endpoint string `json:"endpoint,omitempty"` // upstream path, e.g. "/states/all"
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts either a bare string ("states.all") or a full object, so
|
||||
// external manifests can advertise capabilities in either form.
|
||||
func (c *Capability) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if json.Unmarshal(b, &s) == nil {
|
||||
c.ID = s
|
||||
return nil
|
||||
}
|
||||
type alias Capability
|
||||
var a alias
|
||||
if err := json.Unmarshal(b, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
*c = Capability(a)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Category groups a plugin under a tab in the admin panel. A plugin with an
|
||||
// empty category is treated as CategoryAPIsExternal by the panel.
|
||||
const (
|
||||
CategoryAPIsExternal = "apis-external" // remote HTTP APIs (OpenSky, external plugins)
|
||||
CategoryDrivesExternal = "drives-external" // remote file stores (FTP/SFTP)
|
||||
CategoryDrivesLocal = "drives-local" // drives on the host machine
|
||||
)
|
||||
|
||||
// Descriptor is the static metadata a plugin advertises about itself.
|
||||
type Descriptor struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Version string `json:"version"`
|
||||
Kind string `json:"kind"` // KindBuiltin | KindExternal
|
||||
Category string `json:"category"` // one of Category* — groups the plugin in the panel
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
AuthType AuthType `json:"authType"`
|
||||
ConfigFields []ConfigField `json:"configFields"`
|
||||
}
|
||||
|
||||
// Health is the outcome of a plugin's HealthCheck.
|
||||
type Health struct {
|
||||
Status string `json:"status"` // StatusOK | StatusDegraded | StatusDown
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Credits *HealthCredits `json:"credits,omitempty"`
|
||||
}
|
||||
|
||||
// HealthCredits is optional structured rate-limit/credit accounting a plugin may
|
||||
// report alongside a probe (e.g. OpenSky's daily credit allowance). It lets the UI
|
||||
// render a dedicated usage meter instead of parsing it back out of Detail.
|
||||
type HealthCredits struct {
|
||||
Remaining *int `json:"remaining,omitempty"` // credits left today; nil when the upstream didn't report it (e.g. anonymous)
|
||||
Daily int `json:"daily,omitempty"` // the plan's daily allowance
|
||||
ProbeCost int `json:"probeCost,omitempty"` // credits one query/probe costs
|
||||
Mode string `json:"mode,omitempty"` // "authenticated" | "anonymous"
|
||||
}
|
||||
|
||||
// Plugin is the contract every plugin (builtin or external) implements.
|
||||
type Plugin interface {
|
||||
// Descriptor returns the plugin's static metadata. It may be enriched after
|
||||
// Init (e.g. an external plugin fetching its manifest).
|
||||
Descriptor() Descriptor
|
||||
// Init prepares the plugin with its resolved config (secrets included). It is
|
||||
// called when the plugin is enabled or its config changes.
|
||||
Init(ctx context.Context, config map[string]string) error
|
||||
// HealthCheck probes the upstream and classifies the result.
|
||||
HealthCheck(ctx context.Context) Health
|
||||
// Invoke runs a named capability. Part of the contract for future use; v1
|
||||
// exposes no HTTP endpoint for it.
|
||||
Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error)
|
||||
// Shutdown releases any resources held by the plugin.
|
||||
Shutdown(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Factory builds a fresh instance of a builtin plugin.
|
||||
type Factory func() Plugin
|
||||
|
||||
// registry holds the builtin plugin factories keyed by descriptor name.
|
||||
var registry = map[string]Factory{}
|
||||
|
||||
// Register adds a builtin plugin factory. Called from a builtin package's init().
|
||||
// Panics on a duplicate name so wiring mistakes surface at startup.
|
||||
func Register(name string, f Factory) {
|
||||
if _, dup := registry[name]; dup {
|
||||
panic("plugins: duplicate registration for " + name)
|
||||
}
|
||||
registry[name] = f
|
||||
}
|
||||
|
||||
// builtinFactories returns a copy of the registered builtin factories.
|
||||
func builtinFactories() map[string]Factory {
|
||||
out := make(map[string]Factory, len(registry))
|
||||
for k, v := range registry {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user