diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..51e56b7 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,128 @@ +name: Build and test +on: + push: + branches: + - main + tags: + - v* + workflow_dispatch: { } + pull_request: { } + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: '1.23.x' + cache: true + check-latest: true + - name: lint + run: | + make lint + fmt: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: '1.23.x' + cache: true + check-latest: true + - name: fmt + run: | + make fmt + vet: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: '1.23.x' + cache: true + check-latest: true + - name: vet + run: | + make vet + goimports: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: '1.23.x' + cache: true + check-latest: true + - name: goimports + run: | + make goimports + gosec: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: '1.23.x' + cache: true + check-latest: true + - name: gosec + run: | + make gosec + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: '1.23.x' + cache: true + check-latest: true + - name: unit + run: | + make unit + release: + runs-on: ubuntu-latest + permissions: + contents: write + needs: + - fmt + - goimports + - gosec + - lint + - test + - vet + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: '1.23.x' + cache: true + check-latest: true + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build image + run: | + make koboroot + - name: Release + if: startsWith(github.ref, 'refs/tags/v') + uses: ncipollo/release-action@v1.14.0 + with: + allowUpdates: true + artifacts: _artifacts/KoboRoot.tgz + #generate_release_notes: true + #make_latest: true diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..7fa9944 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,102 @@ +linters: + disable-all: true + # Enable specific linter + # https://golangci-lint.run/usage/linters/#enabled-by-default-linters + enable: + # Default linters + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - typecheck + - unused + # Additional linters + - asciicheck + - bidichk + #- bodyclose + #- contextcheck + #- cyclop + - dogsled + #- dupl + - durationcheck + #- errname + #- errorlint + #- exhaustive + #- exportloopref + #- forcetypeassert + #- funlen + #- gochecknoglobals + #- gocognit + #- goconst + #- gocritic + #- gocyclo + #- godot + #- goerr113 + - gofmt + #- goheader + - goimports + - goprintffuncname + #- gosec + - importas + - makezero + #- misspell + #- nakedret + #- nestif + - nilerr + #- nilnil + #- nlreturn + #- noctx + - nolintlint + #- prealloc + #- predeclared + #- revive + #- stylecheck + #- tagliatelle + - tenv + #- unconvert + #- unparam + #- whitespace + #- wrapcheck + #- wsl +linters-settings: + goheader: + template: |- + Copyright {{YEAR}} Red Hat, Inc. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + nlreturn: + block-size: 2 + revive: + confidence: 0 + rules: + - name: exported + severity: warning + disabled: false + arguments: + - "checkPrivateReceivers" + - "disableStutteringCheck" + stylecheck: + # https://staticcheck.io/docs/options#checks + checks: ["all", "-ST1000"] + dot-import-whitelist: + - "github.com/onsi/gomega" +issues: + exclude: [] + exclude-use-default: false + exclude-rules: + # Exclude some linters from running on tests files. + - path: _test\.go + linters: + - gocyclo + - dupl + - gosec + - gochecknoglobals + - goerr113 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9f47861 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.23 AS builder + +WORKDIR /go/src/app +COPY . . +RUN CGO_ENABLED=0 GOARCH=arm go build -a -o manager main.go + +FROM scratch + +WORKDIR /etc/ssl/certs/ +COPY --from=gcr.io/distroless/base /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ + +WORKDIR /usr/local/nextcloud-kobo/ +COPY --from=builder /go/src/app/manager ./nextcloud-kobo + +COPY /root/ / +ENTRYPOINT ["/usr/local/nextcloud-kobo/nextcloud-kobo"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0b795b2 --- /dev/null +++ b/Makefile @@ -0,0 +1,77 @@ +ifeq ($(shell test -f .env && echo -n yes),yes) + include .env +endif + +ARTIFACT_DIR ?= ./_output + +#### Tool Versions #### +# https://github.com/golangci/golangci-lint/releases +GOLINT_VERSION = v1.60.1 +NO_DOCKER ?= 0 + +# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) +ifeq (,$(shell go env GOBIN)) +GOBIN=$(shell go env GOPATH)/bin +else +GOBIN=$(shell go env GOBIN) +endif + +# Setting SHELL to bash allows bash commands to be executed by recipes. +# Options are set to exit when a recipe line exits non-zero or a piped command fails. +SHELL = /usr/bin/env bash -o pipefail +.SHELLFLAGS = -ec + +SRC_IMAGE ?= golang:1.23 + +GO111MODULE = on +export GO111MODULE + +ifeq ($(shell command -v podman > /dev/null 2>&1 ; echo $$? ), 0) + ENGINE=podman +else ifeq ($(shell command -v docker > /dev/null 2>&1 ; echo $$? ), 0) + ENGINE=docker +else + NO_DOCKER=1 +endif + +FORCE_DOCKER ?= 0 +ifeq ($(FORCE_DOCKER), 1) + ENGINE=docker +endif + +ifeq ($(NO_DOCKER), 1) + DOCKER_CMD = +else + DOCKER_CMD := $(ENGINE) run --env GO111MODULE=$(GO111MODULE) --env GOLINT_VERSION=$(GOLINT_VERSION) --rm -v "$(PWD)":/go/src/github.com/aleskandro/nextcloud-kobo:Z -w /go/src/github.com/aleskandro/nextcloud-kobo $(SRC_IMAGE) +endif + +.PHONY: static +static: fmt vet goimports gosec lint + +.PHONY: fmt +fmt: + $(DOCKER_CMD) hack/go-fmt.sh ./ + +.PHONY: vet +vet: + $(DOCKER_CMD) go vet ./... + +.PHONY: lint +lint: + GOLINT_VERSION=$(GOLINT_VERSION) $(DOCKER_CMD) hack/golangci-lint.sh ./... + +.PHONY: goimports +goimports: + $(DOCKER_CMD) hack/goimports.sh . + +.PHONY: gosec +gosec: + $(DOCKER_CMD) hack/gosec.sh ./... + +.PHONY: koboroot +koboroot: + hack/package.sh + +.PHONY: unit +unit: + $(DOCKER_CMD) go test -v ./... \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..15b0bb5 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# Nextcloud Sync Daemon for Kobo eReaders + +![Go Version](https://img.shields.io/badge/go-1.23%2B-blue) +![License](https://img.shields.io/github/license/aleskandro/nextcloud-kobo) +![Build Status](https://github.com/aleskandro/nextcloud-kobo/actions/workflows/build.yaml/badge.svg) + +## Overview + +**Nextcloud Sync Daemon for Kobo** is a Golang-based software designed to run on Kobo eReaders, allowing users to +synchronize a list of Nextcloud remote endpoints and folders back to a folder on the Kobo filesystem. This daemon is +automatically activated every time the Kobo device connects to the internet, ensuring that your files are always +up-to-date. + +Note: This software has been tested only on the *Kobo Elipsa 2e*. While it may work on other Kobo devices, compatibility +is not guaranteed. + +## Features + +- **Automatic Synchronization**: Syncs specified folders from Nextcloud to a designated folder on your Kobo eReader + every time it connects to the internet. +- **Support for Multiple Remotes**: Manage and sync multiple Nextcloud endpoints and folders. +- **Daemon Mode**: Runs quietly in the background as a daemon process. +- **Efficient Syncing**: Downloads only updated or new files to minimize data usage and speed up synchronization. + +## Installation + +To install the Nextcloud Sync Daemon on your Kobo eReader, follow these steps: + +### Prerequisites + +### Steps + +1. **Download the KoboRoot.tgz**: Go to the [releases page](https://github.com/yourusername/kobo-nextcloud-sync/releases) and + download the latest release with your Kobo device. + +2. **Transfer the binary to your Kobo**: Connect your Kobo eReader to your computer via USB and copy the downloaded + file to the Kobo's internal storage at `(/mnt/onboard).kobo/KoboRoot.tgz`. + +3. **Configure the daemon**: + The daemon reads the configuration from `(/mnt/onboard).adds/nextcloud-kobo/config.yaml`. + Here is an example configuration: + +```yaml +autoUpdate: true # Automatically update the daemon from the GitHub release page +remotes: +- url: https://nextcloud.jdoe.com/s/abc123 + local_path: share1/ +- url: https://nextcloud.jdoe.com/ + username: john # Do not set if using a share link + password: doe + remote_folder: /my-remote-folder/ # Do not set if using a share link + local_path: share2/ +- url: https://nextcloud.jdoe.com/s/abc123 + password: doe + local_path: share3/ +``` + +4. **Reboot your Kobo**: Safely eject your Kobo device from your computer and reboot it to apply the changes. + +5. If the configuration is correct, you will get a message in the UI when the synchronization is complete. + +## Usage + +Once installed and configured, the Nextcloud Sync Daemon will automatically sync the specified folders every time your +Kobo eReader connects to the internet. + +### Logs + +Logs are generated in the `/mnt/onboard/.adds/nextcloud-kobo/nextcloud-kobo.log` directory on your Kobo device. + +## Configuration + +The `config.yaml` file is the core configuration file for this daemon. + +### Configuration Options + +- **autoUpdate**: If set to `true`, the daemon will automatically update from the GitHub release page after the first run. +- **remotes**: a list of Nextcloud remotes to sync with the Kobo device. + +#### Remote Options + +- **URL**: The Nextcloud share link for the folder you want to sync or the nextcloud URL for user-password authentication. +- **userName**: Your Nextcloud username. Leave empty if you are using a share link. +- **password**: Your Nextcloud password or the share link password. +- **remoteFolder**: The folder on the Nextcloud server that you want to sync. Leave empty if you are using a share link. +- **localPath**: The path on your Kobo device where the files will be synchronized. It is a relative path that will be + created in the `/mnt/onboard/nextcloud` directory. + +## Contributing + +We welcome contributions! To contribute to the project: + +1. Fork the repository. +2. Create a new branch for your feature or bugfix. +3. Commit your changes and push them to your fork. +4. Open a pull request detailing your changes. + +Please make sure to update tests as appropriate and adhere to the code style. + +### Requirements + +- [Docker](https://docs.docker.com/get-docker/) or [Podman](https://podman.io/getting-started/installation) +- [Go](https://golang.org/doc/install) +- [Make](https://www.gnu.org/software/make/) + +### Running Tests + +To run the tests, execute the following command: + +```bash +make static +make test +``` + +### Building + +To build the project, execute the following command: + +```bash +make package +``` + +## License + +This project is licensed under the Apache License - see the [LICENSE](LICENSE) file for details. + +## Support + +If you encounter any issues or have questions, please open an issue on this GitHub repository. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f8a242f --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/aleskandro/nextcloud-kobo-synchronizer + +go 1.23.0 + +require ( + github.com/godbus/dbus/v5 v5.1.0 + github.com/google/go-github/v55 v55.0.0 + github.com/stretchr/testify v1.9.0 + github.com/studio-b12/gowebdav v0.9.0 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect + github.com/cloudflare/circl v1.3.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sys v0.23.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..df6b9b9 --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg= +github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/studio-b12/gowebdav v0.9.0 h1:1j1sc9gQnNxbXXM4M/CebPOX4aXYtr7MojAVcN4dHjU= +github.com/studio-b12/gowebdav v0.9.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +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/hack/go-fmt.sh b/hack/go-fmt.sh new file mode 100755 index 0000000..8337658 --- /dev/null +++ b/hack/go-fmt.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +set -eux + +echo "Running gofmt..." +for TARGET in "${@}"; do + find "${TARGET}" -name '*.go' ! -path '*/vendor/*' ! -path '*/.build/*' -exec gofmt -s -w {} \+ +done diff --git a/hack/goimports.sh b/hack/goimports.sh new file mode 100755 index 0000000..b726d1b --- /dev/null +++ b/hack/goimports.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +set -eux +GOFLAGS='' go install golang.org/x/tools/cmd/goimports@v0.13.0 +echo "Running goimports..." +for TARGET in "${@}"; do + find "${TARGET}" -name '*.go' ! -path '*/vendor/*' ! -path '*/.build/*' ! -path '*/zz_generated*' -exec goimports -w {} \+ +done diff --git a/hack/golangci-lint.sh b/hack/golangci-lint.sh new file mode 100755 index 0000000..28f6396 --- /dev/null +++ b/hack/golangci-lint.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +set -eux +echo "Running golangci-lint..." +GOFLAGS='' go install "github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLINT_VERSION:-latest}" +golangci-lint run --timeout 5m0s --verbose --skip-dirs vendor --skip-files zz_generated* diff --git a/hack/gosec.sh b/hack/gosec.sh new file mode 100755 index 0000000..b529cda --- /dev/null +++ b/hack/gosec.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +set -eux + +#cd /tmp +GOFLAGS='' go install github.com/securego/gosec/v2/cmd/gosec@v2.19.0 +gosec -severity medium -confidence medium "${@}" +#cd - diff --git a/hack/package.sh b/hack/package.sh new file mode 100755 index 0000000..bce87b6 --- /dev/null +++ b/hack/package.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +mkdir -p _artifacts/ +${DOCKER_CMD:-docker} build --squash --output type=tar,dest=./_artifacts/KoboRoot.tar ./ +gzip -f ./_artifacts/KoboRoot.tar +mv ./_artifacts/KoboRoot.tar.gz ./_artifacts/KoboRoot.tgz diff --git a/main.go b/main.go new file mode 100644 index 0000000..ab6cb92 --- /dev/null +++ b/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "flag" + "log" + "os" + "os/signal" + "syscall" + + "github.com/aleskandro/nextcloud-kobo-synchronizer/pkg" +) + +func main() { + configFilePath := flag.String("config-file", "", "The path to the yaml config file") + basePath := flag.String("base-path", "", "The base path to use for relative paths in the config file") + sync := flag.Bool("sync", false, "Run the syncer at startup") + flag.Parse() + config, err := pkg.LoadConfig(*configFilePath, *basePath) + if err != nil { + log.Println("NextCloud Kobo syncer failed at loading config") + log.Println(err) + return + } + controller := pkg.NewNetworkConnectionReconciler(config) + ctx := SetupSignalHandler() + if *sync { + _, _, err = controller.SyncNow() + if err != nil { + return + } + } + controller.Run(ctx) +} + +// Gently stolen from the k8s source code +var shutdownSignals = []os.Signal{os.Interrupt, syscall.SIGTERM} +var onlyOneSignalHandler = make(chan struct{}) + +// SetupSignalHandler registers for SIGTERM and SIGINT. A context is returned +// which is canceled on one of these signals. If a second signal is caught, the program +// is terminated with exit code 1. +func SetupSignalHandler() context.Context { + close(onlyOneSignalHandler) // panics when called twice + + ctx, cancel := context.WithCancel(context.Background()) + + c := make(chan os.Signal, 2) + signal.Notify(c, shutdownSignals...) + go func() { + <-c + cancel() + <-c + os.Exit(1) // second signal. Exit directly. + }() + + return ctx +} diff --git a/pkg/config.go b/pkg/config.go new file mode 100644 index 0000000..b34eeb4 --- /dev/null +++ b/pkg/config.go @@ -0,0 +1,113 @@ +package pkg + +import ( + "fmt" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "gopkg.in/yaml.v2" +) + +type Config struct { + Remotes []Remote `yaml:"remotes"` + AutoUpdate bool `yaml:"auto_update,omitempty"` + basePath string `yaml:"-"` +} + +type Remote struct { + // URL can be either a full URL to a Nextcloud shared link or the NextCloud host URL + // If URL is a shared link, the username is not required, and we will extract it from the URL + URL string `yaml:"url"` + // Username is the username to use for authentication. It is only required if URL is not a shared link, and you + // want to authenticate with a specific username to sync a private folder + Username string `yaml:"username,omitempty"` + // Password is the password to use for authentication. It is only required if URL is a protected shared link, or + // you want to authenticate as a specific user to sync a private folder. + Password string `yaml:"password,omitempty"` + // RemoteFolder is the folder on the remote server to sync. + // If not specified, the root folder will be synced by default. + // When syncing a shared link, this should not be set. + RemoteFolder string `yaml:"remote_folder,omitempty"` + // LocalPath is the local path to sync the remote folder to + LocalPath string `yaml:"local_path"` + + // remoteURL is the parsed and processed URL that we will use to connect to the remote server + remoteURL *url.URL + printableURL string +} + +func LoadConfig(configFilePath, basePath string) (*Config, error) { + config := &Config{ + basePath: basePath, + } + configFilePath = filepath.Clean(configFilePath) + configFile, err := os.ReadFile(configFilePath) + if err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + + err = yaml.Unmarshal(configFile, config) + if err != nil { + return nil, fmt.Errorf("error parsing config file: %w", err) + } + for i := range config.Remotes { + err = config.Remotes[i].validateAndSetup(filepath.Clean(basePath)) + if err != nil { + return nil, err + } + } + return config, nil +} + +func (r *Remote) validateAndSetup(basePath string) error { + if r.URL == "" { + return fmt.Errorf("URL is required") + } + if r.LocalPath == "" { + return fmt.Errorf("local path is required") + } + // if URL is a shared link, username should not be set + if r.Username != "" && strings.Contains(r.URL, "/s/") { + return fmt.Errorf("username should not be set for shared links") + } + + // if URL is a shared link, the remote folder should not be set + if r.RemoteFolder != "" && strings.Contains(r.URL, "/s/") { + return fmt.Errorf("remote folder should not be set for shared links") + } + + // We set the remote folder to the root folder if it is not set (or it is a shared link) + if r.RemoteFolder == "" { + r.RemoteFolder = "/" + } + + baseURL, err := url.Parse(r.URL) + if err != nil { + return fmt.Errorf("invalid URL: %s", r.URL) + } + + // Check if URL is a shared link (contains "/s/") + if strings.Contains(baseURL.Path, "/s/") { + parts := strings.Split(baseURL.Path, "/s/") + if len(parts) < 2 || parts[1] == "" { + return fmt.Errorf("invalid URL: %s", r.URL) + } + // Extract the username (share ID) from the URL + r.Username = parts[1] + // Remove "/index.php" if it exists in the path + baseURL.Path = strings.Replace(parts[0], "/index.php", "", -1) + } + // Resolve "public.php/webdav" relative to the base URL + webdavPath, _ := url.Parse("public.php/webdav") + r.remoteURL = baseURL.ResolveReference(webdavPath) + r.printableURL = fmt.Sprintf("%s:%s", r.remoteURL.Host, r.LocalPath) + r.LocalPath = path.Join(basePath, r.LocalPath) + return nil +} + +func (r *Remote) String() string { + return r.printableURL +} diff --git a/pkg/config_test.go b/pkg/config_test.go new file mode 100644 index 0000000..2180ff0 --- /dev/null +++ b/pkg/config_test.go @@ -0,0 +1,136 @@ +package pkg + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoadConfig(t *testing.T) { + // Create temporary configuration file for testing + configContent := ` +remotes: + - url: "http://example.com/s/xyz123" + local_path: "sync_folder" +` + tmpFile, err := os.CreateTemp("", "config*.yaml") + assert.NoError(t, err) + //nolint:errcheck + defer os.Remove(tmpFile.Name()) + + _, err = tmpFile.Write([]byte(configContent)) + assert.NoError(t, err) + //nolint:errcheck + tmpFile.Close() + + // Define base path for test + basePath := "/base/path" + + // Test loading valid configuration + config, err := LoadConfig(tmpFile.Name(), basePath) + assert.NoError(t, err) + assert.NotNil(t, config) + assert.Equal(t, 1, len(config.Remotes)) + assert.Equal(t, "http://example.com/s/xyz123", config.Remotes[0].URL) + assert.Equal(t, filepath.Join(basePath, "sync_folder"), config.Remotes[0].LocalPath) + assert.Equal(t, "/", config.Remotes[0].RemoteFolder) + assert.Equal(t, "xyz123", config.Remotes[0].Username) + assert.Equal(t, "example.com:sync_folder", config.Remotes[0].String()) + + // Test loading configuration with a non-existent file + config, err = LoadConfig("nonexistent.yaml", basePath) + assert.Error(t, err) + assert.Nil(t, config) + assert.Contains(t, err.Error(), "error reading config file") + + // Test loading configuration with invalid YAML content + invalidConfigContent := ` +remotes: + - url: "http://example.com/s/xyz123" + local_path: "sync_folder" +invalid_yaml +` + tmpFile2, err := os.CreateTemp("", "config_invalid*.yaml") + assert.NoError(t, err) + //nolint:errcheck + defer os.Remove(tmpFile2.Name()) + + _, err = tmpFile2.Write([]byte(invalidConfigContent)) + assert.NoError(t, err) + //nolint:errcheck + tmpFile2.Close() + + config, err = LoadConfig(tmpFile2.Name(), basePath) + assert.Error(t, err) + assert.Nil(t, config) + assert.Contains(t, err.Error(), "error parsing config file") +} + +func TestRemote_validateAndSetup(t *testing.T) { + basePath := "/base/path" + + // Test case with a valid shared link + remote := &Remote{ + URL: "http://example.com/s/xyz123", + LocalPath: "sync_folder", + } + + err := remote.validateAndSetup(basePath) + assert.NoError(t, err) + assert.Equal(t, "/", remote.RemoteFolder) + assert.Equal(t, "xyz123", remote.Username) + assert.Equal(t, filepath.Join(basePath, "sync_folder"), remote.LocalPath) + assert.Equal(t, "example.com:sync_folder", remote.String()) + + // Test case with missing URL + remote = &Remote{ + LocalPath: "sync_folder", + } + + err = remote.validateAndSetup(basePath) + assert.Error(t, err) + assert.Equal(t, "URL is required", err.Error()) + + // Test case with missing local path + remote = &Remote{ + URL: "http://example.com/s/xyz123", + } + + err = remote.validateAndSetup(basePath) + assert.Error(t, err) + assert.Equal(t, "local path is required", err.Error()) + + // Test case with username set for a shared link + remote = &Remote{ + URL: "http://example.com/s/xyz123", + Username: "user", + LocalPath: "sync_folder", + } + + err = remote.validateAndSetup(basePath) + assert.Error(t, err) + assert.Equal(t, "username should not be set for shared links", err.Error()) + + // Test case with remote folder set for a shared link + remote = &Remote{ + URL: "http://example.com/s/xyz123", + RemoteFolder: "should_not_set", + LocalPath: "sync_folder", + } + + err = remote.validateAndSetup(basePath) + assert.Error(t, err) + assert.Equal(t, "remote folder should not be set for shared links", err.Error()) + + // Test case with invalid URL + remote = &Remote{ + URL: "://invalid-url", + LocalPath: "sync_folder", + } + + err = remote.validateAndSetup(basePath) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid URL") +} diff --git a/pkg/contoller.go b/pkg/contoller.go new file mode 100644 index 0000000..9413c38 --- /dev/null +++ b/pkg/contoller.go @@ -0,0 +1,227 @@ +package pkg + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "path" + "time" + + "github.com/godbus/dbus/v5" + "github.com/google/go-github/v55/github" +) + +type NetworkConnectionReconciler struct { + conn *dbus.Conn + ch chan *dbus.Signal + syncer *Syncer +} + +var networkConnectionFailedErr = fmt.Errorf("network connection failed") + +func NewNetworkConnectionReconciler(config *Config) *NetworkConnectionReconciler { + conn, err := dbus.SystemBus() + if err != nil { + log.Fatalf("Failed to connect to system bus: %v", err) + } + ch := make(chan *dbus.Signal, 10) + call := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, + "type='signal',interface='com.github.shermp.nickeldbus',member='wmNetworkConnected',path='/nickeldbus'") + if call.Err != nil { + log.Fatalf("Failed to add D-Bus match: %v", call.Err) + } + conn.Signal(ch) + return &NetworkConnectionReconciler{ + conn: conn, + ch: ch, + syncer: NewSyncer(config), + } +} + +func (n *NetworkConnectionReconciler) Run(ctx context.Context) { + defer func() { + fmt.Println("Exiting network connection reconciler") + n.conn.RemoveSignal(n.ch) + close(n.ch) + //nolint:errcheck + n.conn.Close() + }() + for { + fmt.Println("Listening for network connection signals from Nickel...") + select { + case <-ctx.Done(): + fmt.Println("Context done") + return + case signal, ok := <-n.ch: + if !ok { + log.Println("Signal channel closed") + return + } + if signal == nil { + log.Println("Received nil signal") + continue + } + fmt.Printf("Received signal: %s\n", signal.Name) + // Check if the signal is the one we are interested in + if signal.Name != "com.github.shermp.nickeldbus.wmNetworkConnected" { + log.Println("Received unexpected signal", signal.Name) + continue + } + err := n.handleWmNetworkConnected() + if err != nil { + log.Println("Failed to handle network connected signal", err) + } + } + } +} + +func (n *NetworkConnectionReconciler) handleWmNetworkConnected() error { + filesMap, nUpdatedFiles, err := n.SyncNow() + if err != nil { + log.Println("Failed to sync", err) + if errors.Is(err, networkConnectionFailedErr) { + return nil + } + n.notifyNickel(fmt.Sprintf("Failed to sync: %s\n%s", err.Error(), generateFilesString(filesMap))) + return err + } + if nUpdatedFiles > 0 { + n.notifyNickel(fmt.Sprintf("Synced %d files:\n%s", nUpdatedFiles, generateFilesString(filesMap))) + } + log.Println("Sync successful") + if n.syncer.config.AutoUpdate && n.UpdateNow() { + log.Println("Auto update successful") + n.notifyNickel("Nextcloud-Kobo Syncer updated successfully") + } + return nil +} + +func (n *NetworkConnectionReconciler) UpdateNow() bool { + // Check the latest version on GitHub + cli := github.NewClient(nil) + release, _, err := cli.Repositories.GetLatestRelease(context.Background(), "aleskandro", "nextcloud-kobo") + // If we can't get the latest release, don't update + if err != nil { + log.Println("Failed to get latest release", err) + return false + } + // get the latest updated version stored in the config + version, err := os.ReadFile(path.Join(n.syncer.config.basePath, "version.txt")) + if err != nil && !os.IsNotExist(err) { + log.Println("Failed to read version file", err) + return false + } + if string(version) == *release.TagName { + log.Println("Already up to date") + return false + } + // Download the latest release + asset := *release.Assets[0] + resp, err := http.Get(*asset.BrowserDownloadURL) + if err != nil { + log.Println("Failed to download latest release", err) + return false + } + //nolint:errcheck + defer resp.Body.Close() + + // The latest release is a KoboRoot.tgz file. Let's extract it in the / directory + // This will overwrite the existing files. + err = extractTarGz(resp.Body) + if err != nil { + log.Println("Failed to extract tar.gz", err) + return false + } + // Write the latest release to a file + versionFile, err := os.Create(path.Join(n.syncer.config.basePath, "version.txt")) + if err != nil { + log.Println("Failed to create version file", err) + return false + } + _, err = versionFile.Write([]byte(*release.TagName)) + if err != nil { + log.Println("Failed to write version file", err) + return false + } + return true +} + +func (n *NetworkConnectionReconciler) SyncNow() (filesMap map[string][]string, nUpdatedFiles int, err error) { + if err = checkNetwork(); err != nil { + log.Println("Network connection failed", err) + return filesMap, 0, networkConnectionFailedErr + } + + filesMap, err = n.syncer.RunSync() + if err != nil { + return + } + for _, files := range filesMap { + nUpdatedFiles += len(files) + } + if nUpdatedFiles == 0 { + log.Println("No files updated") + return + } + err = n.rescanBooks() + if err != nil { + return + } + return +} + +func (n *NetworkConnectionReconciler) rescanBooks() error { + obj := n.conn.Object("com.github.shermp.nickeldbus", "/nickeldbus") + call := obj.Call("com.github.shermp.nickeldbus.pfmRescanBooks", 0) + if call.Err != nil { + log.Println("Failed to rescan books", call.Err) + return call.Err + } + return nil +} + +func (n *NetworkConnectionReconciler) notifyNickel(message string) { + obj := n.conn.Object("com.github.shermp.nickeldbus", "/nickeldbus") + call := obj.Call("com.github.shermp.nickeldbus.mwcToast", 0, 5000, "NextCloud Kobo Syncer", message) + if call.Err != nil { + log.Println("Failed to notify Nickel", call.Err) + } +} + +func checkNetwork() error { + // Wait for the network to be fully connected + for i := 0; i < 10; i++ { + // Check if a web request to google is successful + client := &http.Client{ + Timeout: 5 * time.Second, + } + req, err := http.NewRequest("GET", "http://www.google.com", nil) + if err != nil { + log.Println("Fatal error", err) + return err + } + resp, err := client.Do(req) + if err == nil { + log.Printf("HTTP request #%d/10 successful\n", i+1) + //nolint:errcheck + resp.Body.Close() + return nil + } + log.Printf("HTTP request #%d/10 failed: %v\n", i+1, err) + time.Sleep(time.Second) + } + return fmt.Errorf("network connection failed") +} + +func generateFilesString(filesMap map[string][]string) (filesString string) { + for remote, files := range filesMap { + filesString += fmt.Sprintf("Remote: %s\n", remote) + for _, file := range files { + filesString += fmt.Sprintf(" - %s\n", file) + } + } + return +} diff --git a/pkg/syncer.go b/pkg/syncer.go new file mode 100644 index 0000000..d24787e --- /dev/null +++ b/pkg/syncer.go @@ -0,0 +1,85 @@ +package pkg + +import ( + "fmt" + "log" + "os" + "path" + + "github.com/studio-b12/gowebdav" +) + +type Syncer struct { + config *Config +} + +func NewSyncer(config *Config) *Syncer { + return &Syncer{config: config} +} + +func (c *Syncer) RunSync() (updatedFiles map[string][]string, err error) { + updatedFiles = make(map[string][]string) + log.Println("Running sync") + for _, r := range c.config.Remotes { + log.Println("Syncing remote", r.String()) + client := gowebdav.NewClient(r.remoteURL.String(), r.Username, r.Password) + updatedFiles[r.String()], err = c.syncFolder(client, r.RemoteFolder, r.LocalPath) + if err != nil { + log.Println("error syncing folder", r.String(), err) + return updatedFiles, fmt.Errorf("error syncing folder %s: %s", r.String(), err) + } + log.Println("Synced remote", r.String()) + } + return +} + +func (c *Syncer) syncFolder(client *gowebdav.Client, remotePath, localPath string) (updatedFiles []string, err error) { + var remoteFiles []os.FileInfo + updatedFiles = []string{} + remoteFiles, err = client.ReadDir(remotePath) + if err != nil { + return + } + + if ensureDirExists(localPath) != nil { + return + } + localFileMap := make(map[string]string) + for _, file := range remoteFiles { + remoteFilePath := path.Join(remotePath, file.Name()) + localFilePath := path.Join(localPath, file.Name()) + log.Println("Checking file", remoteFilePath, localFilePath) + if file.IsDir() { + if ensureDirExists(localFilePath) != nil { + return + } + var updatedFilesRec []string + updatedFilesRec, err = c.syncFolder(client, remoteFilePath+"/", localFilePath) + updatedFiles = append(updatedFiles, updatedFilesRec...) + if err != nil { + return + } + } else { + localFileMap[localFilePath] = remoteFilePath + if shouldDownloadFile(localFilePath, file.ModTime(), file.Size()) { + log.Printf("Downloading file %s to %s\n", remoteFilePath, localFilePath) + var data []byte + data, err = client.Read(remoteFilePath) + if err != nil { + return + } + //#nosec G306 + err = os.WriteFile(localFilePath, data, 0644) + if err != nil { + return + } + updatedFiles = append(updatedFiles, localFilePath) + log.Println("Downloaded file", localFilePath) + } else { + log.Println("Skipping file", remoteFilePath) + } + } + } + err = removeRemotelyDeletedFiles(localFileMap, localPath) + return updatedFiles, err +} diff --git a/pkg/utils.go b/pkg/utils.go new file mode 100644 index 0000000..8dd61b7 --- /dev/null +++ b/pkg/utils.go @@ -0,0 +1,106 @@ +package pkg + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path" + "path/filepath" + "time" +) + +// 8 MB limit per file to mitigate G110: (CWE-409): Potential DoS vulnerability via decompression bomb +const maxFileSize = int64(16 * 1024 * 1024) + +func shouldDownloadFile(localFilePath string, remoteModTime time.Time, size int64) bool { + info, err := os.Stat(localFilePath) + if os.IsNotExist(err) { + return true + } + if err != nil { + fmt.Println("Error getting local file info:", err) + return true + } + if info.Size() != size { + return true + } + return remoteModTime.After(info.ModTime()) +} + +func removeRemotelyDeletedFiles(remoteFileMap map[string]string, localPath string) (err error) { + files, _ := os.ReadDir(localPath) + for _, file := range files { + localFilePath := path.Join(localPath, file.Name()) + if _, ok := remoteFileMap[file.Name()]; !ok { + err = os.RemoveAll(localFilePath) + if err != nil { + return + } + } + } + return +} + +func ensureDirExists(dir string) error { + if _, err := os.Stat(dir); os.IsNotExist(err) { + return os.MkdirAll(dir, os.ModePerm) + } + return nil +} + +func extractTarGz(gzipStream io.ReadCloser) error { + // Create a gzip reader from the io.ReadCloser + gzipReader, err := gzip.NewReader(gzipStream) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + //nolint:errcheck + defer gzipReader.Close() + + // Create a tar reader from the decompressed gzip reader + tarReader := tar.NewReader(gzipReader) + + // Iterate through the files in the tar archive + for { + header, err := tarReader.Next() + if err == io.EOF { + // End of archive + break + } + if err != nil { + return fmt.Errorf("failed to read tar entry: %w", err) + } + + // Handle different file types (e.g., regular files, directories) + switch header.Typeflag { + case tar.TypeDir: + // Create the directory if it doesn't exist + if err := os.MkdirAll(path.Join("/", filepath.Clean(header.Name)), os.FileMode(header.Mode)); err != nil { + return fmt.Errorf("failed to create directory %s: %w", header.Name, err) + } + + case tar.TypeReg: + // Create the file + outFile, err := os.Create(path.Join("/", header.Name)) // #nosec G305 + if err != nil { + return fmt.Errorf("failed to create file %s: %w", header.Name, err) + } + //nolint:errcheck + defer outFile.Close() + + // Copy file content from the tar archive + limitReader := io.LimitReader(tarReader, maxFileSize) + if _, err := io.Copy(outFile, limitReader); err != nil { + return fmt.Errorf("failed to write file %s: %w", header.Name, err) + } + + default: + // Handle other file types if needed + fmt.Printf("Unknown file type: %c in %s\n", header.Typeflag, header.Name) + } + } + + return nil +} diff --git a/pkg/utils_test.go b/pkg/utils_test.go new file mode 100644 index 0000000..95fe7aa --- /dev/null +++ b/pkg/utils_test.go @@ -0,0 +1,127 @@ +package pkg + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// Helper function to create a temporary file for testing +func createTempFile(t *testing.T, content string, modTime time.Time) string { + t.Helper() + + tmpFile, err := os.CreateTemp("", "testfile") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + //nolint:errcheck + defer tmpFile.Close() + + if _, err := tmpFile.WriteString(content); err != nil { + t.Fatalf("Failed to write to temp file: %v", err) + } + + // Set the modification time + if err := os.Chtimes(tmpFile.Name(), modTime, modTime); err != nil { + t.Fatalf("Failed to set modification time: %v", err) + } + + return tmpFile.Name() +} + +func TestShouldDownloadFile(t *testing.T) { + now := time.Now() + oldTime := now.Add(-time.Hour) + newTime := now.Add(time.Hour) + + tests := []struct { + name string + localFilePath string + remoteModTime time.Time + size int64 + expectedResult bool + }{ + { + name: "File does not exist", + localFilePath: "nonexistent.txt", + remoteModTime: now, + size: 100, + expectedResult: true, + }, + { + name: "File exists with different size", + localFilePath: createTempFile(t, "content", oldTime), + remoteModTime: now, + size: 200, // Different size + expectedResult: true, + }, + { + name: "File exists with older mod time", + localFilePath: createTempFile(t, "content", oldTime), + remoteModTime: newTime, + size: 7, // Same size as content + expectedResult: true, + }, + { + name: "File exists with same mod time and size", + localFilePath: createTempFile(t, "content", now), + remoteModTime: now, + size: 7, // Same size as content + expectedResult: false, + }, + } + + for _, tt := range tests[1:] { + //nolint:errcheck + defer os.Remove(tt.localFilePath) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := shouldDownloadFile(tt.localFilePath, tt.remoteModTime, tt.size) + if result != tt.expectedResult { + t.Errorf("expected %v, got %v", tt.expectedResult, result) + } + }) + } +} + +func TestRemoveRemotelyDeletedFiles(t *testing.T) { + // Create a temporary directory for testing + localDir, err := os.MkdirTemp("", "localdir") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + //nolint:errcheck + defer os.RemoveAll(localDir) // Clean up after the test + + // Create some local files + localFile1 := filepath.Join(localDir, "file1.txt") + localFile2 := filepath.Join(localDir, "file2.txt") + if err := os.WriteFile(localFile1, []byte("content1"), 0644); err != nil { + t.Fatalf("Failed to create file1: %v", err) + } + if err := os.WriteFile(localFile2, []byte("content2"), 0644); err != nil { + t.Fatalf("Failed to create file2: %v", err) + } + + // Define remote files map (file2 is missing) + remoteFiles := map[string]string{ + "file1.txt": "", + } + + // Run the function to test + err = removeRemotelyDeletedFiles(remoteFiles, localDir) + if err != nil { + t.Fatalf("Function returned an error: %v", err) + } + + // Check if the correct files were removed + if _, err := os.Stat(localFile1); os.IsNotExist(err) { + t.Errorf("file1.txt should not be deleted") + } + if _, err := os.Stat(localFile2); err == nil { + t.Errorf("file2.txt should have been deleted") + } +} diff --git a/root/etc/udev/rules.d/97-nextcloud-kobo.rules b/root/etc/udev/rules.d/97-nextcloud-kobo.rules new file mode 100644 index 0000000..22bca64 --- /dev/null +++ b/root/etc/udev/rules.d/97-nextcloud-kobo.rules @@ -0,0 +1,2 @@ +KERNEL=="lo", RUN+="/bin/sh /usr/local/nextcloud-kobo/run.sh" + diff --git a/root/usr/local/nextcloud-kobo/config.example.yaml b/root/usr/local/nextcloud-kobo/config.example.yaml new file mode 100644 index 0000000..eb8814a --- /dev/null +++ b/root/usr/local/nextcloud-kobo/config.example.yaml @@ -0,0 +1,11 @@ +remotes: + - url: https://nextcloud.jdoe.com/s/abc123 + local_path: share1/ + - url: https://nextcloud.jdoe.com/ + username: john # Do not set if using a share link + password: doe + remote_folder: /my-remote-folder/ # Do not set if using a share link + local_path: share2/ + - url: https://nextcloud.jdoe.com/s/abc123 + password: doe + local_path: share3/ diff --git a/root/usr/local/nextcloud-kobo/run.sh b/root/usr/local/nextcloud-kobo/run.sh new file mode 100755 index 0000000..5a8cb3c --- /dev/null +++ b/root/usr/local/nextcloud-kobo/run.sh @@ -0,0 +1,27 @@ +#!/bin/sh + +if pgrep "nextcloud-kobo"; then + echo "nextcloud-kobo is already running" + exit 0 +fi + +mkdir -p /mnt/onboard/.adds/nextcloud-kobo +mkdir -p /mnt/onboard/nextcloud +cp /usr/local/nextcloud-kobo/config.example.yaml /mnt/onboard/.adds/nextcloud-kobo/config.example.yaml + +if [ ! -f /mnt/onboard/.adds/nextcloud-kobo/config.yaml ]; then + echo "Configuration file not found. Do not run." + qndb -m mwcToast 5000 "NextCloud-Kobo" "Configuration file not found. Not running." + exit 0 +fi + +# Clean the log file if the size is greater than 2MB +if [ -f /mnt/onboard/.adds/nextcloud-kobo/nextcloud-kobo.log ] && \ + [ "$(stat -c %s /mnt/onboard/.adds/nextcloud-kobo/nextcloud-kobo.log)" -gt 2097152 ]; then + echo "Log file is greater than 2MB. Cleaning it." + echo "" > /mnt/onboard/.adds/nextcloud-kobo/nextcloud-kobo.log +fi +(while true; do +/usr/local/nextcloud-kobo/nextcloud-kobo -config-file /mnt/onboard/.adds/nextcloud-kobo/config.yaml \ + -base-path /mnt/onboard/nextcloud >> /mnt/onboard/.adds/nextcloud-kobo/nextcloud-kobo.log 2>&1 +done) & \ No newline at end of file