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:
commit
afc6952eda
172 files changed
+24591
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user