diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..92be835 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,57 @@ +name: Go package + +on: + pull_request: + push: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: nextcloud:latest + ports: + - 18080:80 + env: + SQLITE_DATABASE: nextcloud + NEXTCLOUD_ADMIN_USER: admin + NEXTCLOUD_ADMIN_PASSWORD: password + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: 1.17 + + - name: Run vet + run: go vet ./... + + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.29 + + - uses: dominikh/staticcheck-action@v1.2.0 + with: + version: "2022.1.1" + + - name: Test + run: go test -v ./... + + build: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: 1.19 + + - name: Build + run: go build . \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5236e1e..f166652 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ *~ - +.idea diff --git a/README.md b/README.md index 5c4ccd8..853d8de 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,23 @@ This is a golang client for [ownCloud](https://owncloud.com) and [NextCloud](https://nextcloud.com). +# Usages +Installation +```shell +go install github.com/eu-erwin/nextcloud-cli +``` + +# Usages +Available cli command +- upload + +## Upload command +To upload a file for example `README.md` from current working directory to directory `Notes` in nextcloud. simply use +```shell +nextcloud-cli --username=hello --password=world --url=http://localhost:18080/ --path=Notes README.md +``` + + # LICENSE MIT diff --git a/cmd/cli.go b/cmd/cli.go new file mode 100644 index 0000000..509f6e2 --- /dev/null +++ b/cmd/cli.go @@ -0,0 +1,92 @@ +package cli + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + + "github.com/eu-erwin/nextcloud-cli/pkg/cloud" + "github.com/eu-erwin/nextcloud-cli/pkg/nextcloud" +) + +var ( + client cloud.Storage + targetPath *string + wd string +) + +func init() { + cloudUrl := flag.String("url", "", "Please enter url") + username := flag.String("username", "", "Please enter username") + password := flag.String("password", "", "Please enter password") + targetPath = flag.String("path", "", "Target path to upload file") + flag.Parse() + + storage, err := nextcloud.NewStorage(*cloudUrl, *username, *password) + if nil != err { + log.Println("storage can't be created. reason: ", err.Error()) + return + } + + client = storage + wd, _ = os.Getwd() + _ = []string{wd} +} + +func printHelp() { + log.Println(`Available command: +upload + +Available flags: +--url url of the nextcloud host (*) +--username your username (*) +--password your password (*) +--path target path + +Ex.: nextcloud-cli upload --username=john --password=supersecret --url=https://cloud.example.com hello.text`) +} + +func Run() { + args := flag.Args() + if len(args) == 0 { + log.Println(`missing command`) + printHelp() + return + } + + switch args[0] { + case "upload": + upload(args[1:]...) + default: + log.Println(`missing command`) + printHelp() + } +} + +func upload(sources ...string) { + for _, source := range sources { + fmt.Printf("Uploading %s\r\n", source) + + content, err := os.ReadFile(strings.Join([]string{wd, source}, "/")) + if err != nil { + log.Println("Can't upload", source, err.Error()) + continue + } + + target := source + if *targetPath != "" { + if err = client.Mkdir(*targetPath); err != nil { + log.Println("New directory created", source) + } + target = strings.Join([]string{*targetPath, source}, "/") + } + + err = client.Upload(content, target) + if err != nil { + log.Println("Upload failed", source, err.Error()) + continue + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4521d64 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/eu-erwin/nextcloud-cli + +go 1.19 + +require github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8 + +require ( + github.com/kr/pretty v0.2.1 // indirect + github.com/kr/text v0.1.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..84250d7 --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8 h1:nRDwTcxV9B3elxMt+1xINX0bwaPdpouqp5fbynexY8U= +github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8/go.mod h1:jOEnp79oIHy5cvQSHeLcgVJk1GHOOHJHQWps/d1N5Yo= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c67d3e5 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import cli "github.com/eu-erwin/nextcloud-cli/cmd" + +func main() { + cli.Run() +} diff --git a/pkg/cloud/storage.go b/pkg/cloud/storage.go new file mode 100644 index 0000000..f3084f2 --- /dev/null +++ b/pkg/cloud/storage.go @@ -0,0 +1,35 @@ +package cloud + +import "encoding/xml" + +type ShareElement struct { + Id uint `xml:"id"` + Url string `xml:"url"` +} + +type ShareResult struct { + XMLName xml.Name `xml:"ocs"` + Status string `xml:"meta>status"` + StatusCode uint `xml:"meta>statuscode"` + Message string `xml:"meta>message"` + Id uint `xml:"data>id"` + Url string `xml:"data>url"` + Elements []ShareElement `xml:"data>element"` +} + +type Storage interface { + Mkdir(path string) error + Delete(path string) error + Upload(src []byte, dest string) error + UploadDir(src string, dest string) ([]string, error) + Download(path string) ([]byte, error) + Exists(path string) bool + CreateGroupFolder(mountPoint string) (*ShareResult, error) + AddGroupToGroupFolder(group string, folderId uint) (*ShareResult, error) + SetGroupPermissionsForGroupFolder(permissions int, group string, folderId uint) (*ShareResult, error) + CreateShare(path string, shareType int, publicUpload string, permissions int) (*ShareResult, error) + GetShare(path string) (*ShareResult, error) + DeleteShare(id uint) (*ShareResult, error) + CreateFileDropShare(path string) (*ShareResult, error) + CreateReadOnlyShare(path string) (*ShareResult, error) +} diff --git a/cloud.go b/pkg/nextcloud/cloud.go similarity index 75% rename from cloud.go rename to pkg/nextcloud/cloud.go index 817f469..9aa5e75 100644 --- a/cloud.go +++ b/pkg/nextcloud/cloud.go @@ -1,17 +1,44 @@ -package cloud +package nextcloud import ( "bytes" "encoding/xml" + "errors" "fmt" - "io/ioutil" + "io" "net/http" "net/url" + "os" "path/filepath" "strings" + + "github.com/eu-erwin/nextcloud-cli/pkg/cloud" ) -// A client represents a client connection to a {own|next}cloud +func NewStorage( + cloudUrl, + username, + password string, +) (cloud.Storage, error) { + if cloudUrl == "" { + return nil, errors.New("missing url") + } + if username == "" || password == "" { + return nil, errors.New("missing credentials") + } + + parsedUrl, err := url.Parse(cloudUrl) + if nil != err { + return nil, errors.New("invalid url") + } + return &Client{ + Url: parsedUrl, + Username: username, + Password: password, + }, nil +} + +// Client represents a client connection to a {own|next}cloud type Client struct { Url *url.URL Username string @@ -33,30 +60,15 @@ func (e *Error) Error() string { return fmt.Sprintf("Exception: %s, Message: %s", e.Exception, e.Message) } -type ShareElement struct { - Id uint `xml:"id"` - Url string `xml:"url"` -} - -type ShareResult struct { - XMLName xml.Name `xml:"ocs"` - Status string `xml:"meta>status"` - StatusCode uint `xml:"meta>statuscode"` - Message string `xml:"meta>message"` - Id uint `xml:"data>id"` - Url string `xml:"data>url"` - Elements []ShareElement `xml:"data>element"` -} - // Dial connects to an {own|next}Cloud instance at the specified // address using the given credentials. func Dial(host, username, password string) (*Client, error) { - url, err := url.Parse(host) + parsedUrl, err := url.Parse(host) if err != nil { return nil, err } return &Client{ - Url: url, + Url: parsedUrl, Username: username, Password: password, }, nil @@ -90,7 +102,7 @@ func (c *Client) UploadDir(src string, dest string) ([]string, error) { return nil, err } for _, file := range files { - data, err := ioutil.ReadFile(file) + data, err := os.ReadFile(file) if err != nil { return nil, err } @@ -113,31 +125,31 @@ func (c *Client) Exists(path string) bool { return err == nil } -func (c *Client) CreateGroupFolder(mountPoint string) (*ShareResult, error) { +func (c *Client) CreateGroupFolder(mountPoint string) (*cloud.ShareResult, error) { return c.sendAppsRequest("POST", "groupfolders/folders", fmt.Sprintf("mountpoint=%s", mountPoint)) } -func (c *Client) AddGroupToGroupFolder(group string, folderId uint) (*ShareResult, error) { +func (c *Client) AddGroupToGroupFolder(group string, folderId uint) (*cloud.ShareResult, error) { return c.sendAppsRequest("POST", fmt.Sprintf("groupfolders/folders/%d/groups", folderId), fmt.Sprintf("group=%s", group)) } -func (c *Client) SetGroupPermissionsForGroupFolder(permissions int, group string, folderId uint) (*ShareResult, error) { +func (c *Client) SetGroupPermissionsForGroupFolder(permissions int, group string, folderId uint) (*cloud.ShareResult, error) { return c.sendAppsRequest("POST", fmt.Sprintf("apps/groupfolders/folders/%d/groups/%s", folderId, group), fmt.Sprintf("permissions=%d", permissions)) } -func (c *Client) CreateShare(path string, shareType int, publicUpload string, permissions int) (*ShareResult, error) { +func (c *Client) CreateShare(path string, shareType int, publicUpload string, permissions int) (*cloud.ShareResult, error) { return c.sendOCSRequest("POST", "shares", fmt.Sprintf("path=%s&shareType=%d&publicUpload=%s&permissions=%d", path, shareType, publicUpload, permissions)) } -func (c *Client) GetShare(path string) (*ShareResult, error) { +func (c *Client) GetShare(path string) (*cloud.ShareResult, error) { return c.sendOCSRequest("GET", fmt.Sprintf("shares?path=%s", path), "") } -func (c *Client) DeleteShare(id uint) (*ShareResult, error) { +func (c *Client) DeleteShare(id uint) (*cloud.ShareResult, error) { return c.sendOCSRequest("DELETE", fmt.Sprintf("shares/%d", id), "") } -func (c *Client) CreateFileDropShare(path string) (*ShareResult, error) { +func (c *Client) CreateFileDropShare(path string) (*cloud.ShareResult, error) { result, err := c.CreateShare(path, 3, "true", 4) if err != nil { return nil, err @@ -146,7 +158,7 @@ func (c *Client) CreateFileDropShare(path string) (*ShareResult, error) { return c.sendOCSRequest("PUT", fmt.Sprintf("shares/%d", id), "permissions=4") } -func (c *Client) CreateReadOnlyShare(path string) (*ShareResult, error) { +func (c *Client) CreateReadOnlyShare(path string) (*cloud.ShareResult, error) { result, err := c.CreateShare(path, 3, "true", 4) if err != nil { return nil, err @@ -178,20 +190,20 @@ func (c *Client) sendWebDavRequest(request string, path string, data []byte) ([] return nil, err } - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if len(body) > 0 { if body[0] == '<' { - error := Error{} - err = xml.Unmarshal(body, &error) - if err != nil { - return body, err + reqErr := &Error{} + decodeErr := xml.Unmarshal(body, reqErr) + if decodeErr != nil { + return body, decodeErr } - if error.Exception != "" { - return nil, err + if reqErr.Exception != "" { + return nil, reqErr } } @@ -200,7 +212,7 @@ func (c *Client) sendWebDavRequest(request string, path string, data []byte) ([] return body, nil } -func (c *Client) sendAppsRequest(request string, path string, data string) (*ShareResult, error) { +func (c *Client) sendAppsRequest(request string, path string, data string) (*cloud.ShareResult, error) { // Create the https request appsPath := filepath.Join("apps", path) @@ -226,24 +238,24 @@ func (c *Client) sendAppsRequest(request string, path string, data string) (*Sha return nil, err } - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } - result := ShareResult{} + result := cloud.ShareResult{} err = xml.Unmarshal(body, &result) if err != nil { return nil, err } if result.StatusCode != 100 { - return nil, fmt.Errorf("Share API returned an unsuccessful status code %d", result.StatusCode) + return nil, fmt.Errorf("share API returned an unsuccessful status code %d", result.StatusCode) } return &result, nil } -func (c *Client) sendOCSRequest(request string, path string, data string) (*ShareResult, error) { +func (c *Client) sendOCSRequest(request string, path string, data string) (*cloud.ShareResult, error) { // Create the https request appsPath := filepath.Join("ocs/v2.php/apps/files_sharing/api/v1", path) @@ -269,20 +281,20 @@ func (c *Client) sendOCSRequest(request string, path string, data string) (*Shar return nil, err } - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } - result := ShareResult{} + result := &cloud.ShareResult{} - err = xml.Unmarshal(body, &result) + err = xml.Unmarshal(body, result) if err != nil { return nil, err } if result.StatusCode != 200 { - return nil, fmt.Errorf("Share API returned an unsuccessful status code %d", result.StatusCode) + return nil, fmt.Errorf("share API returned an unsuccessful status code %d", result.StatusCode) } - return &result, nil + return result, nil } diff --git a/cloud_test.go b/pkg/nextcloud/cloud_test.go similarity index 77% rename from cloud_test.go rename to pkg/nextcloud/cloud_test.go index 9b04494..53ed50f 100644 --- a/cloud_test.go +++ b/pkg/nextcloud/cloud_test.go @@ -1,7 +1,7 @@ -package cloud +package nextcloud import ( - "io/ioutil" + "os" "path/filepath" "testing" @@ -34,11 +34,10 @@ func (t *testSuite) BeforeAll() { if err != nil { panic(err) } - } func (t *testSuite) After() { - client.Delete("Test") + _ = client.Delete("Test") } func (t *testSuite) TestMkDir() { @@ -54,12 +53,13 @@ func (t *testSuite) TestDelete() { } func (t *testSuite) TestDownloadUpload() { - err := client.Mkdir("Test") - t.Nil(err) + mkdirErr := client.Mkdir("Test") + t.Nil(mkdirErr) - src, err := ioutil.ReadFile(filepath.Join(testDir, "test.txt")) - err = client.Upload(src, "Test/test.txt") - t.Nil(err) + src, readErr := os.ReadFile(filepath.Join(testDir, "test.txt")) + t.Nil(readErr) + readErr = client.Upload(src, "Test/test.txt") + t.Nil(readErr) data, err := client.Download("Test/test.txt") t.Nil(err) @@ -124,8 +124,10 @@ func (t *testSuite) TestCreateFileDropShare() { t.True(len(result.Url) > 0) } - client.Delete("ShareTest") - + err = client.Delete("ShareTest") + if err != nil { + panic("can't delete ShareTest on TestCreateFileDropShare") + } } func (t *testSuite) TestGetShare() { @@ -134,12 +136,17 @@ func (t *testSuite) TestGetShare() { result, err := client.CreateFileDropShare("ShareTest") t.Nil(err) + t.Not(t.Nil(result)) result, err = client.GetShare("ShareTest") t.Nil(err) t.True(len(result.Elements) > 0) + t.Not(t.Nil(result)) - client.Delete("ShareTest") + err = client.Delete("ShareTest") + if err != nil { + panic("can't delete ShareTest on TestGetShare") + } } func (t *testSuite) TestDeleteShare() { @@ -148,19 +155,27 @@ func (t *testSuite) TestDeleteShare() { result, err := client.CreateFileDropShare("ShareTest") t.Nil(err) + t.Not(t.Nil(result)) result, err = client.GetShare("ShareTest") t.Nil(err) t.True(len(result.Elements) > 0) + t.Not(t.Nil(result)) result, err = client.DeleteShare(result.Elements[0].Id) t.Nil(err) + t.Not(t.Nil(result)) result, err = client.GetShare("ShareTest") t.Nil(err) t.True(len(result.Elements) == 0) + t.Not(t.Nil(result)) - client.Delete("ShareTest") + err = client.Delete("ShareTest") + if err != nil { + panic("can't delete ShareTest on TestDeleteShare") + } + _ = result } func (t *testSuite) TestCreateReadOnlyShare() { @@ -169,17 +184,24 @@ func (t *testSuite) TestCreateReadOnlyShare() { result, err := client.CreateReadOnlyShare("ShareTest") t.Nil(err) + t.Not(t.Nil(result)) result, err = client.GetShare("ShareTest") t.Nil(err) t.True(len(result.Elements) > 0) + t.Not(t.Nil(result)) result, err = client.DeleteShare(result.Elements[0].Id) t.Nil(err) + t.Not(t.Nil(result)) result, err = client.GetShare("ShareTest") t.Nil(err) t.True(len(result.Elements) == 0) + t.Not(t.Nil(result)) - client.Delete("ShareTest") + err = client.Delete("ShareTest") + if err != nil { + panic("can't delete ShareTest on TestCreateReadOnlyShare") + } } diff --git a/test/testdata/Folder/test.txt b/pkg/nextcloud/test/testdata/Folder/test.txt similarity index 100% rename from test/testdata/Folder/test.txt rename to pkg/nextcloud/test/testdata/Folder/test.txt diff --git a/test/testdata/test.txt b/pkg/nextcloud/test/testdata/test.txt similarity index 100% rename from test/testdata/test.txt rename to pkg/nextcloud/test/testdata/test.txt diff --git a/test/docker/docker-compose.yml b/test/docker/docker-compose.yml deleted file mode 100644 index ed9c1fb..0000000 --- a/test/docker/docker-compose.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: "3" - -services: - - remogatto_cloud_test: - restart: always - image: nextcloud:latest - container_name: nextcloud_cloud_test - # volumes: - # - ./custom_apps:/var/www/html/ - environment: - - SQLITE_DATABASE=nextcloud - - NEXTCLOUD_ADMIN_USER=admin - - NEXTCLOUD_ADMIN_PASSWORD=password - ports: - - 18080:80