package storage import ( "context" "fmt" "io" "log" "net/http" "net/url" "path" "strings" "time" "go.b0esche.cloud/backend/internal/config" ) type WebDAVClient struct { BaseURL string user string pass string basePrefix string httpClient *http.Client } // NewWebDAVClient returns nil if no Nextcloud URL configured func NewWebDAVClient(cfg *config.Config) *WebDAVClient { if cfg == nil || strings.TrimSpace(cfg.NextcloudURL) == "" { log.Printf("[WEBDAV] No Nextcloud URL configured, WebDAV client is nil\n") return nil } u := strings.TrimRight(cfg.NextcloudURL, "/") if !strings.Contains(u, "/remote.php") { u += "/remote.php/dav/files/" + cfg.NextcloudUser } base := cfg.NextcloudBase if base == "" { base = "/" } log.Printf("[WEBDAV] Initializing WebDAV client - URL: %s, User: %s, BasePath: %s\n", u, cfg.NextcloudUser, base) return &WebDAVClient{ BaseURL: u, user: cfg.NextcloudUser, pass: cfg.NextcloudPass, basePrefix: strings.TrimRight(base, "/"), httpClient: &http.Client{Timeout: 60 * time.Second}, } } // ensureParent creates intermediate collections using MKCOL. Ignoring errors when already exists. func (c *WebDAVClient) ensureParent(ctx context.Context, remotePath string) error { // build incremental paths dir := path.Dir(remotePath) if dir == "." || dir == "/" || dir == "" { return nil } // split and build prefixes parts := strings.Split(strings.Trim(dir, "/"), "/") cur := c.basePrefix for _, p := range parts { cur = path.Join(cur, p) var mkurl string // Always ensure a single '/' between BaseURL and the current path // e.g. http://nextcloud/remote.php/dav/files/testuser/orgs/ mkurl = fmt.Sprintf("%s/%s", strings.TrimRight(c.BaseURL, "/"), strings.TrimLeft(cur, "/")) req, _ := http.NewRequestWithContext(ctx, "MKCOL", mkurl, nil) if c.user != "" { req.SetBasicAuth(c.user, c.pass) } resp, err := c.httpClient.Do(req) if err != nil { return err } // Read body for diagnostics b, _ := io.ReadAll(resp.Body) resp.Body.Close() // 201 created, 405 exists — ignore if resp.StatusCode == 201 || resp.StatusCode == 405 { continue } // Any other status is an error: return with diagnostics so caller can log and act on it return fmt.Errorf("MKCOL failed for %s: status=%d body=%s", mkurl, resp.StatusCode, string(b)) } return nil } // Upload streams the content to the remotePath using HTTP PUT (WebDAV). remotePath should be absolute under basePrefix. func (c *WebDAVClient) Upload(ctx context.Context, remotePath string, r io.Reader, size int64) error { if c == nil { return fmt.Errorf("no webdav client configured") } // Ensure parent collections, skip for .avatars as it should exist if !strings.HasPrefix(remotePath, ".avatars/") { if err := c.ensureParent(ctx, remotePath); err != nil { return err } } // Construct URL // remotePath might be like /orgs//file.txt; ensure it joins to basePrefix rel := strings.TrimLeft(remotePath, "/") u := c.basePrefix if u == "/" || u == "" { u = "" } u = strings.TrimRight(u, "/") var full string if u == "" { full = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(rel)) } else { full = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(rel)) } full = strings.ReplaceAll(full, "%2F", "/") fmt.Printf("[WEBDAV-UPLOAD] BaseURL: %s, BasePrefix: %s, RemotePath: %s, Full URL: %s\n", c.BaseURL, c.basePrefix, remotePath, full) req, err := http.NewRequestWithContext(ctx, "PUT", full, r) if err != nil { return err } if size > 0 { req.ContentLength = size } if c.user != "" { req.SetBasicAuth(c.user, c.pass) } req.Header.Set("Content-Type", "application/octet-stream") resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } else if resp.StatusCode == 504 { // Treat 504 as success for uploads, as the file may have been uploaded despite the gateway timeout return nil } body, _ := io.ReadAll(resp.Body) return fmt.Errorf("webdav upload failed: %d %s", resp.StatusCode, string(body)) } // Download retrieves a file from the remotePath using HTTP GET (WebDAV). func (c *WebDAVClient) Download(ctx context.Context, remotePath string, rangeHeader string) (*http.Response, error) { if c == nil { return nil, fmt.Errorf("no webdav client configured") } rel := strings.TrimLeft(remotePath, "/") u := c.basePrefix if u == "/" || u == "" { u = "" } u = strings.TrimRight(u, "/") var full string if u == "" { full = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(rel)) } else { full = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(rel)) } full = strings.ReplaceAll(full, "%2F", "/") req, err := http.NewRequestWithContext(ctx, "GET", full, nil) if err != nil { return nil, err } if c.user != "" { req.SetBasicAuth(c.user, c.pass) } if rangeHeader != "" { req.Header.Set("Range", rangeHeader) } resp, err := c.httpClient.Do(req) if err != nil { return nil, err } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return resp, nil } body, _ := io.ReadAll(resp.Body) resp.Body.Close() return nil, fmt.Errorf("webdav download failed: %d %s", resp.StatusCode, string(body)) } // Delete removes a file or collection from the remotePath using HTTP DELETE (WebDAV). func (c *WebDAVClient) Delete(ctx context.Context, remotePath string) error { if c == nil { return fmt.Errorf("no webdav client configured") } rel := strings.TrimLeft(remotePath, "/") u := c.basePrefix if u == "/" || u == "" { u = "" } u = strings.TrimRight(u, "/") var full string if u == "" { full = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(rel)) } else { full = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(rel)) } full = strings.ReplaceAll(full, "%2F", "/") req, err := http.NewRequestWithContext(ctx, "DELETE", full, nil) if err != nil { return err } if c.user != "" { req.SetBasicAuth(c.user, c.pass) } resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } // 404 means already deleted, consider it success if resp.StatusCode == 404 { return nil } body, _ := io.ReadAll(resp.Body) return fmt.Errorf("webdav delete failed: %d %s", resp.StatusCode, string(body)) } // Move moves/renames a file using WebDAV MOVE method func (c *WebDAVClient) Move(ctx context.Context, sourcePath, targetPath string) error { if c == nil { return fmt.Errorf("no webdav client configured") } // Ensure target parent directory exists before moving if err := c.ensureParent(ctx, targetPath); err != nil { return fmt.Errorf("failed to create target directory: %w", err) } sourceRel := strings.TrimLeft(sourcePath, "/") targetRel := strings.TrimLeft(targetPath, "/") u := c.basePrefix if u == "/" || u == "" { u = "" } u = strings.TrimRight(u, "/") // Build source URL var sourceURL string if u == "" { sourceURL = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(sourceRel)) } else { sourceURL = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(sourceRel)) } sourceURL = strings.ReplaceAll(sourceURL, "%2F", "/") // Build target URL var targetURL string if u == "" { targetURL = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(targetRel)) } else { targetURL = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(targetRel)) } targetURL = strings.ReplaceAll(targetURL, "%2F", "/") req, err := http.NewRequestWithContext(ctx, "MOVE", sourceURL, nil) if err != nil { return err } req.Header.Set("Destination", targetURL) if c.user != "" { req.SetBasicAuth(c.user, c.pass) } resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } body, _ := io.ReadAll(resp.Body) return fmt.Errorf("webdav move failed: %d %s", resp.StatusCode, string(body)) }