1321 lines
39 KiB
Go
1321 lines
39 KiB
Go
//go:build linux || freebsd
|
|
// +build linux freebsd
|
|
|
|
// Package webdav implements WebDAV (Web Distributed Authoring and Versioning) downloads and uploads.
|
|
package webdav
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"codeberg.org/petrbalvin/goget/internal/core"
|
|
"codeberg.org/petrbalvin/goget/internal/output"
|
|
"codeberg.org/petrbalvin/goget/internal/protocol"
|
|
"codeberg.org/petrbalvin/goget/internal/transport"
|
|
)
|
|
|
|
// Multistatus represents a WebDAV PROPFIND response.
|
|
type Multistatus struct {
|
|
XMLName xml.Name `xml:"multistatus"`
|
|
Responses []Response `xml:"response"`
|
|
}
|
|
|
|
// Response represents a single WebDAV resource response.
|
|
type Response struct {
|
|
Href string `xml:"href"`
|
|
Propstats []Propstat `xml:"propstat"`
|
|
}
|
|
|
|
// Propstat represents properties status.
|
|
type Propstat struct {
|
|
Prop Prop `xml:"prop"`
|
|
Status string `xml:"status"`
|
|
}
|
|
|
|
// Prop represents the WebDAV resource properties.
|
|
type Prop struct {
|
|
ResourceType ResourceType `xml:"resourcetype"`
|
|
ContentLength string `xml:"getcontentlength"`
|
|
GetLastModified string `xml:"getlastmodified"`
|
|
}
|
|
|
|
// ResourceType represents the resource type.
|
|
type ResourceType struct {
|
|
Collection *struct{} `xml:"collection"`
|
|
}
|
|
|
|
// isCollection checks if the resource is a directory/collection.
|
|
func (r *Response) isCollection() bool {
|
|
for _, ps := range r.Propstats {
|
|
if strings.Contains(ps.Status, "200") {
|
|
if ps.Prop.ResourceType.Collection != nil {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getContentLength extracts the content length of the resource.
|
|
func (r *Response) getContentLength() int64 {
|
|
for _, ps := range r.Propstats {
|
|
if strings.Contains(ps.Status, "200") && ps.Prop.ContentLength != "" {
|
|
if s, err := strconv.ParseInt(ps.Prop.ContentLength, 10, 64); err == nil {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// getContentType extracts the content type of the resource.
|
|
func (r *Response) getContentType() string {
|
|
for _, ps := range r.Propstats {
|
|
if strings.Contains(ps.Status, "200") && ps.Prop.ResourceType.Collection == nil {
|
|
return "" // Could extract getcontenttype if needed — depends on WebDAV server response
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// getLastModified extracts the last modified date of the resource.
|
|
func (r *Response) getLastModified() string {
|
|
for _, ps := range r.Propstats {
|
|
if strings.Contains(ps.Status, "200") && ps.Prop.GetLastModified != "" {
|
|
return ps.Prop.GetLastModified
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// WebDAVEntry represents a parsed directory listing entry.
|
|
type WebDAVEntry struct {
|
|
URL *url.URL
|
|
IsDir bool
|
|
Size int64
|
|
LastModified string
|
|
}
|
|
|
|
// Protocol implements WebDAV downloads and uploads.
|
|
type Protocol struct {
|
|
protocol.BaseProtocol
|
|
tlsConfig *transport.TLSConfig
|
|
insecureSkip bool
|
|
proxyURL string
|
|
|
|
// httpTransport is a process-wide shared transport for all WebDAV
|
|
// operations. Built lazily on first use; see getHTTPTransport. Sharing
|
|
// the transport enables HTTP connection pooling (keep-alive, TLS
|
|
// session resumption, HTTP/2 multiplexing) across the PROPFIND/GET/PUT
|
|
// calls of a recursive download.
|
|
httpTransport *http.Transport
|
|
transportMu sync.Mutex
|
|
}
|
|
|
|
// NewProtocol creates a new WebDAV protocol handler.
|
|
func NewProtocol() *Protocol {
|
|
return &Protocol{
|
|
BaseProtocol: *protocol.NewBaseProtocol(protocol.ProtocolInfo{
|
|
Name: "webdav",
|
|
Scheme: "webdav",
|
|
DefaultPort: 443,
|
|
Operations: []string{"download", "upload"},
|
|
Features: []string{"download", "upload", "resume", "recursive"},
|
|
}),
|
|
}
|
|
}
|
|
|
|
// ConfigureTLS configures TLS options for WebDAV connections.
|
|
func (p *Protocol) ConfigureTLS(tlsCfg *transport.TLSConfig, insecure bool) {
|
|
p.tlsConfig = tlsCfg
|
|
p.insecureSkip = insecure
|
|
}
|
|
|
|
// ConfigureProxy configures proxy for WebDAV HTTP connections.
|
|
func (p *Protocol) ConfigureProxy(proxyURL string) {
|
|
p.proxyURL = proxyURL
|
|
}
|
|
|
|
// ShowInfo displays WebDAV resource metadata (PROPPATCH info).
|
|
func (p *Protocol) ShowInfo(ctx context.Context, u *url.URL) error {
|
|
httpURL := toHTTPURL(u)
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "PROPFIND", httpURL.String(), nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create PROPFIND request: %w", err)
|
|
}
|
|
|
|
httpReq.Header.Set("Depth", "0")
|
|
httpReq.Header.Set("Content-Type", "text/xml; charset=utf-8")
|
|
httpReq.Header.Set("User-Agent", "Goget/"+core.Version)
|
|
|
|
if u.User != nil {
|
|
username := u.User.Username()
|
|
password, _ := u.User.Password()
|
|
httpReq.SetBasicAuth(username, password)
|
|
}
|
|
|
|
client := p.getHTTPClient(15 * time.Second)
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
return fmt.Errorf("PROPFIND request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusMultiStatus {
|
|
return fmt.Errorf("server returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
var multistatus Multistatus
|
|
if err := xml.Unmarshal(body, &multistatus); err != nil {
|
|
return fmt.Errorf("failed to parse response: %w", err)
|
|
}
|
|
|
|
if len(multistatus.Responses) == 0 {
|
|
return fmt.Errorf("no resources found")
|
|
}
|
|
|
|
r := multistatus.Responses[0]
|
|
fmt.Printf("URL: %s\n", u.String())
|
|
fmt.Printf("HREF: %s\n", r.Href)
|
|
fmt.Printf("Is Directory: %v\n", r.isCollection())
|
|
fmt.Printf("Size: %d\n", r.getContentLength())
|
|
fmt.Printf("LastModified: %s\n", r.getLastModified())
|
|
|
|
return nil
|
|
}
|
|
|
|
// Scheme returns the primary scheme of the protocol.
|
|
func (p *Protocol) Scheme() string {
|
|
return "webdav"
|
|
}
|
|
|
|
// CanHandle checks if the protocol can handle the given URL.
|
|
func (p *Protocol) CanHandle(u *url.URL) bool {
|
|
if u == nil {
|
|
return false
|
|
}
|
|
scheme := strings.ToLower(u.Scheme)
|
|
return scheme == "webdav" || scheme == "webdavs"
|
|
}
|
|
|
|
// toHTTPURL rewrites webdav(s) schemes to http(s).
|
|
func toHTTPURL(u *url.URL) *url.URL {
|
|
newURL := *u
|
|
scheme := strings.ToLower(newURL.Scheme)
|
|
if scheme == "webdav" {
|
|
newURL.Scheme = "http"
|
|
} else if scheme == "webdavs" {
|
|
newURL.Scheme = "https"
|
|
}
|
|
return &newURL
|
|
}
|
|
|
|
// getHTTPTransport returns the protocol's shared http.Transport, building
|
|
// it on first use. The transport is reused across every WebDAV request
|
|
// (PROPFIND/GET/PUT/MKCOL/PROPPATCH) so that idle TCP connections, TLS
|
|
// sessions, and HTTP/2 streams are pooled — turning a 50-file recursive
|
|
// download from 50 fresh TCP+TLS handshakes into a single handshake plus
|
|
// 49 reuse-or-keepalive calls.
|
|
//
|
|
// The lazy init is guarded by transportMu so that concurrent callers
|
|
// (the recursive download's worker pool calls Download in parallel) only
|
|
// build the transport once.
|
|
func (p *Protocol) getHTTPTransport() *http.Transport {
|
|
p.transportMu.Lock()
|
|
defer p.transportMu.Unlock()
|
|
|
|
if p.httpTransport != nil {
|
|
return p.httpTransport
|
|
}
|
|
|
|
tr := &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: p.insecureSkip}, //nolint:gosec
|
|
DisableCompression: true,
|
|
Proxy: http.ProxyFromEnvironment,
|
|
MaxIdleConns: 100,
|
|
MaxIdleConnsPerHost: 10,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
}
|
|
|
|
// Apply custom proxy if configured. SOCKS5 must be implemented as a
|
|
// custom DialContext — Go's stdlib http.ProxyURL only supports HTTP
|
|
// CONNECT proxies.
|
|
if p.proxyURL != "" {
|
|
proxyCfg, err := transport.ParseProxyConfig(p.proxyURL)
|
|
if err == nil {
|
|
proxyTransport, err := transport.NewProxyTransport(proxyCfg, tr)
|
|
if err == nil {
|
|
tr = proxyTransport
|
|
}
|
|
}
|
|
}
|
|
|
|
p.httpTransport = tr
|
|
return p.httpTransport
|
|
}
|
|
|
|
// getHTTPClient returns an http.Client that wraps the protocol's pooled
|
|
// http.Transport. A fresh wrapper is cheap and lets each caller specify
|
|
// its own Timeout — Timeout lives on http.Client, while connection
|
|
// reuse lives on the shared http.Transport.
|
|
//
|
|
// A zero or negative timeout is upgraded to a 30-minute default so that
|
|
// downloadFile and doUpload, which only set req.Timeout, still get a
|
|
// sensible upper bound on long transfers.
|
|
func (p *Protocol) getHTTPClient(timeout time.Duration) *http.Client {
|
|
if timeout <= 0 {
|
|
timeout = 30 * time.Minute
|
|
}
|
|
return &http.Client{
|
|
Transport: p.getHTTPTransport(),
|
|
Timeout: timeout,
|
|
}
|
|
}
|
|
|
|
// isCollection performs a Depth: 0 PROPFIND to check if the target is a collection.
|
|
func (p *Protocol) isCollection(ctx context.Context, u *url.URL, req *core.DownloadRequest) (bool, int64, error) {
|
|
httpURL := toHTTPURL(u)
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "PROPFIND", httpURL.String(), nil)
|
|
if err != nil {
|
|
return false, -1, err
|
|
}
|
|
|
|
httpReq.Header.Set("Depth", "0")
|
|
httpReq.Header.Set("Content-Type", "text/xml; charset=utf-8")
|
|
|
|
// Apply request headers.
|
|
for k, v := range req.Headers {
|
|
httpReq.Header.Set(k, v)
|
|
}
|
|
|
|
userAgent := "Goget/" + core.Version
|
|
if req.Headers != nil && req.Headers["User-Agent"] != "" {
|
|
userAgent = req.Headers["User-Agent"]
|
|
}
|
|
httpReq.Header.Set("User-Agent", userAgent)
|
|
|
|
if u.User != nil {
|
|
username := u.User.Username()
|
|
password, _ := u.User.Password()
|
|
httpReq.SetBasicAuth(username, password)
|
|
}
|
|
|
|
client := p.getHTTPClient(15 * time.Second)
|
|
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
return false, -1, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
switch resp.StatusCode {
|
|
case http.StatusOK, http.StatusMultiStatus:
|
|
// Continue to parse response body.
|
|
case http.StatusLocked:
|
|
return false, -1, &core.GogetError{
|
|
Type: core.ErrProtocol,
|
|
Message: "webdav resource is locked — try again after unlock",
|
|
URL: u.String(),
|
|
}
|
|
case http.StatusUnauthorized:
|
|
return false, -1, &core.GogetError{
|
|
Type: core.ErrAuth,
|
|
Message: "webdav server requires authentication",
|
|
URL: u.String(),
|
|
}
|
|
case http.StatusForbidden:
|
|
return false, -1, &core.GogetError{
|
|
Type: core.ErrProtocol,
|
|
Message: "webdav server forbidden access",
|
|
URL: u.String(),
|
|
}
|
|
default:
|
|
// Fallback to assuming it's a file (non-collection).
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] PROPFIND returned status %d, assuming non-collection\n", resp.StatusCode)
|
|
}
|
|
return false, -1, nil
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return false, -1, err
|
|
}
|
|
|
|
var multistatus Multistatus
|
|
if err := xml.Unmarshal(body, &multistatus); err != nil {
|
|
return false, -1, nil
|
|
}
|
|
|
|
if len(multistatus.Responses) > 0 {
|
|
first := multistatus.Responses[0]
|
|
return first.isCollection(), first.getContentLength(), nil
|
|
}
|
|
|
|
return false, -1, nil
|
|
}
|
|
|
|
// listDirectory performs a PROPFIND to list directory contents.
|
|
// depth can be "0", "1", or "infinity".
|
|
func (p *Protocol) listDirectory(ctx context.Context, u *url.URL, req *core.DownloadRequest, depth string) ([]WebDAVEntry, error) {
|
|
httpURL := toHTTPURL(u)
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "PROPFIND", httpURL.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
httpReq.Header.Set("Depth", depth)
|
|
httpReq.Header.Set("Content-Type", "text/xml; charset=utf-8")
|
|
|
|
for k, v := range req.Headers {
|
|
httpReq.Header.Set(k, v)
|
|
}
|
|
|
|
userAgent := "Goget/" + core.Version
|
|
if req.Headers != nil && req.Headers["User-Agent"] != "" {
|
|
userAgent = req.Headers["User-Agent"]
|
|
}
|
|
httpReq.Header.Set("User-Agent", userAgent)
|
|
|
|
if u.User != nil {
|
|
username := u.User.Username()
|
|
password, _ := u.User.Password()
|
|
httpReq.SetBasicAuth(username, password)
|
|
}
|
|
|
|
client := p.getHTTPClient(15 * time.Second)
|
|
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusMultiStatus {
|
|
return nil, fmt.Errorf("webdav server returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var multistatus Multistatus
|
|
if err := xml.Unmarshal(body, &multistatus); err != nil {
|
|
return nil, fmt.Errorf("failed to parse webdav xml: %w", err)
|
|
}
|
|
|
|
var entries []WebDAVEntry
|
|
dirPath := strings.TrimSuffix(u.Path, "/")
|
|
userAuth := u.User
|
|
|
|
for _, r := range multistatus.Responses {
|
|
resolvedHref := r.Href
|
|
|
|
// Resolve href to an absolute URL.
|
|
var itemURL *url.URL
|
|
if strings.HasPrefix(resolvedHref, "http://") || strings.HasPrefix(resolvedHref, "https://") {
|
|
parsed, err := url.Parse(resolvedHref)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
parsed.Scheme = u.Scheme // Use caller's scheme.
|
|
itemURL = parsed
|
|
} else {
|
|
basePathURL := &url.URL{
|
|
Scheme: u.Scheme,
|
|
Host: u.Host,
|
|
User: userAuth,
|
|
}
|
|
parsed, err := basePathURL.Parse(resolvedHref)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
itemURL = parsed
|
|
}
|
|
|
|
itemPath := strings.TrimSuffix(itemURL.Path, "/")
|
|
if itemPath == dirPath {
|
|
continue
|
|
}
|
|
|
|
name := filepath.Base(itemURL.Path)
|
|
|
|
// Apply accept patterns (glob-style).
|
|
if len(req.AcceptPatterns) > 0 {
|
|
matched := false
|
|
for _, pat := range req.AcceptPatterns {
|
|
if matched, _ = filepath.Match(pat, name); matched {
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Apply reject patterns (glob-style).
|
|
if len(req.RejectPatterns) > 0 {
|
|
matched := false
|
|
for _, pat := range req.RejectPatterns {
|
|
if matched, _ = filepath.Match(pat, name); matched {
|
|
break
|
|
}
|
|
}
|
|
if matched {
|
|
continue
|
|
}
|
|
}
|
|
|
|
entries = append(entries, WebDAVEntry{
|
|
URL: itemURL,
|
|
IsDir: r.isCollection(),
|
|
Size: r.getContentLength(),
|
|
LastModified: r.getLastModified(),
|
|
})
|
|
}
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
// filterEntries applies accept/reject filters and returns only matching entries.
|
|
func filterEntries(entries []WebDAVEntry, req *core.DownloadRequest) []WebDAVEntry {
|
|
var result []WebDAVEntry
|
|
for _, entry := range entries {
|
|
name := filepath.Base(entry.URL.Path)
|
|
|
|
// Accept pattern: entry must match at least one pattern.
|
|
if len(req.AcceptPatterns) > 0 {
|
|
matched := false
|
|
for _, pat := range req.AcceptPatterns {
|
|
if m, _ := filepath.Match(pat, name); m {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Reject pattern: entry must not match any reject pattern.
|
|
if len(req.RejectPatterns) > 0 {
|
|
matched := false
|
|
for _, pat := range req.RejectPatterns {
|
|
if m, _ := filepath.Match(pat, name); m {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if matched {
|
|
continue
|
|
}
|
|
}
|
|
|
|
result = append(result, entry)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// setFileTimestamp sets the file's modification time from WebDAV last-modified.
|
|
func setFileTimestamp(path string, lastModified string) {
|
|
if lastModified == "" {
|
|
return
|
|
}
|
|
modTime, err := time.Parse(http.TimeFormat, lastModified)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := os.Chtimes(path, time.Now(), modTime); err != nil {
|
|
// Non-fatal — log only if verbose.
|
|
}
|
|
}
|
|
|
|
// downloadFile downloads a single file with optional resume support.
|
|
func (p *Protocol) downloadFile(ctx context.Context, req *core.DownloadRequest, entrySize int64, lastModified string) (*core.DownloadResult, error) {
|
|
startTime := time.Now()
|
|
httpURL := toHTTPURL(req.URL)
|
|
|
|
// Max file size check.
|
|
if entrySize > 0 && req.MaxFileSize > 0 && entrySize > req.MaxFileSize {
|
|
return nil, core.NewNetworkError(
|
|
fmt.Sprintf("file too large: %d bytes (max %d)", entrySize, req.MaxFileSize),
|
|
nil, req.URL.String())
|
|
}
|
|
|
|
resumed := false
|
|
startOffset := int64(0)
|
|
|
|
// Check partial download for resume.
|
|
if req.Resume && req.Output != "" && req.Output != "-" {
|
|
if fi, statErr := os.Stat(req.Output); statErr == nil {
|
|
localSize := fi.Size()
|
|
if entrySize > 0 && localSize > 0 && localSize < entrySize {
|
|
startOffset = localSize
|
|
resumed = true
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] Resuming from byte %d (local: %d, remote: %d)\n",
|
|
startOffset, localSize, entrySize)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "GET", httpURL.String(), nil)
|
|
if err != nil {
|
|
return nil, core.NewNetworkError("failed to create request", err, req.URL.String())
|
|
}
|
|
|
|
httpReq.Header.Set("User-Agent", "Goget/"+core.Version)
|
|
|
|
// Apply custom headers and auth.
|
|
for k, v := range req.Headers {
|
|
httpReq.Header.Set(k, v)
|
|
}
|
|
if req.URL.User != nil {
|
|
username := req.URL.User.Username()
|
|
password, _ := req.URL.User.Password()
|
|
httpReq.SetBasicAuth(username, password)
|
|
}
|
|
|
|
// Range resume header.
|
|
if startOffset > 0 {
|
|
httpReq.Header.Set("Range", fmt.Sprintf("bytes=%d-", startOffset))
|
|
if lastModified != "" {
|
|
httpReq.Header.Set("If-Range", lastModified)
|
|
}
|
|
}
|
|
|
|
client := p.getHTTPClient(req.Timeout)
|
|
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, core.NewNetworkError("download request failed", err, req.URL.String())
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
switch resp.StatusCode {
|
|
case http.StatusUnauthorized:
|
|
return nil, &core.GogetError{
|
|
Type: core.ErrAuth, Message: "webdav download requires authentication",
|
|
URL: req.URL.String(),
|
|
}
|
|
case http.StatusLocked:
|
|
return nil, &core.GogetError{
|
|
Type: core.ErrProtocol, Message: "webdav resource is locked",
|
|
URL: req.URL.String(),
|
|
}
|
|
default:
|
|
acceptCode := http.StatusOK
|
|
if startOffset > 0 {
|
|
acceptCode = http.StatusPartialContent
|
|
}
|
|
if resp.StatusCode != acceptCode && resp.StatusCode != http.StatusOK {
|
|
return nil, core.NewProtocolError(
|
|
fmt.Sprintf("webdav download failed with status %d(%s)", resp.StatusCode, resp.Status),
|
|
nil, req.URL.String())
|
|
}
|
|
}
|
|
|
|
// Open output file.
|
|
var outFile *os.File
|
|
if req.Output != "" && req.Output != "-" {
|
|
if err := os.MkdirAll(filepath.Dir(req.Output), 0755); err != nil {
|
|
return nil, core.NewFileError("failed to create output directory", err)
|
|
}
|
|
flag := os.O_CREATE | os.O_WRONLY
|
|
if startOffset > 0 {
|
|
flag |= os.O_APPEND
|
|
} else {
|
|
flag |= os.O_TRUNC
|
|
}
|
|
outFile, err = os.OpenFile(req.Output, flag, 0644)
|
|
if err != nil {
|
|
return nil, core.NewFileError("failed to open output file", err)
|
|
}
|
|
defer outFile.Close()
|
|
}
|
|
|
|
var writer io.Writer
|
|
if outFile != nil {
|
|
writer = outFile
|
|
} else if req.Writer != nil {
|
|
writer = req.Writer
|
|
} else {
|
|
writer = io.Discard
|
|
}
|
|
|
|
bytesDownloaded, err := streamWebDAVBody(ctx, writer, resp.Body, req, startOffset, resp.Header.Get("ETag"), resp.Header.Get("Last-Modified"), entrySize, &resumed)
|
|
if err != nil {
|
|
// On ctx cancellation, streamWebDAVBody returns nil; the error
|
|
// is the context error and partial progress was already saved
|
|
// to ResumeMetadata by the streamer.
|
|
return nil, err
|
|
}
|
|
|
|
duration := time.Since(startTime)
|
|
return &core.DownloadResult{
|
|
BytesDownloaded: bytesDownloaded,
|
|
Duration: duration,
|
|
Protocol: "webdav",
|
|
OutputPath: req.Output,
|
|
Resumed: resumed,
|
|
ChunksCount: 1,
|
|
}, nil
|
|
}
|
|
|
|
// streamWebDAVBody copies the HTTP response body to dst and counts the
|
|
// bytes transferred. On ctx.Done() it saves a ResumeMetadata sidecar
|
|
// (so the user can resume after Ctrl+C) and returns the context error;
|
|
// on a write error it returns a NewNetworkError wrapping the cause.
|
|
// The caller is responsible for deleting the metadata on success.
|
|
func streamWebDAVBody(
|
|
ctx context.Context,
|
|
dst io.Writer,
|
|
src io.Reader,
|
|
req *core.DownloadRequest,
|
|
startOffset int64,
|
|
etag, lastModified string,
|
|
totalSize int64,
|
|
resumed *bool,
|
|
) (int64, error) {
|
|
// 32 KiB matches the buffer size used by io.Copy and the existing
|
|
// HTTP downloader; large enough to amortise Write syscalls, small
|
|
// enough to detect ctx cancellation promptly.
|
|
buf := make([]byte, 32*1024)
|
|
var written int64
|
|
for {
|
|
// Read first, then check the context. A premature EOF caused by
|
|
// the server tearing down the connection (a typical reaction to
|
|
// client-side cancellation) is indistinguishable from a clean
|
|
// EOF without looking at ctx.Err() after the read returns.
|
|
n, readErr := src.Read(buf)
|
|
if n > 0 {
|
|
if _, writeErr := dst.Write(buf[:n]); writeErr != nil {
|
|
return written, core.NewNetworkError("failed to write response body", writeErr, req.URL.String())
|
|
}
|
|
written += int64(n)
|
|
}
|
|
if readErr == io.EOF {
|
|
// Real end of stream, or a server-side close triggered by
|
|
// our cancellation? Only ctx.Err() can tell.
|
|
if ctx.Err() != nil {
|
|
saveWebDAVResume(req, startOffset+written, totalSize, etag, lastModified, *resumed)
|
|
return written, ctx.Err()
|
|
}
|
|
break
|
|
}
|
|
if readErr != nil {
|
|
if ctx.Err() != nil {
|
|
// Cancellation often surfaces as a transport error on
|
|
// the underlying read; treat it as a cancellation so
|
|
// the resume sidecar is written.
|
|
saveWebDAVResume(req, startOffset+written, totalSize, etag, lastModified, *resumed)
|
|
return written, ctx.Err()
|
|
}
|
|
return written, core.NewNetworkError("failed to read response body", readErr, req.URL.String())
|
|
}
|
|
// Healthy read: also poll for cancellation between reads so a
|
|
// pause in the server's response is detected promptly.
|
|
if ctx.Err() != nil {
|
|
saveWebDAVResume(req, startOffset+written, totalSize, etag, lastModified, *resumed)
|
|
return written, ctx.Err()
|
|
}
|
|
}
|
|
// Successful download — drop any stale resume metadata.
|
|
if req.Output != "" && req.Output != "-" {
|
|
_ = output.DeleteResumeMetadata(req.Output)
|
|
}
|
|
return written, nil
|
|
}
|
|
|
|
// saveWebDAVResume persists the current download progress so the user
|
|
// can resume after an interruption. No-op if the destination is stdout
|
|
// or if --resume wasn't requested.
|
|
func saveWebDAVResume(req *core.DownloadRequest, downloaded, total int64, etag, lastModified string, wasResumed bool) {
|
|
if !req.Resume || req.Output == "" || req.Output == "-" || downloaded <= 0 {
|
|
return
|
|
}
|
|
_ = wasResumed // reserved for future "true resume" reporting
|
|
meta := output.NewResumeMetadata(req.URL.String(), etag, lastModified, downloaded, total)
|
|
if err := meta.Save(req.Output); err != nil && req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] Warning: failed to save resume metadata: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// Download downloads a file or directory from a WebDAV server.
|
|
func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
|
|
if !p.CanHandle(req.URL) {
|
|
return nil, core.NewProtocolError("url not supported by webdav protocol", nil, req.URL.String())
|
|
}
|
|
|
|
isDir, size, err := p.isCollection(ctx, req.URL, req)
|
|
if err != nil && req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] PROPFIND failed: %v, trying default download\n", err)
|
|
}
|
|
|
|
if isDir {
|
|
if !req.Recursive {
|
|
return nil, core.NewProtocolError("remote path is a directory, use --recursive to download", nil, req.URL.String())
|
|
}
|
|
return p.downloadRecursive(ctx, req)
|
|
}
|
|
|
|
// Use native WebDAV file download with resume support.
|
|
return p.downloadFile(ctx, req, size, "")
|
|
}
|
|
|
|
// downloadRecursive recursively downloads all files and subdirectories.
|
|
//
|
|
// Dispatches to the sequential implementation (preserved behaviour) when
|
|
// req.RecursiveParallel <= 1, or to a worker-pool implementation when
|
|
// req.RecursiveParallel > 1. Subdirectory recursion is propagated to
|
|
// downloadRecursive itself, so the same worker limit applies at every
|
|
// level of the tree.
|
|
|
|
// dryRunVerb returns the verb used in warning messages depending on
|
|
// whether the request is a dry-run or an actual download. Keeps the
|
|
// warning text grammatical without scattering the same ternary around.
|
|
func dryRunVerb(dryRun bool) string {
|
|
if dryRun {
|
|
return "dry-run"
|
|
}
|
|
return "download"
|
|
}
|
|
func (p *Protocol) downloadRecursive(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
|
|
if req.RecursiveParallel <= 1 {
|
|
return p.downloadRecursiveSequential(ctx, req)
|
|
}
|
|
return p.downloadRecursiveParallel(ctx, req)
|
|
}
|
|
|
|
// downloadRecursiveSequential is the original behaviour: one entry at a
|
|
// time, no goroutines. Used when req.RecursiveParallel <= 1.
|
|
func (p *Protocol) downloadRecursiveSequential(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
|
|
startTime := time.Now()
|
|
|
|
outputDir := req.Output
|
|
if outputDir == "" {
|
|
outputDir = filepath.Base(req.URL.Path)
|
|
if outputDir == "" || outputDir == "/" {
|
|
outputDir = "download"
|
|
}
|
|
}
|
|
|
|
var bytesDownloaded int64
|
|
var chunksCount int
|
|
|
|
// Use infinity depth to get all entries in one PROPFIND call.
|
|
depth := "infinity"
|
|
if req.MaxDepth <= 1 {
|
|
depth = "1"
|
|
}
|
|
entries, err := p.listDirectory(ctx, req.URL, req, depth)
|
|
if err != nil {
|
|
return nil, core.NewProtocolError(fmt.Sprintf("failed to list directory: %v", err), nil, req.URL.String())
|
|
}
|
|
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] listing %d entries in %s (MaxDepth=%d, depth=%s)\n", len(entries), req.URL.Path, req.MaxDepth, depth)
|
|
}
|
|
|
|
filteredEntries := filterEntries(entries, req)
|
|
if req.Verbose && len(filteredEntries) != len(entries) {
|
|
fmt.Fprintf(os.Stderr, "[webdav] filtered %d → %d entries (patterns applied)\n", len(entries), len(filteredEntries))
|
|
}
|
|
|
|
for _, entry := range filteredEntries {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// Respect MaxDepth to avoid infinite recursion.
|
|
if entry.IsDir && req.MaxDepth == 0 {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] MaxDepth reached, skipping directory %s\n", entry.URL.String())
|
|
}
|
|
continue
|
|
}
|
|
|
|
itemName := filepath.Base(entry.URL.Path)
|
|
itemOutputPath := filepath.Join(outputDir, itemName)
|
|
|
|
// Dry-run short-circuits the actual download. The matched entry
|
|
// is printed (size for files, "would recurse" for subdirs) and
|
|
// subdirectories are still recursed into so the operator can see
|
|
// the full blast radius before committing. No files or local
|
|
// directories are created on disk.
|
|
if req.DryRun {
|
|
if entry.IsDir {
|
|
fmt.Fprintf(os.Stderr, "[webdav] [dry-run] would recurse into: %s/\n", entry.URL.String())
|
|
subReq := *req
|
|
subReq.URL = entry.URL
|
|
subReq.Output = itemOutputPath
|
|
subReq.MaxDepth = req.MaxDepth - 1
|
|
subReq.RecursiveParallel = req.RecursiveParallel
|
|
subReq.DryRun = true
|
|
subResult, err := p.downloadRecursive(ctx, &subReq)
|
|
if err != nil {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] Warning: failed to dry-run sub-directory %s: %v\n", entry.URL.String(), err)
|
|
}
|
|
continue
|
|
}
|
|
bytesDownloaded += subResult.BytesDownloaded
|
|
chunksCount += subResult.ChunksCount
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "[webdav] [dry-run] would download: %s (%d bytes)\n", entry.URL.String(), entry.Size)
|
|
bytesDownloaded += entry.Size
|
|
chunksCount++
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Ensure parent directory exists for file output.
|
|
if err := os.MkdirAll(filepath.Dir(itemOutputPath), 0755); err != nil {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] failed to create directory %s: %v\n", filepath.Dir(itemOutputPath), err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if entry.IsDir {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] recurse into directory %s (remaining depth=%d)\n", entry.URL.String(), req.MaxDepth-1)
|
|
}
|
|
|
|
subReq := &core.DownloadRequest{
|
|
URL: entry.URL,
|
|
Output: itemOutputPath,
|
|
Resume: req.Resume,
|
|
Timeout: req.Timeout,
|
|
Verbose: req.Verbose,
|
|
DebugTransport: req.DebugTransport,
|
|
Headers: req.Headers,
|
|
Proxy: req.Proxy,
|
|
AutoDecompress: req.AutoDecompress,
|
|
ProgressCallback: req.ProgressCallback,
|
|
Recursive: true,
|
|
RecursiveParallel: req.RecursiveParallel,
|
|
MaxDepth: req.MaxDepth - 1,
|
|
AcceptPatterns: req.AcceptPatterns,
|
|
RejectPatterns: req.RejectPatterns,
|
|
Ctx: req.Ctx,
|
|
}
|
|
|
|
subResult, err := p.downloadRecursive(ctx, subReq)
|
|
if err != nil {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] Warning: failed to download sub-directory %s: %v\n", entry.URL.String(), err)
|
|
}
|
|
continue
|
|
}
|
|
bytesDownloaded += subResult.BytesDownloaded
|
|
chunksCount += subResult.ChunksCount
|
|
} else {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] Downloading file %s -> %s (depth=%d)\n", entry.URL.String(), itemOutputPath, req.MaxDepth)
|
|
}
|
|
|
|
subReq := &core.DownloadRequest{
|
|
URL: entry.URL,
|
|
Output: itemOutputPath,
|
|
Resume: req.Resume,
|
|
Timeout: req.Timeout,
|
|
Verbose: req.Verbose,
|
|
DebugTransport: req.DebugTransport,
|
|
Headers: req.Headers,
|
|
Proxy: req.Proxy,
|
|
AutoDecompress: req.AutoDecompress,
|
|
ProgressCallback: req.ProgressCallback,
|
|
Recursive: false,
|
|
MaxDepth: req.MaxDepth - 1,
|
|
Ctx: req.Ctx,
|
|
}
|
|
|
|
subResult, err := p.downloadFile(ctx, subReq, entry.Size, entry.LastModified)
|
|
if err != nil {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] Warning: failed to download file %s: %v\n", entry.URL.String(), err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Apply keep-timestamps if requested.
|
|
if req.KeepTimestamps {
|
|
setFileTimestamp(itemOutputPath, entry.LastModified)
|
|
}
|
|
|
|
bytesDownloaded += subResult.BytesDownloaded
|
|
chunksCount++
|
|
}
|
|
}
|
|
|
|
duration := time.Since(startTime)
|
|
return &core.DownloadResult{
|
|
BytesDownloaded: bytesDownloaded,
|
|
Duration: duration,
|
|
ChunksCount: chunksCount,
|
|
Protocol: "webdav",
|
|
}, nil
|
|
}
|
|
|
|
// downloadRecursiveParallel is the worker-pool implementation used when
|
|
// req.RecursiveParallel > 1. Top-level entries are processed concurrently,
|
|
// bounded by a semaphore. Subdirectory recursion is delegated back to
|
|
// downloadRecursive, so the same RecursiveParallel limit applies at every
|
|
// level of the tree. Stats are aggregated under a mutex.
|
|
func (p *Protocol) downloadRecursiveParallel(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
|
|
startTime := time.Now()
|
|
|
|
outputDir := req.Output
|
|
if outputDir == "" {
|
|
outputDir = filepath.Base(req.URL.Path)
|
|
if outputDir == "" || outputDir == "/" {
|
|
outputDir = "download"
|
|
}
|
|
}
|
|
|
|
// Use infinity depth to get all entries in one PROPFIND call.
|
|
depth := "infinity"
|
|
if req.MaxDepth <= 1 {
|
|
depth = "1"
|
|
}
|
|
entries, err := p.listDirectory(ctx, req.URL, req, depth)
|
|
if err != nil {
|
|
return nil, core.NewProtocolError(fmt.Sprintf("failed to list directory: %v", err), nil, req.URL.String())
|
|
}
|
|
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] listing %d entries in %s (MaxDepth=%d, depth=%s, parallel=%d)\n", len(entries), req.URL.Path, req.MaxDepth, depth, req.RecursiveParallel)
|
|
}
|
|
|
|
filteredEntries := filterEntries(entries, req)
|
|
if req.Verbose && len(filteredEntries) != len(entries) {
|
|
fmt.Fprintf(os.Stderr, "[webdav] filtered %d → %d entries (patterns applied)\n", len(entries), len(filteredEntries))
|
|
}
|
|
|
|
// Aggregated stats under a mutex. Subdirectory recursion adds to
|
|
// these counters, so they reflect the entire subtree processed by
|
|
// this call.
|
|
var (
|
|
statsMu sync.Mutex
|
|
totalBytes int64
|
|
totalChunks int
|
|
processedDirs int
|
|
)
|
|
|
|
processedDirs++
|
|
|
|
// Pre-build sub-requests and output paths so the goroutine body has
|
|
// no per-entry allocation cost.
|
|
type task struct {
|
|
entry WebDAVEntry
|
|
subReq *core.DownloadRequest
|
|
outPath string
|
|
isDir bool
|
|
}
|
|
|
|
tasks := make([]task, 0, len(filteredEntries))
|
|
for _, entry := range filteredEntries {
|
|
// Respect MaxDepth to avoid infinite recursion.
|
|
if entry.IsDir && req.MaxDepth == 0 {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] MaxDepth reached, skipping directory %s\n", entry.URL.String())
|
|
}
|
|
continue
|
|
}
|
|
|
|
itemName := filepath.Base(entry.URL.Path)
|
|
itemOutputPath := filepath.Join(outputDir, itemName)
|
|
|
|
// Dry-run short-circuits the actual download. The matched entry
|
|
// is printed (size for files, "would recurse" for subdirs) and
|
|
// subdirectories are still recursed into so the operator can see
|
|
// the full blast radius before committing. No files or local
|
|
// directories are created on disk.
|
|
if req.DryRun {
|
|
if entry.IsDir {
|
|
fmt.Fprintf(os.Stderr, "[webdav] [dry-run] would recurse into: %s/\n", entry.URL.String())
|
|
subReq := *req
|
|
subReq.URL = entry.URL
|
|
subReq.Output = itemOutputPath
|
|
subReq.MaxDepth = req.MaxDepth - 1
|
|
subReq.RecursiveParallel = req.RecursiveParallel
|
|
subReq.DryRun = true
|
|
tasks = append(tasks, task{entry: entry, subReq: &subReq, outPath: itemOutputPath, isDir: true})
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "[webdav] [dry-run] would download: %s (%d bytes)\n", entry.URL.String(), entry.Size)
|
|
statsMu.Lock()
|
|
totalBytes += entry.Size
|
|
totalChunks++
|
|
statsMu.Unlock()
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Ensure parent directory exists for file output.
|
|
if err := os.MkdirAll(filepath.Dir(itemOutputPath), 0755); err != nil {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] failed to create directory %s: %v\n", filepath.Dir(itemOutputPath), err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if entry.IsDir {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] recurse into directory %s (remaining depth=%d)\n", entry.URL.String(), req.MaxDepth-1)
|
|
}
|
|
subReq := &core.DownloadRequest{
|
|
URL: entry.URL,
|
|
Output: itemOutputPath,
|
|
Resume: req.Resume,
|
|
Timeout: req.Timeout,
|
|
Verbose: req.Verbose,
|
|
DebugTransport: req.DebugTransport,
|
|
Headers: req.Headers,
|
|
Proxy: req.Proxy,
|
|
AutoDecompress: req.AutoDecompress,
|
|
ProgressCallback: req.ProgressCallback,
|
|
Recursive: true,
|
|
RecursiveParallel: req.RecursiveParallel,
|
|
MaxDepth: req.MaxDepth - 1,
|
|
AcceptPatterns: req.AcceptPatterns,
|
|
RejectPatterns: req.RejectPatterns,
|
|
Ctx: req.Ctx,
|
|
}
|
|
tasks = append(tasks, task{entry: entry, subReq: subReq, outPath: itemOutputPath, isDir: true})
|
|
} else {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] Downloading file %s -> %s (depth=%d)\n", entry.URL.String(), itemOutputPath, req.MaxDepth)
|
|
}
|
|
subReq := &core.DownloadRequest{
|
|
URL: entry.URL,
|
|
Output: itemOutputPath,
|
|
Resume: req.Resume,
|
|
Timeout: req.Timeout,
|
|
Verbose: req.Verbose,
|
|
DebugTransport: req.DebugTransport,
|
|
Headers: req.Headers,
|
|
Proxy: req.Proxy,
|
|
AutoDecompress: req.AutoDecompress,
|
|
ProgressCallback: req.ProgressCallback,
|
|
Recursive: false,
|
|
MaxDepth: req.MaxDepth - 1,
|
|
Ctx: req.Ctx,
|
|
}
|
|
tasks = append(tasks, task{entry: entry, subReq: subReq, outPath: itemOutputPath, isDir: false})
|
|
}
|
|
}
|
|
|
|
// Worker pool: bounded by req.RecursiveParallel. We use a semaphore
|
|
// channel + WaitGroup so total in-flight workers never exceed the
|
|
// requested count, regardless of how many tasks are enqueued.
|
|
sem := make(chan struct{}, req.RecursiveParallel)
|
|
var wg sync.WaitGroup
|
|
|
|
for _, t := range tasks {
|
|
// Honour context cancellation before launching a new task.
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
|
|
go func(t task) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
|
|
// If the context was cancelled while we were waiting for
|
|
// the semaphore, just exit cleanly.
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
if t.isDir {
|
|
subResult, err := p.downloadRecursive(ctx, t.subReq)
|
|
if err != nil {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] Warning: failed to %s sub-directory %s: %v\n", dryRunVerb(req.DryRun), t.entry.URL.String(), err)
|
|
}
|
|
return
|
|
}
|
|
statsMu.Lock()
|
|
totalBytes += subResult.BytesDownloaded
|
|
totalChunks += subResult.ChunksCount
|
|
statsMu.Unlock()
|
|
return
|
|
}
|
|
|
|
subResult, err := p.downloadFile(ctx, t.subReq, t.entry.Size, t.entry.LastModified)
|
|
if err != nil {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[webdav] Warning: failed to download file %s: %v\n", t.entry.URL.String(), err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Apply keep-timestamps if requested.
|
|
if req.KeepTimestamps {
|
|
setFileTimestamp(t.outPath, t.entry.LastModified)
|
|
}
|
|
|
|
statsMu.Lock()
|
|
totalBytes += subResult.BytesDownloaded
|
|
totalChunks++
|
|
statsMu.Unlock()
|
|
}(t)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
_ = processedDirs // reserved for future per-directory accounting
|
|
|
|
duration := time.Since(startTime)
|
|
return &core.DownloadResult{
|
|
BytesDownloaded: totalBytes,
|
|
Duration: duration,
|
|
ChunksCount: totalChunks,
|
|
Protocol: "webdav",
|
|
}, nil
|
|
}
|
|
|
|
// mkcol creates a WebDAV collection (directory).
|
|
func (p *Protocol) mkcol(ctx context.Context, httpURL *url.URL, req *core.UploadRequest) error {
|
|
mkReq, err := http.NewRequestWithContext(ctx, "MKCOL", httpURL.String(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if req.URL.User != nil {
|
|
username := req.URL.User.Username()
|
|
password, _ := req.URL.User.Password()
|
|
mkReq.SetBasicAuth(username, password)
|
|
}
|
|
|
|
client := p.getHTTPClient(15 * time.Second)
|
|
resp, err := client.Do(mkReq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("MKCOL failed with status: %s", resp.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Upload uploads a file to a WebDAV server using HTTP PUT method.
|
|
func (p *Protocol) Upload(ctx context.Context, req *core.UploadRequest) (*core.UploadResult, error) {
|
|
httpURL := toHTTPURL(req.URL)
|
|
|
|
file, err := os.Open(req.Input)
|
|
if err != nil {
|
|
return nil, core.NewFileError("failed to open input file", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
fileInfo, err := file.Stat()
|
|
if err != nil {
|
|
return nil, core.NewFileError("failed to stat input file", err)
|
|
}
|
|
|
|
startTime := time.Now()
|
|
|
|
// Attempt PUT.
|
|
result, err := p.doUpload(ctx, httpURL, file, fileInfo, req)
|
|
if err != nil {
|
|
// If PUT fails with 405 (Method Not Allowed), try MKCOL on parent.
|
|
if strings.Contains(err.Error(), "405") || strings.Contains(err.Error(), "409") {
|
|
parentURL := &url.URL{
|
|
Scheme: httpURL.Scheme,
|
|
Host: httpURL.Host,
|
|
User: httpURL.User,
|
|
Path: filepath.Dir(httpURL.Path),
|
|
}
|
|
if mkErr := p.mkcol(ctx, parentURL, req); mkErr == nil {
|
|
// Retry PUT after creating collection.
|
|
file.Seek(0, io.SeekStart)
|
|
return p.doUpload(ctx, httpURL, file, fileInfo, req)
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
duration := time.Since(startTime)
|
|
result.Duration = duration
|
|
return result, nil
|
|
}
|
|
|
|
// doUpload performs the actual HTTP PUT request.
|
|
func (p *Protocol) doUpload(ctx context.Context, httpURL *url.URL, file *os.File, fileInfo os.FileInfo, req *core.UploadRequest) (*core.UploadResult, error) {
|
|
httpReq, err := http.NewRequestWithContext(ctx, "PUT", httpURL.String(), file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create PUT request: %w", err)
|
|
}
|
|
|
|
httpReq.Header.Set("Content-Type", "application/octet-stream")
|
|
httpReq.Header.Set("Content-Length", strconv.FormatInt(fileInfo.Size(), 10))
|
|
|
|
for k, v := range req.Headers {
|
|
httpReq.Header.Set(k, v)
|
|
}
|
|
|
|
if req.URL.User != nil {
|
|
username := req.URL.User.Username()
|
|
password, _ := req.URL.User.Password()
|
|
httpReq.SetBasicAuth(username, password)
|
|
}
|
|
|
|
client := p.getHTTPClient(req.Timeout)
|
|
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("PUT request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
switch resp.StatusCode {
|
|
case http.StatusLocked:
|
|
return nil, &core.GogetError{
|
|
Type: core.ErrProtocol,
|
|
Message: "webdav upload failed: resource is locked — try again after unlock",
|
|
URL: req.URL.String(),
|
|
}
|
|
case http.StatusOK, http.StatusCreated, http.StatusNoContent:
|
|
// Success.
|
|
default:
|
|
return nil, core.NewProtocolError(
|
|
fmt.Sprintf("webdav upload failed with status %d(%s)", resp.StatusCode, resp.Status),
|
|
nil, req.URL.String())
|
|
}
|
|
|
|
return &core.UploadResult{
|
|
BytesUploaded: fileInfo.Size(),
|
|
Protocol: "webdav",
|
|
ResultURL: req.URL.String(),
|
|
}, nil
|
|
}
|