Initial commit: PilotVault multi-service project

Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App
(Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment
configs. Design assets and build artifacts are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-13 11:43:33 +02:00
co-authored by Claude Opus 4.8
commit afc6952eda
172 changed files with 24591 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"sync"
"time"
)
// adminClient authenticates to PocketBase as a superuser service account and is
// used only for admin user-management (list/create/delete users). It caches the
// superuser token and transparently re-authenticates when PocketBase rejects it.
//
// This is the one place the server holds elevated PocketBase credentials; every
// admin endpoint that uses it first verifies the *caller* is an app admin.
type adminClient struct {
baseURL string
email string
password string
client *http.Client
mu sync.Mutex
token string
}
func newAdminClient(baseURL, email, password string) *adminClient {
return &adminClient{
baseURL: baseURL,
email: email,
password: password,
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (a *adminClient) configured() bool {
if a == nil {
return false
}
_, email, password := a.creds()
return email != "" && password != ""
}
// creds snapshots the current base URL + service-account credentials under lock,
// so a concurrent reconfigure() can't tear them mid-request.
func (a *adminClient) creds() (baseURL, email, password string) {
a.mu.Lock()
defer a.mu.Unlock()
return a.baseURL, a.email, a.password
}
// reconfigure retargets the service account at a new PocketBase and/or new
// credentials, invalidating any cached superuser token.
func (a *adminClient) reconfigure(baseURL, email, password string) {
a.mu.Lock()
a.baseURL = baseURL
a.email = email
a.password = password
a.token = "" // force re-auth against the new target
a.mu.Unlock()
}
func (a *adminClient) authenticate(ctx context.Context) (string, error) {
baseURL, email, password := a.creds()
tok, _, err := superuserAuth(ctx, a.client, baseURL, email, password)
if err != nil {
return "", err
}
a.mu.Lock()
a.token = tok
a.mu.Unlock()
return tok, nil
}
// superuserAuth performs a PocketBase superuser auth-with-password and returns
// the token and HTTP status. Shared by the live client and the settings
// connection-test so both classify failures identically.
func superuserAuth(ctx context.Context, client *http.Client, baseURL, email, password string) (string, int, error) {
body, _ := json.Marshal(map[string]string{"identity": email, "password": password})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/api/collections/_superusers/auth-with-password", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", resp.StatusCode, errors.New("superuser auth failed: " + string(data))
}
var out struct {
Token string `json:"token"`
}
if err := json.Unmarshal(data, &out); err != nil || out.Token == "" {
return "", resp.StatusCode, errors.New("superuser auth: no token")
}
return out.Token, resp.StatusCode, nil
}
func (a *adminClient) cachedToken() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.token
}
// do performs an admin request, (re)authenticating as needed. It returns the
// upstream response body and status. On a 401 it re-authenticates once and
// retries, so an expired cached token is self-healing.
func (a *adminClient) do(ctx context.Context, method, path string, payload any) ([]byte, int, error) {
token := a.cachedToken()
if token == "" {
var err error
if token, err = a.authenticate(ctx); err != nil {
return nil, 0, err
}
}
baseURL, _, _ := a.creds()
send := func(tok string) ([]byte, int, error) {
var body io.Reader
if payload != nil {
b, _ := json.Marshal(payload)
body = bytes.NewReader(b)
}
req, _ := http.NewRequestWithContext(ctx, method, baseURL+path, body)
req.Header.Set("Authorization", tok)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := a.client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
return data, resp.StatusCode, nil
}
data, status, err := send(token)
if err != nil {
return nil, 0, err
}
if status == http.StatusUnauthorized {
if token, err = a.authenticate(ctx); err != nil {
return nil, 0, err
}
return send(token)
}
return data, status, nil
}
+102
View File
@@ -0,0 +1,102 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"sync"
"time"
)
// authProxy forwards login / token-validation to the PocketBase kept behind the
// API Server. PocketBase's address lives only here — it is never exposed to or
// configurable by clients. The base URL is guarded by a mutex so it can be
// retargeted at runtime from the panel's PocketBase settings.
type authProxy struct {
mu sync.RWMutex
baseURL string
client *http.Client
}
func newAuthProxy(baseURL string) *authProxy {
return &authProxy{
baseURL: baseURL,
client: &http.Client{Timeout: 15 * time.Second},
}
}
// url returns the current PocketBase base URL.
func (a *authProxy) url() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.baseURL
}
// setBaseURL retargets the proxy at a new PocketBase address.
func (a *authProxy) setBaseURL(u string) {
a.mu.Lock()
a.baseURL = u
a.mu.Unlock()
}
// POST /api/auth/login
// Body: {"email"|"identity":"...","password":"..."}
// Proxies to PocketBase users auth-with-password and returns its response verbatim.
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Identity string `json:"identity"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
identity := body.Identity
if identity == "" {
identity = body.Email
}
payload, _ := json.Marshal(map[string]string{"identity": identity, "password": body.Password})
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost,
s.auth.url()+"/api/collections/users/auth-with-password", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, err := s.auth.client.Do(req)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
}
defer resp.Body.Close()
relay(w, resp)
}
// GET /api/auth/validate (Authorization: <pb token>)
// Proxies to PocketBase auth-refresh to confirm a token is still valid.
func (s *Server) handleAuthValidate(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
writeJSON(w, http.StatusUnauthorized, map[string]any{"valid": false})
return
}
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost,
s.auth.url()+"/api/collections/users/auth-refresh", nil)
req.Header.Set("Authorization", token)
resp, err := s.auth.client.Do(req)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"valid": false, "detail": err.Error()})
return
}
defer resp.Body.Close()
relay(w, resp)
}
// relay copies an upstream PocketBase response (status + JSON body) to the client.
func relay(w http.ResponseWriter, resp *http.Response) {
data, _ := io.ReadAll(resp.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(data)
}
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"encoding/json"
"net/http"
)
// GET /api/devices — list all known device states.
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.hub.Snapshot())
}
// GET /api/devices/{id}/track — GPS track for the map trail.
func (s *Server) handleTrack(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.hub.Track(r.PathValue("id")))
}
// POST /api/devices/{id}/command — push a command down to a device.
// Body: {"command":"...","payload":{...}}
func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Command string `json:"command"`
Payload map[string]any `json:"payload"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Command == "" {
writeError(w, http.StatusBadRequest, "command required")
return
}
if !s.hub.SendCommand(id, body.Command, body.Payload) {
writeJSON(w, http.StatusNotFound, map[string]any{"error": "device not connected", "deviceId": id})
return
}
writeJSON(w, http.StatusOK, map[string]any{"sent": true, "deviceId": id, "command": body.Command})
}
// DELETE /api/devices/{id} — forget a device's stored state (clears stale entries).
func (s *Server) handleForget(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
existed := s.hub.Forget(id)
writeJSON(w, http.StatusOK, map[string]any{"removed": existed, "deviceId": id})
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="11" fill="#0F1E3D" />
<g stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none">
<polyline points="10,30 21,17 32,30" stroke="#3D7BF0" />
<polyline points="16,33 27,20 38,33" stroke="#F4F7FC" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 371 B

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