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
+33
View File
@@ -0,0 +1,33 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "api-panel",
"runtimeExecutable": "E:\\VS Code Projects\\PilotVault\\API Server\\api-server.exe",
"runtimeArgs": [],
"cwd": "E:\\VS Code Projects\\PilotVault\\API Server",
"port": 8080
},
{
"name": "web-app",
"runtimeExecutable": "E:\\VS Code Projects\\PilotVault\\Web App\\dji-web-app.exe",
"runtimeArgs": [],
"cwd": "E:\\VS Code Projects\\PilotVault\\Web App",
"port": 8090
},
{
"name": "web-app-ui-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "E:\\VS Code Projects\\PilotVault\\Web App\\ui",
"port": 5173
},
{
"name": "api-panel-ui-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "E:\\VS Code Projects\\PilotVault\\API Server\\panel",
"port": 5174
}
]
}
+6
View File
@@ -0,0 +1,6 @@
# Design assets (excluded from version control)
Design/
# Build artifacts
*.exe
*.exe~
+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
}
+150
View File
@@ -0,0 +1,150 @@
# syntax=docker/dockerfile:1
#
# All-in-one PilotVault image: PocketBase + API Server + Web App in ONE container,
# supervised by supervisord. Convenience/demo image — for production run the three
# services separately (see Docker/docker-compose.yml).
#
# BUILD CONTEXT MUST BE THE REPO ROOT (this Dockerfile COPYs from "API Server/"
# and "Web App/"). From E:\VS Code Projects\PilotVault run:
#
# docker build -f "Docker AIO/Dockerfile" -t pilotvault-aio .
# docker run -p 8090:8090 -p 8080:8080 -p 8026:8026 \
# -v pilotvault_pb:/pb/pb_data pilotvault-aio
#
# Internal ports (loopback-wired): PocketBase 8026, API Server 8080, Web App 8090.
# =============================================================================
# Stage 1 — build the API Server's embedded Vue panel (-> internal/api/dist)
# =============================================================================
FROM node:22-alpine AS panel
WORKDIR /panel
COPY ["API Server/panel/package.json", "API Server/panel/package-lock.json", "./"]
RUN npm ci
COPY ["API Server/panel/", "./"]
RUN npm run build
# =============================================================================
# Stage 2 — build the API Server static binary (go.mod pins go 1.26)
# =============================================================================
FROM golang:1.26-alpine AS api-build
WORKDIR /src
COPY ["API Server/go.mod", "API Server/go.sum", "./"]
RUN go mod download
COPY ["API Server/cmd/", "./cmd/"]
COPY ["API Server/internal/", "./internal/"]
# 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 — build the Web App's embedded Vue UI (-> web/)
# =============================================================================
FROM node:22-alpine AS ui
WORKDIR /ui
COPY ["Web App/ui/package.json", "Web App/ui/package-lock.json", "./"]
RUN npm ci
COPY ["Web App/ui/", "./"]
RUN npm run build
# =============================================================================
# Stage 4 — build the Web App static binary (go.mod pins go 1.24)
# =============================================================================
FROM golang:1.24-alpine AS web-build
WORKDIR /src
COPY ["Web App/go.mod", "Web App/go.sum", "./"]
RUN go mod download
COPY ["Web App/main.go", "Web App/bff.go", "./"]
# Overlay the freshly built UI so //go:embed web picks it up.
COPY --from=ui /web ./web
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
-o /out/dji-web-app .
# =============================================================================
# Stage 5 — fetch the PocketBase binary
# =============================================================================
FROM alpine:latest AS pocketbase
# Override with --build-arg PB_VERSION=x.y.z / PB_ARCH=arm64 as needed.
ARG PB_VERSION=0.22.21
ARG PB_ARCH=amd64
RUN apk add --no-cache unzip wget ca-certificates \
&& wget -O /tmp/pb.zip \
"https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_${PB_ARCH}.zip" \
&& unzip /tmp/pb.zip -d /pb \
&& rm /tmp/pb.zip
# =============================================================================
# Stage 6 — runtime: alpine:latest running all three under supervisord
# =============================================================================
FROM alpine:latest
RUN apk add --no-cache ca-certificates tzdata supervisor
WORKDIR /app
COPY --from=api-build /out/api-server ./api-server
COPY --from=web-build /out/dji-web-app ./dji-web-app
COPY --from=pocketbase /pb/pocketbase ./pocketbase
# JS migrations (schema + seed accounts) applied by PocketBase on first serve.
COPY ["API Server/pocketbase/pb_migrations/", "/pb/pb_migrations/"]
# --- Runtime configuration (loopback-wired between the three services) ---------
# API Server reads API_ADDR / POCKETBASE_URL / CORS_ALLOW_ORIGINS / POCKETBASE_ADMIN_*
# Web App reads ADDR / API_BASE
# NOTE: these bundle default credentials for convenience — override at `docker run`.
ENV API_ADDR=":8080" \
POCKETBASE_URL="http://127.0.0.1:8026" \
CORS_ALLOW_ORIGINS="*" \
POCKETBASE_ADMIN_EMAIL="admin@dji.local" \
POCKETBASE_ADMIN_PASSWORD="djiadmin2026!" \
ADDR=":8090" \
API_BASE="http://127.0.0.1:8080"
# --- supervisord: PocketBase first, then API Server, then Web App --------------
RUN cat > /etc/supervisord.conf <<'EOF'
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
pidfile=/run/supervisord.pid
[program:pocketbase]
priority=10
directory=/pb
# Ensure the service-account superuser exists, then serve on the internal port.
command=/bin/sh -c "/app/pocketbase superuser upsert \"$POCKETBASE_ADMIN_EMAIL\" \"$POCKETBASE_ADMIN_PASSWORD\" --dir=/pb/pb_data ; exec /app/pocketbase serve --http=0.0.0.0:8026 --dir=/pb/pb_data --migrationsDir=/pb/pb_migrations"
autorestart=true
startsecs=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:api-server]
priority=20
directory=/app
command=/app/api-server
autorestart=true
startsecs=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:web-app]
priority=30
directory=/app
command=/app/dji-web-app
autorestart=true
startsecs=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
EOF
# PocketBase data (SQLite). Mount a volume here to persist across restarts.
VOLUME ["/pb/pb_data"]
EXPOSE 8090 8080 8026
ENTRYPOINT ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
+31
View File
@@ -0,0 +1,31 @@
# All-in-one PilotVault stack (PocketBase + API Server + Web App) in ONE container.
# Run from this Docker AIO/ folder: docker compose up --build
# The build context is the repo root (..) because the Dockerfile COPYs from
# "API Server/" and "Web App/".
services:
pilotvault:
build:
context: ".."
dockerfile: "Docker AIO/Dockerfile"
# args:
# PB_VERSION: "0.22.21" # override the bundled PocketBase version
# PB_ARCH: "amd64" # use "arm64" on Apple Silicon
image: pilotvault-aio
container_name: pilotvault-aio
ports:
- "8090:8090" # Web App (control panel)
- "8080:8080" # API Server
- "8026:8026" # PocketBase
environment:
# Loopback-wired between the three in-container services. Override the
# bundled default credentials here for anything real.
POCKETBASE_ADMIN_EMAIL: "admin@dji.local"
POCKETBASE_ADMIN_PASSWORD: "djiadmin2026!"
CORS_ALLOW_ORIGINS: "*"
volumes:
- pb_data:/pb/pb_data # persist PocketBase SQLite across restarts
restart: unless-stopped
volumes:
pb_data:
+40
View File
@@ -0,0 +1,40 @@
# Combined stack: API Server + Web App on a shared network.
# Run from this Docker/ folder: docker compose up --build
# Build contexts point back up to each service directory.
services:
api-server:
build:
context: "../API Server"
image: pilotvault-api-server
container_name: pilotvault-api-server
# Config (POCKETBASE_URL, CORS_ALLOW_ORIGINS, POCKETBASE_ADMIN_*) from .env.
env_file:
- "../API Server/.env"
ports:
- "8080:8080"
networks:
- pilotvault
restart: unless-stopped
web-app:
build:
context: "../Web App"
image: pilotvault-web-app
container_name: pilotvault-web-app
environment:
ADDR: ":8090"
# Reach the API Server by its service name on the shared network —
# no host.docker.internal needed here.
API_BASE: "http://api-server:8080"
ports:
- "8090:8090"
depends_on:
- api-server
networks:
- pilotvault
restart: unless-stopped
networks:
pilotvault:
driver: bridge
+17
View File
@@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "webapp",
"runtimeExecutable": "E:\\VS Code Projects\\PilotVault\\Web App\\dji-web-app.exe",
"runtimeArgs": [],
"port": 8090
},
{
"name": "panel",
"runtimeExecutable": "E:\\VS Code Projects\\PilotVault\\API Server\\dji-api-server.exe",
"runtimeArgs": [],
"port": 8080
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"WebFetch(domain:repo1.maven.org)",
"PowerShell(& C:\\\\flutter\\\\bin\\\\flutter.bat config --jdk-dir \"C:\\\\Program Files\\\\Android\\\\Android Studio\\\\jbr\" 2>&1)",
"PowerShell(\"--- gradle.properties ---\")"
]
}
}
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
- platform: android
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+90
View File
@@ -0,0 +1,90 @@
# DJI MSDK Sample (Flutter)
A sample app demonstrating how to drive the **DJI Mobile SDK V4** from **Flutter**.
DJI does not ship an official Flutter SDK — the Mobile SDK is a native
Android/iOS library. This project therefore puts a Flutter UI on top of a thin
**native Android (Kotlin) bridge** that talks to the DJI MSDK over platform
channels. It covers the core "sample app" flow: SDK registration, product
connection, and live telemetry (battery, GPS, flight status).
> **Android only.** The DJI MSDK V4 native libraries here are wired up for
> Android. iOS would need a parallel Swift/Obj-C bridge (and a Mac to build).
## Architecture
```
┌────────────────────────┐ platform channels ┌─────────────────────────┐
│ Flutter (Dart) │ dji_msdk/methods (MethodChannel)│ Android (Kotlin) │
│ lib/main.dart │ ───────────────────────────────▶ │ DjiSdkBridge.kt │
│ lib/dji_service.dart │ dji_msdk/events (EventChannel) │ └─ DJI Mobile SDK V4 │
│ │ ◀─────────────────────────────── │ DjiApplication.kt │
└────────────────────────┘ └─────────────────────────┘
```
| File | Responsibility |
| --- | --- |
| `lib/dji_service.dart` | Dart wrapper over the method/event channels |
| `lib/main.dart` | UI: registration / connection / telemetry cards |
| `android/app/.../DjiApplication.kt` | Installs the Secneo `Helper` (required by MSDK V4) |
| `android/app/.../MainActivity.kt` | Hosts the bridge, requests runtime permissions |
| `android/app/.../DjiSdkBridge.kt` | Registration, product lifecycle, telemetry callbacks |
| `android/app/build.gradle` | DJI deps, native-lib packaging, multidex, ABI filters |
## Prerequisites
1. **Flutter** (stable) and **Android SDK** with a connected **Android device**
(the DJI SDK does not work on emulators).
2. A **DJI drone + remote controller** supported by MSDK V4 (Phantom 4,
Mavic 2 / Air / Mini 1, Spark, Inspire 2, etc.). The RC connects to the phone
over USB.
3. A **DJI App Key** (see below).
## Set your DJI App Key
SDK registration will fail without a valid App Key bound to this app's
application id.
1. Sign in at <https://developer.dji.com/user/apps/> and create a new app.
- **Package name** must be exactly: `com.dji.flutter.dji_msdk_sample`
- SDK: **Mobile SDK**
2. Copy the generated **App Key**.
3. Paste it into [`android/gradle.properties`](android/gradle.properties):
```properties
DJI_API_KEY=your_real_app_key_here
```
The key is injected into `AndroidManifest.xml` at build time via a
`manifestPlaceholder` (`com.dji.sdk.API_KEY`).
## Run
```bash
flutter pub get
flutter run # device must be plugged in
# or just build the APK:
flutter build apk --debug
```
## Using the app
1. Launch it and grant the location / phone / mic permissions it requests.
2. Tap **Register app** — needs internet on first run; status turns green on
success.
3. Connect the drone's remote controller to the phone over USB and power on the
aircraft. The app auto-starts a connection on successful registration; you can
also tap **Connect to product**.
4. Once an aircraft connects, the **Telemetry** card streams battery %, GPS
satellite count, flight mode, altitude, and position.
## Notes & gotchas
- MSDK V4 version is pinned to `4.18` (`com.dji:dji-sdk` / `dji-sdk-provided`).
- `android.enableJetifier=true` is required — the SDK still uses legacy support
libraries.
- The native `.so` files are kept unstripped and de-duplicated via the
`packaging { }` block in `android/app/build.gradle`; only `armeabi-v7a` and
`arm64-v8a` ABIs are bundled (the only ABIs DJI provides).
- This sample uses the **core** SDK, not the DJI **UX SDK** (its native UI
widgets don't embed cleanly in Flutter).
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+13
View File
@@ -0,0 +1,13 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+151
View File
@@ -0,0 +1,151 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
android {
namespace = "com.dji.flutter.dji_msdk_sample"
compileSdk = 35
// DJI MSDK V4 ships prebuilt native (.so) libraries; pin a known-good NDK.
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
defaultConfig {
applicationId = "com.dji.flutter.dji_msdk_sample"
// DJI MSDK V4 requires Android 5.0+ (API 21).
minSdkVersion = flutter.minSdkVersion
targetSdk = 34
versionCode = flutter.versionCode
versionName = flutter.versionName
// DJI's SDK + Secneo Helper class loading require MultiDex.
multiDexEnabled = true
// DJI provides prebuilt .so files only for these ABIs.
ndk {
abiFilters "armeabi-v7a", "arm64-v8a"
}
// The DJI App Key is injected into AndroidManifest.xml at build time.
// Set DJI_API_KEY in android/gradle.properties (or pass -PDJI_API_KEY=...).
manifestPlaceholders["DJI_API_KEY"] =
(project.findProperty("DJI_API_KEY") ?: "PASTE_YOUR_DJI_APP_KEY_HERE")
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.debug
// DJI requires its ProGuard rules when minify is enabled.
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
// DJI native libraries must not be stripped, and several bundled assets/
// resources collide across the SDK modules and must be de-duplicated.
packaging {
jniLibs {
keepDebugSymbols += [
"**/libdjivideo.so",
"**/libSDKRelativeJNI.so",
"**/libFlyForbid.so",
"**/libduml_vision_bokeh.so",
"**/libyuv2.so",
"**/libGroudStation.so",
"**/libFRCorkscrew.so",
"**/libUpgradeVerify.so",
"**/libFR.so",
"**/libDJIFlySafeCore.so",
"**/libdjifs_jni.so",
"**/libsfjni.so",
"**/libDJICommonJNI.so",
"**/libDJICSDKCommon.so",
"**/libDJIUpgradeCore.so",
"**/libDJIUpgradeJNI.so",
"**/libDJIWaypointV2Core.so",
"**/libdjiwaypointv2.so",
"**/libDJIMOP.so",
"**/libDJISDKLOGJNI.so",
]
}
resources {
excludes += [
"META-INF/rxjava.properties",
"META-INF/proguard/*",
"META-INF/INDEX.LIST",
"META-INF/DEPENDENCIES",
"assets/location_map_gps_locked.png",
"assets/location_map_gps_3d.png",
]
pickFirsts += [
"lib/**/libstlport_shared.so",
"lib/**/libRoadLineRebuildAPI.so",
"lib/**/libGNaviUtils.so",
"lib/**/libGNaviMapex.so",
"lib/**/libGNaviMap.so",
"lib/**/libGNaviSearch.so",
]
}
}
}
flutter {
source = "../.."
}
dependencies {
// DJI Mobile SDK V4 (core). 'provided' contains compile-time-only stubs.
implementation "com.dji:dji-sdk:4.18"
compileOnly "com.dji:dji-sdk-provided:4.18"
implementation "androidx.multidex:multidex:2.0.1"
implementation "androidx.core:core-ktx:1.13.1"
// The DJI SDK bundles layouts that reference AppCompat (srcCompat) and
// ConstraintLayout attributes, so these must be on the resource classpath.
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
// DJI's SDK publishes an event bus dependency used internally by some callbacks.
implementation "com.squareup:otto:1.3.8"
// Required by the FlySafe / GEO modules pulled in by the SDK.
implementation "androidx.recyclerview:recyclerview:1.3.2"
}
// --- Gradle 8 task-validation workaround ------------------------------------
// Flutter's `compileFlutterBuild<Variant>` task declares an output directory
// that overlaps the Android source sets, so Gradle 8's execution-time
// validation flags several AGP tasks (mergeShaders, checkAarMetadata, …) as
// consuming that output without a declared dependency, failing the build.
// Wire the dependency in explicitly. Safe: none of these tasks are inputs to
// the Flutter compile task, so no dependency cycle is introduced.
afterEvaluate {
def flutterTaskFor = { String name ->
for (v in ["Debug", "Release", "Profile"]) {
if (name.contains(v)) return tasks.findByName("compileFlutterBuild${v}")
}
return null
}
// Merge* source-set tasks (shaders / assets / jniLibs) — public AGP type.
tasks.withType(com.android.build.gradle.tasks.MergeSourceSetFolders).configureEach { t ->
def ft = flutterTaskFor(t.name)
if (ft != null) t.dependsOn(ft)
}
// Other AGP tasks that read the merged inputs.
tasks.matching { it.name ==~ /^(check|process|package|bundle|lintVitalAnalyze|lintAnalyze)(Debug|Release|Profile).*/ }.configureEach { t ->
def ft = flutterTaskFor(t.name)
if (ft != null) t.dependsOn(ft)
}
}
+26
View File
@@ -0,0 +1,26 @@
# ── DJI Mobile SDK V4 ProGuard rules ───────────────────────────────────────────
# Required so R8/ProGuard does not strip classes the SDK loads reflectively.
-keepclassmembers enum * { public static **[] values(); public static ** valueOf(java.lang.String); }
-keepclassmembers class * { public <init>(android.content.Context); }
-keep class com.dji.** { *; }
-keep class dji.** { *; }
-keep class com.secneo.** { *; }
-keep class sun.** { *; }
-keep class com.google.** { *; }
-keep class org.** { *; }
-keep class com.squareup.** { *; }
-keep class it.sephiroth.** { *; }
-keep class android.media.** { *; }
-dontwarn dji.**
-dontwarn com.dji.**
-dontwarn com.secneo.**
-dontwarn sun.**
-dontwarn org.**
-dontwarn com.squareup.**
-keepattributes Signature
-keepattributes *Annotation*
-keepattributes Exceptions
-keepattributes InnerClasses
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,91 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- ── Permissions required by the DJI Mobile SDK ─────────────────────────── -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- Biometric / face authentication (local_auth). -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- USB host/accessory: DJI remote controllers connect to the phone over USB. -->
<uses-feature
android:name="android.hardware.usb.host"
android:required="false" />
<uses-feature
android:name="android.hardware.usb.accessory"
android:required="false" />
<application
android:label="PilotVault Fly"
android:name=".DjiApplication"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:usesCleartextTraffic="true">
<!-- DJI MSDK V4 uses the legacy Apache HTTP stack (org.apache.http.*),
which was removed from the default classpath in Android 9 (API 28).
Without this, registerApp() crashes with NoClassDefFoundError on
org.apache.http.params.BasicHttpParams. -->
<uses-library
android:name="org.apache.http.legacy"
android:required="false" />
<!-- DJI App Key. The value is injected from gradle.properties (DJI_API_KEY). -->
<meta-data
android:name="com.dji.sdk.API_KEY"
android:value="${DJI_API_KEY}" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Launch / route the app when a DJI product is attached over USB. -->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED"
android:resource="@xml/accessory_filter" />
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,22 @@
package com.dji.flutter.dji_msdk_sample
import android.app.Application
import android.content.Context
import com.cySdkyc.clx.Helper
/**
* The DJI Mobile SDK V4 relocates and lazily loads its classes through the
* Secneo [Helper]. It MUST be installed in [attachBaseContext] — before any
* DJI class is touched — otherwise SDK registration crashes with a
* NoClassDefFoundError / UnsatisfiedLinkError.
*
* Registered in AndroidManifest.xml via android:name=".DjiApplication".
*/
class DjiApplication : Application() {
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
// Unpacks and prepares the DJI SDK native/dex payload.
Helper.install(this)
}
}
@@ -0,0 +1,189 @@
package com.dji.flutter.dji_msdk_sample
import android.content.Context
import android.os.Handler
import android.os.Looper
import dji.common.battery.BatteryState
import dji.common.error.DJIError
import dji.common.error.DJISDKError
import dji.common.flightcontroller.FlightControllerState
import dji.sdk.base.BaseComponent
import dji.sdk.base.BaseProduct
import dji.sdk.products.Aircraft
import dji.sdk.sdkmanager.DJISDKInitEvent
import dji.sdk.sdkmanager.DJISDKManager
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Bridges the DJI Mobile SDK V4 to Flutter.
*
* - [METHOD_CHANNEL] handles imperative calls from Dart (register, connect, query).
* - [EVENT_CHANNEL] streams registration / connection / telemetry updates to Dart.
*
* All SDK callbacks arrive on arbitrary threads, so every event is marshalled to
* the main thread before being pushed into the Flutter [EventChannel.EventSink].
*/
class DjiSdkBridge(
private val appContext: Context,
messenger: BinaryMessenger,
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
companion object {
private const val METHOD_CHANNEL = "dji_msdk/methods"
private const val EVENT_CHANNEL = "dji_msdk/events"
}
private val mainHandler = Handler(Looper.getMainLooper())
private val methodChannel = MethodChannel(messenger, METHOD_CHANNEL)
private val eventChannel = EventChannel(messenger, EVENT_CHANNEL)
private var eventSink: EventChannel.EventSink? = null
init {
methodChannel.setMethodCallHandler(this)
eventChannel.setStreamHandler(this)
}
// ── MethodChannel ──────────────────────────────────────────────────────────
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getSdkVersion" ->
result.success(DJISDKManager.getInstance().sdkVersion)
"registerApp" -> {
registerApp()
result.success(null)
}
"startConnection" ->
result.success(DJISDKManager.getInstance().startConnectionToProduct())
"stopConnection" -> {
DJISDKManager.getInstance().stopConnectionToProduct()
result.success(null)
}
"getProductInfo" ->
result.success(connectionMap(DJISDKManager.getInstance().product))
else -> result.notImplemented()
}
}
// ── EventChannel ───────────────────────────────────────────────────────────
override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) {
eventSink = sink
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
private fun emit(event: Map<String, Any?>) {
mainHandler.post { eventSink?.success(event) }
}
// ── DJI SDK registration & product lifecycle ─────────────────────────────────
private fun registerApp() {
emit(mapOf("type" to "registration", "state" to "registering"))
DJISDKManager.getInstance().registerApp(
appContext,
object : DJISDKManager.SDKManagerCallback {
override fun onRegister(error: DJIError?) {
if (error == DJISDKError.REGISTRATION_SUCCESS) {
emit(mapOf("type" to "registration", "state" to "success"))
// Begin scanning for an attached product (USB RC / Wi-Fi).
DJISDKManager.getInstance().startConnectionToProduct()
} else {
emit(
mapOf(
"type" to "registration",
"state" to "failed",
"error" to (error?.description ?: "Unknown registration error"),
)
)
}
}
override fun onProductConnect(product: BaseProduct?) {
emit(connectionMap(product))
bindComponentCallbacks(product)
}
override fun onProductChanged(product: BaseProduct?) {
emit(connectionMap(product))
bindComponentCallbacks(product)
}
override fun onProductDisconnect() {
emit(mapOf("type" to "connection", "connected" to false, "model" to null))
}
override fun onComponentChange(
key: BaseProduct.ComponentKey?,
oldComponent: BaseComponent?,
newComponent: BaseComponent?,
) {
// A component (e.g. flight controller, battery) appeared/changed —
// (re)attach the telemetry callbacks.
bindComponentCallbacks(DJISDKManager.getInstance().product)
}
override fun onInitProcess(event: DJISDKInitEvent?, totalProcess: Int) {
emit(mapOf("type" to "init", "event" to event?.toString()))
}
override fun onDatabaseDownloadProgress(current: Long, total: Long) {
emit(mapOf("type" to "database", "current" to current, "total" to total))
}
},
)
}
private fun connectionMap(product: BaseProduct?): Map<String, Any?> {
val connected = product != null && product.isConnected
val model = product?.model?.displayName
return mapOf("type" to "connection", "connected" to connected, "model" to model)
}
/** Attaches flight-controller and battery state listeners when on an aircraft. */
private fun bindComponentCallbacks(product: BaseProduct?) {
if (product !is Aircraft) return
product.flightController?.setStateCallback { state: FlightControllerState ->
val location = state.aircraftLocation
emit(
mapOf(
"type" to "telemetry",
"satelliteCount" to state.satelliteCount,
"isFlying" to state.isFlying,
"flightMode" to state.flightModeString,
"altitude" to location?.altitude,
"latitude" to location?.latitude,
"longitude" to location?.longitude,
"velocityX" to state.velocityX,
"velocityY" to state.velocityY,
"velocityZ" to state.velocityZ,
)
)
}
@Suppress("DEPRECATION")
product.battery?.setStateCallback { batteryState: BatteryState ->
emit(
mapOf(
"type" to "battery",
"percent" to batteryState.chargeRemainingInPercent,
)
)
}
}
}
@@ -0,0 +1,87 @@
package com.dji.flutter.dji_msdk_sample
import android.content.Context
import android.graphics.SurfaceTexture
import android.view.TextureView
import android.view.View
import dji.sdk.camera.VideoFeeder
import dji.sdk.codec.DJICodecManager
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
/**
* Flutter [PlatformView] that renders the DJI product's live H.264 primary video
* feed as a full-bleed background for the Flight Control HUD.
*
* Pipeline: a [TextureView]'s [SurfaceTexture] is handed to a [DJICodecManager]
* (hardware decoder). [VideoFeeder]'s primary-feed data listener pushes raw
* frames straight into the decoder, which renders onto the surface.
*
* Registered under [VIEW_TYPE] in `MainActivity.configureFlutterEngine`; embedded
* on the Dart side by `AndroidView(viewType: 'dji_msdk/video')`.
*
* Note: this uses the *primary* video feed, which is correct for the vast
* majority of products. A few older transcoding models (e.g. Mavic Pro) expose
* their live view only on the transcoded feed — if such a product renders black,
* switch to `VideoFeeder.getInstance().provideTranscodedVideoFeed()`.
*/
class DjiVideoView(context: Context) : PlatformView, TextureView.SurfaceTextureListener {
companion object {
const val VIEW_TYPE = "dji_msdk/video"
}
private val textureView = TextureView(context).also {
it.surfaceTextureListener = this
}
private var codecManager: DJICodecManager? = null
// Pushes raw H.264 frames from the SDK straight into the hardware decoder.
private val videoDataListener = VideoFeeder.VideoDataListener { data, size ->
codecManager?.sendDataToDecoder(data, size)
}
override fun getView(): View = textureView
override fun dispose() {
teardown()
}
// ── TextureView.SurfaceTextureListener ───────────────────────────────────
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
if (codecManager == null) {
codecManager = DJICodecManager(textureView.context, surface, width, height)
}
VideoFeeder.getInstance()?.primaryVideoFeed?.addVideoDataListener(videoDataListener)
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
codecManager?.onSurfaceSizeChanged(width, height, 0)
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
teardown()
return true
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) = Unit
/** Detaches the feed listener and releases the decoder + its surface. */
private fun teardown() {
VideoFeeder.getInstance()?.primaryVideoFeed?.removeVideoDataListener(videoDataListener)
codecManager?.let {
it.cleanSurface()
it.destroyCodec()
}
codecManager = null
}
}
/** Builds a [DjiVideoView] for each `AndroidView(viewType: 'dji_msdk/video')`. */
class DjiVideoViewFactory : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView =
DjiVideoView(context)
}
@@ -0,0 +1,58 @@
package com.dji.flutter.dji_msdk_sample
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
// FlutterFragmentActivity (not FlutterActivity) is required by local_auth so the
// platform BiometricPrompt can attach to a FragmentActivity host.
class MainActivity : FlutterFragmentActivity() {
private var bridge: DjiSdkBridge? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
bridge = DjiSdkBridge(applicationContext, flutterEngine.dartExecutor.binaryMessenger)
// Live DJI video feed rendered behind the Flight Control HUD.
flutterEngine.platformViewsController.registry
.registerViewFactory(DjiVideoView.VIEW_TYPE, DjiVideoViewFactory())
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestDjiPermissions()
}
/**
* The DJI SDK needs these dangerous permissions granted before it can connect
* to / communicate with a product. We request them up front from the host
* Activity so the Flutter UI can stay focused on the SDK flow.
*/
private fun requestDjiPermissions() {
val missing = REQUIRED_PERMISSIONS.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isNotEmpty()) {
ActivityCompat.requestPermissions(this, missing.toTypedArray(), PERMISSION_REQUEST_CODE)
}
}
companion object {
private const val PERMISSION_REQUEST_CODE = 12321
private val REQUIRED_PERMISSIONS = buildList {
add(Manifest.permission.ACCESS_FINE_LOCATION)
add(Manifest.permission.ACCESS_COARSE_LOCATION)
add(Manifest.permission.READ_PHONE_STATE)
add(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}.toTypedArray()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Some files were not shown because too many files have changed in this diff Show More