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 }