Add Nextcloud integration tools
- 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
This commit is contained in:
3
tools/go/nextcloud-client/go.mod
Normal file
3
tools/go/nextcloud-client/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module github.com/wltbagent/nextcloud-client
|
||||
|
||||
go 1.21
|
||||
521
tools/go/nextcloud-client/main.go
Normal file
521
tools/go/nextcloud-client/main.go
Normal file
@@ -0,0 +1,521 @@
|
||||
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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<d:getlastmodified />
|
||||
<d:getcontentlength />
|
||||
<d:getcontenttype />
|
||||
<oc:permissions />
|
||||
<d:resourcetype />
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>`)
|
||||
|
||||
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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<d:getlastmodified />
|
||||
<d:getcontentlength />
|
||||
<d:getcontenttype />
|
||||
<oc:permissions />
|
||||
<d:resourcetype />
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>`)
|
||||
|
||||
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)
|
||||
}
|
||||
BIN
tools/go/nextcloud-client/nextcloud-client
Executable file
BIN
tools/go/nextcloud-client/nextcloud-client
Executable file
Binary file not shown.
Reference in New Issue
Block a user