diff --git a/.dockerignore b/.dockerignore index aa2f1cc..68f4eba 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,3 +16,4 @@ lerna-debug.log* .vscode *.code-workspace .history/ +README.md diff --git a/.gitignore b/.gitignore index ad062f4..03ab056 100644 --- a/.gitignore +++ b/.gitignore @@ -168,3 +168,7 @@ temp/ Network Trash Folder Temporary Items .apdisk + + +# App specific +config.yaml diff --git a/Dockerfile b/Dockerfile index e69de29..422c8f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.27-alpine AS builder +WORKDIR /app/ +ADD go.mod go.sum ./ +RUN go mod download +ADD . . +RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-w -s" -o backup-to-nextcloud main.go + +FROM golang:1.27-alpine +WORKDIR /app/ +COPY --from=builder /app/backup-to-nextcloud /app/backup-to-nextcloud +ENTRYPOINT ["/app/backup-to-nextcloud"] diff --git a/Makefile b/Makefile index 06fb8f0..4170d84 100644 --- a/Makefile +++ b/Makefile @@ -1,49 +1,33 @@ .DEFAULT_GOAL := default -IMAGE ?= rg.fr-par.scw.cloud/averagemarcus-private/backup-to-nextcloud:latest +IMAGE ?= rg.fr-par.scw.cloud/averagemarcus/backup-to-nextcloud:latest .PHONY: test # Run all tests, linting and format checks test: lint check-format run-tests .PHONY: lint # Perform lint checks against code lint: - @echo "⚠️ 'lint' unimplemented" - # GO Projects - # @go vet && golint -set_exit_status ./... + @go vet && golint -set_exit_status ./... .PHONY: check-format # Checks code formatting and returns a non-zero exit code if formatting errors found check-format: - @echo "⚠️ 'check-format' unimplemented" - # GO Projects - # @gofmt -e -l . + @gofmt -e -l . .PHONY: format # Performs automatic format fixes on all code format: - @echo "⚠️ 'format' unimplemented" - # GO Projects - # @gofmt -s -w . + @gofmt -s -w . .PHONY: run-tests # Runs all tests run-tests: - @echo "⚠️ 'run-tests' unimplemented" - # GO Projects - # @go test - # Node Projects - # @npm test + @go test .PHONY: fetch-deps # Fetch all project dependencies fetch-deps: - @echo "⚠️ 'fetch-deps' unimplemented" - # GO Projects - # @go mod tidy - # Node Projects - # @npm install + @go mod tidy .PHONY: build # Build the project build: lint check-format fetch-deps - @echo "⚠️ 'build' unimplemented" - # GO Projects - # @go build -o PROJECT_NAME main.go + @go build -o backup-to-nextcloud main.go .PHONY: docker-build # Build the docker image docker-build: @@ -55,11 +39,7 @@ docker-publish: .PHONY: run # Run the application run: - @echo "⚠️ 'run' unimplemented" - # GO Projects - # @go run main.go - # Node Projects - # @npm start + @go run main.go .PHONY: ci # Perform CI specific tasks to perform on a pull request ci: diff --git a/README.md b/README.md index 2ce6a89..380cbc7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,37 @@ # backup-to-nextcloud -A simple application that will backup files to Nextcloud +A simple application that will backup files to Nextcloud. + +Designed to be run as a CronJob with a focus on directories containing backup archives (e.g. daily zip files). ## Features -## Install +* Define jobs via yaml config +* Optionally clean up files on Nextcloud older than a given age +* Optionally only store the latest X number of files -```sh +## Example Config +```yaml +nextcloudURL: https://nextcloud.example.com +jobs: + - sourceDirectory: backups/example-daily + destinationDirectory: backups/example + # Optional + maxAge: 168h # 7 days + # Optional + maxItems: 7 ``` +By default, this loads from `./config.yaml` but the file path can be specified by setting the `NEXTCLOUD_CONFIG_PATH` environment variable. + +## Credentials + +Generate an app password for your user in Nextcloud then populate the following environment variables: + +* `NEXTCLOUD_USER` +* `NEXTCLOUD_PASSWORD` + ## Building from source With Docker: @@ -26,6 +48,8 @@ make build ## Resources +* [Nextcloud WebDav docs](https://docs.nextcloud.com/server/stable/developer_manual/client_apis/WebDAV/basic.html) + ## Contributing If you find a bug or have an idea for a new feature please [raise an issue](issues/new) to discuss it. diff --git a/client.go b/client.go new file mode 100644 index 0000000..15db225 --- /dev/null +++ b/client.go @@ -0,0 +1,180 @@ +package main + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +type Client struct { + nextcloudURL string + username string + password string +} + +type DirectoryItem struct { + Directory string + FileName string + LastModified *time.Time +} + +func NewClient(nextcloudURL, username, password string) *Client { + return &Client{ + nextcloudURL: nextcloudURL, + username: username, + password: password, + } +} + +func (c *Client) CreateDestDirectory(ctx context.Context, destinationDirectory string) error { + path := fmt.Sprintf("%s/remote.php/dav/files/%s/%s", c.nextcloudURL, c.username, destinationDirectory) + method := "MKCOL" + + response, err := c.doRequest(ctx, path, method, nil, nil) + if err != nil { + if strings.Contains(response, "The resource you tried to create already exists") { + // Directory already exists so safe to skip + return nil + } + } + return err +} + +func (c *Client) UploadFile(ctx context.Context, destinationDirectory string, file *os.File) error { + path := fmt.Sprintf("%s/remote.php/dav/files/%s/%s/%s", c.nextcloudURL, c.username, destinationDirectory, filepath.Base(file.Name())) + method := "PUT" + + headers := map[string]string{} + info, err := os.Stat(file.Name()) + if err == nil { + headers["X-OC-Mtime"] = fmt.Sprintf("%d", info.ModTime().Unix()) + } + + _, err = c.doRequest(ctx, path, method, file, headers) + return err +} + +func (c *Client) ListDirectoryContents(ctx context.Context, destinationDirectory string) ([]DirectoryItem, error) { + directoryItems := []DirectoryItem{} + + path := fmt.Sprintf("%s/remote.php/dav/files/%s/%s", c.nextcloudURL, c.username, destinationDirectory) + method := "PROPFIND" + + response, err := c.doRequest(ctx, path, method, nil, nil) + + if err != nil { + return directoryItems, err + } + + var directoryContents Multistatus + if err := xml.Unmarshal([]byte(response), &directoryContents); err != nil { + logger.ErrorContext(ctx, "Error unmarshalling XML response", + slog.String("destinationDirectory", destinationDirectory), + slog.Any("error", err), + ) + return directoryItems, err + } + + hrefPrefix := fmt.Sprintf("/remote.php/dav/files/%s/%s/", c.username, destinationDirectory) + + for _, item := range directoryContents.Responses { + if !item.IsCollection() { + var lastModified *time.Time + + if item.GetLastModified() != "" { + parsedDate, err := time.Parse(time.RFC1123, item.GetLastModified()) + if err == nil { + lastModified = &parsedDate + } + } + + file := DirectoryItem{ + Directory: destinationDirectory, + FileName: strings.TrimPrefix(item.Href, hrefPrefix), + LastModified: lastModified, + } + directoryItems = append(directoryItems, file) + } + } + + return directoryItems, nil +} + +func (c *Client) DeleteFile(ctx context.Context, destinationDirectory, fileName string) error { + path := fmt.Sprintf("%s/remote.php/dav/files/%s/%s/%s", c.nextcloudURL, c.username, destinationDirectory, fileName) + method := "DELETE" + + _, err := c.doRequest(ctx, path, method, nil, nil) + return err +} + +func (c *Client) doRequest(ctx context.Context, path, method string, payload *os.File, additionalHeaders map[string]string) (string, error) { + client := &http.Client{} + + var err error + var req *http.Request + + if payload != nil { + req, err = http.NewRequestWithContext(ctx, method, path, payload) + } else { + req, err = http.NewRequestWithContext(ctx, method, path, nil) + } + if err != nil { + logger.ErrorContext(ctx, "Failed to create request", + slog.String("path", path), + slog.String("method", method), + slog.Any("error", err), + ) + return "", err + } + + req.SetBasicAuth(c.username, c.password) + + if payload != nil { + req.Header.Set("Content-Type", "application/octet-stream") + } + + for key, val := range additionalHeaders { + req.Header.Set(key, val) + } + + resp, err := client.Do(req) + if err != nil { + logger.ErrorContext(ctx, "Failed to perform request", + slog.String("path", path), + slog.String("method", method), + slog.Any("error", err), + ) + return "", err + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + + if resp.StatusCode >= 400 { + logger.ErrorContext(ctx, "Unexpected error response returned", + slog.String("path", path), + slog.String("method", method), + slog.Int("statusCode", resp.StatusCode), + slog.String("statusMessage", resp.Status), + slog.String("response", string(bodyBytes)), + ) + return string(bodyBytes), fmt.Errorf("Unexpected error response returned") + } else { + logger.DebugContext(ctx, "Request successful", + slog.String("path", path), + slog.String("method", method), + slog.Int("statusCode", resp.StatusCode), + slog.String("statusMessage", resp.Status), + ) + } + + return string(bodyBytes), nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d0bb8d6 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module backup-to-nextcloud + +go 1.27.1 + +require ( + go.opentelemetry.io/otel/trace v1.46.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + go.opentelemetry.io/otel v1.46.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..71ba201 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9fdf35f --- /dev/null +++ b/main.go @@ -0,0 +1,259 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + CONFIG_PATH_VAR = "NEXTCLOUD_CONFIG_PATH" + NEXTCLOUD_USER_VAR = "NEXTCLOUD_USER" + NEXTCLOUD_PASSWORD_VAR = "NEXTCLOUD_PASSWORD" +) + +var ( + config Config + logger *slog.Logger +) + +func init() { + ctx := context.Background() + + jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + AddSource: true, + }) + logger = slog.New(jsonHandler) + slog.SetDefault(logger) + + configPath, exists := os.LookupEnv(CONFIG_PATH_VAR) + if !exists { + configPath = "./config.yaml" + } + logger.InfoContext(ctx, + "Using config path", + slog.String("configPath", configPath), + ) + + file, err := os.ReadFile(configPath) + if err != nil { + logger.ErrorContext(ctx, "Error reading config file", slog.Any("error", err)) + os.Exit(1) + return + } + + err = yaml.Unmarshal(file, &config) + if err != nil { + logger.ErrorContext(ctx, "Error parsing config file", slog.Any("error", err)) + os.Exit(1) + return + } + + switch { + case os.Getenv(NEXTCLOUD_USER_VAR) == "": + logger.ErrorContext(ctx, "NEXTCLOUD_USER environment variable is not set") + os.Exit(1) + return + case os.Getenv(NEXTCLOUD_PASSWORD_VAR) == "": + logger.ErrorContext(ctx, "NEXTCLOUD_PASSWORD environment variable is not set") + os.Exit(1) + return + } +} + +func main() { + ctx := context.Background() + + if err := config.Validate(); err != nil { + logger.ErrorContext(ctx, "Invalid config", slog.Any("error", err)) + os.Exit(1) + return + } + + client := NewClient(config.NextcloudURL, os.Getenv(NEXTCLOUD_USER_VAR), os.Getenv(NEXTCLOUD_PASSWORD_VAR)) + + for _, job := range config.Jobs { + err := processJob(ctx, client, job) + if err != nil { + logger.ErrorContext(ctx, "Failed to process job", + slog.String("sourceDirectory", job.SourceDirectory), + slog.String("destinationDirectory", job.DestinationDirectory), + slog.Any("error", err), + ) + continue + } else { + logger.DebugContext(ctx, "Finished processing job", + slog.String("sourceDirectory", job.SourceDirectory), + slog.String("destinationDirectory", job.DestinationDirectory), + ) + } + } +} + +func processJob(ctx context.Context, client *Client, job Job) error { + // 1. Check that we actually have files to upload... + files, err := os.ReadDir(job.SourceDirectory) + if err != nil { + logger.ErrorContext(ctx, "Failed to read source directory", + slog.String("sourceDirectory", job.SourceDirectory), + slog.Any("error", err), + ) + return err + } + + if len(files) == 0 { + logger.InfoContext(ctx, "No files found in source directory, skipping job", + slog.String("sourceDirectory", job.SourceDirectory), + ) + return nil + } + + // 2. Ensure the destination exists in Nextcloud + if err := client.CreateDestDirectory(ctx, job.DestinationDirectory); err != nil { + logger.ErrorContext(ctx, "Failed to create destination directory in Nextcloud", + slog.String("destinationDirectory", job.DestinationDirectory), + slog.Any("error", err), + ) + return err + } + + // 3. Upload files + for _, file := range files { + if file.IsDir() { + continue + } + + fileName := filepath.Base(file.Name()) + filePath := fmt.Sprintf("%s/%s", job.SourceDirectory, fileName) + + file, err := os.Open(filePath) + if err != nil { + logger.ErrorContext(ctx, "Error opening file", + slog.String("sourceDirectory", job.SourceDirectory), + slog.String("fileName", fileName), + slog.String("filePath", filePath), + slog.Any("error", err), + ) + return err + } + defer file.Close() + + logger.DebugContext(ctx, "Uploading file", + slog.String("sourceDirectory", job.SourceDirectory), + slog.String("destinationDirectory", job.DestinationDirectory), + slog.String("fileName", fileName), + slog.String("filePath", filePath), + ) + if err := client.UploadFile(ctx, job.DestinationDirectory, file); err != nil { + logger.ErrorContext(ctx, "Failed to upload file", + slog.String("sourceDirectory", job.SourceDirectory), + slog.String("destinationDirectory", job.DestinationDirectory), + slog.String("fileName", fileName), + slog.String("filePath", filePath), + slog.Any("error", err), + ) + return err + } + } + + // 4. Remove any files older than the max age specified + if job.MaxAge != nil { + duration, _ := time.ParseDuration(*job.MaxAge) + threshold := time.Now().Add(-duration) + logger.InfoContext(ctx, "Removing files older than max age", + slog.String("destinationDirectory", job.DestinationDirectory), + slog.String("maxAge", *job.MaxAge), + slog.Time("dateThreshold", threshold), + ) + + contents, err := client.ListDirectoryContents(ctx, job.DestinationDirectory) + if err != nil { + logger.ErrorContext(ctx, "Failed to list directory contents", + slog.String("sourceDirectory", job.SourceDirectory), + slog.String("destinationDirectory", job.DestinationDirectory), + slog.Any("error", err), + ) + return err + } + + for _, file := range contents { + if file.LastModified != nil { + if file.LastModified.Before(threshold) { + logger.InfoContext(ctx, "Removing old file", + slog.String("destinationDirectory", job.DestinationDirectory), + slog.String("fileName", file.FileName), + slog.Time("lastModified", *file.LastModified), + ) + if err := client.DeleteFile(ctx, job.DestinationDirectory, file.FileName); err != nil { + logger.ErrorContext(ctx, "Failed to delete file", + slog.String("destinationDirectory", job.DestinationDirectory), + slog.String("fileName", file.FileName), + slog.Any("error", err), + ) + return err + } + } + } + } + } + + // 5. Ensure only newest X files are kept + if job.MaxItems != nil { + logger.InfoContext(ctx, "Removing excess files", + slog.String("destinationDirectory", job.DestinationDirectory), + slog.Int("maxItem", *job.MaxItems), + ) + + contents, err := client.ListDirectoryContents(ctx, job.DestinationDirectory) + if err != nil { + logger.ErrorContext(ctx, "Failed to list directory contents", + slog.String("sourceDirectory", job.SourceDirectory), + slog.String("destinationDirectory", job.DestinationDirectory), + slog.Any("error", err), + ) + return err + } + + sort.SliceStable(contents, func(i, j int) bool { + if contents[i].LastModified == nil && contents[j].LastModified == nil { + return false + } + if contents[i].LastModified == nil { + return false // nil goes to the end + } + if contents[j].LastModified == nil { + return true // nil goes to the end + } + return contents[i].LastModified.After(*contents[j].LastModified) // flipped: After instead of Before + }) + + if len(contents) > *job.MaxItems { + for _, file := range contents[*job.MaxItems:] { + logger.InfoContext(ctx, "Removing old files until max files met", + slog.String("destinationDirectory", job.DestinationDirectory), + slog.String("fileName", file.FileName), + slog.Time("lastModified", *file.LastModified), + slog.String("maxItems", *job.MaxAge), + ) + if err := client.DeleteFile(ctx, job.DestinationDirectory, file.FileName); err != nil { + logger.ErrorContext(ctx, "Failed to delete file", + slog.String("destinationDirectory", job.DestinationDirectory), + slog.String("fileName", file.FileName), + slog.String("maxItems", *job.MaxAge), + slog.Any("error", err), + ) + return err + } + } + } + } + + return nil +} diff --git a/propfind.go b/propfind.go new file mode 100644 index 0000000..43746ef --- /dev/null +++ b/propfind.go @@ -0,0 +1,268 @@ +package main + +import ( + "encoding/xml" + "fmt" + "io" +) + +// Namespace prefixes used in the DAV XML +const ( + NSDAV = "DAV:" + NSSabre = "http://sabredav.org/ns" + NSOwn = "http://owncloud.org/ns" + NSNextcl = "http://nextcloud.org/ns" +) + +// Multistatus represents the root element +type Multistatus struct { + Responses []Response `xml:"DAV:response"` +} + +// Response represents a element containing path and status info +type Response struct { + Href string `xml:"DAV:href"` + Propstats []Propstat `xml:"DAV:propstat"` +} + +// Propstat contains a set of properties and their HTTP status +type Propstat struct { + Prop Prop `xml:"DAV:prop"` + Status string `xml:"DAV:status"` +} + +// Prop holds the various DAV properties (getlastmodified, getetag, etc.) +type Prop struct { + GetLastModified string `xml:"DAV:getlastmodified"` + GetContentLength *int64 `xml:"DAV:getcontentlength"` + GetContentType string `xml:"DAV:getcontenttype"` + GetETag string `xml:"DAV:getetag"` + QuotaUsedBytes *int64 `xml:"DAV:quota-used-bytes"` + QuotaAvailableBytes *int64 `xml:"DAV:quota-available-bytes"` + // ResourceType is special - it's a single element that may contain + // a child (indicating a directory) or be empty (file) + ResourceType ResourceType `xml:"DAV:resourcetype"` +} + +// ResourceType determines if a resource is a collection (directory) or a file +type ResourceType struct { + Collection bool `xml:"DAV:collection"` +} + +// UnmarshalXML implements custom XML unmarshaling for Prop +// to handle properties that may not always be present +func (p *Prop) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + for { + tok, err := d.Token() + if err != nil { + if err == io.EOF { + return nil + } + return err + } + + switch elem := tok.(type) { + case xml.StartElement: + switch elem.Name.Local { + case "getlastmodified": + var content string + if err := d.DecodeElement(&content, &elem); err != nil { + return err + } + p.GetLastModified = content + case "getcontentlength": + var content string + if err := d.DecodeElement(&content, &elem); err != nil { + return err + } + if val, err := parseInt64(content); err == nil { + p.GetContentLength = &val + } + case "getcontenttype": + var content string + if err := d.DecodeElement(&content, &elem); err != nil { + return err + } + p.GetContentType = content + case "getetag": + var content string + if err := d.DecodeElement(&content, &elem); err != nil { + return err + } + p.GetETag = content + case "quota-used-bytes": + var content string + if err := d.DecodeElement(&content, &elem); err != nil { + return err + } + if val, err := parseInt64(content); err == nil { + p.QuotaUsedBytes = &val + } + case "quota-available-bytes": + var content string + if err := d.DecodeElement(&content, &elem); err != nil { + return err + } + if val, err := parseInt64(content); err == nil { + p.QuotaAvailableBytes = &val + } + case "resourcetype": + // For resourcetype, we need to check if it contains + // rather than just counting children + for { + innerTok, err := d.Token() + if err != nil { + return err + } + if innerTok, ok := innerTok.(xml.EndElement); ok && innerTok.Name.Local == "resourcetype" { + break + } + if innerTok, ok := innerTok.(xml.StartElement); ok && innerTok.Name.Local == "collection" { + p.ResourceType.Collection = true + // Skip past the collection element + for { + innerTok, err := d.Token() + if err != nil { + return err + } + if end, ok := innerTok.(xml.EndElement); ok && end.Name.Local == "collection" { + break + } + } + } + } + } + case xml.EndElement: + if elem.Name.Local == start.Name.Local { + return nil + } + } + } +} + +// UnmarshalXML handles multistatus response parsing +func (m *Multistatus) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + m.Responses = []Response{} + + for { + tok, err := d.Token() + if err != nil { + if err == io.EOF { + return nil + } + return err + } + + if start, ok := tok.(xml.StartElement); ok && start.Name.Local == "response" { + var resp Response + if err := d.DecodeElement(&resp, &start); err != nil { + return err + } + m.Responses = append(m.Responses, resp) + } + + if end, ok := tok.(xml.EndElement); ok && end.Name.Local == "multistatus" { + return nil + } + } +} + +// UnmarshalXML handles response parsing +func (r *Response) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + for { + tok, err := d.Token() + if err != nil { + if err == io.EOF { + return nil + } + return err + } + + switch elem := tok.(type) { + case xml.StartElement: + switch elem.Name.Local { + case "href": + if err := d.DecodeElement(&r.Href, &elem); err != nil { + return err + } + case "propstat": + var ps Propstat + if err := d.DecodeElement(&ps, &elem); err != nil { + return err + } + r.Propstats = append(r.Propstats, ps) + } + case xml.EndElement: + if elem.Name.Local == start.Name.Local { + return nil + } + } + } +} + +// UnmarshalXML handles propstat parsing +func (p *Propstat) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + for { + tok, err := d.Token() + if err != nil { + if err == io.EOF { + return nil + } + return err + } + + switch elem := tok.(type) { + case xml.StartElement: + switch elem.Name.Local { + case "prop": + if err := d.DecodeElement(&p.Prop, &elem); err != nil { + return err + } + case "status": + var content string + if err := d.DecodeElement(&content, &elem); err != nil { + return err + } + p.Status = content + } + case xml.EndElement: + if elem.Name.Local == start.Name.Local { + return nil + } + } + } +} + +// RemoveQuotes removes surrounding double quotes from a string (for etags like `"abc123"`) +func RemoveQuotes(s string) string { + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + return s +} + +// parseInt64 converts a string to int64, returning 0 and error on failure +func parseInt64(s string) (int64, error) { + var val int64 + _, err := fmt.Sscanf(s, "%d", &val) + return val, err +} + +// IsCollection returns true if the resource is a directory +func (r *Response) IsCollection() bool { + for _, ps := range r.Propstats { + if ps.Prop.ResourceType.Collection { + return true + } + } + return false +} + +func (r *Response) GetLastModified() string { + for _, ps := range r.Propstats { + if ps.Prop.GetLastModified != "" { + return ps.Prop.GetLastModified + } + } + return "" +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..ae8e391 --- /dev/null +++ b/types.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "time" +) + +type Config struct { + NextcloudURL string `yaml:"nextcloudURL"` + Jobs []Job `yaml:"jobs"` +} + +type Job struct { + SourceDirectory string `yaml:"sourceDirectory"` + DestinationDirectory string `yaml:"destinationDirectory"` + MaxItems *int `yaml:"maxItems"` + MaxAge *string `yaml:"maxAge"` +} + +func (c Config) Validate() error { + if c.NextcloudURL == "" { + return fmt.Errorf("nextcloudURL is required") + } + for _, job := range c.Jobs { + if job.SourceDirectory == "" { + return fmt.Errorf("sourceDirectory is required for job") + } + if job.DestinationDirectory == "" { + return fmt.Errorf("destinationDirectory is required for job") + } + if job.MaxItems != nil && *job.MaxItems <= 0 { + return fmt.Errorf("maxItems must be greater than 0 for job") + } + if job.MaxAge != nil { + if _, err := time.ParseDuration(*job.MaxAge); err != nil { + return fmt.Errorf("maxAge must be a valid duration for job - %v", err) + } + } + } + return nil +}