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:
commit
afc6952eda
172 files changed
+24591
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user