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
+16
View File
@@ -0,0 +1,16 @@
# Build artifacts and secrets — never send to the build context.
.env
plugins.json
*.exe
/server
/tmp/
*.log
# Rebuilt inside the image.
panel/node_modules/
internal/api/dist/
# Repo noise.
.git/
.gitignore
README.md
+20
View File
@@ -0,0 +1,20 @@
# API Server configuration
# Copy to .env and adjust. The server also reads plain environment variables.
# Address the API Server listens on
API_ADDR=:8080
# PocketBase base URL (no trailing slash). The API Server is the only thing that
# talks to PocketBase; it proxies /api/auth/* to this address, which is never
# exposed to clients. (Legacy PB_URL is still honoured for backward compat.)
POCKETBASE_URL=http://10.2.1.10:8026
# CORS allowed origins for the Web App (comma separated, or * for any)
CORS_ALLOW_ORIGINS=*
# Superuser service account — used ONLY for admin user-management
# (list/create/delete users under Settings → User management). Every such call
# still verifies the *caller* has role=admin first. Leave unset to disable those
# endpoints (they return 503); the rest of the server is unaffected.
POCKETBASE_ADMIN_EMAIL=admin@dji.local
POCKETBASE_ADMIN_PASSWORD=change-me
+8
View File
@@ -0,0 +1,8 @@
.env
plugins.json
/server
/server.exe
/api-server.exe
/tmp/
*.log
panel/node_modules/
+35
View File
@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1
# ---- Stage 1: build the embedded Vue panel ----
# vite.config.js writes the build to ../internal/api/dist, i.e. /internal/api/dist
# here, which the Go binary embeds via //go:embed all:dist.
FROM node:22-alpine AS panel
WORKDIR /panel
COPY panel/package.json panel/package-lock.json ./
RUN npm ci
COPY panel/ ./
RUN npm run build
# ---- Stage 2: build the static Go binary (panel embedded) ----
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Overlay the freshly built panel so //go:embed all:dist picks it up.
COPY --from=panel /internal/api/dist ./internal/api/dist
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
-o /out/api-server ./cmd/server
# ---- Stage 3: minimal runtime ----
FROM alpine:latest
RUN apk add --no-cache ca-certificates tzdata \
&& adduser -D -u 10001 app
WORKDIR /app
COPY --from=build /out/api-server ./api-server
USER app
# Default listen address (override with API_ADDR). PocketBase URL, CORS origins,
# and the optional POCKETBASE_ADMIN_* service account come from env at runtime.
ENV API_ADDR=:8080
EXPOSE 8080
ENTRYPOINT ["/app/api-server"]
+145
View File
@@ -0,0 +1,145 @@
# PilotVault — API Server
Go service that is the **single entry point** for PilotVault. The Fly App streams
drone telemetry to it; the Web App and the built-in API Web Panel read live state
and issue commands. It keeps device state in memory and proxies authentication to
a PocketBase kept behind it (PocketBase's address is never exposed to clients).
```
Fly App ──► /ws/device ┐
├─► API Server (:8080) ──► PocketBase (auth only)
Web App ──► /ws/ui ┘
```
The server root (`GET /`) serves a PilotVault-branded **web panel**: a live health
readout plus a quick reference of both API audiences. Open
http://localhost:8080/ in a browser to check the server at a glance.
The panel is a **Vue 3 + Tailwind v4** app in [`panel/`](panel/), built into
`internal/api/dist` and embedded into the Go binary at compile time:
```powershell
cd panel
npm install
npm run build # outputs to ../internal/api/dist
cd ..; go build -o api-server.exe ./cmd/server # embeds the fresh dist
```
For panel development with hot reload (proxies `/api` to a running server on
`:8080`): `cd panel; npm run dev` → http://localhost:5174.
## Requirements
- Go 1.24+ (`go version`)
- Node 18+ (only for building the panel)
- A reachable PocketBase instance with a `users` auth collection (for login). To
persist user settings, that collection needs a `preferences` JSON field — see
[`pocketbase/README.md`](pocketbase/README.md).
## Configure
```powershell
Copy-Item .env.example .env
# edit .env: set POCKETBASE_URL (and CORS_ALLOW_ORIGINS if needed)
```
| Variable | Purpose | Default |
|---|---|---|
| `API_ADDR` | Listen address | `:8080` |
| `POCKETBASE_URL` | PocketBase base URL (login proxy). Legacy `PB_URL` still honoured. | `http://10.2.1.10:8026` |
| `CORS_ALLOW_ORIGINS` | Comma list, or `*` | `*` |
## Run
```powershell
./scripts/Run-ApiServer.ps1
# or: go run ./cmd/server
```
Health check: `GET http://localhost:8080/healthz`.
## API
### Client / dashboard endpoints
| Method | Path | Description |
|---|---|---|
| `GET` | `/healthz`, `/api/health` | Readiness probe + device count (public) |
| `POST` | `/api/auth/login` | `{email, password}` → PocketBase session (proxied) |
| `GET` | `/api/auth/validate` | Validate the `Authorization` token |
| `GET` | `/api/me` | Caller's `{id, email, role, organization, organizationName}` resolved from their token |
| `GET` | `/api/preferences` | Read the caller's saved settings blob (from their PocketBase user record) |
| `PUT` | `/api/preferences` | `{preferences}` → persist the caller's settings onto their user record |
| `GET` | `/api/users` | List users (**manager**; admin → own org, superadmin → all) |
| `POST` | `/api/users` | `{email, password, role, organization?}` → create a user (**manager**; admin scoped to own org) |
| `PATCH` | `/api/users/{id}` | Edit `{email?, role?, verified?, password?, organization?}` (**manager**; scope-checked; cannot demote self) |
| `DELETE` | `/api/users/{id}` | Delete a user (**manager**; admin → own org only; cannot delete self) |
| `GET` | `/api/orgs` | List organizations (**manager**; admin → own org, superadmin → all) |
| `POST` | `/api/orgs` | `{name}` → create an organization (**superadmin only**) |
| `PATCH` | `/api/orgs/{id}` | `{name}` → rename an organization (**superadmin only**) |
| `DELETE` | `/api/orgs/{id}` | Delete an empty organization (**superadmin only**) |
| `GET` | `/api/admin/pb-config` | Read the PocketBase connection + a live probe (**superadmin only**) |
| `POST` | `/api/admin/pb-config/test` | `{url?, adminEmail?, adminPassword?}` → probe a candidate connection without applying (**superadmin only**) |
| `PUT` | `/api/admin/pb-config` | `{url, adminEmail?, adminPassword?}` → apply at runtime + persist to `.env` (**superadmin only**) |
| `GET` | `/api/admin/plugins` | List plugins with state + last health (**superadmin only**) |
| `POST` | `/api/admin/plugins` | `{name, baseURL, provider?}` → register an external plugin, no rebuild (**superadmin only**) |
| `GET` | `/api/admin/plugins/{name}` | One plugin's view (**superadmin only**) |
| `PUT` | `/api/admin/plugins/{name}` | `{enabled?, config?}` → enable/disable + configure (**superadmin only**) |
| `DELETE` | `/api/admin/plugins/{name}` | Remove an external plugin (**superadmin only**) |
| `POST` | `/api/admin/plugins/{name}/health` | Run a health check now (**superadmin only**) |
| `GET` | `/api/devices` | List devices and their last-known state |
| `GET` | `/api/devices/{id}/track` | GPS track history for a device |
| `POST` | `/api/devices/{id}/command` | Send `{command, payload?}` to a connected device |
| `DELETE` | `/api/devices/{id}` | Forget a device's stored state |
| `GET` | `/ws/ui` | Live telemetry stream (WebSocket) |
### Device endpoints (Fly App)
| Method | Path | Description |
|---|---|---|
| `GET` | `/ws/device?id={id}` | Telemetry uplink (WebSocket) |
| `POST` | `/api/telemetry?id={id}` | Push a single telemetry event over HTTP |
### Telemetry events
Device messages carry a `type`: `registration`, `connection`, `battery`, or
`telemetry` (altitude, lat/lng, velocity, GPS sats, flight mode…). The server
merges them into a per-device `DeviceState` and fans each update out to every
connected dashboard as `{type:"update", device, event}`. `latitude`/`longitude`
samples are appended to the device's GPS track.
## Plugins
The server integrates external third-party services through a uniform **plugin**
contract (`internal/plugins`), managed by a superadmin from the panel. Two kinds
share one interface:
- **Built-in** — Go connectors compiled into the server (type-safe, first-party).
The reference example is **OpenSky Network** (`internal/plugins/builtin/opensky`),
a live ADS-B flight-state connector with an OAuth2 / anonymous auth provider.
Adding a *new* built-in needs a rebuild.
- **External** — a remote HTTP service **registered at runtime, no rebuild**. It
answers a small contract (`GET /health`, `GET /manifest`, `POST /invoke`) and can
run as its own process/container (the sandboxing story).
Enable-state and per-plugin config (secrets included) persist to a local,
gitignored `plugins.json` (override with `PLUGINS_FILE`), loaded on boot. Every
plugin exposes a real `HealthCheck`. Deferred extension points (invocation API,
retry/circuit-breaker, per-tenant credentials, audit logging) are documented in
`internal/plugins/doc.go`.
**Writing a plugin:** see the developer guide
[`internal/plugins/README.md`](internal/plugins/README.md) — step-by-step for both
built-in (Go) and external (HTTP, no rebuild) plugins, with complete examples.
## Project layout
```
cmd/server/main.go entry point, wiring, graceful shutdown
internal/config env/.env configuration
internal/hub in-memory device state + websocket fan-out (drone core)
internal/api router, middleware, handlers, embedded panel
internal/plugins plugin contract, manager, external kind + built-in connectors
panel/ Vue 3 + Tailwind v4 web panel (built into internal/api/dist)
scripts/ run helper
```
+63
View File
@@ -0,0 +1,63 @@
// Command server runs the PilotVault API Server. It is the single entry point
// the Fly App (drone/telemetry uplink) and the Web App / API Web Panel talk to.
// It keeps live device state in memory, fans telemetry out to dashboards over a
// websocket, and proxies authentication to a PocketBase kept behind it.
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"pilotvault/apiserver/internal/api"
"pilotvault/apiserver/internal/config"
"pilotvault/apiserver/internal/hub"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[api] ")
cfg := config.Load()
h := hub.New()
srv := api.New(cfg, h)
if err := srv.StartPlugins(); err != nil {
log.Printf("plugins: load failed: %v", err)
}
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
// No WriteTimeout: /ws/* are long-lived streaming connections.
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("listening on %s (PocketBase: %s, panel at /)", cfg.Addr, cfg.PocketBaseURL)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
// Graceful shutdown.
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Stop(ctx)
if err := httpServer.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}
log.Println("stopped")
}
+12
View File
@@ -0,0 +1,12 @@
services:
api-server:
build: .
image: pilotvault-api-server
container_name: pilotvault-api-server
# Config comes from .env (POCKETBASE_URL, CORS_ALLOW_ORIGINS, and the
# optional POCKETBASE_ADMIN_* service account). API_ADDR defaults to :8080.
env_file:
- .env
ports:
- "8080:8080"
restart: unless-stopped
+15
View File
@@ -0,0 +1,15 @@
module pilotvault/apiserver
go 1.26
require (
github.com/gorilla/websocket v1.5.3
github.com/jlaffaye/ftp v0.2.1
github.com/pkg/sftp v1.13.10
golang.org/x/crypto v0.41.0
)
require (
github.com/kr/fs v0.1.0 // indirect
golang.org/x/sys v0.35.0 // indirect
)
+22
View File
@@ -0,0 +1,22 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jlaffaye/ftp v0.2.1 h1:AICcTYPMkaXlmjLMm9I+lB36f6jXCsCvBqVQc6EfC1Y=
github.com/jlaffaye/ftp v0.2.1/go.mod h1:gXSIr1pA9NhynDNigiFHs4+yL7o7I6bGF9Za9wi9tcE=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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
}
+14
View File
@@ -0,0 +1,14 @@
<!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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1964
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "pilotvault-api-panel",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.0.0",
"vite": "^6.0.7"
}
}
+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

