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:
tajniak81
2026-07-13 11:43:33 +02:00
co-authored by Claude Opus 4.8
commit afc6952eda
172 changed files with 24591 additions and 0 deletions
+155
View File
@@ -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
}
+102
View File
@@ -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)
}
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+15
View File
@@ -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()),
})
}
+534
View File
@@ -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})
}
+201
View File
@@ -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
}
+23
View File
@@ -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)
}
+108
View File
@@ -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})
}
+137
View File
@@ -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})
}
+29
View File
@@ -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})
}
+240
View File
@@ -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()
}
+159
View File
@@ -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),
},
})
}
+65
View File
@@ -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,
})
}
+22
View File
@@ -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})
}
+498
View File
@@ -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
}
}
+13
View File
@@ -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)
}
+141
View File
@@ -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)
}
}
}
+376
View File
@@ -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
}
}
}
}
+112
View File
@@ -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
}
}
+307
View File
@@ -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 (14 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)
}
}
+20
View File
@@ -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.
+149
View File
@@ -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 }
+387
View File
@@ -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) }
+167
View File
@@ -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
}