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>
552 lines
18 KiB
Go
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"
|
|
}
|