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:
tajniak81andClaude Opus 4.8 committed 2026-07-13 11:43:33 +02:00
commit afc6952eda
172 files changed
+24591

No files matched your search

+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})
}
@@ -0,0 +1 @@
@import"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap";/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:"Space Grotesk", ui-sans-serif, system-ui, "Segoe UI", sans-serif;--font-mono:"Space Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;--spacing:.25rem;--container-sm:24rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wider:.05em;--leading-tight:1.25;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Space Grotesk", ui-sans-serif, system-ui, "Segoe UI", sans-serif;--default-mono-font-family:"Space Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;--font-display:"Space Grotesk", ui-sans-serif, system-ui, "Segoe UI", sans-serif;--radius-sm:6px;--radius-md:10px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-fielLine truncated
+17
View File
@@ -0,0 +1,17 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const r of o.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&n(r)}).observe(document,{childList:!0,subtree:!0});function s(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function n(i){if(i.ep)return;i.ep=!0;const o=s(i);fetch(i.href,o)}})();/**
* @vue/shared v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/function nn(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const ee={},Ct=[],Ge=()=>{},oi=()=>!1,_s=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),ws=e=>e.startsWith("onUpdate:"),fe=Object.assign,on=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},bo=Object.prototype.hasOwnProperty,J=(e,t)=>bo.call(e,t),j=Array.isArray,Tt=e=>Qt(e)==="[object Map]",Mt=e=>Qt(e)==="[object Set]",En=e=>Qt(e)==="[object Date]",N=e=>typeof e=="function",ne=e=>typeof e=="string",qe=e=>typeof e=="symbol",Z=e=>e!==null&&typeof e=="object",ri=e=>(Z(e)||N(e))&&N(e.then)&&N(e.catch),li=Object.prototype.toString,Qt=e=>li.call(e),vo=e=>Qt(e).slice(8,-1),ai=e=>Qt(e)==="[object Object]",rn=e=>ne(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Nt=nn(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Ss=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},yo=/-\w/g,Re=Ss(e=>e.replace(yo,t=>t.slice(1).toUpperCase())),xo=/\B([A-Z])/g,xt=Ss(e=>e.replace(xo,"-$1").toLowerCase()),ci=Ss(e=>e.charAt(0).toUpperCase()+e.slice(1)),Rs=Ss(e=>e?`on${ci(e)}`:""),We=(e,t)=>!Object.is(e,t),cs=(e,...t)=>{for(let s=0;s<e.length;s++)e[s](...t)},ui=(e,t,s,n=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:s})},Cs=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let Pn;const Ts=()=>Pn||(Pn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function ln(e){if(j(e)){const t={};for(let s=0;s<e.length;s++){const n=e[s],i=ne(n)?Co(n):ln(n);if(i)for(const o in i)t[o]=i[o]}return t}else if(ne(e)||Z(e))return e}const _o=/;(?![^(]*\))/g,wo=/:([^]+)/,So=/\/\*[^]*?\*\//g;function Co(e){const t={};return e.replace(So,"").split(_o).forEach(s=>{if(s){const n=s.split(wo);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function Ee(e){let t="";if(ne(e))t=e;else if(j(e))for(let s=0;s<e.length;s++){const n=Ee(e[s]);n&&(t+=n+" ")}else if(Z(e))for(const s in e)e[s]&&(t+=s+" ");return t.trim()}const To="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Eo=nn(To);function fi(e){return!!e||e===""}function Po(e,t){if(e.length!==t.length)return!1;let s=!0;for(let n=0;s&&n<e.length;n++)s=ct(e[n],t[n]);return s}function ct(e,t){if(e===t)return!0;let s=En(e),n=En(t);if(s||n)return s&&n?e.getTime()===t.getTime():!1;if(s=qe(e),n=qe(t),s||n)return e===t;if(s=j(e),n=j(t),s||n)return s&&n?Po(e,t):!1;if(s=Z(e),n=Z(t),s||n){if(!s||!n)return!1;const i=Object.keys(e).length,o=Object.keys(t).length;if(i!==o)return!1;for(const r in e){const l=e.hasOwnProperty(r),c=t.hasOwnProperty(r);if(l&&!c||!l&&c||!ct(e[r],t[r]))return!1}}return String(e)===String(t)}function an(e,t){return e.findIndex(s=>ct(s,t))}const di=e=>!!(e&&e.__v_isRef===!0),U=e=>ne(e)?e:e==null?"":j(e)||Z(e)&&(e.toString===li||!N(e.toString))?di(e)?U(e.value):JSON.stringify(e,pi,2):String(e),pi=(e,t)=>di(t)?pi(e,t.value):Tt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[n,i],o)=>(s[Ds(n,o)+" =>"]=i,s),{})}:Mt(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>Ds(s))}:qe(t)?Ds(t):Z(t)&&!j(t)&&!ai(t)?String(t):t,Ds=(e,t="")=>{var s;return qe(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};/**
* @vue/reactivity v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/let le;class ko{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&le&&(le.active?(this.parent=le,this.index=(le.scopes||(le.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].pause();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].resume();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].resume()}}run(t){if(this._active){const s=le;try{return le=this,t()}finally{le=s}}}on(){++this._on===1&&(this.prevScope=le,le=this)}off(){if(this._on>0&&--this._on===0){if(le===this)le=this.prevScope;else{let t=le;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s<n;s++)this.effects[s].stop();for(this.effects.length=0,s=0,n=this.cleanups.length;s<n;s++)this.cleanups[s]();if(this.cleanups.length=0,this.scopes){for(s=0,n=this.scopes.length;s<n;s++)this.scopes[s].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!t){const i=this.parent.scopes.pop();i&&i!==this&&(this.parent.scopes[this.index]=i,i.index=this.index)}this.parent=void 0}}}function Ao(){return le}let te;const Fs=new WeakSet;class hi{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,le&&(le.active?le.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,Fs.has(this)&&(Fs.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||mi(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,kn(this),bi(this);const t=te,s=De;te=this,De=!0;try{return this.fn()}finally{vi(this),te=t,De=s,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)fn(t);this.deps=this.depsTail=void 0,kn(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?Fs.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){Gs(this)&&this.run()}get dirty(){return Gs(this)}}let gi=0,Ht,Vt;function mi(e,t=!1){if(e.flags|=8,t){e.next=Vt,Vt=e;return}e.next=Ht,Ht=e}function cn(){gi++}function un(){if(--gi>0)return;if(Vt){let t=Vt;for(Vt=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;Ht;){let t=Ht;for(Ht=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=s}}if(e)throw e}function bi(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function vi(e){let t,s=e.depsTail,n=s;for(;n;){const i=n.prevDep;n.version===-1?(n===s&&(s=i),fn(n),Oo(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=i}e.deps=t,e.depsTail=s}function Gs(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(yi(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function yi(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===qt)||(e.globalVersion=qt,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Gs(e))))return;e.flags|=2;const t=e.dep,s=te,n=De;te=e,De=!0;try{bi(e);const i=e.fn(e._value);(t.version===0||We(i,e._value))&&(e.flags|=128,e._value=i,t.version++)}catch(i){throw t.version++,i}finally{te=s,De=n,vi(e),e.flags&=-3}}function fn(e,t=!1){const{dep:s,prevSub:n,nextSub:i}=e;if(n&&(n.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=n,e.nextSub=void 0),s.subs===e&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let o=s.computed.deps;o;o=o.nextDep)fn(o,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function Oo(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}let De=!0;const xi=[];function Je(){xi.push(De),De=!1}function ze(){const e=xi.pop();De=e===void 0?!0:e}function kn(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=te;te=void 0;try{t()}finally{te=s}}}let qt=0;class Mo{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class dn{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!te||!De||te===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==te)s=this.activeLink=new Mo(te,this),te.deps?(s.prevDep=te.depsTail,te.depsTail.nextDep=s,te.depsTail=s):te.deps=te.depsTail=s,_i(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=te.depsTail,s.nextDep=void 0,te.depsTail.nextDep=s,te.depsTail=s,te.deps===s&&(te.deps=n)}return s}trigger(t){this.versiLine truncated
* @vue/runtime-core v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/function es(e,t,s,n){try{return n?e(...n):e()}catch(i){Ps(i,t,s)}}function Le(e,t,s,n){if(N(e)){const i=es(e,t,s,n);return i&&ri(i)&&i.catch(o=>{Ps(o,t,s)}),i}if(j(e)){const i=[];for(let o=0;o<e.length;o++)i.push(Le(e[o],t,s,n));return i}}function Ps(e,t,s,n=!0){const i=t?t.vnode:null,{errorHandler:o,throwUnhandledErrorInProduction:r}=t&&t.appContext.config||ee;if(t){let l=t.parent;const c=t.proxy,p=`https://vuejs.org/error-reference/#runtime-${s}`;for(;l;){const f=l.ec;if(f){for(let m=0;m<f.length;m++)if(f[m](e,c,p)===!1)return}l=l.parent}if(o){Je(),es(o,null,10,[e,c,p]),ze();return}}sr(e,s,i,n,r)}function sr(e,t,s,n=!0,i=!1){if(i)throw e;console.error(e)}const pe=[];let Ve=-1;const Et=[];let at=null,St=0;const Ai=Promise.resolve();let hs=null;function Oi(e){const t=hs||Ai;return e?t.then(this?e.bind(this):e):t}function nr(e){let t=Ve+1,s=pe.length;for(;t<s;){const n=t+s>>>1,i=pe[n],o=zt(i);o<e||o===e&&i.flags&2?t=n+1:s=n}return t}function bn(e){if(!(e.flags&1)){const t=zt(e),s=pe[pe.length-1];!s||!(e.flags&2)&&t>=zt(s)?pe.push(e):pe.splice(nr(t),0,e),e.flags|=1,Mi()}}function Mi(){hs||(hs=Ai.then(Ri))}function ir(e){j(e)?Et.push(...e):at&&e.id===-1?at.splice(St+1,0,e):e.flags&1||(Et.push(e),e.flags|=1),Mi()}function On(e,t,s=Ve+1){for(;s<pe.length;s++){const n=pe[s];if(n&&n.flags&2){if(e&&n.id!==e.uid)continue;pe.splice(s,1),s--,n.flags&4&&(n.flags&=-2),n(),n.flags&4||(n.flags&=-2)}}}function Ii(e){if(Et.length){const t=[...new Set(Et)].sort((s,n)=>zt(s)-zt(n));if(Et.length=0,at){at.push(...t);return}for(at=t,St=0;St<at.length;St++){const s=at[St];s.flags&4&&(s.flags&=-2),s.flags&8||s(),s.flags&=-2}at=null,St=0}}const zt=e=>e.id==null?e.flags&2?-1:1/0:e.id;function Ri(e){try{for(Ve=0;Ve<pe.length;Ve++){const t=pe[Ve];t&&!(t.flags&8)&&(t.flags&4&&(t.flags&=-2),es(t,t.i,t.i?15:14),t.flags&4||(t.flags&=-2))}}finally{for(;Ve<pe.length;Ve++){const t=pe[Ve];t&&(t.flags&=-2)}Ve=-1,pe.length=0,Ii(),hs=null,(pe.length||Et.length)&&Ri()}}let Pe=null,Di=null;function gs(e){const t=Pe;return Pe=e,Di=e&&e.type.__scopeId||null,t}function or(e,t=Pe,s){if(!t||e._n)return e;const n=(...i)=>{n._d&&Hn(-1);const o=gs(t);let r;try{r=e(...i)}finally{gs(o),n._d&&Hn(1)}return r};return n._n=!0,n._c=!0,n._d=!0,n}function Ce(e,t){if(Pe===null)return e;const s=Ms(Pe),n=e.dirs||(e.dirs=[]);for(let i=0;i<t.length;i++){let[o,r,l,c=ee]=t[i];o&&(N(o)&&(o={mounted:o,updated:o}),o.deep&&nt(r),n.push({dir:o,instance:s,value:r,oldValue:void 0,arg:l,modifiers:c}))}return e}function gt(e,t,s,n){const i=e.dirs,o=t&&t.dirs;for(let r=0;r<i.length;r++){const l=i[r];o&&(l.oldValue=o[r].value);let c=l.dir[n];c&&(Je(),Le(c,s,8,[e.el,l,e,t]),ze())}}function rr(e,t){if(he){let s=he.provides;const n=he.parent&&he.parent.provides;n===s&&(s=he.provides=Object.create(n)),s[e]=t}}function us(e,t,s=!1){const n=ol();if(n||Pt){let i=Pt?Pt._context.provides:n?n.parent==null||n.ce?n.vnode.appContext&&n.vnode.appContext.provides:n.parent.provides:void 0;if(i&&e in i)return i[e];if(arguments.length>1)return s&&N(t)?t.call(n&&n.proxy):t}}const lr=Symbol.for("v-scx"),ar=()=>us(lr);function Us(e,t,s){return Fi(e,t,s)}function Fi(e,t,s=ee){const{immediate:n,deep:i,flush:o,once:r}=s,l=fe({},s),c=t&&n||!t&&o!=="post";let p;if(Xt){if(o==="sync"){const A=ar();p=A.__watcherHandles||(A.__watcherHandles=[])}else if(!c){const A=()=>{};return A.stop=Ge,A.resume=Ge,A.pause=Ge,A}}const f=he;l.call=(A,B,O)=>Le(A,f,B,O);let m=!1;o==="post"?l.scheduler=A=>{me(A,f&&f.suspense)}:o!=="sync"&&(m=!0,l.scheduler=(A,B)=>{B?A():bn(A)}),l.augmentJob=A=>{t&&(A.flags|=4),m&&(A.flags|=2,f&&(A.id=f.uid,A.i=f))};const P=tr(e,t,l);return Xt&&(p?p.push(P):c&&P()),P}function cr(e,t,s){const n=this.proxy,i=ne(e)?e.includes(".")?Li(n,e):()=>n[e]:e.bind(n,n);let o;N(t)?o=t:(o=t.handler,s=t);const r=ts(this),l=Fi(i,o.bind(n),s);return r(),l}function Li(e,t){const s=t.split(".");return()=>{let n=e;for(let i=0;i<s.length&&n;i++)n=n[s[i]];return n}}const ur=Symbol("_vte"),fr=e=>e.__isTeleport,$s=Symbol("_leaveCb");function vn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,vn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function ji(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function Mn(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const ms=new WeakMap;function Bt(e,t,s,n,i=!1){if(j(e)){e.forEach((O,H)=>Bt(O,t&&(j(t)?t[H]:t),s,n,i));return}if(Kt(n)&&!i){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&Bt(e,t,s,n.component.subTree);return}const o=n.shapeFlag&4?Ms(n.component):n.el,r=i?null:o,{i:l,r:c}=e,p=t&&t.r,f=l.refs===ee?l.refs={}:l.refs,m=l.setupState,P=q(m),A=m===ee?oi:O=>Mn(f,O)?!1:J(P,O),B=(O,H)=>!(H&&Mn(f,H));if(p!=null&&p!==c){if(In(t),ne(p))f[p]=null,A(p)&&(m[p]=null);else if(ue(p)){const O=t;B(p,O.k)&&(p.value=null),O.k&&(f[O.k]=null)}}if(N(c)){Je();try{es(c,l,12,[r,f])}finally{ze()}}else{const O=ne(c),H=ue(c);if(O||H){const K=()=>{if(e.f Line truncated
* @vue/runtime-dom v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/let sn;const Kn=typeof window<"u"&&window.trustedTypes;if(Kn)try{sn=Kn.createPolicy("vue",{createHTML:e=>e})}catch{}const uo=sn?e=>sn.createHTML(e):e=>e,dl="http://www.w3.org/2000/svg",pl="http://www.w3.org/1998/Math/MathML",Qe=typeof document<"u"?document:null,Wn=Qe&&Qe.createElement("template"),hl={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const i=t==="svg"?Qe.createElementNS(dl,e):t==="mathml"?Qe.createElementNS(pl,e):s?Qe.createElement(e,{is:s}):Qe.createElement(e);return e==="select"&&n&&n.multiple!=null&&i.setAttribute("multiple",n.multiple),i},createText:e=>Qe.createTextNode(e),createComment:e=>Qe.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Qe.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,i,o){const r=s?s.previousSibling:t.lastChild;if(i&&(i===o||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),s),!(i===o||!(i=i.nextSibling)););else{Wn.innerHTML=uo(n==="svg"?`<svg>${e}</svg>`:n==="mathml"?`<math>${e}</math>`:e);const l=Wn.content;if(n==="svg"||n==="mathml"){const c=l.firstChild;for(;c.firstChild;)l.appendChild(c.firstChild);l.removeChild(c)}t.insertBefore(l,s)}return[r?r.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},gl=Symbol("_vtc");function ml(e,t,s){const n=e[gl];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const xs=Symbol("_vod"),fo=Symbol("_vsh"),Vs={name:"show",beforeMount(e,{value:t},{transition:s}){e[xs]=e.style.display==="none"?"":e.style.display,s&&t?s.beforeEnter(e):Lt(e,t)},mounted(e,{value:t},{transition:s}){s&&t&&s.enter(e)},updated(e,{value:t,oldValue:s},{transition:n}){!t!=!s&&(n?t?(n.beforeEnter(e),Lt(e,!0),n.enter(e)):n.leave(e,()=>{Lt(e,!1)}):Lt(e,t))},beforeUnmount(e,{value:t}){Lt(e,t)}};function Lt(e,t){e.style.display=t?e[xs]:"none",e[fo]=!t}const bl=Symbol(""),vl=/(?:^|;)\s*display\s*:/;function yl(e,t,s){const n=e.style,i=ne(s);let o=!1;if(s&&!i){if(t)if(ne(t))for(const r of t.split(";")){const l=r.slice(0,r.indexOf(":")).trim();s[l]==null&&$t(n,l,"")}else for(const r in t)s[r]==null&&$t(n,r,"");for(const r in s){r==="display"&&(o=!0);const l=s[r];l!=null?_l(e,r,!ne(t)&&t?t[r]:void 0,l)||$t(n,r,l):$t(n,r,"")}}else if(i){if(t!==s){const r=n[bl];r&&(s+=";"+r),n.cssText=s,o=vl.test(s)}}else t&&e.removeAttribute("style");xs in e&&(e[xs]=o?n.display:"",e[fo]&&(n.display="none"))}const Gn=/\s*!important$/;function $t(e,t,s){if(j(s))s.forEach(n=>$t(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=xl(e,t);Gn.test(s)?e.setProperty(xt(n),s.replace(Gn,""),"important"):e[n]=s}}const qn=["Webkit","Moz","ms"],Bs={};function xl(e,t){const s=Bs[t];if(s)return s;let n=Re(t);if(n!=="filter"&&n in e)return Bs[t]=n;n=ci(n);for(let i=0;i<qn.length;i++){const o=qn[i]+n;if(o in e)return Bs[t]=o}return t}function _l(e,t,s,n){return e.tagName==="TEXTAREA"&&(t==="width"||t==="height")&&ne(n)&&s===n}const Jn="http://www.w3.org/1999/xlink";function zn(e,t,s,n,i,o=Eo(t)){n&&t.startsWith("xlink:")?s==null?e.removeAttributeNS(Jn,t.slice(6,t.length)):e.setAttributeNS(Jn,t,s):s==null||o&&!fi(s)?e.removeAttribute(t):e.setAttribute(t,o?"":qe(s)?String(s):s)}function Yn(e,t,s,n,i){if(t==="innerHTML"||t==="textContent"){s!=null&&(e[t]=t==="innerHTML"?uo(s):s);return}const o=e.tagName;if(t==="value"&&o!=="PROGRESS"&&!o.includes("-")){const l=o==="OPTION"?e.getAttribute("value")||"":e.value,c=s==null?e.type==="checkbox"?"on":"":String(s);(l!==c||!("_value"in e))&&(e.value=c),s==null&&e.removeAttribute(t),e._value=s;return}let r=!1;if(s===""||s==null){const l=typeof e[t];l==="boolean"?s=fi(s):s==null&&l==="string"?(s="",r=!0):l==="number"&&(s=0,r=!0)}try{e[t]=s}catch{}r&&e.removeAttribute(i||t)}function it(e,t,s,n){e.addEventListener(t,s,n)}function wl(e,t,s,n){e.removeEventListener(t,s,n)}const Xn=Symbol("_vei");function Sl(e,t,s,n,i=null){const o=e[Xn]||(e[Xn]={}),r=o[t];if(n&&r)r.value=n;else{const[l,c]=El(t);if(n){const p=o[t]=Al(n,i);it(e,l,p,c)}else r&&(wl(e,l,r,c),o[t]=void 0)}}const Cl=/(Once|Passive|Capture)$/,Tl=/^on:?(?:Once|Passive|Capture)$/;function El(e){let t,s;for(;(s=e.match(Cl))&&!Tl.test(e);)t||(t={}),e=e.slice(0,e.length-s[1].length),t[s[1].toLowerCase()]=!0;return[e[2]===":"?e.slice(3):xt(e.slice(2)),t]}let Ks=0;const Pl=Promise.resolve(),kl=()=>Ks||(Pl.then(()=>Ks=0),Ks=Date.now());function Al(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const i=s.value;if(j(i)){const o=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{o.call(n),n._stopped=!0};const r=i.slice(),l=[n];for(let c=0;c<r.length&&!n._stopped;c++){const p=r[c];p&&Le(p,t,5,l)}}else Le(i,t,5,[n])};return s.value=e,s.attached=kl(),s}const Zn=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Ol=(e,t,s,n,i Line truncated
+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)
}