cli/filesystem/webdav/webdav.go

183 lines
5.2 KiB
Go

package webdav
import (
"bytes"
_ "context"
"fmt"
"io"
"net/http"
"path"
"strings"
)
// New creates a new, connected instance of the WebDAV storage medium.
func New(cfg ConnectionConfig) (*Medium, error) {
transport := &authTransport{
Username: cfg.User,
Password: cfg.Password,
Wrapped: http.DefaultTransport,
}
httpClient := &http.Client{Transport: transport}
// Ping the server to ensure the connection and credentials are valid.
// We do a PROPFIND on the root, which is a standard WebDAV operation.
req, err := http.NewRequest("PROPFIND", cfg.URL, nil)
if err != nil {
return nil, fmt.Errorf("webdav: failed to create ping request: %w", err)
}
req.Header.Set("Depth", "0")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("webdav: connection test failed: %w", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("webdav: connection test failed with status %s", resp.Status)
}
return &Medium{
client: httpClient,
baseURL: cfg.URL,
}, nil
}
// Read retrieves the content of a file from the WebDAV server.
func (m *Medium) Read(p string) (string, error) {
url := m.resolveURL(p)
resp, err := m.client.Get(url)
if err != nil {
return "", fmt.Errorf("webdav: GET request for %s failed: %w", p, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("webdav: failed to read %s, status: %s", p, resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("webdav: failed to read response body for %s: %w", p, err)
}
return string(data), nil
}
// Write saves the given content to a file on the WebDAV server.
func (m *Medium) Write(p, content string) error {
// Ensure the parent directory exists first.
dir := path.Dir(p)
if dir != "." && dir != "/" {
if err := m.EnsureDir(dir); err != nil {
return err // This will be a detailed error from EnsureDir
}
}
url := m.resolveURL(p)
req, err := http.NewRequest("PUT", url, bytes.NewReader([]byte(content)))
if err != nil {
return fmt.Errorf("webdav: failed to create PUT request: %w", err)
}
resp, err := m.client.Do(req)
if err != nil {
return fmt.Errorf("webdav: PUT request for %s failed: %w", p, err)
}
defer resp.Body.Close()
// StatusCreated (201) or StatusNoContent (204) are success codes for PUT.
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("webdav: failed to write %s, status: %s", p, resp.Status)
}
return nil
}
// EnsureDir makes sure a directory exists on the WebDAV server, creating parent dirs as needed.
func (m *Medium) EnsureDir(p string) error {
// To mimic MkdirAll, we create each part of the path sequentially.
parts := strings.Split(p, "/")
currentPath := ""
for _, part := range parts {
if part == "" {
continue
}
currentPath = path.Join(currentPath, part)
url := m.resolveURL(currentPath) + "/" // MKCOL needs a trailing slash
req, err := http.NewRequest("MKCOL", url, nil)
if err != nil {
return fmt.Errorf("webdav: failed to create MKCOL request for %s: %w", currentPath, err)
}
resp, err := m.client.Do(req)
if err != nil {
return fmt.Errorf("webdav: MKCOL request for %s failed: %w", currentPath, err)
}
resp.Body.Close()
// 405 Method Not Allowed means it already exists, which is fine for us.
// 201 Created is a success.
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusMethodNotAllowed {
return fmt.Errorf("webdav: failed to create directory %s, status: %s", currentPath, resp.Status)
}
}
return nil
}
// IsFile checks if a path exists and is a regular file on the WebDAV server.
func (m *Medium) IsFile(p string) bool {
url := m.resolveURL(p)
req, err := http.NewRequest("PROPFIND", url, nil)
if err != nil {
return false
}
req.Header.Set("Depth", "0")
resp, err := m.client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
// If we get anything other than a Multi-Status, it's probably not a file.
if resp.StatusCode != http.StatusMultiStatus {
return false
}
// A simple check: if the response body contains the string for a collection, it's a directory.
// A more robust implementation would parse the XML response.
body, err := io.ReadAll(resp.Body)
if err != nil {
return false
}
return !strings.Contains(string(body), "<D:collection/>")
}
// resolveURL joins the base URL with a path segment, ensuring correct slashes.
func (m *Medium) resolveURL(p string) string {
return strings.TrimSuffix(m.baseURL, "/") + "/" + strings.TrimPrefix(p, "/")
}
// authTransport is a custom http.RoundTripper to inject Basic Auth.
type authTransport struct {
Username string
Password string
Wrapped http.RoundTripper
}
func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.SetBasicAuth(t.Username, t.Password)
return t.Wrapped.RoundTrip(req)
}
// FileGet is a convenience function that reads a file from the medium.
func (m *Medium) FileGet(path string) (string, error) {
return m.Read(path)
}
// FileSet is a convenience function that writes a file to the medium.
func (m *Medium) FileSet(path, content string) error {
return m.Write(path, content)
}