+807
View File
@@ -0,0 +1,807 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { theme, toggleTheme } from "./theme";
import EndpointTable from "./components/EndpointTable.vue";
// ---- Auth gate: this panel is restricted to superadmins ------------------
// The token is kept only for this browser (localStorage). Login proxies through
// the API Server to PocketBase; the caller's role is then confirmed via /api/me,
// and anyone who is not a superadmin is refused.
const TOKEN_KEY = "pv_panel_token";
const token = ref(localStorage.getItem(TOKEN_KEY) || "");
const me = ref(null); // { email, role } once verified as superadmin
const authed = ref(false);
const booting = ref(true);
const form = ref({ email: "", password: "" });
const loginErr = ref("");
const busy = ref(false);
// Console tabs.
const tab = ref("overview");
const TABS = [
{ id: "overview", label: "Overview" },
{ id: "pocketbase", label: "PocketBase" },
{ id: "plugins", label: "Plugins" },
];
// Verify a token resolves to a superadmin. Returns true when access is granted.
async function verify(tok) {
try {
const r = await fetch("/api/me", { headers: { Authorization: tok } });
if (!r.ok) return false;
const who = await r.json();
if (who.role !== "superadmin") return false;
me.value = { email: who.email, role: who.role };
return true;
} catch {
return false;
}
}
async function grant(tok) {
token.value = tok;
localStorage.setItem(TOKEN_KEY, tok);
authed.value = true;
startPolling();
loadPbConfig();
loadPlugins();
}
async function doLogin() {
loginErr.value = "";
const email = form.value.email.trim().toLowerCase();
if (!email || !form.value.password) {
loginErr.value = "Enter your email and password.";
return;
}
busy.value = true;
try {
const r = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password: form.value.password }),
});
const body = await r.json().catch(() => ({}));
if (!r.ok || !body.token) {
loginErr.value = "Invalid email or password.";
return;
}
// Authenticated — now enforce the superadmin-only rule.
if (!(await verify(body.token))) {
loginErr.value = "Access to this panel is restricted to superadmins.";
return;
}
form.value.password = "";
await grant(body.token);
} catch {
loginErr.value = "Cannot reach the API server.";
} finally {
busy.value = false;
}
}
function logout() {
stopPolling();
localStorage.removeItem(TOKEN_KEY);
token.value = "";
me.value = null;
authed.value = false;
}
// ---- PocketBase connection settings (superadmin) -------------------------
const pb = ref(null); // { url, adminEmail, adminConfigured, probe }
const pbForm = ref({ url: "", adminEmail: "", adminPassword: "" });
const pbProbe = ref(null); // most recent test/save probe result
const pbMsg = ref("");
const pbErr = ref("");
const pbBusy = ref(false);
const pbTesting = ref(false);
function authHeaders(json) {
const h = { Authorization: token.value };
if (json) h["Content-Type"] = "application/json";
return h;
}
async function loadPbConfig() {
try {
const r = await fetch("/api/admin/pb-config", { headers: authHeaders() });
if (!r.ok) return;
const d = await r.json();
pb.value = d;
pbForm.value = { url: d.url || "", adminEmail: d.adminEmail || "", adminPassword: "" };
pbProbe.value = d.probe || null;
} catch {
/* leave settings unloaded; the card shows a retry */
}
}
async function testPbConfig() {
pbMsg.value = "";
pbErr.value = "";
pbTesting.value = true;
try {
const r = await fetch("/api/admin/pb-config/test", {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify(pbForm.value),
});
pbProbe.value = await r.json();
} catch {
pbErr.value = "Could not run the test.";
} finally {
pbTesting.value = false;
}
}
async function savePbConfig() {
pbMsg.value = "";
pbErr.value = "";
if (!pbForm.value.url.trim()) {
pbErr.value = "A PocketBase URL is required.";
return;
}
pbBusy.value = true;
try {
const r = await fetch("/api/admin/pb-config", {
method: "PUT",
headers: authHeaders(true),
body: JSON.stringify(pbForm.value),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
pbErr.value = d.error || "Could not save the connection.";
return;
}
pb.value = d.config;
pbProbe.value = d.config?.probe || null;
pbForm.value = { url: d.config.url, adminEmail: d.config.adminEmail, adminPassword: "" };
pbMsg.value = d.warning || "Connection saved.";
} catch {
pbErr.value = "Could not reach the API server.";
} finally {
pbBusy.value = false;
}
}
// ---- Plugins (external-service integrations, superadmin) ------------------
const plugins = ref([]); // [{ name, provider, version, kind, capabilities, authType, configFields, enabled, config, baseURL, health }]
const pluginsErr = ref("");
const pluginsMsg = ref("");
const editingPlugin = ref(""); // name whose config form is expanded
const editConfig = ref({}); // working copy of the expanded plugin's config
const busyPlugin = ref(""); // name of a plugin with an in-flight action
const newExt = ref({ name: "", baseURL: "", provider: "" });
const newExtErr = ref("");
const newExtBusy = ref(false);
// Plugins are grouped into category tabs (mirrors the Web App's Integrations tabs).
// A plugin's server-side `category` picks its tab; an unset category falls back to APIs — External.
const PLUGIN_CATEGORIES = [
{ id: "apis-external", label: "APIs — External" },
{ id: "drives-external", label: "Drives — External" },
{ id: "drives-local", label: "Drives — Local" },
];
const pluginTab = ref("apis-external");
function pluginCategory(p) {
return p.category || "apis-external";
}
function pluginsInCategory(id) {
return plugins.value.filter((p) => pluginCategory(p) === id);
}
const activePlugins = computed(() => pluginsInCategory(pluginTab.value));
const kindBadge = {
builtin: "bg-success-tint text-success",
external: "bg-warning-tint text-warning",
};
const healthBadge = {
ok: "bg-success-tint text-success",
degraded: "bg-warning-tint text-warning",
down: "bg-danger-tint text-danger",
};
async function loadPlugins() {
pluginsErr.value = "";
try {
const r = await fetch("/api/admin/plugins", { headers: authHeaders() });
if (!r.ok) {
pluginsErr.value = "Could not load plugins.";
return;
}
const d = await r.json();
plugins.value = d.plugins || [];
// Populate health (and live credit usage) for enabled plugins that don't have
// it yet — e.g. right after a server restart. Cached server-side afterwards,
// so this probes at most once per plugin per server run.
for (const p of plugins.value) {
if (p.enabled && !p.health) autoCheckHealth(p.name);
}
} catch {
pluginsErr.value = "Could not reach the API server.";
}
}
async function autoCheckHealth(name) {
try {
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(name)}/health`, {
method: "POST",
headers: authHeaders(),
});
const d = await r.json().catch(() => ({}));
if (r.ok && d.health) {
const t = pluginBy(name);
if (t) t.health = d.health;
}
} catch {
/* leave health unset */
}
}
function pluginBy(name) {
return plugins.value.find((p) => p.name === name);
}
async function togglePlugin(p) {
busyPlugin.value = p.name;
pluginsMsg.value = "";
try {
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
method: "PUT",
headers: authHeaders(true),
body: JSON.stringify({ enabled: !p.enabled }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
pluginsMsg.value = d.error || "Could not update the plugin.";
} else if (d.warning) {
pluginsMsg.value = `${p.name}: ${d.warning}`;
}
await loadPlugins();
} finally {
busyPlugin.value = "";
}
}
function startPluginEdit(p) {
editingPlugin.value = editingPlugin.value === p.name ? "" : p.name;
const cfg = { ...(p.config || {}) };
// Preselect each field's effective default when nothing is stored yet.
for (const f of p.configFields || []) {
if ((cfg[f.key] === undefined || cfg[f.key] === "") && f.default) cfg[f.key] = f.default;
}
editConfig.value = cfg;
}
async function savePluginConfig(p) {
busyPlugin.value = p.name;
pluginsMsg.value = "";
try {
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
method: "PUT",
headers: authHeaders(true),
body: JSON.stringify({ config: editConfig.value }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
pluginsMsg.value = d.error || "Could not save the configuration.";
} else {
pluginsMsg.value = d.warning ? `${p.name}: ${d.warning}` : "Configuration saved.";
editingPlugin.value = "";
}
await loadPlugins();
} finally {
busyPlugin.value = "";
}
}
async function checkPlugin(p) {
busyPlugin.value = p.name;
pluginsMsg.value = "";
try {
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}/health`, {
method: "POST",
headers: authHeaders(),
});
const d = await r.json().catch(() => ({}));
if (r.ok && d.health) {
const t = pluginBy(p.name);
if (t) t.health = d.health;
} else {
pluginsMsg.value = d.error || "Health check failed.";
}
} finally {
busyPlugin.value = "";
}
}
async function removePlugin(p) {
if (p.kind !== "external") return;
busyPlugin.value = p.name;
try {
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
method: "DELETE",
headers: authHeaders(),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
pluginsMsg.value = d.error || "Could not remove the plugin.";
}
await loadPlugins();
} finally {
busyPlugin.value = "";
}
}
async function registerExternal() {
newExtErr.value = "";
if (!newExt.value.name.trim() || !newExt.value.baseURL.trim()) {
newExtErr.value = "Name and base URL are required.";
return;
}
newExtBusy.value = true;
try {
const r = await fetch("/api/admin/plugins", {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify(newExt.value),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
newExtErr.value = d.error || "Could not register the plugin.";
return;
}
newExt.value = { name: "", baseURL: "", provider: "" };
pluginsMsg.value = "External plugin registered.";
await loadPlugins();
} catch {
newExtErr.value = "Could not reach the API server.";
} finally {
newExtBusy.value = false;
}
}
// Live health poll against this server's aggregate /api/status, which probes
// PocketBase and the Web App server-side (the browser only talks to the API).
const checkedAt = ref(null);
const devices = ref(null);
const svc = ref({
apiServer: { status: "checking", detail: "" },
pocketBase: { status: "checking", detail: "" },
webApp: { status: "checking", detail: "" },
});
let timer = null;
const rows = [
{ key: "apiServer", label: "API server" },
{ key: "pocketBase", label: "PocketBase" },
{ key: "webApp", label: "Web App" },
];
const badge = {
checking: { label: "checking", cls: "bg-sunken text-secondary" },
ok: { label: "operational", cls: "bg-success-tint text-success" },
down: { label: "unreachable", cls: "bg-danger-tint text-danger" },
unreachable: { label: "unreachable", cls: "bg-danger-tint text-danger" },
};
function meta(h) {
const bits = [];
if (typeof h.latencyMs === "number") bits.push(h.latencyMs + "ms");
if (h.httpStatus) bits.push("HTTP " + h.httpStatus);
if (h.url) bits.push(h.url);
return bits.join(" · ");
}
async function check() {
try {
const r = await fetch("/api/status");
if (!r.ok) throw new Error("status " + r.status);
const b = await r.json();
const api = b.apiServer || {};
devices.value = api.devices ?? 0;
svc.value = {
apiServer: { status: "ok", detail: "" },
pocketBase: { status: b.pocketBase?.status === "ok" ? "ok" : "down", detail: meta(b.pocketBase || {}) },
webApp: { status: b.webApp?.status === "ok" ? "ok" : "down", detail: meta(b.webApp || {}) },
};
} catch {
// The API server itself is unreachable → status of everything is unknown.
devices.value = null;
svc.value = {
apiServer: { status: "unreachable", detail: "" },
pocketBase: { status: "unreachable", detail: "" },
webApp: { status: "unreachable", detail: "" },
};
}
checkedAt.value = new Date();
}
function startPolling() {
check();
clearInterval(timer);
timer = setInterval(check, 10000);
}
function stopPolling() {
clearInterval(timer);
timer = null;
}
onMounted(async () => {
// Resume a previous superadmin session if the stored token still checks out.
if (token.value && (await verify(token.value))) {
authed.value = true;
startPolling();
loadPbConfig();
loadPlugins();
} else if (token.value) {
localStorage.removeItem(TOKEN_KEY);
token.value = "";
}
booting.value = false;
});
onUnmounted(() => stopPolling());
// Client / dashboard API — used by the Web App and the API Web Panel.
const clientApi = [
{ method: "POST", path: "/api/auth/login", desc: "Exchange email + password for a session (via PocketBase)" },
{ method: "GET", path: "/api/auth/validate", desc: "Validate the current session token" },
{ method: "GET", path: "/api/me", desc: "Caller's id, email, role, and organization from their token" },
{ method: "GET", path: "/api/preferences", desc: "Read the caller's saved settings blob" },
{ method: "PUT", path: "/api/preferences", desc: "Persist the caller's settings onto their user record" },
{ method: "GET", path: "/api/devices", desc: "List connected devices and last-known state" },
{ method: "GET", path: "/api/devices/{id}/track", desc: "GPS track history for a device" },
{ method: "POST", path: "/api/devices/{id}/command", desc: "Send a command down to a device" },
{ method: "DELETE", path: "/api/devices/{id}", desc: "Forget a device's stored state" },
{ method: "GET", path: "/ws/ui", desc: "Live telemetry stream (WebSocket)" },
];
// Management API — user + organization management. Requires a manager
// (admin or superadmin) token; admins are scoped to their own organization,
// superadmins span all of them.
const managementApi = [
{ method: "GET", path: "/api/users", desc: "List users (admin: own org · superadmin: all)" },
{ method: "POST", path: "/api/users", desc: "Create a user {email, password, role, organization?}" },
{ method: "PATCH", path: "/api/users/{id}", desc: "Edit a user (role/org changes are scope-checked)" },
{ method: "DELETE", path: "/api/users/{id}", desc: "Delete a user (not self; admins in-org only)" },
{ method: "GET", path: "/api/orgs", desc: "List organizations (admin: own · superadmin: all)" },
{ method: "POST", path: "/api/orgs", desc: "Create an organization {name} (superadmin)" },
{ method: "PATCH", path: "/api/orgs/{id}", desc: "Rename an organization (superadmin)" },
{ method: "DELETE", path: "/api/orgs/{id}", desc: "Delete an empty organization (superadmin)" },
{ method: "GET", path: "/api/admin/pb-config", desc: "Read the PocketBase connection + live probe (superadmin)" },
{ method: "POST", path: "/api/admin/pb-config/test", desc: "Test a candidate connection without applying (superadmin)" },
{ method: "PUT", path: "/api/admin/pb-config", desc: "Update + persist the PocketBase connection (superadmin)" },
{ method: "GET", path: "/api/admin/plugins", desc: "List plugins + state + last health (superadmin)" },
{ method: "POST", path: "/api/admin/plugins", desc: "Register an external plugin {name, baseURL} (superadmin)" },
{ method: "PUT", path: "/api/admin/plugins/{name}", desc: "Enable/disable + configure a plugin (superadmin)" },
{ method: "DELETE", path: "/api/admin/plugins/{name}", desc: "Remove an external plugin (superadmin)" },
{ method: "POST", path: "/api/admin/plugins/{name}/health", desc: "Run a plugin health check (superadmin)" },
];
// Device API — used by the Fly App running on the drone/controller.
const deviceApi = [
{ method: "GET", path: "/ws/device?id={id}", desc: "Device telemetry uplink (WebSocket)" },
{ method: "POST", path: "/api/telemetry?id={id}", desc: "Push a single telemetry event over HTTP" },
{ method: "GET", path: "/healthz", desc: "Readiness probe" },
];
</script>
<template>
<div class="mx-auto flex max-w-5xl flex-col gap-6 px-6 pt-12 pb-16">
<!-- Header -->
<div class="flex items-center gap-3">
<svg width="32" height="32" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<g stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
<polyline points="8,30 19,17 30,30" stroke="var(--brand)" />
<polyline points="18,33 29,20 40,33" stroke="currentColor" />
</g>
</svg>
<div class="leading-tight text-primary">
<div class="font-display text-lg font-bold tracking-tight">PilotVault</div>
<span class="pv-eyebrow">API server</span>
</div>
<div class="flex-1"></div>
<span v-if="authed && me" class="pv-eyebrow hidden truncate sm:inline">{{ me.email }}</span>
<button v-if="authed" class="pv-btn-sec pv-btn-sm" @click="logout">Sign out</button>
<button
class="pv-btn-sec pv-btn-sm"
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
@click="toggleTheme"
>
<!-- sun (shown in dark mode) -->
<svg v-if="theme === 'dark'" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
<!-- moon (shown in light mode) -->
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
Theme
</button>
</div>
<!-- Booting: resolving a stored session -->
<div v-if="booting" class="rounded-lg border border-subtle bg-card px-5 py-10 text-center shadow-sm">
<span class="pv-eyebrow">Checking session</span>
</div>
<!-- Login gate superadmin only -->
<div v-else-if="!authed" class="mx-auto w-full max-w-sm rounded-lg border border-subtle bg-card shadow-sm">
<div class="border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">Sign in</div>
<span class="pv-eyebrow">Superadmin access only</span>
</div>
<form class="flex flex-col gap-3 px-5 py-5" @submit.prevent="doLogin">
<label class="flex flex-col gap-1">
<span class="pv-eyebrow">Email</span>
<input
v-model="form.email"
type="email"
autocomplete="username"
class="pv-input"
placeholder="superadmin@pilotvault.local"
/>
</label>
<label class="flex flex-col gap-1">
<span class="pv-eyebrow">Password</span>
<input
v-model="form.password"
type="password"
autocomplete="current-password"
class="pv-input"
placeholder="••••••••"
/>
</label>
<p v-if="loginErr" class="rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger">
{{ loginErr }}
</p>
<button type="submit" class="pv-btn mt-1" :disabled="busy">
{{ busy ? "Signing in…" : "Sign in" }}
</button>
</form>
</div>
<!-- Console visible only to an authenticated superadmin -->
<template v-else>
<!-- Tabs -->
<div class="flex gap-1 self-start rounded-lg border border-subtle bg-card p-1 shadow-sm">
<button
v-for="t in TABS"
:key="t.id"
class="rounded-md px-3.5 py-1.5 text-sm font-semibold transition"
:class="tab === t.id ? 'bg-brand text-on-brand' : 'text-secondary hover:text-primary'"
@click="tab = t.id"
>
{{ t.label }}
</button>
</div>
<!-- Overview tab -->
<div v-show="tab === 'overview'" class="flex flex-col gap-6">
<!-- Status -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">Status</div>
<span class="pv-eyebrow">{{ checkedAt ? "checked " + checkedAt.toLocaleTimeString() : "—" }}</span>
</div>
<div>
<div
v-for="row in rows"
:key="row.key"
class="flex items-center justify-between gap-3 border-t border-subtle px-5 py-3.5 first:border-t-0"
>
<div class="min-w-0">
<div class="text-sm font-semibold text-primary">{{ row.label }}</div>
<div v-if="svc[row.key].detail" class="truncate font-mono text-xs text-secondary">{{ svc[row.key].detail }}</div>
</div>
<span
class="inline-flex shrink-0 items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-xs font-medium"
:class="badge[svc[row.key].status].cls"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ badge[svc[row.key].status].label }}
</span>
</div>
<!-- Devices metric -->
<div class="flex items-center justify-between gap-3 border-t border-subtle px-5 py-3.5">
<div class="text-sm font-semibold text-primary">Devices</div>
<span class="font-mono text-xs text-secondary">
{{ devices === null ? "—" : devices + " online" }}
</span>
</div>
</div>
</div>
<EndpointTable title="Client API" auth="PocketBase session" :endpoints="clientApi" />
<EndpointTable title="Management API" auth="Admin · superadmin" :endpoints="managementApi" />
<EndpointTable title="Device API" auth="Device uplink" :endpoints="deviceApi" />
</div>
<!-- PocketBase tab -->
<div v-show="tab === 'pocketbase'" class="flex flex-col gap-6">
<!-- PocketBase connection settings -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-semibold text-primary">PocketBase connection</div>
<span class="pv-eyebrow">Settings</span>
</div>
<span
v-if="pbProbe"
class="inline-flex shrink-0 items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-xs font-medium"
:class="pbProbe.reachable ? (pbProbe.superuser ? 'bg-success-tint text-success' : 'bg-warning-tint text-warning') : 'bg-danger-tint text-danger'"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ pbProbe.reachable ? (pbProbe.superuser ? "connected" : "reachable") : "unreachable" }}
</span>
</div>
<div class="flex flex-col gap-3 px-5 py-5">
<label class="flex flex-col gap-1">
<span class="pv-eyebrow">PocketBase URL</span>
<input v-model="pbForm.url" class="pv-input" placeholder="http://10.2.1.10:8026" spellcheck="false" />
</label>
<label class="flex flex-col gap-1">
<span class="pv-eyebrow">Service account email</span>
<input v-model="pbForm.adminEmail" class="pv-input" placeholder="admin@pilotvault.local" autocomplete="off" spellcheck="false" />
</label>
<label class="flex flex-col gap-1">
<span class="pv-eyebrow">Service account password</span>
<input
v-model="pbForm.adminPassword"
type="password"
class="pv-input"
autocomplete="new-password"
:placeholder="pb && pb.adminConfigured ? 'leave blank to keep current' : 'set a password'"
/>
</label>
<div v-if="pbProbe" class="font-mono text-[11px] text-muted">
health: {{ pbProbe.reachable ? "ok" : "down" }}<span v-if="pbProbe.latencyMs"> · {{ pbProbe.latencyMs }}ms</span>
· superuser auth: {{ pbProbe.superuser ? "ok" : "" }}<span v-if="pbProbe.detail"> · {{ pbProbe.detail }}</span>
</div>
<p v-if="pbErr" class="rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger">{{ pbErr }}</p>
<p v-else-if="pbMsg" class="rounded-sm bg-success-tint px-3 py-2 text-xs font-medium text-success">{{ pbMsg }}</p>
<div class="flex flex-wrap items-center gap-2">
<button class="pv-btn" :disabled="pbBusy" @click="savePbConfig">{{ pbBusy ? "Saving" : "Save connection" }}</button>
<button class="pv-btn-sec" :disabled="pbTesting" @click="testPbConfig">{{ pbTesting ? "Testing" : "Test connection" }}</button>
</div>
<p class="text-[11px] text-muted">
The service account is used only for user &amp; organization management. Changing the URL
repoints the whole API Server at a new PocketBase and may sign you out.
</p>
</div>
</div>
</div>
<!-- Plugins tab -->
<div v-show="tab === 'plugins'" class="flex flex-col gap-6">
<!-- Plugins -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-semibold text-primary">Plugins</div>
<span class="pv-eyebrow">External integrations</span>
</div>
<button class="pv-btn-sec pv-btn-sm" @click="loadPlugins">Refresh</button>
</div>
<div class="flex flex-col">
<p v-if="pluginsErr" class="px-5 py-3 text-xs font-medium text-danger">{{ pluginsErr }}</p>
<p v-if="pluginsMsg" class="border-b border-subtle bg-sunken px-5 py-2.5 font-mono text-xs text-secondary">{{ pluginsMsg }}</p>
<p v-if="!plugins.length && !pluginsErr" class="px-5 py-6 text-sm text-secondary">No plugins registered yet.</p>
<!-- category tabs -->
<div v-if="plugins.length" class="flex gap-1 overflow-x-auto overflow-y-hidden border-b border-subtle px-3 pt-1">
<button
v-for="t in PLUGIN_CATEGORIES"
:key="t.id"
class="-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition"
:class="pluginTab === t.id ? 'border-brand text-primary' : 'border-transparent text-secondary hover:text-primary'"
@click="pluginTab = t.id"
>
{{ t.label }}
</button>
</div>
<!-- per-tab empty state -->
<p v-if="plugins.length && !activePlugins.length" class="px-5 py-6 text-sm text-secondary">No plugins in this category.</p>
<!-- plugin rows -->
<div v-for="p in activePlugins" :key="p.name" class="border-t border-subtle px-5 py-4 first:border-t-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-primary">{{ p.name }}</span>
<span class="rounded-sm px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase tracking-wider" :class="kindBadge[p.kind]">{{ p.kind }}</span>
<span
v-if="p.health"
class="inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 font-mono text-[10px] font-medium"
:class="healthBadge[p.health.status]"
:title="p.health.detail || ''"
>
<span class="h-1 w-1 rounded-full bg-current"></span>{{ p.health.status }}<span v-if="p.health.latencyMs"> · {{ p.health.latencyMs }}ms</span>
</span>
</div>
<div class="mt-0.5 font-mono text-xs text-secondary">{{ p.provider }} · v{{ p.version }} · {{ p.authType }}</div>
<div v-if="p.health && p.health.detail" class="mt-0.5 text-[11px] text-secondary">{{ p.health.detail }}</div>
<div v-if="p.capabilities && p.capabilities.length" class="mt-2 flex flex-col gap-1.5">
<div class="pv-eyebrow">Capabilities</div>
<div v-for="c in p.capabilities" :key="c.id" class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span class="rounded-sm bg-sunken px-1.5 py-0.5 font-mono text-[10px] text-secondary">{{ c.id }}</span>
<span v-if="c.method || c.endpoint" class="font-mono text-[10px] text-muted">{{ c.method }} {{ c.endpoint }}</span>
<span v-if="c.description" class="w-full text-[11px] text-secondary sm:w-auto">{{ c.description }}</span>
</div>
</div>
<div v-if="p.baseURL" class="mt-1 truncate font-mono text-[11px] text-muted">{{ p.baseURL }}</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
class="pv-btn-sec pv-btn-sm"
:disabled="busyPlugin === p.name"
:class="p.enabled ? '!border-transparent !bg-success-tint !text-success' : ''"
@click="togglePlugin(p)"
>
<span class="h-1.5 w-1.5 rounded-full" :class="p.enabled ? 'bg-success' : 'bg-muted'"></span>
{{ p.enabled ? "Enabled" : "Disabled" }}
</button>
<button v-if="p.configFields && p.configFields.length" class="pv-btn-sec pv-btn-sm" @click="startPluginEdit(p)">Configure</button>
<button class="pv-btn-sec pv-btn-sm" :disabled="busyPlugin === p.name" @click="checkPlugin(p)">Check</button>
<button v-if="p.kind === 'external'" class="pv-btn-sec pv-btn-sm !text-danger" :disabled="busyPlugin === p.name" @click="removePlugin(p)">Remove</button>
</div>
</div>
<!-- config form -->
<div v-if="editingPlugin === p.name" class="mt-3 flex flex-col gap-2 rounded-md border border-subtle bg-sunken px-4 py-4">
<label v-for="f in p.configFields" :key="f.key" class="flex flex-col gap-1">
<span class="pv-eyebrow">{{ f.label }}<span v-if="f.required" class="text-danger"> *</span></span>
<select v-if="f.type === 'select'" v-model="editConfig[f.key]" class="pv-input">
<option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<input
v-else
v-model="editConfig[f.key]"
:type="f.secret || f.type === 'password' ? 'password' : f.type === 'number' ? 'number' : 'text'"
class="pv-input"
autocomplete="off"
:placeholder="f.help || ''"
/>
<span v-if="f.help" class="text-[11px] text-muted">{{ f.help }}</span>
</label>
<div class="flex items-center gap-2">
<button class="pv-btn pv-btn-sm" :disabled="busyPlugin === p.name" @click="savePluginConfig(p)">Save configuration</button>
<button class="pv-btn-sec pv-btn-sm" @click="editingPlugin = ''">Cancel</button>
</div>
</div>
</div>
<!-- register external plugin -->
<div class="border-t border-subtle px-5 py-4">
<div class="pv-eyebrow mb-2">Register external plugin</div>
<div class="flex flex-col gap-2 sm:flex-row">
<input v-model="newExt.name" class="pv-input sm:w-40" placeholder="name" autocomplete="off" spellcheck="false" />
<input v-model="newExt.baseURL" class="pv-input flex-1" placeholder="https://plugin.example.com" autocomplete="off" spellcheck="false" />
<button class="pv-btn" :disabled="newExtBusy" @click="registerExternal">{{ newExtBusy ? "Adding" : "Add" }}</button>
</div>
<p v-if="newExtErr" class="mt-2 text-xs font-medium text-danger">{{ newExtErr }}</p>
<p class="mt-2 text-[11px] text-muted">
A remote service that answers <span class="font-mono">GET /health</span> and
<span class="font-mono">GET /manifest</span> added at runtime, no rebuild.
</p>
</div>
</div>
</div>
</div>
</template>
<p class="text-center font-mono text-[11px] text-muted">
PilotVault live drone telemetry, command &amp; control.
</p>
</div>
</template>
@@ -0,0 +1,40 @@
<script setup>
defineProps({
title: String,
auth: String,
endpoints: Array, // [{ method, path, desc }]
});
const methodClass = {
GET: "text-success",
POST: "text-brand-text",
PATCH: "text-warning",
DELETE: "text-danger",
};
</script>
<template>
<div class="overflow-hidden rounded-lg border border-subtle bg-card shadow-xs">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">{{ title }}</div>
<span class="pv-eyebrow">{{ auth }}</span>
</div>
<table class="w-full text-left text-sm">
<thead>
<tr class="pv-eyebrow">
<th class="px-5 py-2.5 font-medium">Endpoint</th>
<th class="px-5 py-2.5 font-medium">Description</th>
</tr>
</thead>
<tbody>
<tr v-for="e in endpoints" :key="e.method + e.path" class="transition-colors hover:bg-sunken">
<td class="border-t border-subtle px-5 py-2.5 font-mono text-xs whitespace-nowrap">
<span class="font-semibold" :class="methodClass[e.method]">{{ e.method }}</span>
<span class="text-primary"> {{ e.path }}</span>
</td>
<td class="border-t border-subtle px-5 py-2.5 text-secondary">{{ e.desc }}</td>
</tr>
</tbody>
</table>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
import "./theme";
createApp(App).mount("#app");
+261
View File
@@ -0,0 +1,261 @@
/* PilotVault API panel — design tokens (Vault Navy + Signal Blue) mapped into
Tailwind v4. Light is default; data-theme="dark" flips the semantic layer. */
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
@import "tailwindcss";
/* ============================================================
RAW RAMPS + SEMANTIC ALIASES (light)
============================================================ */
:root,
[data-theme="light"] {
/* Brand ramps (do not theme-flip) */
--navy-950: #0B1730;
--navy-900: #0F1E3D; /* Vault Navy — core brand */
--navy-800: #1B2E52;
--navy-700: #26406E;
--blue-50: #EAF1FE;
--blue-100: #D6E3FD;
--blue-300: #8FB4F6;
--blue-400: #5B93F5;
--blue-500: #3D7BF0; /* Signal Blue — accent */
--blue-600: #2B62CC;
--blue-700: #1F4CA0;
--slate-0: #FFFFFF;
--slate-50: #F6F7F9;
--slate-100: #EEF0F3;
--slate-150: #E6E9EE;
--slate-200: #DCE0E7;
--slate-300: #C5CCD7;
--slate-400: #97A1B0;
--slate-500: #6B7688;
--slate-700: #333B4A;
--steel: #5A6B85;
--green-500: #1F8A5B; --green-100: #DCF1E7; --green-600: #177049;
--amber-500: #D9852B; --amber-100: #FBEBD5; --amber-600: #B86C1B;
--red-500: #D64545; --red-100: #FBE0E0; --red-600: #B83232;
/* Semantic aliases — LIGHT */
--bg-page: var(--slate-100);
--bg-sunken: var(--slate-50);
--surface-card: var(--slate-0);
--border-subtle: var(--slate-200);
--border-strong: var(--slate-300);
--border-focus: var(--blue-500);
--text-primary: var(--navy-900);
--text-secondary: var(--steel);
--text-muted: var(--slate-400);
--brand: var(--blue-500);
--brand-hover: var(--blue-600);
--brand-active: var(--blue-700);
--brand-contrast: #FFFFFF;
--text-brand: var(--blue-600);
--success: var(--green-600);
--success-tint: var(--green-100);
--warning: var(--amber-600);
--warning-tint: var(--amber-100);
--danger: var(--red-600);
--danger-tint: var(--red-100);
--ring-focus: 0 0 0 3px color-mix(in srgb, var(--blue-500) 45%, transparent);
--sh-xs: 0 1px 2px rgba(15, 30, 61, 0.06);
--sh-sm: 0 1px 2px rgba(15, 30, 61, 0.06), 0 1px 3px rgba(15, 30, 61, 0.04);
--dur-fast: 120ms;
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
color-scheme: light;
}
/* ============================================================
DARK THEME — only the semantic layer remaps.
============================================================ */
[data-theme="dark"] {
--bg-page: var(--navy-950);
--bg-sunken: #0B111C;
--surface-card: #10203F;
--border-subtle: color-mix(in srgb, #ffffff 8%, transparent);
--border-strong: color-mix(in srgb, #ffffff 18%, transparent);
--border-focus: var(--blue-400);
--text-primary: #F4F7FC;
--text-secondary: #8FA0BE;
--text-muted: #5E6E8C;
--brand: var(--blue-400);
--brand-hover: var(--blue-300);
--brand-active: var(--blue-100);
--brand-contrast: #0F1E3D;
--text-brand: var(--blue-300);
--success: #5FD3A0;
--success-tint: color-mix(in srgb, var(--green-500) 22%, transparent);
--warning: #F0B26A;
--warning-tint: color-mix(in srgb, var(--amber-500) 22%, transparent);
--danger: #F08A8A;
--danger-tint: color-mix(in srgb, var(--red-500) 22%, transparent);
--ring-focus: 0 0 0 3px color-mix(in srgb, var(--blue-400) 55%, transparent);
--sh-xs: 0 1px 2px rgba(0, 0, 0, 0.35);
--sh-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
color-scheme: dark;
}
/* ============================================================
TAILWIND THEME — utilities resolve to the semantic vars, so
everything flips automatically under data-theme="dark".
============================================================ */
@theme inline {
--color-*: initial;
--color-page: var(--bg-page);
--color-sunken: var(--bg-sunken);
--color-card: var(--surface-card);
--color-subtle: var(--border-subtle);
--color-strong: var(--border-strong);
--color-primary: var(--text-primary);
--color-secondary: var(--text-secondary);
--color-muted: var(--text-muted);
--color-on-brand: var(--brand-contrast);
--color-brand: var(--brand);
--color-brand-text: var(--text-brand);
--color-success: var(--success);
--color-success-tint: var(--success-tint);
--color-warning: var(--warning);
--color-warning-tint: var(--warning-tint);
--color-danger: var(--danger);
--color-danger-tint: var(--danger-tint);
--font-display: 'Space Grotesk', ui-sans-serif, system-ui, 'Segoe UI', sans-serif;
--font-sans: 'Space Grotesk', ui-sans-serif, system-ui, 'Segoe UI', sans-serif;
--font-mono: 'Space Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
--radius-*: initial;
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-full: 9999px;
--shadow-*: initial;
--shadow-xs: var(--sh-xs);
--shadow-sm: var(--sh-sm);
}
/* ============================================================
BASE
============================================================ */
html,
body,
#app {
height: 100%;
}
body {
font-family: var(--font-sans);
background: var(--bg-page);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
font-family: var(--font-display);
letter-spacing: -0.02em;
}
/* Secondary button */
@utility pv-btn-sec {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 16px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: var(--surface-card);
color: var(--text-primary);
font-family: var(--font-sans);
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: background-color var(--dur-fast) var(--ease-standard),
transform var(--dur-fast) var(--ease-standard);
}
.pv-btn-sec:hover:not(:disabled) { background: var(--bg-sunken); }
.pv-btn-sec:active:not(:disabled) { transform: translateY(1px); }
.pv-btn-sec:focus-visible { outline: none; box-shadow: var(--ring-focus); }
/* Small button size modifier */
@utility pv-btn-sm {
height: 32px;
padding: 0 12px;
font-size: 0.75rem;
border-radius: var(--radius-sm);
}
/* Primary button */
@utility pv-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 16px;
border-radius: var(--radius-md);
border: 1px solid transparent;
background: var(--brand);
color: var(--brand-contrast);
font-family: var(--font-sans);
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: background-color var(--dur-fast) var(--ease-standard),
transform var(--dur-fast) var(--ease-standard);
}
.pv-btn:hover:not(:disabled) { background: var(--brand-hover); }
.pv-btn:active:not(:disabled) { transform: translateY(1px); }
.pv-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.pv-btn:focus-visible { outline: none; box-shadow: var(--ring-focus); }
/* Text input */
@utility pv-input {
width: 100%;
height: 40px;
padding: 0 12px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: var(--surface-card);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 0.875rem;
}
.pv-input::placeholder { color: var(--text-muted); }
.pv-input:focus { outline: none; border-color: var(--border-focus); box-shadow: var(--ring-focus); }
/* Mono eyebrow — uppercase, tracked out */
@utility pv-eyebrow {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-muted);
}
+30
View File
@@ -0,0 +1,30 @@
import { ref } from "vue";
// Persisted light/dark theme, shared key with the PilotVault design-system kits.
const KEY = "pilotvault-theme";
function initial() {
try {
return localStorage.getItem(KEY) === "dark" ? "dark" : "light";
} catch {
return "light";
}
}
export const theme = ref(initial());
export function applyTheme(t) {
theme.value = t;
document.documentElement.setAttribute("data-theme", t);
try {
localStorage.setItem(KEY, t);
} catch {
/* private mode — theme just won't persist */
}
}
export function toggleTheme() {
applyTheme(theme.value === "dark" ? "light" : "dark");
}
applyTheme(theme.value);
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// Builds into internal/api/dist, which the Go server embeds via go:embed
// and serves at the server root.
export default defineConfig({
plugins: [vue(), tailwindcss()],
build: {
outDir: "../internal/api/dist",
emptyOutDir: true,
},
server: {
port: 5174,
proxy: {
// Dev-mode proxy to a locally running API Server.
"/api": "http://localhost:8080",
},
},
});
+79
View File
@@ -0,0 +1,79 @@
# PocketBase — PilotVault schema
PilotVault adds an `organizations` collection and three fields to the `users`
auth collection:
- **`preferences`** (JSON) — each user's settings blob. Written with the user's
own token, so PocketBase's default owner-only update rule
(`@request.auth.id = id`) is all the authorization needed.
- **`role`** (select: `superadmin` | `admin` | `user`) — the user-rights level.
A **superadmin** spans every organization; an **admin** is scoped to their own
organization (may manage its users *and* admins, but not superadmins); a
**user** has no management rights. Missing/empty is treated as `user`.
- **`organization`** (relation → `organizations`, maxSelect 1, optional) — which
org the user belongs to. Nullable: a user may belong to **no** organization.
The **`organizations`** collection is a plain base collection with a unique
`name`. It is reached only through the API Server's superuser service account
(its API rules stay locked to superusers), the same way user management works.
## User + org management requires a service account
Listing/creating/deleting users and organizations is done by the API Server
using a **superuser service account** (`POCKETBASE_ADMIN_EMAIL` /
`POCKETBASE_ADMIN_PASSWORD`), but only *after* verifying the caller's own token
resolves to a manager role (`admin` for user management, `superadmin` for org
management). This is the single place the server uses elevated PocketBase
credentials; without the env vars, the `/api/users` and `/api/orgs` endpoints
return 503 and the rest is unaffected.
Preferences never need the service account — they use the caller's own token.
## Add the schema
Pick **one** of the following.
### Option A — migration (recommended)
Copy the migration files into your PocketBase deployment's `pb_migrations/`
directory and restart PocketBase (migrations run automatically on boot; they
target the PocketBase v0.22+/v0.23 JS migration API). They are idempotent, so
they are safe even if the schema was already provisioned live:
- [`pb_migrations/1720300000_add_users_preferences.js`](pb_migrations/1720300000_add_users_preferences.js)
- [`pb_migrations/1720300100_add_users_role.js`](pb_migrations/1720300100_add_users_role.js)
- [`pb_migrations/1720300200_add_organizations.js`](pb_migrations/1720300200_add_organizations.js)
- [`pb_migrations/1720300300_add_users_organization.js`](pb_migrations/1720300300_add_users_organization.js)
- [`pb_migrations/1720300400_extend_users_role_superadmin.js`](pb_migrations/1720300400_extend_users_role_superadmin.js)
- [`pb_migrations/1720300500_seed_orgs_and_users.js`](pb_migrations/1720300500_seed_orgs_and_users.js) — seeds the PilotVault org + baseline accounts
### Option B — Admin UI (any version)
1. Open the PocketBase Admin UI → **Collections → New collection** `organizations`
(base); add a **text** field **`name`** (required) with a unique index.
2. **Collections → `users` → New field.** Add **JSON** field **`preferences`**,
not required, max size ~5 MB.
3. Add **Select** field **`role`**, values `superadmin`, `admin`, `user`, max select 1.
4. Add **Relation** field **`organization`** → `organizations`, max select 1, not
required, cascade delete off.
5. Save.
## Verify
With a normal user token you should be able to round-trip the field:
```bash
# 1) log in (PocketBase directly, or via the API Server /api/auth/login)
TOKEN=... # the "token" from the auth response
# 2) save
curl -X PATCH "$PB_URL/api/collections/users/records/$USER_ID" \
-H "Authorization: $TOKEN" -H "Content-Type: application/json" \
-d '{"preferences":{"fontSize":"lg","themeMode":"dark"}}'
# 3) read back
curl "$PB_URL/api/collections/users/auth-refresh" -X POST -H "Authorization: $TOKEN"
```
In the app the round-trip is: browser → `GET/PUT /bff/preferences` → API Server
`GET/PUT /api/preferences` → PocketBase user record.
@@ -0,0 +1,32 @@
/// <reference path="../pb_data/types.d.ts" />
// Adds a `preferences` JSON field to the `users` auth collection so each user
// can persist their PilotVault settings (theme, appearance, profile, etc.).
//
// Apply by copying this file into your PocketBase deployment's `pb_migrations/`
// directory and restarting PocketBase (migrations run automatically on boot).
// Written for PocketBase v0.22+/v0.23 (JSVM `migrate((app) => …)` API). If your
// PocketBase is older, add the field manually — see pocketbase/README.md.
migrate(
(app) => {
const users = app.findCollectionByNameOrId('users')
users.fields.add(
new Field({
name: 'preferences',
type: 'json',
required: false,
presentable: false,
// ~5 MB — generous headroom (an optional base64 avatar rides along).
maxSize: 5000000,
}),
)
app.save(users)
},
(app) => {
const users = app.findCollectionByNameOrId('users')
users.fields.removeByName('preferences')
app.save(users)
},
)
@@ -0,0 +1,32 @@
/// <reference path="../pb_data/types.d.ts" />
// Adds a `role` select field (admin | user) to the `users` auth collection.
// Drives PilotVault's user-rights model: admins can add/remove users; the API
// Server reads this field from the caller's token to gate admin endpoints.
// Missing/empty role is treated as "user" by the app.
//
// Apply by copying this file into your PocketBase deployment's `pb_migrations/`
// directory and restarting PocketBase. Written for PocketBase v0.22+/v0.23.
migrate(
(app) => {
const users = app.findCollectionByNameOrId('users')
users.fields.add(
new Field({
name: 'role',
type: 'select',
required: false,
presentable: false,
maxSelect: 1,
values: ['admin', 'user'],
}),
)
app.save(users)
},
(app) => {
const users = app.findCollectionByNameOrId('users')
users.fields.removeByName('role')
app.save(users)
},
)
@@ -0,0 +1,43 @@
/// <reference path="../pb_data/types.d.ts" />
// Creates the `organizations` collection. PilotVault scopes admins to a single
// organization; a superadmin spans all of them. Users may belong to no org.
//
// The API Server reaches this collection only through its superuser service
// account (like `users` management), so the collection API rules are left locked
// (superusers only). Apply by copying into your PocketBase deployment's
// `pb_migrations/` directory and restarting. Written for PocketBase v0.22+/v0.23.
//
// Idempotent: if the collection already exists (e.g. it was provisioned live via
// the admin API), this migration is a no-op.
migrate(
(app) => {
try {
app.findCollectionByNameOrId('organizations')
return // already present
} catch (_) {
// not found → create it
}
const collection = new Collection({
type: 'base',
name: 'organizations',
fields: [
{ name: 'name', type: 'text', required: true, max: 120, presentable: true },
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
],
indexes: ['CREATE UNIQUE INDEX `idx_org_name` ON `organizations` (`name`)'],
})
app.save(collection)
},
(app) => {
try {
const c = app.findCollectionByNameOrId('organizations')
app.delete(c)
} catch (_) {
// already gone
}
},
)
@@ -0,0 +1,34 @@
/// <reference path="../pb_data/types.d.ts" />
// Adds an `organization` relation field to the `users` auth collection, pointing
// at the `organizations` collection. Not required (maxSelect 1), so users may be
// org-less. cascadeDelete is false: deleting an org does not delete its members.
//
// Depends on 1720300200_add_organizations.js. Idempotent: no-op if the field is
// already present. Written for PocketBase v0.22+/v0.23.
migrate(
(app) => {
const users = app.findCollectionByNameOrId('users')
if (users.fields.getByName('organization')) return
const orgs = app.findCollectionByNameOrId('organizations')
users.fields.add(
new Field({
name: 'organization',
type: 'relation',
required: false,
collectionId: orgs.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
presentable: false,
}),
)
app.save(users)
},
(app) => {
const users = app.findCollectionByNameOrId('users')
users.fields.removeByName('organization')
app.save(users)
},
)
@@ -0,0 +1,25 @@
/// <reference path="../pb_data/types.d.ts" />
// Extends the `users.role` select field with a top-level `superadmin` value.
// Final set: superadmin | admin | user. superadmin spans all organizations;
// admin is scoped to one org; user has no management rights. Missing/empty role
// is still treated as `user` by the app.
//
// Idempotent: no-op if `superadmin` is already an allowed value. Written for
// PocketBase v0.22+/v0.23.
migrate(
(app) => {
const users = app.findCollectionByNameOrId('users')
const role = users.fields.getByName('role')
if (!role || (role.values && role.values.indexOf('superadmin') !== -1)) return
role.values = ['superadmin'].concat(role.values || [])
app.save(users)
},
(app) => {
const users = app.findCollectionByNameOrId('users')
const role = users.fields.getByName('role')
if (!role) return
role.values = (role.values || []).filter((v) => v !== 'superadmin')
app.save(users)
},
)
@@ -0,0 +1,85 @@
/// <reference path="../pb_data/types.d.ts" />
// Seeds PilotVault's baseline org + accounts. Idempotent: existing records are
// left in place (org/role are reconciled, passwords are not touched once a user
// exists). Safe to run alongside a live-provisioned deployment.
//
// Organization: PilotVault
// superadmin@pilotvault.local (superadmin, no org)
// dariusz@pilotvault.local (admin, PilotVault) — expected to pre-exist
// pilot@pilotvault.local (user, PilotVault)
// pilot@dji.local (user, no org) — left untouched
//
// Depends on the three schema migrations above. Written for PocketBase v0.22+/v0.23.
migrate(
(app) => {
// organization
let org = null
try {
org = app.findFirstRecordByFilter('organizations', 'name = "PilotVault"')
} catch (_) {
/* not found */
}
if (!org) {
const oc = app.findCollectionByNameOrId('organizations')
org = new Record(oc)
org.set('name', 'PilotVault')
app.save(org)
}
const uc = app.findCollectionByNameOrId('users')
const ensure = (email, password, role, orgId) => {
let u = null
try {
u = app.findAuthRecordByEmail('users', email)
} catch (_) {
/* not found */
}
if (u) {
let dirty = false
if (u.get('role') !== role) {
u.set('role', role)
dirty = true
}
if ((u.get('organization') || '') !== (orgId || '')) {
u.set('organization', orgId || '')
dirty = true
}
if (dirty) app.save(u)
return
}
u = new Record(uc)
u.set('email', email)
if (password) u.setPassword(password)
u.set('role', role)
u.set('verified', true)
u.set('emailVisibility', false)
if (orgId) u.set('organization', orgId)
app.save(u)
}
ensure('superadmin@pilotvault.local', 'pilotvaultsuperadmin2026!', 'superadmin', null)
ensure('dariusz@pilotvault.local', null, 'admin', org.id)
ensure('pilot@pilotvault.local', 'pilotvaultuser2026!', 'user', org.id)
// pilot@dji.local is intentionally left as an org-less user.
},
(app) => {
// Best-effort revert: drop the two seeded PilotVault accounts and the org.
// dariusz@pilotvault.local / pilot@dji.local predate this seed and are kept.
const drop = (email) => {
try {
app.delete(app.findAuthRecordByEmail('users', email))
} catch (_) {
/* already gone */
}
}
drop('superadmin@pilotvault.local')
drop('pilot@pilotvault.local')
try {
app.delete(app.findFirstRecordByFilter('organizations', 'name = "PilotVault"'))
} catch (_) {
/* already gone */
}
},
)
@@ -0,0 +1,38 @@
/// <reference path="../pb_data/types.d.ts" />
// Adds a `pluginSettings` JSON field to both the `users` auth collection and the
// `organizations` collection. It backs the per-user / per-organization layers of
// the OpenSky plugin's cascading settings (API Server → Org Admin → User):
// { "opensky": { "config": { clientId, clientSecret, plan, bbox }, "enabled": bool } }
// The `enabled` flag is only meaningful on `users` (enablement is strictly per-user).
//
// Apply by copying this file into your PocketBase deployment's `pb_migrations/`
// directory and restarting PocketBase (migrations run automatically on boot).
// Idempotent: skips a collection whose field is already present. Written for
// PocketBase v0.22+/v0.23 (JSVM `migrate((app) => …)` API).
migrate(
(app) => {
for (const name of ['users', 'organizations']) {
const col = app.findCollectionByNameOrId(name)
if (col.fields.getByName('pluginSettings')) continue
col.fields.add(
new Field({
name: 'pluginSettings',
type: 'json',
required: false,
presentable: false,
maxSize: 100000, // small JSON blob of plugin config
}),
)
app.save(col)
}
},
(app) => {
for (const name of ['users', 'organizations']) {
const col = app.findCollectionByNameOrId(name)
col.fields.removeByName('pluginSettings')
app.save(col)
}
},
)
+25
View File
@@ -0,0 +1,25 @@
# Runs the PilotVault API Server.
# Loads .env (if present), then starts the Go server.
#
# ./scripts/Run-ApiServer.ps1
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$root = Split-Path -Parent $here # the "API Server" folder
Push-Location $root
try {
# Ensure Go is on PATH for this session.
$goBin = "C:\Program Files\Go\bin"
if (Test-Path $goBin) { $env:Path = "$goBin;$env:Path" }
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
throw "Go is not installed or not on PATH."
}
Write-Host "Starting API Server (Ctrl+C to stop)..." -ForegroundColor Cyan
go run ./cmd/server
}
finally {
Pop-Location
}