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:
tajniak81
2026-07-13 11:43:33 +02:00
co-authored by Claude Opus 4.8
commit afc6952eda
172 changed files with 24591 additions and 0 deletions
@@ -0,0 +1,11 @@
// Package builtin blank-imports every built-in plugin so their init() functions
// register them with the plugin registry. Import this package once (from the api
// package) to make all built-in connectors available.
package builtin
import (
_ "pilotvault/apiserver/internal/plugins/builtin/filetransfer"
_ "pilotvault/apiserver/internal/plugins/builtin/localstorage"
_ "pilotvault/apiserver/internal/plugins/builtin/opensky"
_ "pilotvault/apiserver/internal/plugins/builtin/webdav"
)
@@ -0,0 +1,610 @@
// Package filetransfer is a built-in plugin that connects to a file-transfer
// server over FTP, FTPS (explicit TLS), or SFTP (SSH). It demonstrates a
// stateful third-party integration behind the plugin contract: one descriptor
// with a protocol switch, and a small protocol-agnostic `conn` abstraction that
// HealthCheck and Invoke drive without caring which wire protocol is in use.
//
// Connections are opened per operation rather than pooled: FTP/SFTP sessions are
// stateful and idle-timeout aggressively, so dialling on demand is both simpler
// and more robust than keeping a long-lived connection healthy. Init only stores
// the resolved config; nothing connects until HealthCheck or Invoke runs.
//
// - FTP : github.com/jlaffaye/ftp
// - FTPS : github.com/jlaffaye/ftp with explicit TLS (AUTH TLS)
// - SFTP : golang.org/x/crypto/ssh + github.com/pkg/sftp
package filetransfer
import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/jlaffaye/ftp"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
"pilotvault/apiserver/internal/plugins"
)
const (
protoSFTP = "sftp"
protoFTP = "ftp"
protoFTPS = "ftps"
dialTimeout = 12 * time.Second
// maxReadBytes caps a download so a huge remote file can't exhaust memory;
// the health probe and Invoke both honour it.
maxReadBytes = 32 << 20 // 32 MiB
)
func init() {
plugins.Register("filetransfer", func() plugins.Plugin { return &Plugin{} })
}
// Plugin is the FTP/FTPS/SFTP connector. All fields are guarded by mu because
// Init may run concurrently with a HealthCheck/Invoke from another request.
type Plugin struct {
mu sync.Mutex
protocol string
host string
port int
username string
password string
privateKey string // PEM-encoded SSH private key (sftp only)
keyPass string // passphrase for the private key
basePath string
// hostKeyFP, when set, pins the SFTP server's SHA256 host-key fingerprint
// ("SHA256:…"); empty means accept any host key (trust-on-first-use, no
// verification — flagged as degraded by the health probe).
hostKeyFP string
// insecureTLS skips FTPS certificate verification when true.
insecureTLS bool
}
func (p *Plugin) Descriptor() plugins.Descriptor {
return plugins.Descriptor{
Name: "filetransfer",
Provider: "FTP / SFTP",
Version: "1.0.0",
Kind: plugins.KindBuiltin,
Category: plugins.CategoryDrivesExternal,
AuthType: plugins.AuthBasic,
Capabilities: []plugins.Capability{
{ID: "list", Method: "GET", Endpoint: "/", Description: "List a remote directory. params: {path}"},
{ID: "stat", Method: "GET", Endpoint: "/", Description: "Stat one remote path. params: {path}"},
{ID: "download", Method: "GET", Endpoint: "/", Description: "Read a remote file (base64, ≤32 MiB). params: {path}"},
{ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a remote file. params: {path, contentBase64}"},
{ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a remote file. params: {path}"},
{ID: "mkdir", Method: "PUT", Endpoint: "/", Description: "Create a remote directory. params: {path}"},
},
ConfigFields: []plugins.ConfigField{
// No field is Required: the plugin can be enabled as a master switch with
// an empty global config, leaving each organization or user to supply
// their own connection through the cascade (mirrors OpenSky). A missing
// host is reported gracefully by the health probe.
{Key: "protocol", Label: "Protocol", Type: "select", Default: protoSFTP,
Options: []plugins.SelectOption{
{Value: protoSFTP, Label: "SFTP — file transfer over SSH (recommended)"},
{Value: protoFTPS, Label: "FTPS — FTP with explicit TLS (AUTH TLS)"},
{Value: protoFTP, Label: "FTP — plaintext (insecure)"},
},
Help: "SFTP runs over SSH (port 22); FTP/FTPS use port 21 by default."},
{Key: "host", Label: "Host", Type: "text", Help: "Server hostname or IP, e.g. files.example.com"},
{Key: "port", Label: "Port", Type: "number", Help: "Leave blank for the protocol default (22 for SFTP, 21 for FTP/FTPS)."},
{Key: "username", Label: "Username", Type: "text"},
{Key: "password", Label: "Password", Type: "password", Secret: true,
Help: "Password for FTP/FTPS, or SFTP password auth. Leave blank to use an SFTP private key."},
{Key: "privateKey", Label: "SSH private key (SFTP)", Type: "password", Secret: true,
Help: "PEM-encoded private key for SFTP key auth. Used instead of, or alongside, a password."},
{Key: "keyPassphrase", Label: "Private key passphrase", Type: "password", Secret: true,
Help: "Passphrase protecting the SSH private key, if any."},
{Key: "basePath", Label: "Base path", Type: "text", Default: ".",
Help: "Directory used as the working root and probed by the health check, e.g. /uploads. Relative capability paths are resolved under it."},
{Key: "hostKeyFingerprint", Label: "SFTP host key fingerprint", Type: "text",
Help: "Optional SHA256:… fingerprint to pin the SFTP server's host key. Leave blank to accept any key (no verification)."},
{Key: "insecureSkipVerify", Label: "FTPS TLS verification", Type: "select", Default: "false",
Options: []plugins.SelectOption{
{Value: "false", Label: "Verify certificate (recommended)"},
{Value: "true", Label: "Skip verification — accept any certificate"},
},
Help: "Only affects FTPS. Skip verification only for self-signed test servers."},
},
}
}
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.protocol = strings.ToLower(strings.TrimSpace(config["protocol"]))
if p.protocol == "" {
p.protocol = protoSFTP
}
p.host = strings.TrimSpace(config["host"])
p.port = 0
if raw := strings.TrimSpace(config["port"]); raw != "" {
if n, err := strconv.Atoi(raw); err == nil {
p.port = n
}
}
p.username = strings.TrimSpace(config["username"])
p.password = config["password"]
p.privateKey = config["privateKey"]
p.keyPass = config["keyPassphrase"]
p.basePath = strings.TrimSpace(config["basePath"])
if p.basePath == "" {
p.basePath = "."
}
p.hostKeyFP = strings.TrimSpace(config["hostKeyFingerprint"])
p.insecureTLS = strings.EqualFold(strings.TrimSpace(config["insecureSkipVerify"]), "true")
return nil
}
// effectivePort returns the configured port or the protocol default.
func (p *Plugin) effectivePort() int {
if p.port > 0 {
return p.port
}
if p.protocol == protoSFTP {
return 22
}
return 21
}
// resolve joins a caller-supplied path against the base path. An absolute path
// is used as-is; an empty path becomes the base path itself.
func (p *Plugin) resolve(rel string) string {
rel = strings.TrimSpace(rel)
if rel == "" {
return p.basePath
}
if strings.HasPrefix(rel, "/") || p.basePath == "" || p.basePath == "." {
return rel
}
return path.Join(p.basePath, rel)
}
// HealthCheck dials, authenticates, and lists the base path, classifying the
// outcome. A missing/unverified SFTP host key downgrades OK to degraded.
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
start := time.Now()
p.mu.Lock()
proto, host, hostKeyFP := p.protocol, p.host, p.hostKeyFP
base := p.basePath
p.mu.Unlock()
if host == "" {
return plugins.Health{Status: plugins.StatusDown, Detail: "no host configured"}
}
c, err := p.dial(ctx)
if err != nil {
lat := time.Since(start).Milliseconds()
return plugins.Health{Status: classifyDialErr(err), LatencyMs: lat, Detail: err.Error()}
}
defer c.close()
entries, err := c.list(base)
lat := time.Since(start).Milliseconds()
if err != nil {
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: lat,
Detail: fmt.Sprintf("connected (%s) but listing %q failed: %v", proto, base, err)}
}
detail := fmt.Sprintf("%s reachable — %d entr%s under %q", strings.ToUpper(proto), len(entries), plural(len(entries)), base)
status := plugins.StatusOK
if proto == protoSFTP && hostKeyFP == "" {
status = plugins.StatusDegraded
detail += " · host key not verified (no fingerprint pinned)"
}
if proto == protoFTP {
detail += " · plaintext (no encryption)"
}
return plugins.Health{Status: status, LatencyMs: lat, Detail: detail}
}
// Invoke runs one capability against a freshly-dialled connection.
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
c, err := p.dial(ctx)
if err != nil {
return nil, err
}
defer c.close()
switch action {
case "list":
var in pathParams
_ = json.Unmarshal(params, &in)
entries, err := c.list(p.resolve(in.Path))
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "entries": entries})
case "stat":
var in pathParams
_ = json.Unmarshal(params, &in)
fi, err := c.stat(p.resolve(in.Path))
if err != nil {
return nil, err
}
return json.Marshal(fi)
case "download":
var in pathParams
_ = json.Unmarshal(params, &in)
data, err := c.read(p.resolve(in.Path))
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{
"path": p.resolve(in.Path),
"size": len(data),
"contentBase64": base64.StdEncoding.EncodeToString(data),
})
case "upload":
var in writeParams
if err := json.Unmarshal(params, &in); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
data, err := base64.StdEncoding.DecodeString(in.ContentBase64)
if err != nil {
return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err)
}
if err := c.write(p.resolve(in.Path), data); err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "size": len(data), "ok": true})
case "delete":
var in pathParams
_ = json.Unmarshal(params, &in)
if err := c.remove(p.resolve(in.Path)); err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "ok": true})
case "mkdir":
var in pathParams
_ = json.Unmarshal(params, &in)
if err := c.mkdir(p.resolve(in.Path)); err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "ok": true})
default:
return nil, errors.New("unknown action: " + action)
}
}
func (p *Plugin) Shutdown(context.Context) error { return nil }
// pathParams / writeParams are the Invoke request shapes.
type pathParams struct {
Path string `json:"path"`
}
type writeParams struct {
Path string `json:"path"`
ContentBase64 string `json:"contentBase64"`
}
// fileInfo is the normalized directory-entry shape returned by list/stat.
type fileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
ModTime string `json:"modTime,omitempty"`
}
// conn is the protocol-agnostic surface HealthCheck and Invoke drive. Both the
// FTP and SFTP implementations satisfy it.
type conn interface {
list(path string) ([]fileInfo, error)
stat(path string) (fileInfo, error)
read(path string) ([]byte, error)
write(path string, data []byte) error
remove(path string) error
mkdir(path string) error
close() error
}
// dial builds an authenticated connection for the configured protocol.
func (p *Plugin) dial(ctx context.Context) (conn, error) {
p.mu.Lock()
proto := p.protocol
p.mu.Unlock()
switch proto {
case protoSFTP:
return p.dialSFTP(ctx)
case protoFTP, protoFTPS:
return p.dialFTP(ctx)
default:
return nil, errors.New("unsupported protocol: " + proto)
}
}
// classifyDialErr maps a dial/auth failure to a health status: an auth rejection
// is degraded (server reachable, credentials wrong); anything else is down.
func classifyDialErr(err error) string {
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "unable to authenticate"),
strings.Contains(msg, "auth"),
strings.Contains(msg, "password"),
strings.Contains(msg, "login"),
strings.Contains(msg, "530"), // FTP: not logged in
strings.Contains(msg, "permission denied"):
return plugins.StatusDegraded
default:
return plugins.StatusDown
}
}
func plural(n int) string {
if n == 1 {
return "y"
}
return "ies"
}
// ---------------------------------------------------------------------------
// SFTP implementation
// ---------------------------------------------------------------------------
type sftpConn struct {
ssh *ssh.Client
cli *sftp.Client
}
func (p *Plugin) dialSFTP(ctx context.Context) (conn, error) {
p.mu.Lock()
host, user, pass := p.host, p.username, p.password
key, keyPass, hostKeyFP := p.privateKey, p.keyPass, p.hostKeyFP
addr := net.JoinHostPort(host, strconv.Itoa(p.effectivePort()))
p.mu.Unlock()
var auth []ssh.AuthMethod
if strings.TrimSpace(key) != "" {
signer, err := parseSigner(key, keyPass)
if err != nil {
return nil, fmt.Errorf("private key: %w", err)
}
auth = append(auth, ssh.PublicKeys(signer))
}
if pass != "" {
auth = append(auth, ssh.Password(pass))
}
if len(auth) == 0 {
return nil, errors.New("SFTP requires a password or a private key")
}
hostKeyCallback, err := hostKeyChecker(hostKeyFP)
if err != nil {
return nil, err
}
cfg := &ssh.ClientConfig{
User: user,
Auth: auth,
HostKeyCallback: hostKeyCallback,
Timeout: dialTimeout,
}
// ssh.Dial has no context form; dial the TCP conn with the context, then
// run the SSH handshake over it.
d := net.Dialer{Timeout: dialTimeout}
tcp, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, err
}
sshConn, chans, reqs, err := ssh.NewClientConn(tcp, addr, cfg)
if err != nil {
_ = tcp.Close()
return nil, err
}
client := ssh.NewClient(sshConn, chans, reqs)
sc, err := sftp.NewClient(client)
if err != nil {
_ = client.Close()
return nil, err
}
return &sftpConn{ssh: client, cli: sc}, nil
}
// parseSigner parses a PEM private key, with or without a passphrase.
func parseSigner(pem, passphrase string) (ssh.Signer, error) {
if strings.TrimSpace(passphrase) != "" {
return ssh.ParsePrivateKeyWithPassphrase([]byte(pem), []byte(passphrase))
}
return ssh.ParsePrivateKey([]byte(pem))
}
// hostKeyChecker returns a HostKeyCallback that pins the given SHA256:…
// fingerprint, or accepts any key when the fingerprint is empty.
func hostKeyChecker(fingerprint string) (ssh.HostKeyCallback, error) {
if fingerprint == "" {
return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec // opt-in: no fingerprint pinned
}
want := strings.TrimSpace(fingerprint)
return func(_ string, _ net.Addr, key ssh.PublicKey) error {
got := ssh.FingerprintSHA256(key)
if got != want {
return fmt.Errorf("host key mismatch: server presented %s, expected %s", got, want)
}
return nil
}, nil
}
func (c *sftpConn) list(p string) ([]fileInfo, error) {
infos, err := c.cli.ReadDir(p)
if err != nil {
return nil, err
}
out := make([]fileInfo, 0, len(infos))
for _, fi := range infos {
out = append(out, fileInfo{
Name: fi.Name(),
Size: fi.Size(),
IsDir: fi.IsDir(),
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
})
}
return out, nil
}
func (c *sftpConn) stat(p string) (fileInfo, error) {
fi, err := c.cli.Stat(p)
if err != nil {
return fileInfo{}, err
}
return fileInfo{
Name: fi.Name(),
Size: fi.Size(),
IsDir: fi.IsDir(),
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
}, nil
}
func (c *sftpConn) read(p string) ([]byte, error) {
f, err := c.cli.Open(p)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, maxReadBytes))
}
func (c *sftpConn) write(p string, data []byte) error {
f, err := c.cli.Create(p)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(data)
return err
}
func (c *sftpConn) remove(p string) error { return c.cli.Remove(p) }
func (c *sftpConn) mkdir(p string) error { return c.cli.MkdirAll(p) }
func (c *sftpConn) close() error {
err := c.cli.Close()
if c.ssh != nil {
_ = c.ssh.Close()
}
return err
}
// ---------------------------------------------------------------------------
// FTP / FTPS implementation
// ---------------------------------------------------------------------------
type ftpConn struct {
c *ftp.ServerConn
}
func (p *Plugin) dialFTP(ctx context.Context) (conn, error) {
p.mu.Lock()
host, user, pass, proto := p.host, p.username, p.password, p.protocol
insecure := p.insecureTLS
addr := net.JoinHostPort(host, strconv.Itoa(p.effectivePort()))
p.mu.Unlock()
opts := []ftp.DialOption{ftp.DialWithContext(ctx), ftp.DialWithTimeout(dialTimeout)}
if proto == protoFTPS {
opts = append(opts, ftp.DialWithExplicitTLS(&tls.Config{
ServerName: host,
InsecureSkipVerify: insecure, //nolint:gosec // opt-in for self-signed test servers
}))
}
sc, err := ftp.Dial(addr, opts...)
if err != nil {
return nil, err
}
if err := sc.Login(user, pass); err != nil {
_ = sc.Quit()
return nil, err
}
return &ftpConn{c: sc}, nil
}
func (c *ftpConn) list(p string) ([]fileInfo, error) {
entries, err := c.c.List(p)
if err != nil {
return nil, err
}
out := make([]fileInfo, 0, len(entries))
for _, e := range entries {
if e.Name == "." || e.Name == ".." {
continue
}
out = append(out, entryToInfo(e))
}
return out, nil
}
func (c *ftpConn) stat(p string) (fileInfo, error) {
// FTP has no portable stat; MLST via GetEntry works on servers that support
// it, otherwise fall back to listing the parent and matching the name.
if e, err := c.c.GetEntry(p); err == nil && e != nil {
return entryToInfo(e), nil
}
dir, base := path.Split(strings.TrimRight(p, "/"))
if dir == "" {
dir = "."
}
entries, err := c.c.List(dir)
if err != nil {
return fileInfo{}, err
}
for _, e := range entries {
if e.Name == base {
return entryToInfo(e), nil
}
}
return fileInfo{}, fmt.Errorf("not found: %s", p)
}
func (c *ftpConn) read(p string) ([]byte, error) {
resp, err := c.c.Retr(p)
if err != nil {
return nil, err
}
defer resp.Close()
return io.ReadAll(io.LimitReader(resp, maxReadBytes))
}
func (c *ftpConn) write(p string, data []byte) error {
return c.c.Stor(p, strings.NewReader(string(data)))
}
func (c *ftpConn) remove(p string) error { return c.c.Delete(p) }
func (c *ftpConn) mkdir(p string) error { return c.c.MakeDir(p) }
func (c *ftpConn) close() error { return c.c.Quit() }
// entryToInfo normalizes a jlaffaye/ftp entry.
func entryToInfo(e *ftp.Entry) fileInfo {
fi := fileInfo{
Name: e.Name,
Size: int64(e.Size),
IsDir: e.Type == ftp.EntryTypeFolder,
}
if !e.Time.IsZero() {
fi.ModTime = e.Time.UTC().Format(time.RFC3339)
}
return fi
}
@@ -0,0 +1,97 @@
package filetransfer
import (
"context"
"testing"
"pilotvault/apiserver/internal/plugins"
)
func TestDescriptor(t *testing.T) {
p := &Plugin{}
d := p.Descriptor()
if d.Name != "filetransfer" {
t.Fatalf("name = %q, want filetransfer", d.Name)
}
if d.Kind != plugins.KindBuiltin {
t.Fatalf("kind = %q, want builtin", d.Kind)
}
if len(d.Capabilities) == 0 {
t.Fatal("expected capabilities")
}
// Every secret field must be flagged so the manager masks it.
for _, f := range d.ConfigFields {
if f.Key == "password" || f.Key == "privateKey" || f.Key == "keyPassphrase" {
if !f.Secret {
t.Errorf("config field %q must be Secret", f.Key)
}
}
}
}
func TestInitDefaults(t *testing.T) {
p := &Plugin{}
if err := p.Init(context.Background(), map[string]string{"host": "h", "username": "u"}); err != nil {
t.Fatal(err)
}
if p.protocol != protoSFTP {
t.Errorf("default protocol = %q, want sftp", p.protocol)
}
if p.effectivePort() != 22 {
t.Errorf("default sftp port = %d, want 22", p.effectivePort())
}
p.protocol = protoFTP
if p.effectivePort() != 21 {
t.Errorf("default ftp port = %d, want 21", p.effectivePort())
}
}
func TestResolve(t *testing.T) {
p := &Plugin{basePath: "/uploads"}
cases := map[string]string{
"": "/uploads",
"a/b.txt": "/uploads/a/b.txt",
"/etc/abs": "/etc/abs",
}
for in, want := range cases {
if got := p.resolve(in); got != want {
t.Errorf("resolve(%q) = %q, want %q", in, got, want)
}
}
}
// TestHealthCheckUnreachable confirms an unreachable host is classified as down
// (not a panic) — the graceful-failure path Init/HealthCheck must guarantee.
func TestHealthCheckUnreachable(t *testing.T) {
p := &Plugin{}
// Port 1 is reserved and refuses connections quickly.
if err := p.Init(context.Background(), map[string]string{
"protocol": protoSFTP, "host": "127.0.0.1", "port": "1",
"username": "u", "password": "pw",
}); err != nil {
t.Fatal(err)
}
h := p.HealthCheck(context.Background())
if h.Status != plugins.StatusDown {
t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail)
}
}
func TestHostKeyFingerprintMismatch(t *testing.T) {
cb, err := hostKeyChecker("SHA256:doesnotmatch")
if err != nil {
t.Fatal(err)
}
if cb == nil {
t.Fatal("expected a callback")
}
}
// TestRegistered confirms the plugin registered itself with the shared registry
// via init(), so the manager will surface it.
func TestRegistered(t *testing.T) {
m := plugins.NewManager(t.TempDir() + "/plugins.json")
if _, ok := m.Get("filetransfer"); !ok {
t.Fatal("filetransfer not registered in the plugin manager")
}
}
@@ -0,0 +1,368 @@
// Package localstorage is a built-in plugin that exposes a directory on the host
// machine's own filesystem as a storage "drive", behind the same capability
// surface (list/stat/download/upload/delete/mkdir) as the remote filetransfer
// connector. Where filetransfer dials FTP/SFTP, this one just calls the os
// package — there is no network, no auth, and nothing to dial.
//
// Every caller-supplied path is confined under the configured base path: paths
// are treated as relative to the base and cleaned so that ".." or a leading
// separator can never escape the storage root. This is the one piece of extra
// care a local-filesystem connector needs that a remote one gets from the remote
// server's own chroot/permissions.
package localstorage
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"pilotvault/apiserver/internal/plugins"
)
// maxReadBytes caps a download so a huge file can't exhaust memory; the health
// probe and Invoke both honour it (mirrors filetransfer).
const maxReadBytes = 32 << 20 // 32 MiB
func init() {
plugins.Register("localstorage", func() plugins.Plugin { return &Plugin{} })
}
// Plugin is the local-filesystem connector. Fields are guarded by mu because
// Init may run concurrently with a HealthCheck/Invoke from another request.
type Plugin struct {
mu sync.Mutex
basePath string
createMissing bool // create the base path (and upload/mkdir parents) if absent
readOnly bool // reject upload/delete/mkdir when true
}
func (p *Plugin) Descriptor() plugins.Descriptor {
return plugins.Descriptor{
Name: "localstorage",
Provider: "Local Filesystem",
Version: "1.0.0",
Kind: plugins.KindBuiltin,
Category: plugins.CategoryDrivesLocal,
AuthType: plugins.AuthNone,
Capabilities: []plugins.Capability{
{ID: "list", Method: "GET", Endpoint: "/", Description: "List a directory under the base path. params: {path}"},
{ID: "stat", Method: "GET", Endpoint: "/", Description: "Stat one path under the base path. params: {path}"},
{ID: "download", Method: "GET", Endpoint: "/", Description: "Read a file (base64, ≤32 MiB). params: {path}"},
{ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a file (creates parent dirs). params: {path, contentBase64}"},
{ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a file or empty directory. params: {path}"},
{ID: "mkdir", Method: "PUT", Endpoint: "/", Description: "Create a directory. params: {path}"},
},
ConfigFields: []plugins.ConfigField{
// No field is Required: like the other drive plugins, this can be enabled
// as a master switch with an empty config; a missing base path is reported
// gracefully by the health probe rather than blocking the switch.
{Key: "basePath", Label: "Base path", Type: "text",
Help: `Absolute directory used as the storage root, e.g. /data or /var/lib/pilotvault. In Docker this should be a mounted volume so data survives redeploys, and the container user must own it. Every operation is confined within it — ".." and absolute paths cannot escape.`},
{Key: "createMissing", Label: "Create base path", Type: "select", Default: "false",
Options: []plugins.SelectOption{
{Value: "false", Label: "Require the directory to already exist"},
{Value: "true", Label: "Create it if missing (also creates upload/mkdir parents)"},
},
Help: "When on, the base path is created by the health check and parent directories are created on upload/mkdir."},
{Key: "readOnly", Label: "Access mode", Type: "select", Default: "false",
Options: []plugins.SelectOption{
{Value: "false", Label: "Read-write"},
{Value: "true", Label: "Read-only — reject upload, delete and mkdir"},
},
Help: "Read-only is a safety guard for pointing at a directory you only want to serve from."},
},
}
}
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.basePath = strings.TrimSpace(config["basePath"])
p.createMissing = strings.EqualFold(strings.TrimSpace(config["createMissing"]), "true")
p.readOnly = strings.EqualFold(strings.TrimSpace(config["readOnly"]), "true")
return nil
}
// resolve confines a caller-supplied path under the base path. The path is always
// treated as relative to the base; a leading separator or ".." segments are
// neutralized by cleaning against a virtual root, so the result can never escape.
func (p *Plugin) resolve(rel string) (string, error) {
base := strings.TrimSpace(p.basePath)
if base == "" {
return "", errors.New("no base path configured")
}
absBase, err := filepath.Abs(base)
if err != nil {
return "", err
}
// Clean against a virtual root so "..", ".", and leading separators collapse to
// a path that stays at or below "/", then strip the root and join under base.
virtual := filepath.ToSlash(strings.TrimSpace(rel))
cleaned := filepath.Clean("/" + strings.TrimLeft(virtual, "/"))
sub := filepath.FromSlash(strings.TrimPrefix(cleaned, "/"))
joined := filepath.Join(absBase, sub)
// Belt-and-braces containment check after joining.
if joined != absBase && !strings.HasPrefix(joined, absBase+string(os.PathSeparator)) {
return "", fmt.Errorf("path %q escapes the base directory", rel)
}
return joined, nil
}
// HealthCheck verifies the base path exists, is a directory, is readable, and
// (unless read-only) is writable. Missing-but-creatable resolves to OK.
func (p *Plugin) HealthCheck(_ context.Context) plugins.Health {
start := time.Now()
p.mu.Lock()
base, create, readOnly := p.basePath, p.createMissing, p.readOnly
p.mu.Unlock()
if strings.TrimSpace(base) == "" {
return plugins.Health{Status: plugins.StatusDown, Detail: "no base path configured"}
}
absBase, err := filepath.Abs(base)
if err != nil {
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: err.Error()}
}
info, err := os.Stat(absBase)
if err != nil {
if os.IsNotExist(err) && create {
if mkErr := os.MkdirAll(absBase, 0o755); mkErr != nil {
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start),
Detail: fmt.Sprintf("base path %q does not exist and could not be created: %v", absBase, mkErr)}
}
info, err = os.Stat(absBase)
}
if err != nil {
detail := fmt.Sprintf("base path %q not accessible: %v", absBase, err)
if os.IsNotExist(err) {
detail = fmt.Sprintf("base path %q does not exist (enable \"Create base path\" to create it)", absBase)
}
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: detail}
}
}
if !info.IsDir() {
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start),
Detail: fmt.Sprintf("base path %q is not a directory", absBase)}
}
entries, err := os.ReadDir(absBase)
if err != nil {
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: ms(start),
Detail: fmt.Sprintf("base path %q is not readable: %v", absBase, err)}
}
detail := fmt.Sprintf("%q reachable — %d entr%s", absBase, len(entries), plural(len(entries)))
status := plugins.StatusOK
if readOnly {
detail += " · read-only"
} else if werr := probeWritable(absBase); werr != nil {
status = plugins.StatusDegraded
detail += fmt.Sprintf(" · not writable: %v", werr)
} else {
detail += " · read-write"
}
return plugins.Health{Status: status, LatencyMs: ms(start), Detail: detail}
}
// Invoke runs one capability against the local filesystem.
func (p *Plugin) Invoke(_ context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
p.mu.Lock()
create, readOnly := p.createMissing, p.readOnly
p.mu.Unlock()
switch action {
case "list":
var in pathParams
_ = json.Unmarshal(params, &in)
target, err := p.resolve(in.Path)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(target)
if err != nil {
return nil, err
}
out := make([]fileInfo, 0, len(entries))
for _, e := range entries {
out = append(out, dirEntryToInfo(e))
}
return json.Marshal(map[string]any{"path": target, "entries": out})
case "stat":
var in pathParams
_ = json.Unmarshal(params, &in)
target, err := p.resolve(in.Path)
if err != nil {
return nil, err
}
fi, err := os.Stat(target)
if err != nil {
return nil, err
}
return json.Marshal(statToInfo(fi))
case "download":
var in pathParams
_ = json.Unmarshal(params, &in)
target, err := p.resolve(in.Path)
if err != nil {
return nil, err
}
data, err := readCapped(target)
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{
"path": target,
"size": len(data),
"contentBase64": base64.StdEncoding.EncodeToString(data),
})
case "upload":
if readOnly {
return nil, errReadOnly
}
var in writeParams
if err := json.Unmarshal(params, &in); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
target, err := p.resolve(in.Path)
if err != nil {
return nil, err
}
data, err := base64.StdEncoding.DecodeString(in.ContentBase64)
if err != nil {
return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err)
}
if create {
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return nil, err
}
}
if err := os.WriteFile(target, data, 0o644); err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": target, "size": len(data), "ok": true})
case "delete":
if readOnly {
return nil, errReadOnly
}
var in pathParams
_ = json.Unmarshal(params, &in)
target, err := p.resolve(in.Path)
if err != nil {
return nil, err
}
// Refuse to delete the base path itself.
absBase, _ := filepath.Abs(strings.TrimSpace(p.basePath))
if target == absBase {
return nil, errors.New("refusing to delete the base directory")
}
if err := os.Remove(target); err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": target, "ok": true})
case "mkdir":
if readOnly {
return nil, errReadOnly
}
var in pathParams
_ = json.Unmarshal(params, &in)
target, err := p.resolve(in.Path)
if err != nil {
return nil, err
}
if err := os.MkdirAll(target, 0o755); err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": target, "ok": true})
default:
return nil, errors.New("unknown action: " + action)
}
}
func (p *Plugin) Shutdown(context.Context) error { return nil }
var errReadOnly = errors.New("plugin is configured read-only")
// pathParams / writeParams are the Invoke request shapes (mirrors filetransfer).
type pathParams struct {
Path string `json:"path"`
}
type writeParams struct {
Path string `json:"path"`
ContentBase64 string `json:"contentBase64"`
}
// fileInfo is the normalized directory-entry shape returned by list/stat.
type fileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
ModTime string `json:"modTime,omitempty"`
}
func statToInfo(fi os.FileInfo) fileInfo {
return fileInfo{
Name: fi.Name(),
Size: fi.Size(),
IsDir: fi.IsDir(),
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
}
}
// dirEntryToInfo normalizes an os.DirEntry, tolerating a stat failure on a single
// entry (e.g. a broken symlink) by reporting name/isDir without size/modtime.
func dirEntryToInfo(e os.DirEntry) fileInfo {
fi, err := e.Info()
if err != nil {
return fileInfo{Name: e.Name(), IsDir: e.IsDir()}
}
return statToInfo(fi)
}
// readCapped reads a file up to maxReadBytes.
func readCapped(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, maxReadBytes))
}
// probeWritable confirms the directory accepts a write by creating and removing a
// short-lived temp file.
func probeWritable(dir string) error {
f, err := os.CreateTemp(dir, ".pilotvault-health-*")
if err != nil {
return err
}
name := f.Name()
_ = f.Close()
return os.Remove(name)
}
func ms(start time.Time) int64 { return time.Since(start).Milliseconds() }
func plural(n int) string {
if n == 1 {
return "y"
}
return "ies"
}
@@ -0,0 +1,139 @@
package localstorage
import (
"context"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"pilotvault/apiserver/internal/plugins"
)
func TestDescriptor(t *testing.T) {
p := &Plugin{}
d := p.Descriptor()
if d.Name != "localstorage" {
t.Fatalf("name = %q, want localstorage", d.Name)
}
if d.Kind != plugins.KindBuiltin {
t.Fatalf("kind = %q, want builtin", d.Kind)
}
if d.Category != plugins.CategoryDrivesLocal {
t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryDrivesLocal)
}
if len(d.Capabilities) == 0 {
t.Fatal("expected capabilities")
}
}
// TestRegistered confirms the plugin registered itself with the shared registry
// via init(), so the manager will surface it.
func TestRegistered(t *testing.T) {
m := plugins.NewManager(t.TempDir() + "/plugins.json")
if _, ok := m.Get("localstorage"); !ok {
t.Fatal("localstorage not registered in the plugin manager")
}
}
// TestResolveConfinement verifies that traversal, absolute-looking, and
// backslash paths all stay under the base directory.
func TestResolveConfinement(t *testing.T) {
base := t.TempDir()
p := &Plugin{basePath: base}
absBase, _ := filepath.Abs(base)
contained := []string{"a/b.txt", "/etc/passwd", "../../../etc/passwd", "a\\b", "./x", ""}
for _, in := range contained {
got, err := p.resolve(in)
if err != nil {
t.Fatalf("resolve(%q) errored: %v", in, err)
}
if got != absBase && !strings.HasPrefix(got, absBase+string(os.PathSeparator)) {
t.Errorf("resolve(%q) = %q escaped base %q", in, got, absBase)
}
}
}
func TestResolveNoBase(t *testing.T) {
p := &Plugin{}
if _, err := p.resolve("x"); err == nil {
t.Fatal("expected error when base path unset")
}
}
func TestHealthCheckMissing(t *testing.T) {
p := &Plugin{}
// Base path unset -> down.
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
t.Errorf("unset base: status = %q, want down", h.Status)
}
// Nonexistent path without createMissing -> down.
_ = p.Init(context.Background(), map[string]string{"basePath": filepath.Join(t.TempDir(), "nope")})
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
t.Errorf("missing base: status = %q, want down (detail=%q)", h.Status, h.Detail)
}
}
func TestHealthCheckCreateMissing(t *testing.T) {
dir := filepath.Join(t.TempDir(), "created")
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"basePath": dir, "createMissing": "true"})
h := p.HealthCheck(context.Background())
if h.Status != plugins.StatusOK {
t.Fatalf("status = %q, want ok (detail=%q)", h.Status, h.Detail)
}
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
t.Fatalf("base path was not created: %v", err)
}
}
// TestRoundTrip exercises upload -> list -> download -> delete end to end.
func TestRoundTrip(t *testing.T) {
base := t.TempDir()
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"basePath": base, "createMissing": "true"})
payload := []byte("hello pilotvault")
up, _ := json.Marshal(writeParams{Path: "sub/dir/file.txt", ContentBase64: base64.StdEncoding.EncodeToString(payload)})
if _, err := p.Invoke(context.Background(), "upload", up); err != nil {
t.Fatalf("upload: %v", err)
}
dl, _ := json.Marshal(pathParams{Path: "sub/dir/file.txt"})
raw, err := p.Invoke(context.Background(), "download", dl)
if err != nil {
t.Fatalf("download: %v", err)
}
var got struct {
ContentBase64 string `json:"contentBase64"`
}
_ = json.Unmarshal(raw, &got)
if decoded, _ := base64.StdEncoding.DecodeString(got.ContentBase64); string(decoded) != string(payload) {
t.Fatalf("download content = %q, want %q", decoded, payload)
}
if _, err := p.Invoke(context.Background(), "delete", dl); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := os.Stat(filepath.Join(base, "sub", "dir", "file.txt")); !os.IsNotExist(err) {
t.Fatalf("file still present after delete: %v", err)
}
}
func TestReadOnlyRejectsWrites(t *testing.T) {
base := t.TempDir()
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"basePath": base, "readOnly": "true"})
up, _ := json.Marshal(writeParams{Path: "x.txt", ContentBase64: ""})
if _, err := p.Invoke(context.Background(), "upload", up); err == nil {
t.Error("upload should be rejected in read-only mode")
}
del, _ := json.Marshal(pathParams{Path: "x.txt"})
if _, err := p.Invoke(context.Background(), "delete", del); err == nil {
t.Error("delete should be rejected in read-only mode")
}
}
@@ -0,0 +1,339 @@
// Package opensky is a built-in plugin connecting the OpenSky Network REST API
// (live ADS-B aircraft state vectors). It demonstrates a real third-party
// integration behind the plugin contract, including an OAuth2 client-credentials
// AuthProvider with an anonymous fallback.
//
// Docs: https://openskynetwork.github.io/opensky-api/rest.html
package opensky
import (
"context"
"encoding/json"
"errors"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"pilotvault/apiserver/internal/plugins"
)
const (
apiBase = "https://opensky-network.org/api"
tokenURL = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token"
// Small default bounding box (Netherlands) keeps the health probe cheap.
defaultBBox = "50.5,3.2,53.7,7.3" // lamin,lomin,lamax,lomax
)
func init() {
plugins.Register("opensky", func() plugins.Plugin { return &Plugin{} })
}
// Plugin is the OpenSky connector.
type Plugin struct {
mu sync.Mutex
clientID string
clientSecret string
bbox string
plan string
allowAnonymous bool
client *http.Client
token string
tokenExp time.Time
}
// errAnonDisabled is returned when a probe/call has no resolved credentials and
// the operator has disabled anonymous access.
var errAnonDisabled = errors.New("OpenSky credentials required — anonymous access is disabled")
func (p *Plugin) Descriptor() plugins.Descriptor {
return plugins.Descriptor{
Name: "opensky",
Provider: "OpenSky Network",
Version: "1.0.0",
Kind: plugins.KindBuiltin,
Category: plugins.CategoryAPIsExternal,
Capabilities: []plugins.Capability{
{ID: "states.all", Method: "GET", Endpoint: "/states/all",
Description: "All current aircraft state vectors, world-wide (costs 4 credits/call)."},
{ID: "states.bbox", Method: "GET", Endpoint: "/states/all?lamin&lomin&lamax&lomax",
Description: "State vectors within the configured bounding box (1–4 credits by area)."},
},
AuthType: plugins.AuthOAuth2,
ConfigFields: []plugins.ConfigField{
{Key: "plan", Label: "OpenSky plan", Type: "select",
Options: []plugins.SelectOption{
{Value: "", Label: "Not set — let organizations and users choose"},
{Value: "anonymous", Label: "Anonymous — 400 credits/day"},
{Value: "standard", Label: "Standard (registered) — 4000 credits/day"},
{Value: "contributor", Label: "Contributor — 8000 credits/day"},
},
Help: "Global account tier. Leave it unset to let each organization or user pick their own plan; set a value only to force one plan for everyone. Determines the daily credit allowance shown next to remaining credits."},
{Key: "clientId", Label: "OAuth2 client ID", Type: "text", Help: "Optional — leave blank for anonymous access (lower rate limits)."},
{Key: "clientSecret", Label: "OAuth2 client secret", Type: "password", Secret: true, Help: "Paired with the client ID for authenticated access."},
{Key: "bbox", Label: "Default bounding box", Type: "text", Default: defaultBBox, Help: "lamin,lomin,lamax,lomax — used by the health probe and states.bbox."},
{Key: "allowAnonymous", Label: "Anonymous access", Type: "select", Default: "true",
Options: []plugins.SelectOption{
{Value: "true", Label: "Enabled — allow use without credentials"},
{Value: "false", Label: "Disabled — require OAuth2 credentials"},
},
Help: "Global policy: when disabled, the plugin can only be used once OAuth2 credentials resolve from some layer (superadmin, organization, or user)."},
},
}
}
// planDailyCredits maps an OpenSky plan to its daily credit allowance.
// See https://openskynetwork.github.io/opensky-api/rest.html#api-credits
func planDailyCredits(plan string) int {
switch plan {
case "anonymous":
return 400
case "contributor":
return 8000
default: // "standard"
return 4000
}
}
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.clientID = strings.TrimSpace(config["clientId"])
p.clientSecret = config["clientSecret"]
p.bbox = strings.TrimSpace(config["bbox"])
if p.bbox == "" {
p.bbox = defaultBBox
}
p.plan = strings.TrimSpace(config["plan"])
if p.plan == "" {
p.plan = "standard" // OpenSky registered-user default
}
// Anonymous access defaults to enabled; only an explicit "false" turns it off.
p.allowAnonymous = !strings.EqualFold(strings.TrimSpace(config["allowAnonymous"]), "false")
p.client = &http.Client{Timeout: 10 * time.Second}
p.token, p.tokenExp = "", time.Time{}
return nil
}
// bearer returns a valid OAuth2 token, fetching/refreshing via client-credentials
// when configured. Returns "" (no error) when running anonymously.
func (p *Plugin) bearer(ctx context.Context) (string, error) {
p.mu.Lock()
id, secret, allowAnon := p.clientID, p.clientSecret, p.allowAnonymous
if p.token != "" && time.Now().Before(p.tokenExp) {
tok := p.token
p.mu.Unlock()
return tok, nil
}
p.mu.Unlock()
if id == "" || secret == "" {
if !allowAnon {
return "", errAnonDisabled
}
return "", nil // anonymous
}
form := url.Values{
"grant_type": {"client_credentials"},
"client_id": {id},
"client_secret": {secret},
}
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", errors.New("token endpoint returned HTTP " + resp.Status)
}
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(data, &out); err != nil || out.AccessToken == "" {
return "", errors.New("no access_token in token response")
}
p.mu.Lock()
p.token = out.AccessToken
ttl := out.ExpiresIn
if ttl <= 0 {
ttl = 1800
}
p.tokenExp = time.Now().Add(time.Duration(ttl-30) * time.Second)
p.mu.Unlock()
return out.AccessToken, nil
}
// statesURLBBox builds the /states/all request URL constrained to the configured
// bounding box. Falls back to the whole world if the bbox is malformed.
func (p *Plugin) statesURLBBox() string {
p.mu.Lock()
bbox := p.bbox
p.mu.Unlock()
parts := strings.Split(bbox, ",")
if len(parts) != 4 {
return apiBase + "/states/all"
}
q := url.Values{
"lamin": {strings.TrimSpace(parts[0])},
"lomin": {strings.TrimSpace(parts[1])},
"lamax": {strings.TrimSpace(parts[2])},
"lomax": {strings.TrimSpace(parts[3])},
}
return apiBase + "/states/all?" + q.Encode()
}
// statesURLAll returns the world-wide /states/all URL (no bounding box).
func (p *Plugin) statesURLAll() string { return apiBase + "/states/all" }
// creditCost returns the OpenSky credit cost of a /states/all call over the given
// bounding box, per https://openskynetwork.github.io/opensky-api/rest.html#api-credits:
// 1 credit ≤ 25 sq°, 2 ≤ 100, 3 ≤ 400, 4 for larger or the whole world.
func creditCost(bbox string) int {
parts := strings.Split(bbox, ",")
if len(parts) != 4 {
return 4 // no/invalid box → whole world
}
lamin, e1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
lomin, e2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
lamax, e3 := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
lomax, e4 := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64)
if e1 != nil || e2 != nil || e3 != nil || e4 != nil {
return 4
}
area := math.Abs(lamax-lamin) * math.Abs(lomax-lomin)
switch {
case area <= 25:
return 1
case area <= 100:
return 2
case area <= 400:
return 3
default:
return 4
}
}
// creditWord renders a credit count with correct pluralisation.
func creditWord(n int) string {
if n == 1 {
return "1 credit"
}
return strconv.Itoa(n) + " credits"
}
// HealthCheck performs a live states query (authenticated when configured, else
// anonymous) and classifies the outcome.
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
start := time.Now()
token, err := p.bearer(ctx)
if errors.Is(err, errAnonDisabled) {
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
Detail: err.Error()}
}
if err != nil {
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
Detail: "auth failed: " + err.Error()}
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, p.statesURLBBox(), nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := p.client.Do(req)
lat := time.Since(start).Milliseconds()
if err != nil {
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
mode := "anonymous"
if token != "" {
mode = "authenticated"
}
h := plugins.Health{LatencyMs: lat}
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
h.Status, h.Detail = plugins.StatusOK, "OpenSky reachable ("+mode+")"
case resp.StatusCode == http.StatusTooManyRequests:
h.Status, h.Detail = plugins.StatusDegraded, "rate limited (HTTP 429)"
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
h.Status, h.Detail = plugins.StatusDegraded, "auth rejected (HTTP "+resp.Status+")"
default:
h.Status, h.Detail = plugins.StatusDown, "HTTP "+resp.Status
}
// Surface live credit usage from the rate-limit header, the plan's daily
// allowance, and this probe's cost (e.g. "3996/4000 credits left today · 1 credit/probe").
// The same figures are also exposed structurally (h.Credits) so the UI can
// render a dedicated usage meter without parsing this string.
p.mu.Lock()
bbox, plan := p.bbox, p.plan
p.mu.Unlock()
cost := creditCost(bbox)
credits := &plugins.HealthCredits{Daily: planDailyCredits(plan), ProbeCost: cost, Mode: mode}
if rem := strings.TrimSpace(resp.Header.Get("X-Rate-Limit-Remaining")); rem != "" {
if n, err := strconv.Atoi(rem); err == nil {
credits.Remaining = &n
}
h.Detail += " · " + p.creditsText(rem)
}
h.Detail += " · " + creditWord(cost) + "/probe"
h.Credits = credits
return h
}
// creditsText formats the remaining-credit header against the plan's daily
// allowance. Empty when the header is absent.
func (p *Plugin) creditsText(remaining string) string {
remaining = strings.TrimSpace(remaining)
if remaining == "" {
return ""
}
p.mu.Lock()
daily := planDailyCredits(p.plan)
p.mu.Unlock()
return remaining + "/" + strconv.Itoa(daily) + " credits left today"
}
// Invoke exposes states.all / states.bbox. Part of the contract; no HTTP endpoint
// surfaces it in v1, but it keeps the connector functional for future use.
func (p *Plugin) Invoke(ctx context.Context, action string, _ json.RawMessage) (json.RawMessage, error) {
switch action {
case "states.all", "states.bbox":
token, err := p.bearer(ctx)
if err != nil {
return nil, err
}
target := p.statesURLBBox()
if action == "states.all" {
target = p.statesURLAll() // world-wide (4 credits)
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
return data, nil
default:
return nil, errors.New("unknown action: " + action)
}
}
func (p *Plugin) Shutdown(context.Context) error { return nil }
@@ -0,0 +1,551 @@
// Package webdav is a built-in plugin that connects to a WebDAV server over
// HTTP(S). It offers the same capability surface as the filetransfer plugin
// (list/stat/download/upload/delete/mkdir) but speaks WebDAV verbs — PROPFIND,
// GET, PUT, DELETE, MKCOL — directly over net/http, so it needs no third-party
// client library and cross-compiles cleanly for the Linux container.
//
// Like filetransfer, nothing connects during Init; each capability (and the
// health probe) issues its own HTTP request against the configured base URL,
// authenticating with HTTP Basic auth. This suits WebDAV, which is stateless
// per request, and keeps the plugin free of long-lived connection state.
package webdav
import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"sync"
"time"
"pilotvault/apiserver/internal/plugins"
)
const (
dialTimeout = 12 * time.Second
// maxReadBytes caps a download so a huge remote file can't exhaust memory;
// the health probe and Invoke both honour it.
maxReadBytes = 32 << 20 // 32 MiB
// propfindBody requests just the properties we normalize into fileInfo.
propfindBody = `<?xml version="1.0" encoding="utf-8"?>` +
`<d:propfind xmlns:d="DAV:"><d:prop>` +
`<d:displayname/><d:getcontentlength/><d:getlastmodified/><d:resourcetype/>` +
`</d:prop></d:propfind>`
)
func init() {
plugins.Register("webdav", func() plugins.Plugin { return &Plugin{} })
}
// Plugin is the WebDAV connector. All fields are guarded by mu because Init may
// run concurrently with a HealthCheck/Invoke from another request.
type Plugin struct {
mu sync.Mutex
baseURL string // e.g. https://cloud.example.com/remote.php/dav/files/alice/
username string
password string
basePath string // working root, resolved under the base URL's path
// insecureTLS skips HTTPS certificate verification when true.
insecureTLS bool
client *http.Client
}
func (p *Plugin) Descriptor() plugins.Descriptor {
return plugins.Descriptor{
Name: "webdav",
Provider: "WebDAV",
Version: "1.0.0",
Kind: plugins.KindBuiltin,
Category: plugins.CategoryDrivesExternal,
AuthType: plugins.AuthBasic,
Capabilities: []plugins.Capability{
{ID: "list", Method: "PROPFIND", Endpoint: "/", Description: "List a remote directory. params: {path}"},
{ID: "stat", Method: "PROPFIND", Endpoint: "/", Description: "Stat one remote path. params: {path}"},
{ID: "download", Method: "GET", Endpoint: "/", Description: "Read a remote file (base64, ≤32 MiB). params: {path}"},
{ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a remote file. params: {path, contentBase64}"},
{ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a remote file or directory. params: {path}"},
{ID: "mkdir", Method: "MKCOL", Endpoint: "/", Description: "Create a remote directory. params: {path}"},
},
ConfigFields: []plugins.ConfigField{
// No field is Required: the plugin can be enabled as a master switch with
// an empty global config, leaving each organization or user to supply
// their own connection through the cascade (mirrors filetransfer). A
// missing base URL is reported gracefully by the health probe.
{Key: "baseURL", Label: "Server URL", Type: "text",
Help: "WebDAV endpoint, e.g. https://cloud.example.com/remote.php/dav/files/alice/ — must include the scheme."},
{Key: "username", Label: "Username", Type: "text"},
{Key: "password", Label: "Password", Type: "password", Secret: true,
Help: "Password or app-specific token for HTTP Basic auth. Leave blank for an anonymous/public share."},
{Key: "basePath", Label: "Base path", Type: "text", Default: ".",
Help: "Directory under the server URL used as the working root and probed by the health check, e.g. /Documents. Relative capability paths resolve under it."},
{Key: "insecureSkipVerify", Label: "TLS verification", Type: "select", Default: "false",
Options: []plugins.SelectOption{
{Value: "false", Label: "Verify certificate (recommended)"},
{Value: "true", Label: "Skip verification — accept any certificate"},
},
Help: "Only affects HTTPS. Skip verification only for self-signed test servers."},
},
}
}
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.baseURL = strings.TrimSpace(config["baseURL"])
p.username = strings.TrimSpace(config["username"])
p.password = config["password"]
p.basePath = strings.TrimSpace(config["basePath"])
if p.basePath == "" {
p.basePath = "."
}
p.insecureTLS = strings.EqualFold(strings.TrimSpace(config["insecureSkipVerify"]), "true")
p.client = &http.Client{
// No client-level timeout: request lifetime is bounded by the caller's
// context so large downloads aren't cut off mid-stream.
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: dialTimeout}).DialContext,
TLSHandshakeTimeout: dialTimeout,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: p.insecureTLS, //nolint:gosec // opt-in for self-signed test servers
},
},
}
return nil
}
// resolve joins a caller-supplied path against the base path. A leading "/" is
// treated as relative to the server URL's own path root; an empty path becomes
// the base path itself. It never allows escaping above that root: the joined
// path is cleaned against a virtual "/" so "..", stray separators, and
// backslashes can't climb out.
func (p *Plugin) resolve(rel string) string {
rel = strings.TrimSpace(rel)
rel = strings.ReplaceAll(rel, "\\", "/")
base := p.basePath
if base == "." {
base = ""
}
var joined string
switch {
case rel == "":
joined = base
case strings.HasPrefix(rel, "/"):
joined = rel // relative to the server URL root, not the base path
case base == "":
joined = rel
default:
joined = base + "/" + rel
}
// Clean against a virtual root so nothing escapes above it.
return strings.TrimPrefix(path.Clean("/"+joined), "/")
}
// requestURL builds the absolute request URL for a resolved path. When dir is
// true a trailing slash is kept, which WebDAV servers expect for collection
// operations (PROPFIND/MKCOL). url.URL.String() percent-escapes the path, so
// callers pass unescaped segments.
func (p *Plugin) requestURL(resolved string, dir bool) (string, error) {
base, err := url.Parse(p.baseURL)
if err != nil {
return "", fmt.Errorf("invalid server URL: %w", err)
}
if base.Scheme == "" || base.Host == "" {
return "", errors.New("server URL must include scheme and host")
}
full := *base
full.Path = path.Join("/"+strings.Trim(base.Path, "/"), resolved)
full.RawPath = "" // force re-escaping from Path
if dir && !strings.HasSuffix(full.Path, "/") {
full.Path += "/"
}
return full.String(), nil
}
// do issues one authenticated WebDAV request and returns the response. The
// caller is responsible for closing the body.
func (p *Plugin) do(ctx context.Context, method, rawURL string, body io.Reader, headers map[string]string) (*http.Response, error) {
p.mu.Lock()
client, user, pass := p.client, p.username, p.password
p.mu.Unlock()
if client == nil {
return nil, errors.New("plugin not initialized")
}
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return nil, err
}
if user != "" || pass != "" {
req.SetBasicAuth(user, pass)
}
for k, v := range headers {
req.Header.Set(k, v)
}
return client.Do(req)
}
// HealthCheck issues a PROPFIND against the base path and classifies the
// outcome. A 401/403 means the server is reachable but auth failed (degraded);
// a transport error is down.
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
start := time.Now()
p.mu.Lock()
base, baseURL := p.basePath, p.baseURL
p.mu.Unlock()
if baseURL == "" {
return plugins.Health{Status: plugins.StatusDown, Detail: "no server URL configured"}
}
entries, err := p.propfind(ctx, p.resolve(""), 1)
lat := time.Since(start).Milliseconds()
if err != nil {
var he *httpError
if errors.As(err, &he) {
return plugins.Health{Status: classifyStatus(he.code), LatencyMs: lat,
Detail: fmt.Sprintf("connected but PROPFIND %q returned %d %s", base, he.code, http.StatusText(he.code))}
}
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
}
detail := fmt.Sprintf("WebDAV reachable — %d entr%s under %q", len(entries), plural(len(entries)), base)
status := plugins.StatusOK
if strings.HasPrefix(strings.ToLower(baseURL), "http://") {
status = plugins.StatusDegraded
detail += " · plaintext HTTP (no encryption)"
}
return plugins.Health{Status: status, LatencyMs: lat, Detail: detail}
}
// Invoke runs one capability against the WebDAV server.
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
switch action {
case "list":
var in pathParams
_ = json.Unmarshal(params, &in)
rp := p.resolve(in.Path)
entries, err := p.propfind(ctx, rp, 1)
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": rp, "entries": entries})
case "stat":
var in pathParams
_ = json.Unmarshal(params, &in)
rp := p.resolve(in.Path)
entries, err := p.propfind(ctx, rp, 0)
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, fmt.Errorf("not found: %s", rp)
}
return json.Marshal(entries[0])
case "download":
var in pathParams
_ = json.Unmarshal(params, &in)
rp := p.resolve(in.Path)
data, err := p.read(ctx, rp)
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{
"path": rp,
"size": len(data),
"contentBase64": base64.StdEncoding.EncodeToString(data),
})
case "upload":
var in writeParams
if err := json.Unmarshal(params, &in); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
data, err := base64.StdEncoding.DecodeString(in.ContentBase64)
if err != nil {
return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err)
}
rp := p.resolve(in.Path)
if err := p.write(ctx, rp, data); err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": rp, "size": len(data), "ok": true})
case "delete":
var in pathParams
_ = json.Unmarshal(params, &in)
rp := p.resolve(in.Path)
if err := p.remove(ctx, rp); err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": rp, "ok": true})
case "mkdir":
var in pathParams
_ = json.Unmarshal(params, &in)
rp := p.resolve(in.Path)
if err := p.mkdir(ctx, rp); err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": rp, "ok": true})
default:
return nil, errors.New("unknown action: " + action)
}
}
func (p *Plugin) Shutdown(context.Context) error { return nil }
// pathParams / writeParams are the Invoke request shapes.
type pathParams struct {
Path string `json:"path"`
}
type writeParams struct {
Path string `json:"path"`
ContentBase64 string `json:"contentBase64"`
}
// fileInfo is the normalized directory-entry shape returned by list/stat. It
// matches filetransfer's shape so callers can treat the drives uniformly.
type fileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
ModTime string `json:"modTime,omitempty"`
}
// httpError carries a non-2xx status so HealthCheck can classify it.
type httpError struct {
code int
method string
}
func (e *httpError) Error() string {
return fmt.Sprintf("%s: %d %s", e.method, e.code, http.StatusText(e.code))
}
// ---------------------------------------------------------------------------
// WebDAV operations
// ---------------------------------------------------------------------------
// propfind lists (depth 1) or stats (depth 0) a path. For depth 1 the entry
// describing the collection itself is dropped so only children are returned.
func (p *Plugin) propfind(ctx context.Context, resolved string, depth int) ([]fileInfo, error) {
u, err := p.requestURL(resolved, true)
if err != nil {
return nil, err
}
resp, err := p.do(ctx, "PROPFIND", u, strings.NewReader(propfindBody), map[string]string{
"Depth": strconv.Itoa(depth),
"Content-Type": "application/xml; charset=utf-8",
})
if err != nil {
return nil, err
}
defer drainClose(resp.Body)
// 207 Multi-Status is the success case; 200 is tolerated for lenient servers.
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return nil, &httpError{code: resp.StatusCode, method: "PROPFIND"}
}
var ms davMultistatus
if err := xml.NewDecoder(io.LimitReader(resp.Body, maxReadBytes)).Decode(&ms); err != nil {
return nil, fmt.Errorf("parse PROPFIND response: %w", err)
}
// The request path, cleaned, is used to recognise and drop the self entry.
self := strings.Trim(resolved, "/")
out := make([]fileInfo, 0, len(ms.Responses))
for _, r := range ms.Responses {
hrefPath := hrefToPath(r.Href)
if depth == 1 && strings.Trim(hrefPath, "/") == self {
continue // the collection itself
}
out = append(out, r.toFileInfo())
}
return out, nil
}
func (p *Plugin) read(ctx context.Context, resolved string) ([]byte, error) {
u, err := p.requestURL(resolved, false)
if err != nil {
return nil, err
}
resp, err := p.do(ctx, http.MethodGet, u, nil, nil)
if err != nil {
return nil, err
}
defer drainClose(resp.Body)
if resp.StatusCode/100 != 2 {
return nil, &httpError{code: resp.StatusCode, method: "GET"}
}
return io.ReadAll(io.LimitReader(resp.Body, maxReadBytes))
}
func (p *Plugin) write(ctx context.Context, resolved string, data []byte) error {
u, err := p.requestURL(resolved, false)
if err != nil {
return err
}
resp, err := p.do(ctx, http.MethodPut, u, strings.NewReader(string(data)),
map[string]string{"Content-Type": "application/octet-stream"})
if err != nil {
return err
}
defer drainClose(resp.Body)
if resp.StatusCode/100 != 2 {
return &httpError{code: resp.StatusCode, method: "PUT"}
}
return nil
}
func (p *Plugin) remove(ctx context.Context, resolved string) error {
u, err := p.requestURL(resolved, false)
if err != nil {
return err
}
resp, err := p.do(ctx, http.MethodDelete, u, nil, nil)
if err != nil {
return err
}
defer drainClose(resp.Body)
// 404 is tolerated as already-gone.
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
return &httpError{code: resp.StatusCode, method: "DELETE"}
}
return nil
}
func (p *Plugin) mkdir(ctx context.Context, resolved string) error {
u, err := p.requestURL(resolved, true)
if err != nil {
return err
}
resp, err := p.do(ctx, "MKCOL", u, nil, nil)
if err != nil {
return err
}
defer drainClose(resp.Body)
// 405 Method Not Allowed is what most servers return when the collection
// already exists — treat it as success (idempotent mkdir).
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusMethodNotAllowed {
return &httpError{code: resp.StatusCode, method: "MKCOL"}
}
return nil
}
// ---------------------------------------------------------------------------
// PROPFIND XML shapes and helpers
// ---------------------------------------------------------------------------
type davMultistatus struct {
XMLName xml.Name `xml:"DAV: multistatus"`
Responses []davResponse `xml:"DAV: response"`
}
type davResponse struct {
Href string `xml:"DAV: href"`
Propstats []davPropstat `xml:"DAV: propstat"`
}
type davPropstat struct {
Status string `xml:"DAV: status"`
Prop davProp `xml:"DAV: prop"`
}
type davProp struct {
DisplayName string `xml:"DAV: displayname"`
ContentLen string `xml:"DAV: getcontentlength"`
LastModified string `xml:"DAV: getlastmodified"`
ResourceType davResourceType `xml:"DAV: resourcetype"`
}
type davResourceType struct {
Collection *xml.Name `xml:"DAV: collection"`
}
// toFileInfo normalizes a PROPFIND <response>, preferring the 2xx propstat.
func (r davResponse) toFileInfo() fileInfo {
fi := fileInfo{Name: nameFromHref(r.Href)}
for _, ps := range r.Propstats {
if !strings.Contains(ps.Status, " 2") { // "HTTP/1.1 200 OK"
continue
}
if ps.Prop.ResourceType.Collection != nil {
fi.IsDir = true
}
if n, err := strconv.ParseInt(strings.TrimSpace(ps.Prop.ContentLen), 10, 64); err == nil {
fi.Size = n
}
if lm := strings.TrimSpace(ps.Prop.LastModified); lm != "" {
if t, err := http.ParseTime(lm); err == nil {
fi.ModTime = t.UTC().Format(time.RFC3339)
}
}
if fi.Name == "" && strings.TrimSpace(ps.Prop.DisplayName) != "" {
fi.Name = ps.Prop.DisplayName
}
}
return fi
}
// hrefToPath extracts the URL path from an href, which may be absolute
// (http://host/a/b) or path-only (/a/b), and percent-decodes it.
func hrefToPath(href string) string {
if u, err := url.Parse(href); err == nil && u.Path != "" {
return u.Path
}
if dec, err := url.PathUnescape(href); err == nil {
return dec
}
return href
}
// nameFromHref returns the last path segment of an href, percent-decoded.
func nameFromHref(href string) string {
p := strings.TrimRight(hrefToPath(href), "/")
if i := strings.LastIndex(p, "/"); i >= 0 {
p = p[i+1:]
}
return p
}
// classifyStatus maps an HTTP status to a health status: an auth rejection means
// the server is reachable but credentials are wrong (degraded); anything else is
// down.
func classifyStatus(code int) string {
switch code {
case http.StatusUnauthorized, http.StatusForbidden:
return plugins.StatusDegraded
default:
return plugins.StatusDown
}
}
// drainClose drains and closes a response body so the connection can be reused.
func drainClose(body io.ReadCloser) {
_, _ = io.Copy(io.Discard, io.LimitReader(body, 4<<10))
_ = body.Close()
}
func plural(n int) string {
if n == 1 {
return "y"
}
return "ies"
}
@@ -0,0 +1,300 @@
package webdav
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"pilotvault/apiserver/internal/plugins"
)
func TestDescriptor(t *testing.T) {
p := &Plugin{}
d := p.Descriptor()
if d.Name != "webdav" {
t.Fatalf("name = %q, want webdav", d.Name)
}
if d.Kind != plugins.KindBuiltin {
t.Fatalf("kind = %q, want builtin", d.Kind)
}
if d.Category != plugins.CategoryDrivesExternal {
t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryDrivesExternal)
}
if len(d.Capabilities) == 0 {
t.Fatal("expected capabilities")
}
// The password field must be flagged so the manager masks it, and no field
// may be Required (so the plugin can be enabled as an empty master switch).
for _, f := range d.ConfigFields {
if f.Key == "password" && !f.Secret {
t.Errorf("config field %q must be Secret", f.Key)
}
if f.Required {
t.Errorf("config field %q must not be Required", f.Key)
}
}
}
func TestInitDefaults(t *testing.T) {
p := &Plugin{}
if err := p.Init(context.Background(), map[string]string{"baseURL": "https://h/dav"}); err != nil {
t.Fatal(err)
}
if p.basePath != "." {
t.Errorf("default basePath = %q, want .", p.basePath)
}
if p.client == nil {
t.Error("Init must build an http client")
}
}
func TestResolveConfinement(t *testing.T) {
p := &Plugin{basePath: "Documents"}
cases := map[string]string{
"": "Documents",
"a/b.txt": "Documents/a/b.txt",
"/etc/abs": "etc/abs", // leading slash → relative to dav root, not base
"../../escape": "escape", // cannot climb above the root
"a/../../escape": "escape", // nor via traversal
"a\\b": "Documents/a/b", // backslashes normalized
}
for in, want := range cases {
if got := p.resolve(in); got != want {
t.Errorf("resolve(%q) = %q, want %q", in, got, want)
}
}
}
func TestRequestURL(t *testing.T) {
p := &Plugin{baseURL: "https://cloud.example.com/remote.php/dav/files/alice/"}
got, err := p.requestURL("Documents/report 1.txt", false)
if err != nil {
t.Fatal(err)
}
want := "https://cloud.example.com/remote.php/dav/files/alice/Documents/report%201.txt"
if got != want {
t.Errorf("requestURL = %q, want %q", got, want)
}
// A directory op keeps the trailing slash servers expect for collections.
dir, _ := p.requestURL("Documents", true)
if !strings.HasSuffix(dir, "/") {
t.Errorf("dir URL %q should end with /", dir)
}
}
func TestRequestURLRejectsBadBase(t *testing.T) {
p := &Plugin{baseURL: "not-a-url"}
if _, err := p.requestURL("x", false); err == nil {
t.Fatal("expected error for base URL without scheme/host")
}
}
// TestHealthCheckNoURL confirms an empty base URL is reported as down, not a panic.
func TestHealthCheckNoURL(t *testing.T) {
p := &Plugin{}
if err := p.Init(context.Background(), nil); err != nil {
t.Fatal(err)
}
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail)
}
}
func TestNameFromHref(t *testing.T) {
cases := map[string]string{
"/dav/files/alice/report%201.txt": "report 1.txt",
"http://host/dav/Photos/": "Photos",
"/dav/": "dav",
}
for in, want := range cases {
if got := nameFromHref(in); got != want {
t.Errorf("nameFromHref(%q) = %q, want %q", in, got, want)
}
}
}
func TestRegistered(t *testing.T) {
m := plugins.NewManager(t.TempDir() + "/plugins.json")
if _, ok := m.Get("webdav"); !ok {
t.Fatal("webdav not registered in the plugin manager")
}
}
// fakeDAV is a minimal in-memory WebDAV server exercising the verbs the plugin
// uses. It is not spec-complete — just enough to drive the round-trip test.
type fakeDAV struct {
files map[string][]byte // path (no leading slash) → contents; dirs end in "/"
}
func newFakeDAV() *fakeDAV {
return &fakeDAV{files: map[string][]byte{
"": nil, // root collection
"docs/": nil, // a subdirectory
"hello.txt": []byte("hi"), // a file
}}
}
func (f *fakeDAV) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if u, _, ok := r.BasicAuth(); !ok || u != "alice" {
w.WriteHeader(http.StatusUnauthorized)
return
}
key := strings.Trim(r.URL.Path, "/")
switch r.Method {
case "PROPFIND":
f.propfind(w, r, key)
case http.MethodGet:
if data, ok := f.files[key]; ok && data != nil {
_, _ = w.Write(data)
return
}
w.WriteHeader(http.StatusNotFound)
case http.MethodPut:
body := make([]byte, r.ContentLength)
_, _ = r.Body.Read(body)
f.files[key] = body
w.WriteHeader(http.StatusCreated)
case http.MethodDelete:
delete(f.files, key)
w.WriteHeader(http.StatusNoContent)
case "MKCOL":
f.files[key+"/"] = nil
w.WriteHeader(http.StatusCreated)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (f *fakeDAV) propfind(w http.ResponseWriter, r *http.Request, key string) {
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.WriteHeader(http.StatusMultiStatus)
var b strings.Builder
b.WriteString(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">`)
writeResp := func(href string, isDir bool, size int) {
rt := ""
if isDir {
rt = "<d:collection/>"
}
fmt.Fprintf(&b, `<d:response><d:href>%s</d:href><d:propstat>`+
`<d:prop><d:getcontentlength>%d</d:getcontentlength>`+
`<d:getlastmodified>Wed, 08 Jul 2026 10:00:00 GMT</d:getlastmodified>`+
`<d:resourcetype>%s</d:resourcetype></d:prop>`+
`<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`,
href, size, rt)
}
// Self entry first.
writeResp("/"+key, true, 0)
if r.Header.Get("Depth") == "1" && key == "" {
writeResp("/docs/", true, 0)
writeResp("/hello.txt", false, 2)
}
b.WriteString(`</d:multistatus>`)
_, _ = w.Write([]byte(b.String()))
}
// TestRoundTrip drives list/stat/download/upload/delete/mkdir against the fake
// server and checks the plugin's normalized responses.
func TestRoundTrip(t *testing.T) {
srv := httptest.NewServer(newFakeDAV())
defer srv.Close()
p := &Plugin{}
if err := p.Init(context.Background(), map[string]string{
"baseURL": srv.URL, "username": "alice", "password": "pw",
}); err != nil {
t.Fatal(err)
}
ctx := context.Background()
// list: the self entry is dropped, leaving docs/ and hello.txt.
raw, err := p.Invoke(ctx, "list", json.RawMessage(`{"path":""}`))
if err != nil {
t.Fatalf("list: %v", err)
}
var listed struct {
Entries []fileInfo `json:"entries"`
}
mustJSON(t, raw, &listed)
if len(listed.Entries) != 2 {
t.Fatalf("list returned %d entries, want 2: %+v", len(listed.Entries), listed.Entries)
}
var sawDir, sawFile bool
for _, e := range listed.Entries {
if e.Name == "docs" && e.IsDir {
sawDir = true
}
if e.Name == "hello.txt" && !e.IsDir && e.Size == 2 {
sawFile = true
}
}
if !sawDir || !sawFile {
t.Errorf("unexpected entries: %+v", listed.Entries)
}
// download
raw, err = p.Invoke(ctx, "download", json.RawMessage(`{"path":"hello.txt"}`))
if err != nil {
t.Fatalf("download: %v", err)
}
var dl struct {
ContentBase64 string `json:"contentBase64"`
}
mustJSON(t, raw, &dl)
if got, _ := base64.StdEncoding.DecodeString(dl.ContentBase64); string(got) != "hi" {
t.Errorf("download content = %q, want hi", got)
}
// upload → then download it back
body := base64.StdEncoding.EncodeToString([]byte("new-file"))
if _, err := p.Invoke(ctx, "upload", json.RawMessage(fmt.Sprintf(`{"path":"new.txt","contentBase64":%q}`, body))); err != nil {
t.Fatalf("upload: %v", err)
}
raw, err = p.Invoke(ctx, "download", json.RawMessage(`{"path":"new.txt"}`))
if err != nil {
t.Fatalf("download after upload: %v", err)
}
mustJSON(t, raw, &dl)
if got, _ := base64.StdEncoding.DecodeString(dl.ContentBase64); string(got) != "new-file" {
t.Errorf("round-tripped content = %q, want new-file", got)
}
// mkdir and delete should succeed without error
if _, err := p.Invoke(ctx, "mkdir", json.RawMessage(`{"path":"newdir"}`)); err != nil {
t.Fatalf("mkdir: %v", err)
}
if _, err := p.Invoke(ctx, "delete", json.RawMessage(`{"path":"new.txt"}`)); err != nil {
t.Fatalf("delete: %v", err)
}
// health check is OK against a live (http) server, but degraded because it's plaintext
if h := p.HealthCheck(ctx); h.Status != plugins.StatusDegraded {
t.Errorf("health status = %q, want degraded (plaintext http); detail=%q", h.Status, h.Detail)
}
}
// TestHealthCheckAuthFailure confirms a 401 is classified as degraded, not down.
func TestHealthCheckAuthFailure(t *testing.T) {
srv := httptest.NewServer(newFakeDAV())
defer srv.Close()
p := &Plugin{}
if err := p.Init(context.Background(), map[string]string{
"baseURL": srv.URL, "username": "wrong", "password": "pw",
}); err != nil {
t.Fatal(err)
}
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDegraded {
t.Errorf("status = %q, want degraded on 401 (detail=%q)", h.Status, h.Detail)
}
}
func mustJSON(t *testing.T, raw json.RawMessage, v any) {
t.Helper()
if err := json.Unmarshal(raw, v); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
}