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:
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user