Files
PilotVault/API Server/internal/api/integrations_filetransfer.go
T
tajniak81andClaude Opus 4.8 afc6952eda 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>
2026-07-13 11:43:33 +02:00

503 lines
17 KiB
Go

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
}