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}) }