package main import ( "encoding/xml" "flag" "fmt" "io" "net/http" "net/url" "os" "strings" "time" ) // 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 CardDAV string } // Client is a Nextcloud CardDAV client type Client struct { Config Config HTTPClient *http.Client } // MultiStatus represents 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 { DisplayName string `xml:"displayname"` GetETag string `xml:"getetag"` } // AddressBook represents a CardDAV address book type AddressBook struct { Href string DisplayName string ETag string } // Contact represents a vCard contact type Contact struct { UID string FormattedName string Email string Phone string Company string Title string VCard string // Raw vCard content } 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-books", "Operation: list-books, list-contacts, get-contact, create-contact, delete-contact") bookName := flag.String("book", "contacts", "Address book name (default: contacts)") contactUID := flag.String("uid", "", "Contact UID for get/delete-contact operation") vcardPath := flag.String("vcard", "", "Path to vCard file for create-contact operation") name := flag.String("name", "", "Contact name (for create-contact)") email := flag.String("email", "", "Contact email (for create-contact)") phone := flag.String("phone", "", "Contact phone (for create-contact)") 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, CardDAV: fmt.Sprintf("%s/remote.php/dav/addressbooks/users/%s", strings.TrimSuffix(*serverURL, "/"), encodedUsername), } // Create client client := &Client{ Config: config, HTTPClient: &http.Client{}, } var err error switch *operation { case "list-books": err = client.listAddressBooks() case "list-contacts": err = client.listContacts(*bookName) case "get-contact": if *contactUID == "" { fmt.Println("Error: --uid is required for get-contact operation") os.Exit(1) } err = client.getContact(*bookName, *contactUID) case "create-contact": contact := &Contact{ FormattedName: *name, Email: *email, Phone: *phone, } if *vcardPath != "" { vcardContent, readErr := os.ReadFile(*vcardPath) if readErr != nil { fmt.Printf("Error reading vCard file: %v\n", readErr) os.Exit(1) } contact.VCard = string(vcardContent) } err = client.createContact(*bookName, contact) case "delete-contact": if *contactUID == "" { fmt.Println("Error: --uid is required for delete-contact operation") os.Exit(1) } err = client.deleteContact(*bookName, *contactUID) 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) listAddressBooks() error { // PROPFIND body body := fmt.Sprintf(` `) req, err := http.NewRequest("PROPFIND", c.Config.CardDAV+"/", strings.NewReader(body)) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("Content-Type", "application/xml; charset=utf-8") req.Header.Set("Depth", "1") 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 address books fmt.Println("Address Books:") fmt.Println("--------------") for _, response := range multiStatus.Responses { relPath := strings.TrimPrefix(response.Href, c.Config.CardDAV) if relPath == "" || !strings.HasSuffix(relPath, "/") { continue } // Skip root directory (no additional path after CardDAV base) if relPath == "/" || strings.TrimSuffix(relPath, "/") == "" { continue } // Only show actual address books (have a displayname property) var mergedProp Prop hasDisplayName := false for _, propStat := range response.PropStats { if strings.Contains(propStat.Status, "200") { if propStat.Prop.DisplayName != "" { mergedProp.DisplayName = propStat.Prop.DisplayName hasDisplayName = true } if propStat.Prop.GetETag != "" { mergedProp.GetETag = propStat.Prop.GetETag } } } if !hasDisplayName { continue } bookName := strings.TrimSuffix(relPath, "/") bookName = strings.TrimPrefix(bookName, "/") fmt.Printf("Name: %s\n", mergedProp.DisplayName) fmt.Printf("Path: %s\n", bookName) fmt.Printf("ETag: %s\n", mergedProp.GetETag) fmt.Println() } return nil } func (c *Client) listContacts(bookName string) error { bookPath := fmt.Sprintf("%s/%s/", c.Config.CardDAV, bookName) // PROPFIND body body := fmt.Sprintf(` `) req, err := http.NewRequest("PROPFIND", bookPath, strings.NewReader(body)) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("Content-Type", "application/xml; charset=utf-8") req.Header.Set("Depth", "1") 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 contacts fmt.Printf("Contacts in '%s':\n", bookName) fmt.Println("--------------") contactCount := 0 for _, response := range multiStatus.Responses { relPath := strings.TrimPrefix(response.Href, bookPath) if relPath == "" || strings.HasSuffix(relPath, "/") { continue } contactCount++ var etag string for _, propStat := range response.PropStats { if strings.Contains(propStat.Status, "200") { etag = propStat.Prop.GetETag } } // Extract UID from filename (just the filename part, not full path) filename := relPath if idx := strings.LastIndex(relPath, "/"); idx != -1 { filename = relPath[idx+1:] } contactUID := strings.TrimSuffix(filename, ".vcf") fmt.Printf("UID: %s\n", contactUID) fmt.Printf("ETag: %s\n", etag) fmt.Println() } if contactCount == 0 { fmt.Println("(No contacts found)") } return nil } func (c *Client) getContact(bookName, contactUID string) error { contactPath := fmt.Sprintf("%s/%s/%s.vcf", c.Config.CardDAV, bookName, contactUID) req, err := http.NewRequest("GET", contactPath, nil) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("Accept", "text/vcard; charset=utf-8") resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("get contact failed with status: %d", resp.StatusCode) } content, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %v", err) } fmt.Printf("Contact: %s\n", contactUID) fmt.Println("--------------") fmt.Println(string(content)) return nil } func (c *Client) createContact(bookName string, contact *Contact) error { bookPath := fmt.Sprintf("%s/%s/", c.Config.CardDAV, bookName) // Generate vCard if not provided vcardContent := contact.VCard if vcardContent == "" { if contact.FormattedName == "" { return fmt.Errorf("contact name is required") } // Generate a simple vCard uid := fmt.Sprintf("%d", time.Now().UnixNano()) vcardContent = fmt.Sprintf(`BEGIN:VCARD VERSION:3.0 UID:%s FN:%s`, uid, contact.FormattedName) if contact.Email != "" { vcardContent += fmt.Sprintf("\nEMAIL;TYPE=INTERNET:%s", contact.Email) } if contact.Phone != "" { vcardContent += fmt.Sprintf("\nTEL;TYPE=VOICE:%s", contact.Phone) } if contact.Company != "" { vcardContent += fmt.Sprintf("\nORG:%s", contact.Company) } if contact.Title != "" { vcardContent += fmt.Sprintf("\nTITLE:%s", contact.Title) } vcardContent += "\nEND:VCARD" } contactPath := fmt.Sprintf("%s/%s.vcf", bookPath, fmt.Sprintf("%d", time.Now().UnixNano())) req, err := http.NewRequest("PUT", contactPath, strings.NewReader(vcardContent)) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) req.Header.Set("Content-Type", "text/vcard; charset=utf-8") 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("create contact failed with status: %d", resp.StatusCode) } fmt.Printf("Contact created successfully\n") return nil } func (c *Client) deleteContact(bookName, contactUID string) error { contactPath := fmt.Sprintf("%s/%s/%s.vcf", c.Config.CardDAV, bookName, contactUID) req, err := http.NewRequest("DELETE", contactPath, nil) if err != nil { return fmt.Errorf("request failed: %v", err) } req.SetBasicAuth(c.Config.User, c.Config.Token) resp, err := c.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 204 { return fmt.Errorf("delete contact failed with status: %d", resp.StatusCode) } fmt.Printf("Contact deleted: %s\n", contactUID) return nil }