// 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 = `` + `` + `` + `` ) 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 , 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" }