Files
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

552 lines
18 KiB
Go

// 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"
}