package main import ( "encoding/xml" "flag" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" ) // Build-time configuration (set via ldflags at compile time) var ( BuildServerURL string BuildUsername string BuildToken string ) // Config holds Nextcloud connection configuration type Config struct { URL string User string Token string WebDAV string } // Client is a Nextcloud WebDAV client type Client struct { Config Config HTTPClient *http.Client } // MultiStatus represents the WebDAV multistatus response type MultiStatus struct { XMLName xml.Name `xml:"multistatus"` Responses []Response `xml:"response"` } // Response represents a single WebDAV response type Response struct { Href string `xml:"href"` PropStats []PropStat `xml:"propstat"` } // PropStat represents a WebDAV propstat element type PropStat struct { Prop Prop `xml:"prop"` Status string `xml:"status"` } // Prop represents WebDAV properties type Prop struct { GetLastModified string `xml:"getlastmodified"` GetContentLength int64 `xml:"getcontentlength"` GetContentType string `xml:"getcontenttype"` ResourceType ResourceType `xml:"resourcetype"` GetETag string `xml:"getetag"` } // ResourceType represents the resourcetype element type ResourceType struct { Collection string `xml:"collection"` } func main() { // Parse flags (with build-time defaults) serverURL := flag.String("url", BuildServerURL, "Nextcloud server URL (e.g., https://cloud.example.com)") username := flag.String("user", BuildUsername, "Nextcloud username") token := flag.String("token", BuildToken, "Nextcloud app token") operation := flag.String("op", "list", "Operation: list, upload, download, mkdir, delete, move, copy, info") path := flag.String("path", "/", "Remote path") localPath := flag.String("local", "", "Local path for upload/download") destPath := flag.String("dest", "", "Destination path for move/copy") recursive := flag.Bool("r", false, "Recursive operation") flag.Parse() // Use environment variables as fallback if not set at build time or via flags if *serverURL == "" { *serverURL = os.Getenv("NEXTCLOUD_URL") } if *username == "" { *username = os.Getenv("NEXTCLOUD_USER") } if *token == "" { *token = os.Getenv("NEXTCLOUD_TOKEN") } if serverURL == nil || username == nil || token == nil || *serverURL == "" || *username == "" || *token == "" { fmt.Println("Error: URL, user, and token are required (set via ldflags, flags, or env vars)") os.Exit(1) } // Build config encodedUsername := url.PathEscape(*username) config := Config{ URL: *serverURL, User: *username, Token: *token, WebDAV: fmt.Sprintf("%s/remote.php/dav/files/%s", strings.TrimSuffix(*serverURL, "/"), encodedUsername), } // Create client client := &Client{ Config: config, HTTPClient: &http.Client{}, } var err error switch *operation { case "list": err = client.list(*path, *recursive) case "upload": err = client.upload(*localPath, *path) case "download": err = client.download(*path, *localPath) case "mkdir": err = client.mkdir(*path) case "delete": err = client.delete(*path) case "move": err = client.move(*path, *destPath) case "copy": err = client.copy(*path, *destPath) case "info": err = client.info(*path) default: fmt.Printf("Unknown operation: %s\n", *operation) os.Exit(1) } if err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } } func (c *Client) list(path string, recursive bool) error { depth := "1" if recursive { depth = "infinity" } // PROPFIND body body := fmt.Sprintf(` `) req, err := http.NewRequest("PROPFIND", c.Config.WebDAV+path, strings.NewReader(body)) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("OCS-APIRequest", "true") req.Header.Set("Content-Type", "application/xml; charset=utf-8") req.Header.Set("Depth", depth) resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 207 { return fmt.Errorf("unexpected status: %d (expected 207 Multi-Status)", resp.StatusCode) } var multiStatus MultiStatus decoder := xml.NewDecoder(resp.Body) if err := decoder.Decode(&multiStatus); err != nil { return fmt.Errorf("xml decode failed: %v", err) } // Print results for _, response := range multiStatus.Responses { relPath := strings.TrimPrefix(response.Href, c.Config.WebDAV) if relPath == "" || relPath == c.Config.WebDAV+"/" { continue } // Merge props from successful propstats (200 OK) var mergedProp Prop isFolder := false for _, propStat := range response.PropStats { if strings.Contains(propStat.Status, "200") { // Merge successful props if propStat.Prop.GetLastModified != "" { mergedProp.GetLastModified = propStat.Prop.GetLastModified } if propStat.Prop.GetContentLength > 0 { mergedProp.GetContentLength = propStat.Prop.GetContentLength } if propStat.Prop.GetContentType != "" { mergedProp.GetContentType = propStat.Prop.GetContentType } if propStat.Prop.GetETag != "" { mergedProp.GetETag = propStat.Prop.GetETag } if propStat.Prop.ResourceType.Collection != "" { mergedProp.ResourceType = propStat.Prop.ResourceType isFolder = true } } } size := mergedProp.GetContentLength if isFolder { size = 0 } indicator := "-" if isFolder { indicator = "d" } fmt.Printf("%s %-10d %s\n", indicator, size, relPath) } return nil } func (c *Client) upload(localPath, remotePath string) error { file, err := os.Open(localPath) if err != nil { return fmt.Errorf("failed to open local file: %v", err) } defer file.Close() stat, err := file.Stat() if err != nil { return fmt.Errorf("failed to stat file: %v", err) } req, err := http.NewRequest("PUT", c.Config.WebDAV+remotePath, file) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("OCS-APIRequest", "true") req.ContentLength = stat.Size() resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 201 && resp.StatusCode != 200 { return fmt.Errorf("upload failed with status: %d", resp.StatusCode) } fmt.Printf("Uploaded %s to %s\n", localPath, remotePath) return nil } func (c *Client) download(remotePath, localPath string) error { req, err := http.NewRequest("GET", c.Config.WebDAV+remotePath, nil) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("OCS-APIRequest", "true") resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("download failed with status: %d", resp.StatusCode) } // Create parent directories if needed if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil { return fmt.Errorf("failed to create local directory: %v", err) } outFile, err := os.Create(localPath) if err != nil { return fmt.Errorf("failed to create local file: %v", err) } defer outFile.Close() _, err = io.Copy(outFile, resp.Body) if err != nil { return fmt.Errorf("failed to copy file: %v", err) } fmt.Printf("Downloaded %s to %s\n", remotePath, localPath) return nil } func (c *Client) mkdir(path string) error { req, err := http.NewRequest("MKCOL", c.Config.WebDAV+path, nil) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("OCS-APIRequest", "true") resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 201 && resp.StatusCode != 204 { return fmt.Errorf("mkdir failed with status: %d", resp.StatusCode) } fmt.Printf("Created directory: %s\n", path) return nil } func (c *Client) delete(path string) error { req, err := http.NewRequest("DELETE", c.Config.WebDAV+path, nil) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("OCS-APIRequest", "true") resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 204 { return fmt.Errorf("delete failed with status: %d", resp.StatusCode) } fmt.Printf("Deleted: %s\n", path) return nil } func (c *Client) move(from, to string) error { fullURL := c.Config.WebDAV + from req, err := http.NewRequest("MOVE", fullURL, nil) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("OCS-APIRequest", "true") dest := c.Config.WebDAV + to req.Header.Set("Destination", dest) resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 201 && resp.StatusCode != 204 { return fmt.Errorf("move failed with status: %d", resp.StatusCode) } fmt.Printf("Moved %s to %s\n", from, to) return nil } func (c *Client) copy(from, to string) error { fullURL := c.Config.WebDAV + from req, err := http.NewRequest("COPY", fullURL, nil) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("OCS-APIRequest", "true") dest := c.Config.WebDAV + to req.Header.Set("Destination", dest) resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 201 { return fmt.Errorf("copy failed with status: %d", resp.StatusCode) } fmt.Printf("Copied %s to %s\n", from, to) return nil } func (c *Client) info(path string) error { depth := "0" // PROPFIND body body := fmt.Sprintf(` `) fullURL := c.Config.WebDAV + path req, err := http.NewRequest("PROPFIND", fullURL, strings.NewReader(body)) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("OCS-APIRequest", "true") req.Header.Set("Content-Type", "application/xml; charset=utf-8") req.Header.Set("Depth", depth) resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 207 { return fmt.Errorf("info failed with status: %d", resp.StatusCode) } var multiStatus MultiStatus decoder := xml.NewDecoder(resp.Body) if err := decoder.Decode(&multiStatus); err != nil { return fmt.Errorf("xml decode failed: %v", err) } // Print details for _, response := range multiStatus.Responses { relPath := strings.TrimPrefix(response.Href, c.Config.WebDAV) if relPath == "" || relPath == c.Config.WebDAV+"/" { continue } // Merge props from successful propstats (200 OK) var mergedProp Prop isFolder := false for _, propStat := range response.PropStats { if strings.Contains(propStat.Status, "200") { // Merge successful props if propStat.Prop.GetLastModified != "" { mergedProp.GetLastModified = propStat.Prop.GetLastModified } if propStat.Prop.GetContentLength > 0 { mergedProp.GetContentLength = propStat.Prop.GetContentLength } if propStat.Prop.GetContentType != "" { mergedProp.GetContentType = propStat.Prop.GetContentType } if propStat.Prop.GetETag != "" { mergedProp.GetETag = propStat.Prop.GetETag } if propStat.Prop.ResourceType.Collection != "" { mergedProp.ResourceType = propStat.Prop.ResourceType isFolder = true } } } size := mergedProp.GetContentLength if isFolder { size = 0 } fmt.Printf("Path: %s\n", relPath) fmt.Printf(" Type: %s\n", map[bool]string{true: "Folder", false: "File"}[isFolder]) fmt.Printf(" Size: %d bytes\n", size) fmt.Printf(" Modified: %s\n", mergedProp.GetLastModified) fmt.Printf(" ETag: %s\n", mergedProp.GetETag) if mergedProp.GetContentType != "" { fmt.Printf(" Type: %s\n", mergedProp.GetContentType) } } return nil } func (c *Client) request(method, path string, body io.Reader) (*http.Response, error) { fullURL := c.Config.WebDAV + path req, err := http.NewRequest(method, fullURL, body) if err != nil { return nil, fmt.Errorf("http request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("OCS-APIRequest", "true") // For PROPFIND, set Content-Type if method == "PROPFIND" { req.Header.Set("Content-Type", "application/xml; charset=utf-8") } return c.HTTPClient.Do(req) }