- CLI tools: nextcloud-client, nextcloud-contacts, nextcloud-calendar, nextcloud-mail - Build script with compile-time credentials - Skills for all four tools - Email tool supports IMAP/SMTP with attachment download
437 lines
11 KiB
Go
437 lines
11 KiB
Go
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(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<d:propfind xmlns:d="DAV:">
|
|
<d:prop>
|
|
<d:displayname />
|
|
<d:getetag />
|
|
</d:prop>
|
|
</d:propfind>`)
|
|
|
|
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(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<d:propfind xmlns:d="DAV:">
|
|
<d:prop>
|
|
<d:getetag />
|
|
</d:prop>
|
|
</d:propfind>`)
|
|
|
|
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
|
|
}
|