Compare commits

...

144 Commits
2 ... master

Author SHA1 Message Date
shoopea
f073d3021d remove all debug 2024-01-20 12:33:07 +01:00
shoopea
d8c0f204df test 2024-01-20 12:18:22 +01:00
shoopea
45e36d9254 details 2024-01-20 12:11:08 +01:00
shoopea
526c74ad21 fix 2024-01-20 12:06:08 +01:00
shoopea
1dc1a2b378 still more debug 2024-01-20 12:05:02 +01:00
shoopea
e3bd2a5fb2 even more debug 2024-01-20 11:59:18 +01:00
shoopea
331e1f8e16 more debug 2024-01-20 11:43:26 +01:00
shoopea
d59e787694 debug 2024-01-20 11:27:24 +01:00
shoopea
42ddb5a83e migrate 2024-01-20 11:02:25 +01:00
Christoph Polcin
f9157dbec1 Update Readme 2023-06-22 14:01:37 +02:00
Yuriy Zaytsev
3cf99ede06 Fix missing last modified time on Stat() 2023-06-22 13:42:44 +02:00
Julian Imhof
ea9c8490dd feat(auth): add support for MS-PASS
typo: fix comment


fix: fix verify flow and precompile cookies


fix: implement requested changes


fix: implement requested changes


fix: implement requested changes


test(auth): add test for MS-PASS


fix: remove pointless return
2023-06-22 13:37:18 +02:00
Christoph Polcin
036581a6c8 io.Discard is already handeld by Body.Close() 2023-06-22 13:32:03 +02:00
Christoph Polcin
11f0c10ed2 Runs test on go 1.18, 1.19 and 1.20 2023-06-22 13:32:03 +02:00
Christoph Polcin
b601d4bcbf Update preemtiveAuthorizer documentation 2023-06-22 13:32:03 +02:00
Christoph Polcin
9c049da1f7 Updates API 2023-06-22 13:32:03 +02:00
Christoph Polcin
fcd6cac508 Removes panic from authShim 2023-06-22 13:32:03 +02:00
Christoph Polcin
a4cd839b8a Adds len checks to negoAuth 2023-06-22 13:32:03 +02:00
Christoph Polcin
d59c1705b2 Explicit assignment of struct fields 2023-06-22 13:32:03 +02:00
Christoph Polcin
ad27fc07a7 Adds interface documentation 2023-06-22 13:32:03 +02:00
Steffen Brüheim
5fd8037a63 Add multiple auth test 2023-06-22 13:32:03 +02:00
Christoph Polcin
ca40e2802e Feat: Authentication API
The changes simplify the `req` method by moving the
authentication-related code into the API.
This makes it easy to add additional authentication methods.

The API introduces an `Authorizer` that acts as an
authenticator factory. The authentication flow itself
is divided down into `Authorize` and `Verify` steps in order
to encapsulate and control complex authentication challenges.

The default `NewAutoAuth` negotiates the algorithms.
Under the hood, it creates an authenticator shim per request,
which delegates the authentication flow to our authenticators.

The `NewEmptyAuth` and `NewPreemptiveAuth` authorizers
allow you to have more control over algorithms and resources.

The API also allows interception of the redirect mechanism by setting
the `XInhibitRedirect` header.

This closes: #15 #24 #38
2023-06-22 13:32:03 +02:00
Christoph Polcin
3282f94193 Feat: Makefile adds target dependencies 2023-02-03 21:22:12 +01:00
yyeltsyn
cd21842fb6
fix: Allow concurrent use for DigestAuth (#69)
fix: Allow concurrent use for DigestAuth
2023-02-03 18:59:54 +01:00
Steffen Brüheim
60ec5ad560 Fixes #64 Support for cookies
expose a SetJar method to set a http.CookieJar to the http client
2022-11-09 18:19:24 +01:00
Christoph Polcin
200a600c02 Add TestWriteStreamFromPipe 2022-11-02 16:54:56 +01:00
Christoph Polcin
17255f2e74 Updates badges 2022-10-16 01:27:16 +02:00
Christoph Polcin
937a18c9a3 removes travis-ci 2022-10-16 01:22:06 +02:00
Christoph Polcin
d2a480ffa9 updates workflows 2022-10-16 01:13:50 +02:00
Christoph Polcin
8528c01163 updates API 2022-10-16 01:02:00 +02:00
Christoph Polcin
bf6102194f fix: Write creates parent collections on 404 2022-10-16 00:59:14 +02:00
Christoph Polcin
2c20e7e763 Revert "feat: handle 404 on propfind (#57)"
This reverts commit 8190232c061223ef86e381fe762cc9dcf8420a00.
2022-10-16 00:59:14 +02:00
Christoph Polcin
fbeb69f25b Add client tests 2022-10-16 00:59:05 +02:00
Christoph Polcin
4adca27344 uses log instead of fmt 2022-10-15 14:44:49 +02:00
Felipe Martin Garcia
8190232c06
feat: handle 404 on propfind (#57)
Client.Stat was not returning a proper Go err for not found files, the
ideal way to check this is using `errors.Is(err, fs.ErrNotExist)` but
the client was returning a generic error.

I've updated the `propfind` to take 404 errors into account, retuning
the above error making easier to evaluate that kind of situations.
2022-10-13 23:11:52 +02:00
zhijian
e70a598e94
supports get range offset with unkown length (#58)
https://www.rfc-editor.org/rfc/rfc9110.html#name-byte-ranges
2022-10-12 18:09:28 +02:00
Ringo Hoffmann
c7b1ff8a5e
Improve Error Handling (#54)
* bubble up request errors [#28]

* inhibit stream close on request

* add `StatusError`

* `PUT`: check if given target is a directory

* Revert "inhibit stream close on request"

Cherry-picked into branch dev-bodyclosing.

This reverts commit 2889239999c7ef561d20228311ea947fea154996.

Co-authored-by: Christoph Polcin <coco@miconoco.de>
2022-01-28 17:20:35 +01:00
Christoph Polcin
a047320e42 Updates README 2022-01-27 16:33:26 +01:00
Christoph Polcin
b5bd04e2b5 Escapes destination path on copy and move #42 2022-01-27 16:32:05 +01:00
Ringo Hoffmann
3f8721cd4b fix crash when req is called with no body 2021-11-09 09:32:28 +01:00
Ringo Hoffmann
adba8dc051 add .vscode directory 2021-11-09 09:32:28 +01:00
Ringo Hoffmann
2f2cda4122 use seeker when available on request 2021-11-08 09:54:01 +01:00
Ringo Hoffmann
73a7f0bf37 add artifacts workflow 2021-11-07 22:37:54 +01:00
Ringo Hoffmann
aff231de53 add github workflow for unit tests 2021-11-07 22:36:17 +01:00
Ringo Hoffmann
e5dd1e70b1 create go module 2021-11-07 22:35:47 +01:00
Christoph Polcin
29e74efa70
Merge pull request #46 from jkowalski/read-stream-range
added ReadStreamRange() method to efficiently read a range of data
2021-11-06 10:05:35 +01:00
Jarek Kowalski
741fdbda3d added ReadStreamRange() method to efficiently read a range of data
It passes "Range: bytes=X-Y" and if the server returns HTTP 206,
we know it complied with the request.

For servers that don't understand range and return HTTP 200 instead we
discard some bytes and limit the result to emulate this behavior.

This will greatly help https://github.com/kopia/kopia which relies on
partial reads from pack blobs.
2021-11-04 23:32:32 -07:00
Christoph Polcin
a3a86976a1
Merge pull request #45 from marcelblijleven/master
Fix index out of range runtime error when empty string is provided to FixSlashes
2021-09-17 15:32:50 +02:00
Marcel Blijleven
a2cbdfa976 Fix index out of range runtime error when provided string is empty 2021-09-17 13:24:28 +02:00
Marcel Blijleven
9a1ba21162 Fix typo in description 2021-09-17 13:23:09 +02:00
Mark Severson
7ff61aa87b Handle request errors in copymove 2021-06-30 12:06:26 +02:00
Christoph Polcin
86f8378cf1 Update API description 2021-04-27 23:21:33 +02:00
Felix Pojtinger
4145fa842c Add ability to define custom interceptors (fixes #35) 2021-04-27 23:12:51 +02:00
David Holdeman
8244b5a5f5
Merge pull request #41 from needsaholiday/bug/pluscharacter
fix unescape
2021-02-03 15:23:56 -06:00
Nick Kratzke
d02a1ebcd2 switched to PathUnescape 2021-02-03 21:16:30 +01:00
Nick
3ed042db71 fix unescape 2021-02-03 15:25:12 +01:00
Christoph Polcin
bdacfab947 update README due to API changes 2020-09-29 10:07:39 +02:00
Jarek Kowalski
617404b525 fixed panic due to concurrent map writes
Fixes #36
2020-09-28 11:17:19 +02:00
Christoph Polcin
9380631c29 update README 2020-03-03 16:07:24 +01:00
Lukáš Lalinský
a93005d73c
Add File.Path to return the full path (#34)
Very useful when iterating over ReadDir results and wanting to do some operation on them.
2020-03-03 15:28:06 +01:00
vitalii
321978fa73 ref: make names more descriptive 2019-12-15 01:31:08 +02:00
vitalii
c4c707907d fix method of getting user's home path 2019-12-15 01:28:41 +02:00
Justus Flerlage
9f625b1b8e Added check for root path 2019-11-14 22:08:20 +02:00
Justus Flerlage
e53b818e1b Added check of parentPath in createParentCollection 2019-11-14 22:08:20 +02:00
vitalii
ff7f737904 fix(requests.go): allow www-authenticate to be case-insensitive. close #32 2019-10-05 22:13:01 +03:00
vitalii
38f79aeaf1 fix uploading file with wrong content. close #30 2019-01-03 20:40:47 +02:00
vitalii
6c32839dbd fix import for command line tool 2018-12-30 13:28:02 +02:00
vitalii
4d70d7ea28 use 'application/xml' instead of 'text/xml'. related with (1) in #15 2018-12-29 22:04:04 +02:00
yatsen1
8bcb1b383c Fix early defer panic. (#29) 2018-11-08 09:39:42 +01:00
vitalii
cba565a9dc links was updated in readme file. Related to studio-b12/gowebdav#27
RFC 2518 was removed because it is obsolete
RFC 4918 was added because it is actual
RFC 5689 was added because it updates RFC 4918
2018-10-24 14:05:51 +03:00
vitalii
425530b55e cmd: readme was updated. closes studio-b12/gowebdav#27
Wrapper script section was updated
2018-10-22 08:29:10 +03:00
vitalii
7493d8befb readme file for command line tool was added
Close #27
2018-10-22 08:10:23 +03:00
vitalii
e29bc0f031 main readme file was updated 2018-10-22 08:09:12 +03:00
vitalii
c8fc9ca590 Merge remote-tracking branch 'upstream/master' 2018-10-21 12:52:46 +03:00
vitalii
a68e21e92b requests: nil pointer dereference panic was fixed [#25]
http.Do() method will return non-nil [error] in following cases:
1. Request is nil
2. Response missing Location header
3. Client failed to parse Location header
4. Method "request.GetBody()" returns error
5. Http.Client.Send() returns error
5. Client timeout was exceeded

Signed-off-by: Christoph Polcin <labs@polcin.de>
2018-10-21 11:15:52 +02:00
vitalii
02aa9bdaeb unused imports was removed 2018-10-20 23:08:19 +03:00
vitalii
8de8ce169b Merge remote-tracking branch 'upstream/master' 2018-07-17 14:00:43 +03:00
Christoph Polcin
3cd755d6c4 make check api 2018-07-14 01:55:58 +02:00
Vitalii
83e3d1e31e Creating parent collection method was added (#22)
* method for creating parent collection was added to Client struct

"func (c *Client) createParentCollection(itemPath string) error" was added to request.go file

* using Client's method to create parent collection

in following methods:
Client.Write()
Client.WriteStream()
Client.copymove()

deadlock is impossible in method Client.copymove() because of paragraph #6 section 9.8.5 (https://tools.ietf.org/html/rfc4918#section-9.8.5) and paragraph #6 section 9.9.4 (https://tools.ietf.org/html/rfc4918#section-9.9.4) of RFC 4918 (https://tools.ietf.org/html/rfc4918)

* install dependencies script was added to Travis-CI file

* testing was added to Travis-CI file

* error wrapping was removed from Client.put() method

* using an early return on error in case of 409 in Client.Write() method
2018-07-14 01:48:30 +02:00
Christoph Polcin
28039fda22 fmt 2018-07-13 12:12:09 +02:00
Christoph Polcin
45a56c2115 cmd: remove Connect() due to #16 2018-07-13 12:11:56 +02:00
vitalii
f821ab73e9 Merge branch 'b12-master' 2018-07-11 12:41:49 +03:00
Vitalii
ec1263db2f all cases of Digest authorization was implemented (#19)
Digest authentication was improved
2018-07-10 18:51:11 +02:00
Vitalii
95706c0747 .gitignore was expanded (#18) 2018-07-08 14:27:10 +02:00
Vitalii
f43a0a4cf8 quick bugfix for issue #20 (#21)
issue title: "Can't upload file with content"
https://github.com/studio-b12/gowebdav/issues/20
2018-07-08 14:25:00 +02:00
vitalii
68824ef55e createParentCollection() function was added 2018-06-21 16:37:02 +03:00
vitalii
6ca20e2a70 copyMove() function returns error in case of 409 status code 2018-06-21 16:35:39 +03:00
vitalii
790397514e all cases of Digest authorization was implemented 2018-06-21 16:35:26 +03:00
vitalii
4ca2f77e2b Merge remote-tracking branch 'upstream/master' 2018-06-21 16:34:17 +03:00
Vitalii
008b27eb0f bug #14 fixed (#17)
"opaque" field should not be specified if server did not provide it
2018-06-21 10:57:32 +02:00
vitalii
d1ebcbebf2 Merge branch 'develop' 2018-06-20 04:02:49 +03:00
vitalii
4f450cfd02 Merge branch 'master' into develop 2018-06-20 04:01:37 +03:00
vitalii
876ef52924 not all webdav servers returns "Dav" header 2018-06-20 03:21:30 +03:00
vitalii
21d86ab356 use import of current fork (instead of original project) 2018-06-20 03:19:23 +03:00
vitalii
97a0b83aeb bug #14 fixed
"opaque" field should not be specified if server did not provide it
2018-06-20 03:17:52 +03:00
vitalii
1fe9163c92 Merge remote-tracking branch 'studio-b12/master' 2018-06-19 11:44:16 +03:00
vitalii
8bab650703 gitignore: .idea folder and *.exe files was added 2018-06-19 11:42:02 +03:00
Christoph Polcin
c4c24955e1 update README 2018-06-19 08:43:13 +02:00
Christoph Polcin
2593a81bf0 Update issue templates 2018-06-19 08:36:40 +02:00
David
b45378c08f Check status on every request to fix #14 2018-06-18 10:02:01 -05:00
vitalii
aebc3ef9d2 .idea/ folder and *.exe files was added to .gitignore 2018-06-14 09:24:10 +03:00
misha-plus
9ff8e33634 fix authorization 2018-06-10 11:01:51 +02:00
Aaron Bieber
a33240e4ab add ability to read login / pw from ~/.netrc 2018-05-26 01:36:44 +02:00
Christoph Polcin
fbcb29d33e docs 2018-05-25 23:59:53 +02:00
David
6d8c168f72 Add Authenticator interface and Digest auth support 2018-05-25 22:40:13 +02:00
Christoph Polcin
5bedad6f1e cmd: refactor method 2018-05-25 20:31:58 +02:00
Christoph Polcin
31e0b57e53 cmd: PUT with optinal FILE argument 2018-05-25 20:27:56 +02:00
Christoph Polcin
ba3a71318b cmd: refactor output message 2018-05-25 19:58:46 +02:00
Christoph Polcin
fa51555f16 cmd: simplify arg handling 2018-05-25 12:28:37 +02:00
Christoph Polcin
e0b778960b cmd: GET with optional FILE argument 2018-05-25 12:28:37 +02:00
Christoph Polcin
a98da9745e cmd: create parent directories if writing files 2018-05-25 12:28:37 +02:00
Christoph Polcin
1786d37966 ignore second cmd arg 2018-05-25 12:28:37 +02:00
Christoph Polcin
32d5561fb6 add make check 2018-05-25 12:28:37 +02:00
Christoph Polcin
d6b82df09a update cmd usage 2018-05-25 12:28:34 +02:00
Christoph Polcin
c16034adb0 refactor getCmd 2018-05-25 10:15:26 +02:00
Christoph Polcin
1e4b7a5554 refactor client 2018-05-24 01:24:27 +02:00
Christoph Polcin
3993494db0 refactor cmd 2018-05-24 01:24:25 +02:00
Christoph Polcin
34368960d0 refactor 2018-05-23 14:15:46 +02:00
Christoph Polcin
375f391c8a Escape URL 2018-05-23 13:41:06 +02:00
Shishkin Artem
c49c91989e % and # in the URL 2018-05-22 12:34:34 +02:00
Christoph Polcin
7ea52a8e4f fix close 2018-05-17 01:24:14 +02:00
Abdurrahman
0e7fe90d28 Fetch ContentType and ETag 2018-05-17 01:22:58 +02:00
Christoph Polcin
0120c3c3c4 hello travis-ci 2018-05-17 00:27:52 +02:00
Christoph Polcin
410e447c49 simplify 409 write error handler 2018-05-16 23:39:37 +02:00
Christoph Polcin
65a34986d4 update Readme 2018-05-16 14:35:38 +02:00
Keith Ball
73b90bf27e Do not do a read on the root on connect 2018-05-16 14:17:35 +02:00
Keith Ball
9c695fe0df Return codes 2018-05-16 14:15:47 +02:00
Christoph Polcin
732590873e docs 2018-05-16 14:05:18 +02:00
Christoph Polcin
5a1a85f622 update README 2018-05-16 14:05:18 +02:00
Christoph Polcin
056ad2c4d5 refactor main 2018-05-16 12:23:42 +02:00
Aaron Bieber
8a7d0f4982 cleanup 2018-05-16 10:43:04 +02:00
Aaron Bieber
beeefa572c more docs / cleanup 2018-05-16 10:42:53 +02:00
Aaron Bieber
b290410ef9 add a bit of doc, cleanup some warnings 2018-05-16 10:42:38 +02:00
Aaron Bieber
af17afdfdc reference full path for gowebdav 2018-05-16 10:34:52 +02:00
Aaron Bieber
76d61cc2bc use defaults for GOPATH 2018-05-16 10:34:42 +02:00
Christoph Polcin
06583fe483 update rfc links 2018-05-15 12:14:08 +02:00
Christoph Polcin
49e0e45d8f fmt 2018-05-15 11:51:59 +02:00
Christoph Polcin
a3c4dbab4a add SetTimeout 2018-05-15 11:51:53 +02:00
Christoph Polcin
f60c73fbb4
Merge pull request #8 from Ferada/fix-read-status-code
Handle error response when reading a file.
2017-12-20 19:29:10 +01:00
Olof-Joachim Frahm
a11466bd13 Handle error response when reading a file. 2017-12-20 10:53:49 +01:00
Christoph Polcin
e3cd1f98e7 Merge pull request #6 from ProgramYazar/master
fix delete for yandex
2017-09-19 07:22:16 +02:00
Christoph Polcin
e3a31466a7 Merge pull request #4 from ayllon/master
Add SetTransport method
2017-09-19 07:19:23 +02:00
Engin KIZILGÜN
2b5dab74d3 fix delete for yandex
204 success added
2017-09-18 15:54:19 +03:00
Alejandro Alvarez Ayllon
79a29f3ad5 Add SetTransport method 2017-08-07 12:13:13 +02:00
29 changed files with 3483 additions and 343 deletions

27
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,27 @@
---
name: Bug report
about: Create a report to help us improve
---
Hello Collaborators,
**Describe the bug**
A short description of what you think the bug is.
**Software**
- OS:
- Golang:
- Version:
**To Reproduce**
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected**
A short description of what you expected to happen.
**Additional context**
Add any other context about the problem here.

46
.github/workflows/artifacts.yml vendored Normal file
View File

@ -0,0 +1,46 @@
name: Build Artifacts
on:
workflow_dispatch:
push:
branches:
- master
paths-ignore:
- "**.md"
jobs:
build_artifacts:
name: Build Artifcats
runs-on: ubuntu-latest
strategy:
matrix:
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
steps:
- name: Set up Go
uses: actions/setup-go@v1
with:
go-version: "^1.17"
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get ./...
- name: Build Client (${{ matrix.goos }}-${{ matrix.goarch }})
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: go build -v -o ./bin/gowebdav-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/gowebdav/main.go
- name: Rename Windows Binary
if: ${{ matrix.goos == 'windows' }}
env:
FNAME: ./bin/gowebdav-${{ matrix.goos }}-${{ matrix.goarch }}
run: mv ${{ env.FNAME }} ${{ env.FNAME }}.exe
- name: Upload Artifcats
uses: actions/upload-artifact@v2
with:
name: ${{ matrix.goos }}-${{ matrix.goarch }}
path: ./bin/

31
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Unit Tests
on:
workflow_dispatch:
push:
branches:
- "*"
paths-ignore:
- "**.md"
pull_request:
jobs:
unit_tests:
name: Unit Tests
runs-on: ubuntu-latest
strategy:
matrix:
goversion:
- "1.20"
- "1.19"
- "1.18"
steps:
- name: Set up Go
uses: actions/setup-go@v1
with:
go-version: ${{ matrix.goversion }}
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get ./...
- name: Run Unit Tests
run: go test -modfile=go_test.mod -v -cover -race ./...

23
.gitignore vendored
View File

@ -1,2 +1,21 @@
src
bin
# Folders to ignore
/src
/bin
/pkg
/gowebdav
/.idea
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, build with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
.vscode/

View File

@ -1,21 +1,42 @@
SRC := $(wildcard *.go) main/client.go
BIN := bin
CLIENT := ${BIN}/client
GOPATH ?= ${PWD}
BIN := gowebdav
SRC := $(wildcard *.go) cmd/gowebdav/main.go
all: test client
all: test cmd
client: ${CLIENT}
cmd: ${BIN}
${CLIENT}: ${SRC}
@echo build $@
@GOPATH=${GOPATH} go build -o $@ -- main/client.go
${BIN}: ${SRC}
go build -o $@ ./cmd/gowebdav
test:
@GOPATH=${GOPATH} go test
go test -modfile=go_test.mod -v -short -cover ./...
api: .go/bin/godoc2md
@sed '/^## API$$/,$$d' -i README.md
@echo '## API' >> README.md
@$< github.com/studio-b12/gowebdav | sed '/^$$/N;/^\n$$/D' |\
sed '2d' |\
sed 's/\/src\/github.com\/studio-b12\/gowebdav\//https:\/\/github.com\/studio-b12\/gowebdav\/blob\/master\//g' |\
sed 's/\/src\/target\//https:\/\/github.com\/studio-b12\/gowebdav\/blob\/master\//g' |\
sed 's/^#/##/g' >> README.md
check: .go/bin/gocyclo
gofmt -w -s $(SRC)
@echo
.go/bin/gocyclo -over 15 .
@echo
go vet -modfile=go_test.mod ./...
.go/bin/godoc2md:
@mkdir -p $(@D)
@GOPATH="$(CURDIR)/.go" go install github.com/davecheney/godoc2md@latest
.go/bin/gocyclo:
@mkdir -p $(@D)
@GOPATH="$(CURDIR)/.go" go install github.com/fzipp/gocyclo/cmd/gocyclo@latest
clean:
@echo clean ${BIN}
@rm -f ${BIN}/*
@rm -f ${BIN}
.PHONY: all client clean
.PHONY: all cmd clean test api check

795
README.md
View File

@ -1,10 +1,793 @@
# GOWEBDAV - WebDAV Client for golang
# GoWebDAV
## LINKS
[![Unit Tests Status](https://github.com/studio-b12/gowebdav/actions/workflows/tests.yml/badge.svg)](https://github.com/studio-b12/gowebdav/actions/workflows/tests.yml)
[![Build Artifacts Status](https://github.com/studio-b12/gowebdav/actions/workflows/artifacts.yml/badge.svg)](https://github.com/studio-b12/gowebdav/actions/workflows/artifacts.yml)
[![GoDoc](https://godoc.org/github.com/studio-b12/gowebdav?status.svg)](https://godoc.org/github.com/studio-b12/gowebdav)
[![Go Report Card](https://goreportcard.com/badge/github.com/studio-b12/gowebdav)](https://goreportcard.com/report/github.com/studio-b12/gowebdav)
* [rfc2518](http://asg.andrew.cmu.edu/rfc/rfc2518.html)
* [rfc2616](http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html)
A pure Golang WebDAV client library that comes with a [reference implementation](https://github.com/studio-b12/gowebdav/tree/master/cmd/gowebdav).
## Books
## Features at a glance
* [WebDav: Next Gen](https://books.google.de/books?isbn=0130652083 "WebDav: Next Generation Collaborative Web Authoring By Lisa Dusseault")
Our `gowebdav` library allows to perform following actions on the remote WebDAV server:
* [create path](#create-path-on-a-webdav-server)
* [get files list](#get-files-list)
* [download file](#download-file-to-byte-array)
* [upload file](#upload-file-from-byte-array)
* [get information about specified file/folder](#get-information-about-specified-filefolder)
* [move file to another location](#move-file-to-another-location)
* [copy file to another location](#copy-file-to-another-location)
* [delete file](#delete-file)
It also provides an [authentication API](#type-authenticator) that makes it easy to encapsulate and control complex authentication challenges.
The default implementation negotiates the algorithm based on the user's preferences and the methods offered by the remote server.
Out-of-box authentication support for:
* [BasicAuth](https://en.wikipedia.org/wiki/Basic_access_authentication)
* [DigestAuth](https://en.wikipedia.org/wiki/Digest_access_authentication)
* [MS-PASS](https://github.com/studio-b12/gowebdav/pull/70#issuecomment-1421713726)
* [WIP Kerberos](https://github.com/studio-b12/gowebdav/pull/71#issuecomment-1416465334)
* [WIP Bearer Token](https://github.com/studio-b12/gowebdav/issues/61)
## Usage
First of all you should create `Client` instance using `NewClient()` function:
```go
root := "https://webdav.mydomain.me"
user := "user"
password := "password"
c := gowebdav.NewClient(root, user, password)
c.Connect()
// kick of your work!
```
After you can use this `Client` to perform actions, described below.
**NOTICE:** We will not check for errors in the examples, to focus you on the `gowebdav` library's code, but you should do it in your code!
### Create path on a WebDAV server
```go
err := c.Mkdir("folder", 0644)
```
In case you want to create several folders you can use `c.MkdirAll()`:
```go
err := c.MkdirAll("folder/subfolder/subfolder2", 0644)
```
### Get files list
```go
files, _ := c.ReadDir("folder/subfolder")
for _, file := range files {
//notice that [file] has os.FileInfo type
fmt.Println(file.Name())
}
```
### Download file to byte array
```go
webdavFilePath := "folder/subfolder/file.txt"
localFilePath := "/tmp/webdav/file.txt"
bytes, _ := c.Read(webdavFilePath)
os.WriteFile(localFilePath, bytes, 0644)
```
### Download file via reader
Also you can use `c.ReadStream()` method:
```go
webdavFilePath := "folder/subfolder/file.txt"
localFilePath := "/tmp/webdav/file.txt"
reader, _ := c.ReadStream(webdavFilePath)
file, _ := os.Create(localFilePath)
defer file.Close()
io.Copy(file, reader)
```
### Upload file from byte array
```go
webdavFilePath := "folder/subfolder/file.txt"
localFilePath := "/tmp/webdav/file.txt"
bytes, _ := os.ReadFile(localFilePath)
c.Write(webdavFilePath, bytes, 0644)
```
### Upload file via writer
```go
webdavFilePath := "folder/subfolder/file.txt"
localFilePath := "/tmp/webdav/file.txt"
file, _ := os.Open(localFilePath)
defer file.Close()
c.WriteStream(webdavFilePath, file, 0644)
```
### Get information about specified file/folder
```go
webdavFilePath := "folder/subfolder/file.txt"
info := c.Stat(webdavFilePath)
//notice that [info] has os.FileInfo type
fmt.Println(info)
```
### Move file to another location
```go
oldPath := "folder/subfolder/file.txt"
newPath := "folder/subfolder/moved.txt"
isOverwrite := true
c.Rename(oldPath, newPath, isOverwrite)
```
### Copy file to another location
```go
oldPath := "folder/subfolder/file.txt"
newPath := "folder/subfolder/file-copy.txt"
isOverwrite := true
c.Copy(oldPath, newPath, isOverwrite)
```
### Delete file
```go
webdavFilePath := "folder/subfolder/file.txt"
c.Remove(webdavFilePath)
```
## Links
More details about WebDAV server you can read from following resources:
* [RFC 4918 - HTTP Extensions for Web Distributed Authoring and Versioning (WebDAV)](https://tools.ietf.org/html/rfc4918)
* [RFC 5689 - Extended MKCOL for Web Distributed Authoring and Versioning (WebDAV)](https://tools.ietf.org/html/rfc5689)
* [RFC 2616 - HTTP/1.1 Status Code Definitions](http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html "HTTP/1.1 Status Code Definitions")
* [WebDav: Next Generation Collaborative Web Authoring By Lisa Dusseaul](https://books.google.de/books?isbn=0130652083 "WebDav: Next Generation Collaborative Web Authoring By Lisa Dusseault")
**NOTICE**: RFC 2518 is obsoleted by RFC 4918 in June 2007
## Contributing
All contributing are welcome. If you have any suggestions or find some bug - please create an Issue to let us make this project better. We appreciate your help!
## License
This library is distributed under the BSD 3-Clause license found in the [LICENSE](https://github.com/studio-b12/gowebdav/blob/master/LICENSE) file.
## API
`import "github.com/studio-b12/gowebdav"`
* [Overview](#pkg-overview)
* [Index](#pkg-index)
* [Examples](#pkg-examples)
* [Subdirectories](#pkg-subdirectories)
### <a name="pkg-overview">Overview</a>
Package gowebdav is a WebDAV client library with a command line tool
included.
### <a name="pkg-index">Index</a>
* [Constants](#pkg-constants)
* [Variables](#pkg-variables)
* [func FixSlash(s string) string](#FixSlash)
* [func FixSlashes(s string) string](#FixSlashes)
* [func IsErrCode(err error, code int) bool](#IsErrCode)
* [func IsErrNotFound(err error) bool](#IsErrNotFound)
* [func Join(path0 string, path1 string) string](#Join)
* [func NewPathError(op string, path string, statusCode int) error](#NewPathError)
* [func NewPathErrorErr(op string, path string, err error) error](#NewPathErrorErr)
* [func PathEscape(path string) string](#PathEscape)
* [func ReadConfig(uri, netrc string) (string, string)](#ReadConfig)
* [func String(r io.Reader) string](#String)
* [type AuthFactory](#AuthFactory)
* [type Authenticator](#Authenticator)
* [func NewDigestAuth(login, secret string, rs *http.Response) (Authenticator, error)](#NewDigestAuth)
* [func NewPassportAuth(c *http.Client, user, pw, partnerURL string, header *http.Header) (Authenticator, error)](#NewPassportAuth)
* [type Authorizer](#Authorizer)
* [func NewAutoAuth(login string, secret string) Authorizer](#NewAutoAuth)
* [func NewEmptyAuth() Authorizer](#NewEmptyAuth)
* [func NewPreemptiveAuth(auth Authenticator) Authorizer](#NewPreemptiveAuth)
* [type BasicAuth](#BasicAuth)
* [func (b *BasicAuth) Authorize(c *http.Client, rq *http.Request, path string) error](#BasicAuth.Authorize)
* [func (b *BasicAuth) Clone() Authenticator](#BasicAuth.Clone)
* [func (b *BasicAuth) Close() error](#BasicAuth.Close)
* [func (b *BasicAuth) String() string](#BasicAuth.String)
* [func (b *BasicAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error)](#BasicAuth.Verify)
* [type Client](#Client)
* [func NewAuthClient(uri string, auth Authorizer) *Client](#NewAuthClient)
* [func NewClient(uri, user, pw string) *Client](#NewClient)
* [func (c *Client) Connect() error](#Client.Connect)
* [func (c *Client) Copy(oldpath, newpath string, overwrite bool) error](#Client.Copy)
* [func (c *Client) Mkdir(path string, _ os.FileMode) (err error)](#Client.Mkdir)
* [func (c *Client) MkdirAll(path string, _ os.FileMode) (err error)](#Client.MkdirAll)
* [func (c *Client) Read(path string) ([]byte, error)](#Client.Read)
* [func (c *Client) ReadDir(path string) ([]os.FileInfo, error)](#Client.ReadDir)
* [func (c *Client) ReadStream(path string) (io.ReadCloser, error)](#Client.ReadStream)
* [func (c *Client) ReadStreamRange(path string, offset, length int64) (io.ReadCloser, error)](#Client.ReadStreamRange)
* [func (c *Client) Remove(path string) error](#Client.Remove)
* [func (c *Client) RemoveAll(path string) error](#Client.RemoveAll)
* [func (c *Client) Rename(oldpath, newpath string, overwrite bool) error](#Client.Rename)
* [func (c *Client) SetHeader(key, value string)](#Client.SetHeader)
* [func (c *Client) SetInterceptor(interceptor func(method string, rq *http.Request))](#Client.SetInterceptor)
* [func (c *Client) SetJar(jar http.CookieJar)](#Client.SetJar)
* [func (c *Client) SetTimeout(timeout time.Duration)](#Client.SetTimeout)
* [func (c *Client) SetTransport(transport http.RoundTripper)](#Client.SetTransport)
* [func (c *Client) Stat(path string) (os.FileInfo, error)](#Client.Stat)
* [func (c *Client) Write(path string, data []byte, _ os.FileMode) (err error)](#Client.Write)
* [func (c *Client) WriteStream(path string, stream io.Reader, _ os.FileMode) (err error)](#Client.WriteStream)
* [type DigestAuth](#DigestAuth)
* [func (d *DigestAuth) Authorize(c *http.Client, rq *http.Request, path string) error](#DigestAuth.Authorize)
* [func (d *DigestAuth) Clone() Authenticator](#DigestAuth.Clone)
* [func (d *DigestAuth) Close() error](#DigestAuth.Close)
* [func (d *DigestAuth) String() string](#DigestAuth.String)
* [func (d *DigestAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error)](#DigestAuth.Verify)
* [type File](#File)
* [func (f File) ContentType() string](#File.ContentType)
* [func (f File) ETag() string](#File.ETag)
* [func (f File) IsDir() bool](#File.IsDir)
* [func (f File) ModTime() time.Time](#File.ModTime)
* [func (f File) Mode() os.FileMode](#File.Mode)
* [func (f File) Name() string](#File.Name)
* [func (f File) Path() string](#File.Path)
* [func (f File) Size() int64](#File.Size)
* [func (f File) String() string](#File.String)
* [func (f File) Sys() interface{}](#File.Sys)
* [type PassportAuth](#PassportAuth)
* [func (p *PassportAuth) Authorize(c *http.Client, rq *http.Request, path string) error](#PassportAuth.Authorize)
* [func (p *PassportAuth) Clone() Authenticator](#PassportAuth.Clone)
* [func (p *PassportAuth) Close() error](#PassportAuth.Close)
* [func (p *PassportAuth) String() string](#PassportAuth.String)
* [func (p *PassportAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error)](#PassportAuth.Verify)
* [type StatusError](#StatusError)
* [func (se StatusError) Error() string](#StatusError.Error)
##### <a name="pkg-examples">Examples</a>
* [PathEscape](#example_PathEscape)
##### <a name="pkg-files">Package files</a>
[auth.go](https://github.com/studio-b12/gowebdav/blob/master/auth.go) [basicAuth.go](https://github.com/studio-b12/gowebdav/blob/master/basicAuth.go) [client.go](https://github.com/studio-b12/gowebdav/blob/master/client.go) [digestAuth.go](https://github.com/studio-b12/gowebdav/blob/master/digestAuth.go) [doc.go](https://github.com/studio-b12/gowebdav/blob/master/doc.go) [errors.go](https://github.com/studio-b12/gowebdav/blob/master/errors.go) [file.go](https://github.com/studio-b12/gowebdav/blob/master/file.go) [netrc.go](https://github.com/studio-b12/gowebdav/blob/master/netrc.go) [passportAuth.go](https://github.com/studio-b12/gowebdav/blob/master/passportAuth.go) [requests.go](https://github.com/studio-b12/gowebdav/blob/master/requests.go) [utils.go](https://github.com/studio-b12/gowebdav/blob/master/utils.go)
### <a name="pkg-constants">Constants</a>
``` go
const XInhibitRedirect = "X-Gowebdav-Inhibit-Redirect"
```
### <a name="pkg-variables">Variables</a>
``` go
var ErrAuthChanged = errors.New("authentication failed, change algorithm")
```
ErrAuthChanged must be returned from the Verify method as an error
to trigger a re-authentication / negotiation with a new authenticator.
``` go
var ErrTooManyRedirects = errors.New("stopped after 10 redirects")
```
ErrTooManyRedirects will be used as return error if a request exceeds 10 redirects.
### <a name="FixSlash">func</a> [FixSlash](https://github.com/studio-b12/gowebdav/blob/master/utils.go?s=354:384#L23)
``` go
func FixSlash(s string) string
```
FixSlash appends a trailing / to our string
### <a name="FixSlashes">func</a> [FixSlashes](https://github.com/studio-b12/gowebdav/blob/master/utils.go?s=506:538#L31)
``` go
func FixSlashes(s string) string
```
FixSlashes appends and prepends a / if they are missing
### <a name="IsErrCode">func</a> [IsErrCode](https://github.com/studio-b12/gowebdav/blob/master/errors.go?s=740:780#L29)
``` go
func IsErrCode(err error, code int) bool
```
IsErrCode returns true if the given error
is an os.PathError wrapping a StatusError
with the given status code.
### <a name="IsErrNotFound">func</a> [IsErrNotFound](https://github.com/studio-b12/gowebdav/blob/master/errors.go?s=972:1006#L39)
``` go
func IsErrNotFound(err error) bool
```
IsErrNotFound is shorthand for IsErrCode
for status 404.
### <a name="Join">func</a> [Join](https://github.com/studio-b12/gowebdav/blob/master/utils.go?s=639:683#L40)
``` go
func Join(path0 string, path1 string) string
```
Join joins two paths
### <a name="NewPathError">func</a> [NewPathError](https://github.com/studio-b12/gowebdav/blob/master/errors.go?s=1040:1103#L43)
``` go
func NewPathError(op string, path string, statusCode int) error
```
### <a name="NewPathErrorErr">func</a> [NewPathErrorErr](https://github.com/studio-b12/gowebdav/blob/master/errors.go?s=1194:1255#L51)
``` go
func NewPathErrorErr(op string, path string, err error) error
```
### <a name="PathEscape">func</a> [PathEscape](https://github.com/studio-b12/gowebdav/blob/master/utils.go?s=153:188#L14)
``` go
func PathEscape(path string) string
```
PathEscape escapes all segments of a given path
### <a name="ReadConfig">func</a> [ReadConfig](https://github.com/studio-b12/gowebdav/blob/master/netrc.go?s=428:479#L27)
``` go
func ReadConfig(uri, netrc string) (string, string)
```
ReadConfig reads login and password configuration from ~/.netrc
machine foo.com login username password 123456
### <a name="String">func</a> [String](https://github.com/studio-b12/gowebdav/blob/master/utils.go?s=813:844#L45)
``` go
func String(r io.Reader) string
```
String pulls a string out of our io.Reader
### <a name="AuthFactory">type</a> [AuthFactory](https://github.com/studio-b12/gowebdav/blob/master/auth.go?s=150:251#L13)
``` go
type AuthFactory func(c *http.Client, rs *http.Response, path string) (auth Authenticator, err error)
```
AuthFactory prototype function to create a new Authenticator
### <a name="Authenticator">type</a> [Authenticator](https://github.com/studio-b12/gowebdav/blob/master/auth.go?s=2155:2695#L56)
``` go
type Authenticator interface {
// Authorizes a request. Usually by adding some authorization headers.
Authorize(c *http.Client, rq *http.Request, path string) error
// Verifies the response if the authorization was successful.
// May trigger some round trips to pass the authentication.
// May also trigger a new Authenticator negotiation by returning `ErrAuthChenged`
Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error)
// Creates a copy of the underlying Authenticator.
Clone() Authenticator
io.Closer
}
```
A Authenticator implements a specific way to authorize requests.
Each request is bound to a separate Authenticator instance.
The authentication flow itself is broken down into `Authorize`
and `Verify` steps. The former method runs before, and the latter
runs after the `Request` is submitted.
This makes it easy to encapsulate and control complex
authentication challenges.
Some authentication flows causing authentication round trips,
which can be archived by returning the `redo` of the Verify
method. `True` restarts the authentication process for the
current action: A new `Request` is spawned, which must be
authorized, sent, and re-verified again, until the action
is successfully submitted.
The preferred way is to handle the authentication ping-pong
within `Verify`, and then `redo` with fresh credentials.
The result of the `Verify` method can also trigger an
`Authenticator` change by returning the `ErrAuthChanged`
as an error. Depending on the `Authorizer` this may trigger
an `Authenticator` negotiation.
Set the `XInhibitRedirect` header to '1' in the `Authorize`
method to get control over request redirection.
Attention! You must handle the incoming request yourself.
To store a shared session state the `Clone` method **must**
return a new instance, initialized with the shared state.
#### <a name="NewDigestAuth">func</a> [NewDigestAuth](https://github.com/studio-b12/gowebdav/blob/master/digestAuth.go?s=324:406#L21)
``` go
func NewDigestAuth(login, secret string, rs *http.Response) (Authenticator, error)
```
NewDigestAuth creates a new instance of our Digest Authenticator
#### <a name="NewPassportAuth">func</a> [NewPassportAuth](https://github.com/studio-b12/gowebdav/blob/master/passportAuth.go?s=386:495#L21)
``` go
func NewPassportAuth(c *http.Client, user, pw, partnerURL string, header *http.Header) (Authenticator, error)
```
constructor for PassportAuth creates a new PassportAuth object and
automatically authenticates against the given partnerURL
### <a name="Authorizer">type</a> [Authorizer](https://github.com/studio-b12/gowebdav/blob/master/auth.go?s=349:764#L17)
``` go
type Authorizer interface {
// Creates a new Authenticator Shim per request.
// It may track request related states and perform payload buffering
// for authentication round trips.
// The underlying Authenticator will perform the real authentication.
NewAuthenticator(body io.Reader) (Authenticator, io.Reader)
// Registers a new Authenticator factory to a key.
AddAuthenticator(key string, fn AuthFactory)
}
```
Authorizer our Authenticator factory which creates an
`Authenticator` per action/request.
#### <a name="NewAutoAuth">func</a> [NewAutoAuth](https://github.com/studio-b12/gowebdav/blob/master/auth.go?s=3789:3845#L109)
``` go
func NewAutoAuth(login string, secret string) Authorizer
```
NewAutoAuth creates an auto Authenticator factory.
It negotiates the default authentication method
based on the order of the registered Authenticators
and the remotely offered authentication methods.
First In, First Out.
#### <a name="NewEmptyAuth">func</a> [NewEmptyAuth](https://github.com/studio-b12/gowebdav/blob/master/auth.go?s=4694:4724#L132)
``` go
func NewEmptyAuth() Authorizer
```
NewEmptyAuth creates an empty Authenticator factory
The order of adding the Authenticator matters.
First In, First Out.
It offers the `NewAutoAuth` features.
#### <a name="NewPreemptiveAuth">func</a> [NewPreemptiveAuth](https://github.com/studio-b12/gowebdav/blob/master/auth.go?s=5300:5353#L148)
``` go
func NewPreemptiveAuth(auth Authenticator) Authorizer
```
NewPreemptiveAuth creates a preemptive Authenticator
The preemptive authorizer uses the provided Authenticator
for every request regardless of any `Www-Authenticate` header.
It may only have one authentication method,
so calling `AddAuthenticator` **will panic**!
Look out!! This offers the skinniest and slickest implementation
without any synchronisation!!
Still applicable with `BasicAuth` within go routines.
### <a name="BasicAuth">type</a> [BasicAuth](https://github.com/studio-b12/gowebdav/blob/master/basicAuth.go?s=94:145#L9)
``` go
type BasicAuth struct {
// contains filtered or unexported fields
}
```
BasicAuth structure holds our credentials
#### <a name="BasicAuth.Authorize">func</a> (\*BasicAuth) [Authorize](https://github.com/studio-b12/gowebdav/blob/master/basicAuth.go?s=180:262#L15)
``` go
func (b *BasicAuth) Authorize(c *http.Client, rq *http.Request, path string) error
```
Authorize the current request
#### <a name="BasicAuth.Clone">func</a> (\*BasicAuth) [Clone](https://github.com/studio-b12/gowebdav/blob/master/basicAuth.go?s=666:707#L34)
``` go
func (b *BasicAuth) Clone() Authenticator
```
Clone creates a Copy of itself
#### <a name="BasicAuth.Close">func</a> (\*BasicAuth) [Close](https://github.com/studio-b12/gowebdav/blob/master/basicAuth.go?s=581:614#L29)
``` go
func (b *BasicAuth) Close() error
```
Close cleans up all resources
#### <a name="BasicAuth.String">func</a> (\*BasicAuth) [String](https://github.com/studio-b12/gowebdav/blob/master/basicAuth.go?s=778:813#L40)
``` go
func (b *BasicAuth) String() string
```
String toString
#### <a name="BasicAuth.Verify">func</a> (\*BasicAuth) [Verify](https://github.com/studio-b12/gowebdav/blob/master/basicAuth.go?s=352:449#L21)
``` go
func (b *BasicAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error)
```
Verify verifies if the authentication
### <a name="Client">type</a> [Client](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=220:388#L19)
``` go
type Client struct {
// contains filtered or unexported fields
}
```
Client defines our structure
#### <a name="NewAuthClient">func</a> [NewAuthClient](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=608:663#L33)
``` go
func NewAuthClient(uri string, auth Authorizer) *Client
```
NewAuthClient creates a new client instance with a custom Authorizer
#### <a name="NewClient">func</a> [NewClient](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=436:480#L28)
``` go
func NewClient(uri, user, pw string) *Client
```
NewClient creates a new instance of client
#### <a name="Client.Connect">func</a> (\*Client) [Connect](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=1829:1861#L74)
``` go
func (c *Client) Connect() error
```
Connect connects to our dav server
#### <a name="Client.Copy">func</a> (\*Client) [Copy](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=6815:6883#L310)
``` go
func (c *Client) Copy(oldpath, newpath string, overwrite bool) error
```
Copy copies a file from A to B
#### <a name="Client.Mkdir">func</a> (\*Client) [Mkdir](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=5790:5852#L259)
``` go
func (c *Client) Mkdir(path string, _ os.FileMode) (err error)
```
Mkdir makes a directory
#### <a name="Client.MkdirAll">func</a> (\*Client) [MkdirAll](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=6065:6130#L273)
``` go
func (c *Client) MkdirAll(path string, _ os.FileMode) (err error)
```
MkdirAll like mkdir -p, but for webdav
#### <a name="Client.Read">func</a> (\*Client) [Read](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=6989:7039#L315)
``` go
func (c *Client) Read(path string) ([]byte, error)
```
Read reads the contents of a remote file
#### <a name="Client.ReadDir">func</a> (\*Client) [ReadDir](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=2855:2915#L117)
``` go
func (c *Client) ReadDir(path string) ([]os.FileInfo, error)
```
ReadDir reads the contents of a remote directory
#### <a name="Client.ReadStream">func</a> (\*Client) [ReadStream](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=7350:7413#L333)
``` go
func (c *Client) ReadStream(path string) (io.ReadCloser, error)
```
ReadStream reads the stream for a given path
#### <a name="Client.ReadStreamRange">func</a> (\*Client) [ReadStreamRange](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=8162:8252#L355)
``` go
func (c *Client) ReadStreamRange(path string, offset, length int64) (io.ReadCloser, error)
```
ReadStreamRange reads the stream representing a subset of bytes for a given path,
utilizing HTTP Range Requests if the server supports it.
The range is expressed as offset from the start of the file and length, for example
offset=10, length=10 will return bytes 10 through 19.
If the server does not support partial content requests and returns full content instead,
this function will emulate the behavior by skipping `offset` bytes and limiting the result
to `length`.
#### <a name="Client.Remove">func</a> (\*Client) [Remove](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=5296:5338#L236)
``` go
func (c *Client) Remove(path string) error
```
Remove removes a remote file
#### <a name="Client.RemoveAll">func</a> (\*Client) [RemoveAll](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=5404:5449#L241)
``` go
func (c *Client) RemoveAll(path string) error
```
RemoveAll removes remote files
#### <a name="Client.Rename">func</a> (\*Client) [Rename](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=6649:6719#L305)
``` go
func (c *Client) Rename(oldpath, newpath string, overwrite bool) error
```
Rename moves a file from A to B
#### <a name="Client.SetHeader">func</a> (\*Client) [SetHeader](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=1092:1137#L49)
``` go
func (c *Client) SetHeader(key, value string)
```
SetHeader lets us set arbitrary headers for a given client
#### <a name="Client.SetInterceptor">func</a> (\*Client) [SetInterceptor](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=1244:1326#L54)
``` go
func (c *Client) SetInterceptor(interceptor func(method string, rq *http.Request))
```
SetInterceptor lets us set an arbitrary interceptor for a given client
#### <a name="Client.SetJar">func</a> (\*Client) [SetJar](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=1727:1770#L69)
``` go
func (c *Client) SetJar(jar http.CookieJar)
```
SetJar exposes the ability to set a cookie jar to the client.
#### <a name="Client.SetTimeout">func</a> (\*Client) [SetTimeout](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=1428:1478#L59)
``` go
func (c *Client) SetTimeout(timeout time.Duration)
```
SetTimeout exposes the ability to set a time limit for requests
#### <a name="Client.SetTransport">func</a> (\*Client) [SetTransport](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=1571:1629#L64)
``` go
func (c *Client) SetTransport(transport http.RoundTripper)
```
SetTransport exposes the ability to define custom transports
#### <a name="Client.Stat">func</a> (\*Client) [Stat](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=4241:4296#L184)
``` go
func (c *Client) Stat(path string) (os.FileInfo, error)
```
Stat returns the file stats for a specified path
#### <a name="Client.Write">func</a> (\*Client) [Write](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=9272:9347#L389)
``` go
func (c *Client) Write(path string, data []byte, _ os.FileMode) (err error)
```
Write writes data to a given path
#### <a name="Client.WriteStream">func</a> (\*Client) [WriteStream](https://github.com/studio-b12/gowebdav/blob/master/client.go?s=9771:9857#L419)
``` go
func (c *Client) WriteStream(path string, stream io.Reader, _ os.FileMode) (err error)
```
WriteStream writes a stream
### <a name="DigestAuth">type</a> [DigestAuth](https://github.com/studio-b12/gowebdav/blob/master/digestAuth.go?s=157:254#L14)
``` go
type DigestAuth struct {
// contains filtered or unexported fields
}
```
DigestAuth structure holds our credentials
#### <a name="DigestAuth.Authorize">func</a> (\*DigestAuth) [Authorize](https://github.com/studio-b12/gowebdav/blob/master/digestAuth.go?s=525:608#L26)
``` go
func (d *DigestAuth) Authorize(c *http.Client, rq *http.Request, path string) error
```
Authorize the current request
#### <a name="DigestAuth.Clone">func</a> (\*DigestAuth) [Clone](https://github.com/studio-b12/gowebdav/blob/master/digestAuth.go?s=1228:1270#L49)
``` go
func (d *DigestAuth) Clone() Authenticator
```
Clone creates a copy of itself
#### <a name="DigestAuth.Close">func</a> (\*DigestAuth) [Close](https://github.com/studio-b12/gowebdav/blob/master/digestAuth.go?s=1142:1176#L44)
``` go
func (d *DigestAuth) Close() error
```
Close cleans up all resources
#### <a name="DigestAuth.String">func</a> (\*DigestAuth) [String](https://github.com/studio-b12/gowebdav/blob/master/digestAuth.go?s=1466:1502#L58)
``` go
func (d *DigestAuth) String() string
```
String toString
#### <a name="DigestAuth.Verify">func</a> (\*DigestAuth) [Verify](https://github.com/studio-b12/gowebdav/blob/master/digestAuth.go?s=912:1010#L36)
``` go
func (d *DigestAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error)
```
Verify checks for authentication issues and may trigger a re-authentication
### <a name="File">type</a> [File](https://github.com/studio-b12/gowebdav/blob/master/file.go?s=93:253#L10)
``` go
type File struct {
// contains filtered or unexported fields
}
```
File is our structure for a given file
#### <a name="File.ContentType">func</a> (File) [ContentType](https://github.com/studio-b12/gowebdav/blob/master/file.go?s=476:510#L31)
``` go
func (f File) ContentType() string
```
ContentType returns the content type of a file
#### <a name="File.ETag">func</a> (File) [ETag](https://github.com/studio-b12/gowebdav/blob/master/file.go?s=929:956#L56)
``` go
func (f File) ETag() string
```
ETag returns the ETag of a file
#### <a name="File.IsDir">func</a> (File) [IsDir](https://github.com/studio-b12/gowebdav/blob/master/file.go?s=1035:1061#L61)
``` go
func (f File) IsDir() bool
```
IsDir let us see if a given file is a directory or not
#### <a name="File.ModTime">func</a> (File) [ModTime](https://github.com/studio-b12/gowebdav/blob/master/file.go?s=836:869#L51)
``` go
func (f File) ModTime() time.Time
```
ModTime returns the modified time of a file
#### <a name="File.Mode">func</a> (File) [Mode](https://github.com/studio-b12/gowebdav/blob/master/file.go?s=665:697#L41)
``` go
func (f File) Mode() os.FileMode
```
Mode will return the mode of a given file
#### <a name="File.Name">func</a> (File) [Name](https://github.com/studio-b12/gowebdav/blob/master/file.go?s=378:405#L26)
``` go
func (f File) Name() string
```
Name returns the name of a file
#### <a name="File.Path">func</a> (File) [Path](https://github.com/studio-b12/gowebdav/blob/master/file.go?s=295:322#L21)
``` go
func (f File) Path() string
```
Path returns the full path of a file
#### <a name="File.Size">func</a> (File) [Size](https://github.com/studio-b12/gowebdav/blob/master/file.go?s=573:599#L36)
``` go
func (f File) Size() int64
```
Size returns the size of a file
#### <a name="File.String">func</a> (File) [String](https://github.com/studio-b12/gowebdav/blob/master/file.go?s=1183:1212#L71)
``` go
func (f File) String() string
```
String lets us see file information
#### <a name="File.Sys">func</a> (File) [Sys](https://github.com/studio-b12/gowebdav/blob/master/file.go?s=1095:1126#L66)
``` go
func (f File) Sys() interface{}
```
Sys ????
### <a name="PassportAuth">type</a> [PassportAuth](https://github.com/studio-b12/gowebdav/blob/master/passportAuth.go?s=125:254#L12)
``` go
type PassportAuth struct {
// contains filtered or unexported fields
}
```
PassportAuth structure holds our credentials
#### <a name="PassportAuth.Authorize">func</a> (\*PassportAuth) [Authorize](https://github.com/studio-b12/gowebdav/blob/master/passportAuth.go?s=690:775#L32)
``` go
func (p *PassportAuth) Authorize(c *http.Client, rq *http.Request, path string) error
```
Authorize the current request
#### <a name="PassportAuth.Clone">func</a> (\*PassportAuth) [Clone](https://github.com/studio-b12/gowebdav/blob/master/passportAuth.go?s=1701:1745#L69)
``` go
func (p *PassportAuth) Clone() Authenticator
```
Clone creates a Copy of itself
#### <a name="PassportAuth.Close">func</a> (\*PassportAuth) [Close](https://github.com/studio-b12/gowebdav/blob/master/passportAuth.go?s=1613:1649#L64)
``` go
func (p *PassportAuth) Close() error
```
Close cleans up all resources
#### <a name="PassportAuth.String">func</a> (\*PassportAuth) [String](https://github.com/studio-b12/gowebdav/blob/master/passportAuth.go?s=2048:2086#L83)
``` go
func (p *PassportAuth) String() string
```
String toString
#### <a name="PassportAuth.Verify">func</a> (\*PassportAuth) [Verify](https://github.com/studio-b12/gowebdav/blob/master/passportAuth.go?s=1075:1175#L46)
``` go
func (p *PassportAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error)
```
Verify verifies if the authentication is good
### <a name="StatusError">type</a> [StatusError](https://github.com/studio-b12/gowebdav/blob/master/errors.go?s=499:538#L18)
``` go
type StatusError struct {
Status int
}
```
StatusError implements error and wraps
an erroneous status code.
#### <a name="StatusError.Error">func</a> (StatusError) [Error](https://github.com/studio-b12/gowebdav/blob/master/errors.go?s=540:576#L22)
``` go
func (se StatusError) Error() string
```
- - -
Generated by [godoc2md](http://godoc.org/github.com/davecheney/godoc2md)

409
auth.go Normal file
View File

@ -0,0 +1,409 @@
package gowebdav
import (
"bytes"
"errors"
"io"
"net/http"
"strings"
"sync"
)
// AuthFactory prototype function to create a new Authenticator
type AuthFactory func(c *http.Client, rs *http.Response, path string) (auth Authenticator, err error)
// Authorizer our Authenticator factory which creates an
// `Authenticator` per action/request.
type Authorizer interface {
// Creates a new Authenticator Shim per request.
// It may track request related states and perform payload buffering
// for authentication round trips.
// The underlying Authenticator will perform the real authentication.
NewAuthenticator(body io.Reader) (Authenticator, io.Reader)
// Registers a new Authenticator factory to a key.
AddAuthenticator(key string, fn AuthFactory)
}
// A Authenticator implements a specific way to authorize requests.
// Each request is bound to a separate Authenticator instance.
//
// The authentication flow itself is broken down into `Authorize`
// and `Verify` steps. The former method runs before, and the latter
// runs after the `Request` is submitted.
// This makes it easy to encapsulate and control complex
// authentication challenges.
//
// Some authentication flows causing authentication round trips,
// which can be archived by returning the `redo` of the Verify
// method. `True` restarts the authentication process for the
// current action: A new `Request` is spawned, which must be
// authorized, sent, and re-verified again, until the action
// is successfully submitted.
// The preferred way is to handle the authentication ping-pong
// within `Verify`, and then `redo` with fresh credentials.
//
// The result of the `Verify` method can also trigger an
// `Authenticator` change by returning the `ErrAuthChanged`
// as an error. Depending on the `Authorizer` this may trigger
// an `Authenticator` negotiation.
//
// Set the `XInhibitRedirect` header to '1' in the `Authorize`
// method to get control over request redirection.
// Attention! You must handle the incoming request yourself.
//
// To store a shared session state the `Clone` method **must**
// return a new instance, initialized with the shared state.
type Authenticator interface {
// Authorizes a request. Usually by adding some authorization headers.
Authorize(c *http.Client, rq *http.Request, path string) error
// Verifies the response if the authorization was successful.
// May trigger some round trips to pass the authentication.
// May also trigger a new Authenticator negotiation by returning `ErrAuthChenged`
Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error)
// Creates a copy of the underlying Authenticator.
Clone() Authenticator
io.Closer
}
type authfactory struct {
key string
create AuthFactory
}
// authorizer structure holds our Authenticator create functions
type authorizer struct {
factories []authfactory
defAuthMux sync.Mutex
defAuth Authenticator
}
// preemptiveAuthorizer structure holds the preemptive Authenticator
type preemptiveAuthorizer struct {
auth Authenticator
}
// authShim structure that wraps the real Authenticator
type authShim struct {
factory AuthFactory
body io.Reader
auth Authenticator
}
// negoAuth structure holds the authenticators that are going to be negotiated
type negoAuth struct {
auths []Authenticator
setDefaultAuthenticator func(auth Authenticator)
}
// nullAuth initializes the whole authentication flow
type nullAuth struct{}
// noAuth structure to perform no authentication at all
type noAuth struct{}
// NewAutoAuth creates an auto Authenticator factory.
// It negotiates the default authentication method
// based on the order of the registered Authenticators
// and the remotely offered authentication methods.
// First In, First Out.
func NewAutoAuth(login string, secret string) Authorizer {
fmap := make([]authfactory, 0)
az := &authorizer{factories: fmap, defAuthMux: sync.Mutex{}, defAuth: &nullAuth{}}
az.AddAuthenticator("basic", func(c *http.Client, rs *http.Response, path string) (auth Authenticator, err error) {
return &BasicAuth{user: login, pw: secret}, nil
})
az.AddAuthenticator("digest", func(c *http.Client, rs *http.Response, path string) (auth Authenticator, err error) {
return NewDigestAuth(login, secret, rs)
})
az.AddAuthenticator("passport1.4", func(c *http.Client, rs *http.Response, path string) (auth Authenticator, err error) {
return NewPassportAuth(c, login, secret, rs.Request.URL.String(), &rs.Header)
})
return az
}
// NewEmptyAuth creates an empty Authenticator factory
// The order of adding the Authenticator matters.
// First In, First Out.
// It offers the `NewAutoAuth` features.
func NewEmptyAuth() Authorizer {
fmap := make([]authfactory, 0)
az := &authorizer{factories: fmap, defAuthMux: sync.Mutex{}, defAuth: &nullAuth{}}
return az
}
// NewPreemptiveAuth creates a preemptive Authenticator
// The preemptive authorizer uses the provided Authenticator
// for every request regardless of any `Www-Authenticate` header.
//
// It may only have one authentication method,
// so calling `AddAuthenticator` **will panic**!
//
// Look out!! This offers the skinniest and slickest implementation
// without any synchronisation!!
// Still applicable with `BasicAuth` within go routines.
func NewPreemptiveAuth(auth Authenticator) Authorizer {
return &preemptiveAuthorizer{auth: auth}
}
// NewAuthenticator creates an Authenticator (Shim) per request
func (a *authorizer) NewAuthenticator(body io.Reader) (Authenticator, io.Reader) {
var retryBuf io.Reader = body
if body != nil {
// If the authorization fails, we will need to restart reading
// from the passed body stream.
// When body is seekable, use seek to reset the streams
// cursor to the start.
// Otherwise, copy the stream into a buffer while uploading
// and use the buffers content on retry.
if _, ok := retryBuf.(io.Seeker); ok {
body = io.NopCloser(body)
} else {
buff := &bytes.Buffer{}
retryBuf = buff
body = io.TeeReader(body, buff)
}
}
a.defAuthMux.Lock()
defAuth := a.defAuth.Clone()
a.defAuthMux.Unlock()
return &authShim{factory: a.factory, body: retryBuf, auth: defAuth}, body
}
// AddAuthenticator appends the AuthFactory to our factories.
// It converts the key to lower case and preserves the order.
func (a *authorizer) AddAuthenticator(key string, fn AuthFactory) {
key = strings.ToLower(key)
for _, f := range a.factories {
if f.key == key {
panic("Authenticator exists: " + key)
}
}
a.factories = append(a.factories, authfactory{key, fn})
}
// factory picks all valid Authenticators based on Www-Authenticate headers
func (a *authorizer) factory(c *http.Client, rs *http.Response, path string) (auth Authenticator, err error) {
headers := rs.Header.Values("Www-Authenticate")
if len(headers) > 0 {
auths := make([]Authenticator, 0)
for _, f := range a.factories {
for _, header := range headers {
headerLower := strings.ToLower(header)
if strings.Contains(headerLower, f.key) {
rs.Header.Set("Www-Authenticate", header)
if auth, err = f.create(c, rs, path); err == nil {
auths = append(auths, auth)
break
}
}
}
}
switch len(auths) {
case 0:
return nil, NewPathError("NoAuthenticator", path, rs.StatusCode)
case 1:
auth = auths[0]
default:
auth = &negoAuth{auths: auths, setDefaultAuthenticator: a.setDefaultAuthenticator}
}
} else {
auth = &noAuth{}
}
a.setDefaultAuthenticator(auth)
return auth, nil
}
// setDefaultAuthenticator sets the default Authenticator
func (a *authorizer) setDefaultAuthenticator(auth Authenticator) {
a.defAuthMux.Lock()
a.defAuth.Close()
a.defAuth = auth
a.defAuthMux.Unlock()
}
// Authorize the current request
func (s *authShim) Authorize(c *http.Client, rq *http.Request, path string) error {
if err := s.auth.Authorize(c, rq, path); err != nil {
return err
}
body := s.body
rq.GetBody = func() (io.ReadCloser, error) {
if body != nil {
if sk, ok := body.(io.Seeker); ok {
if _, err := sk.Seek(0, io.SeekStart); err != nil {
return nil, err
}
}
return io.NopCloser(body), nil
}
return nil, nil
}
return nil
}
// Verify checks for authentication issues and may trigger a re-authentication.
// Catches AlgoChangedErr to update the current Authenticator
func (s *authShim) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) {
redo, err = s.auth.Verify(c, rs, path)
if err != nil && errors.Is(err, ErrAuthChanged) {
if auth, aerr := s.factory(c, rs, path); aerr == nil {
s.auth.Close()
s.auth = auth
return true, nil
} else {
return false, aerr
}
}
return
}
// Close closes all resources
func (s *authShim) Close() error {
s.auth.Close()
s.auth, s.factory = nil, nil
if s.body != nil {
if closer, ok := s.body.(io.Closer); ok {
return closer.Close()
}
}
return nil
}
// It's not intend to Clone the shim
// therefore it returns a noAuth instance
func (s *authShim) Clone() Authenticator {
return &noAuth{}
}
// String toString
func (s *authShim) String() string {
return "AuthShim"
}
// Authorize authorizes the current request with the top most Authorizer
func (n *negoAuth) Authorize(c *http.Client, rq *http.Request, path string) error {
if len(n.auths) == 0 {
return NewPathError("NoAuthenticator", path, 400)
}
return n.auths[0].Authorize(c, rq, path)
}
// Verify verifies the authentication and selects the next one based on the result
func (n *negoAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) {
if len(n.auths) == 0 {
return false, NewPathError("NoAuthenticator", path, 400)
}
redo, err = n.auths[0].Verify(c, rs, path)
if err != nil {
if len(n.auths) > 1 {
n.auths[0].Close()
n.auths = n.auths[1:]
return true, nil
}
} else if redo {
return
} else {
auth := n.auths[0]
n.auths = n.auths[1:]
n.setDefaultAuthenticator(auth)
return
}
return false, NewPathError("NoAuthenticator", path, rs.StatusCode)
}
// Close will close the underlying authenticators.
func (n *negoAuth) Close() error {
for _, a := range n.auths {
a.Close()
}
n.setDefaultAuthenticator = nil
return nil
}
// Clone clones the underlying authenticators.
func (n *negoAuth) Clone() Authenticator {
auths := make([]Authenticator, len(n.auths))
for i, e := range n.auths {
auths[i] = e.Clone()
}
return &negoAuth{auths: auths, setDefaultAuthenticator: n.setDefaultAuthenticator}
}
func (n *negoAuth) String() string {
return "NegoAuth"
}
// Authorize the current request
func (n *noAuth) Authorize(c *http.Client, rq *http.Request, path string) error {
return nil
}
// Verify checks for authentication issues and may trigger a re-authentication
func (n *noAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) {
if "" != rs.Header.Get("Www-Authenticate") {
err = ErrAuthChanged
}
return
}
// Close closes all resources
func (n *noAuth) Close() error {
return nil
}
// Clone creates a copy of itself
func (n *noAuth) Clone() Authenticator {
// no copy due to read only access
return n
}
// String toString
func (n *noAuth) String() string {
return "NoAuth"
}
// Authorize the current request
func (n *nullAuth) Authorize(c *http.Client, rq *http.Request, path string) error {
rq.Header.Set(XInhibitRedirect, "1")
return nil
}
// Verify checks for authentication issues and may trigger a re-authentication
func (n *nullAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) {
return true, ErrAuthChanged
}
// Close closes all resources
func (n *nullAuth) Close() error {
return nil
}
// Clone creates a copy of itself
func (n *nullAuth) Clone() Authenticator {
// no copy due to read only access
return n
}
// String toString
func (n *nullAuth) String() string {
return "NullAuth"
}
// NewAuthenticator creates an Authenticator (Shim) per request
func (b *preemptiveAuthorizer) NewAuthenticator(body io.Reader) (Authenticator, io.Reader) {
return b.auth.Clone(), body
}
// AddAuthenticator Will PANIC because it may only have a single authentication method
func (b *preemptiveAuthorizer) AddAuthenticator(key string, fn AuthFactory) {
panic("You're funny! A preemptive authorizer may only have a single authentication method")
}

62
auth_test.go Normal file
View File

@ -0,0 +1,62 @@
package gowebdav
import (
"bytes"
"net/http"
"strings"
"testing"
)
func TestEmptyAuth(t *testing.T) {
auth := NewEmptyAuth()
srv, _, _ := newAuthSrv(t, basicAuth)
defer srv.Close()
cli := NewAuthClient(srv.URL, auth)
if err := cli.Connect(); err == nil {
t.Fatalf("got nil want error")
}
}
func TestRedirectAuthWIP(t *testing.T) {
hasPassedAuthServer := false
authHandler := func(h http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if user, passwd, ok := r.BasicAuth(); ok {
if user == "user" && passwd == "password" {
hasPassedAuthServer = true
w.WriteHeader(200)
return
}
}
w.Header().Set("Www-Authenticate", `Basic realm="x"`)
w.WriteHeader(401)
}
}
psrv, _, _ := newAuthSrv(t, authHandler)
defer psrv.Close()
dataHandler := func(h http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
hasAuth := strings.Contains(r.Header.Get("Authorization"), "Basic dXNlcjpwYXNzd29yZA==")
if hasPassedAuthServer && hasAuth {
h.ServeHTTP(w, r)
return
}
w.Header().Set("Www-Authenticate", `Basic realm="x"`)
http.Redirect(w, r, psrv.URL+"/", 302)
}
}
srv, _, _ := newAuthSrv(t, dataHandler)
defer srv.Close()
cli := NewClient(srv.URL, "user", "password")
data, err := cli.Read("/hello.txt")
if err != nil {
t.Logf("WIP got error=%v; want nil", err)
}
if bytes.Compare(data, []byte("hello gowebdav\n")) != 0 {
t.Logf("WIP got data=%v; want=hello gowebdav", data)
}
}

47
basicAuth.go Normal file
View File

@ -0,0 +1,47 @@
package gowebdav
import (
"fmt"
"net/http"
)
// BasicAuth structure holds our credentials
type BasicAuth struct {
user string
pw string
}
// NewDigestAuth creates a new instance of our Digest Authenticator
func NewBasicAuth(login, secret string) (Authenticator, error) {
return &BasicAuth{user: login, pw: secret}, nil
}
// Authorize the current request
func (b *BasicAuth) Authorize(c *http.Client, rq *http.Request, path string) error {
rq.SetBasicAuth(b.user, b.pw)
return nil
}
// Verify verifies if the authentication
func (b *BasicAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) {
if rs.StatusCode == 401 {
err = NewPathError("Authorize", path, rs.StatusCode)
}
return
}
// Close cleans up all resources
func (b *BasicAuth) Close() error {
return nil
}
// Clone creates a Copy of itself
func (b *BasicAuth) Clone() Authenticator {
// no copy due to read only access
return b
}
// String toString
func (b *BasicAuth) String() string {
return fmt.Sprintf("BasicAuth login: %s", b.user)
}

51
basicAuth_test.go Normal file
View File

@ -0,0 +1,51 @@
package gowebdav
import (
"net/http"
"testing"
)
func TestNewBasicAuth(t *testing.T) {
a := &BasicAuth{user: "user", pw: "password"}
ex := "BasicAuth login: user"
if a.String() != ex {
t.Error("expected: " + ex + " got: " + a.String())
}
if a.Clone() != a {
t.Error("expected the same instance")
}
if a.Close() != nil {
t.Error("expected close without errors")
}
}
func TestBasicAuthAuthorize(t *testing.T) {
a := &BasicAuth{user: "user", pw: "password"}
rq, _ := http.NewRequest("GET", "http://localhost/", nil)
a.Authorize(nil, rq, "/")
if rq.Header.Get("Authorization") != "Basic dXNlcjpwYXNzd29yZA==" {
t.Error("got wrong Authorization header: " + rq.Header.Get("Authorization"))
}
}
func TestPreemtiveBasicAuth(t *testing.T) {
a := &BasicAuth{user: "user", pw: "password"}
auth := NewPreemptiveAuth(a)
n, b := auth.NewAuthenticator(nil)
if b != nil {
t.Error("expected body to be nil")
}
if n != a {
t.Error("expected the same instance")
}
srv, _, _ := newAuthSrv(t, basicAuth)
defer srv.Close()
cli := NewAuthClient(srv.URL, auth)
if err := cli.Connect(); err != nil {
t.Fatalf("got error: %v, want nil", err)
}
}

302
client.go
View File

@ -2,8 +2,8 @@ package gowebdav
import (
"bytes"
"encoding/base64"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
@ -13,51 +13,92 @@ import (
"time"
)
const XInhibitRedirect = "X-Gowebdav-Inhibit-Redirect"
// Client defines our structure
type Client struct {
root string
headers http.Header
c *http.Client
root string
headers http.Header
interceptor func(method string, rq *http.Request)
c *http.Client
auth Authorizer
}
func NewClient(uri string, user string, pw string) *Client {
c := &Client{uri, make(http.Header), &http.Client{}}
// NewClient creates a new instance of client
func NewClient(uri, user, pw string) *Client {
return NewAuthClient(uri, NewAutoAuth(user, pw))
}
if len(user) > 0 && len(pw) > 0 {
a := user + ":" + pw
auth := "Basic " + base64.StdEncoding.EncodeToString([]byte(a))
c.headers.Add("Authorization", auth)
// NewAuthClient creates a new client instance with a custom Authorizer
func NewAuthClient(uri string, auth Authorizer) *Client {
c := &http.Client{
CheckRedirect: func(rq *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return ErrTooManyRedirects
}
if via[0].Header.Get(XInhibitRedirect) != "" {
return http.ErrUseLastResponse
}
return nil
},
}
c.root = FixSlash(c.root)
return c
return &Client{root: FixSlash(uri), headers: make(http.Header), interceptor: nil, c: c, auth: auth}
}
// SetHeader lets us set arbitrary headers for a given client
func (c *Client) SetHeader(key, value string) {
c.headers.Add(key, value)
}
// SetInterceptor lets us set an arbitrary interceptor for a given client
func (c *Client) SetInterceptor(interceptor func(method string, rq *http.Request)) {
c.interceptor = interceptor
}
// SetTimeout exposes the ability to set a time limit for requests
func (c *Client) SetTimeout(timeout time.Duration) {
c.c.Timeout = timeout
}
// SetTransport exposes the ability to define custom transports
func (c *Client) SetTransport(transport http.RoundTripper) {
c.c.Transport = transport
}
// SetJar exposes the ability to set a cookie jar to the client.
func (c *Client) SetJar(jar http.CookieJar) {
c.c.Jar = jar
}
// Connect connects to our dav server
func (c *Client) Connect() error {
rs, err := c.options("/")
if err == nil {
rs.Body.Close()
if rs.StatusCode != 200 || (rs.Header.Get("Dav") == "" && rs.Header.Get("DAV") == "") {
return newPathError("Connect", c.root, rs.StatusCode)
}
_, err = c.ReadDir("/")
if err != nil {
return err
}
return err
err = rs.Body.Close()
if err != nil {
return err
}
if rs.StatusCode != 200 {
return NewPathError("Connect", c.root, rs.StatusCode)
}
return nil
}
type props struct {
Status string `xml:"DAV: status"`
Name string `xml:"DAV: prop>displayname,omitempty"`
Type xml.Name `xml:"DAV: prop>resourcetype>collection,omitempty"`
Size string `xml:"DAV: prop>getcontentlength,omitempty"`
Modified string `xml:"DAV: prop>getlastmodified,omitempty"`
Status string `xml:"DAV: status"`
Name string `xml:"DAV: prop>displayname,omitempty"`
Type xml.Name `xml:"DAV: prop>resourcetype>collection,omitempty"`
Size string `xml:"DAV: prop>getcontentlength,omitempty"`
ContentType string `xml:"DAV: prop>getcontenttype,omitempty"`
ETag string `xml:"DAV: prop>getetag,omitempty"`
Modified string `xml:"DAV: prop>getlastmodified,omitempty"`
}
type response struct {
Href string `xml:"DAV: href"`
Props []props `xml:"DAV: propstat"`
@ -65,13 +106,14 @@ type response struct {
func getProps(r *response, status string) *props {
for _, prop := range r.Props {
if strings.Index(prop.Status, status) != -1 {
if strings.Contains(prop.Status, status) {
return &prop
}
}
return nil
}
// ReadDir reads the contents of a remote directory
func (c *Client) ReadDir(path string) ([]os.FileInfo, error) {
path = FixSlashes(path)
files := make([]os.FileInfo, 0)
@ -85,18 +127,20 @@ func (c *Client) ReadDir(path string) ([]os.FileInfo, error) {
r.Props = nil
return nil
}
return newPathError("ReadDir", path, 405)
return NewPathError("ReadDir", path, 405)
}
if p := getProps(r, "200"); p != nil {
f := new(File)
if ps, err := url.QueryUnescape(r.Href); err == nil {
if ps, err := url.PathUnescape(r.Href); err == nil {
f.name = pathpkg.Base(ps)
} else {
f.name = p.Name
}
f.path = path + f.name
f.modified = parseModified(&p.Modified)
f.etag = p.ETag
f.contentType = p.ContentType
if p.Type.Local == "collection" {
f.path += "/"
@ -120,6 +164,8 @@ func (c *Client) ReadDir(path string) ([]os.FileInfo, error) {
<d:displayname/>
<d:resourcetype/>
<d:getcontentlength/>
<d:getcontenttype/>
<d:getetag/>
<d:getlastmodified/>
</d:prop>
</d:propfind>`,
@ -128,27 +174,30 @@ func (c *Client) ReadDir(path string) ([]os.FileInfo, error) {
if err != nil {
if _, ok := err.(*os.PathError); !ok {
err = &os.PathError{"ReadDir", path, err}
err = NewPathErrorErr("ReadDir", path, err)
}
}
return files, err
}
// Stat returns the file stats for a specified path
func (c *Client) Stat(path string) (os.FileInfo, error) {
var f *File = nil
var f *File
parse := func(resp interface{}) error {
r := resp.(*response)
if p := getProps(r, "200"); p != nil && f == nil {
f = new(File)
f.name = p.Name
f.path = path
f.etag = p.ETag
f.contentType = p.ContentType
if p.Type.Local == "collection" {
if !strings.HasSuffix(f.path, "/") {
f.path += "/"
}
f.size = 0
f.modified = time.Unix(0, 0)
f.modified = parseModified(&p.Modified)
f.isdir = true
} else {
f.size = parseInt64(&p.Size)
@ -167,6 +216,8 @@ func (c *Client) Stat(path string) (os.FileInfo, error) {
<d:displayname/>
<d:resourcetype/>
<d:getcontentlength/>
<d:getcontenttype/>
<d:getetag/>
<d:getlastmodified/>
</d:prop>
</d:propfind>`,
@ -175,46 +226,60 @@ func (c *Client) Stat(path string) (os.FileInfo, error) {
if err != nil {
if _, ok := err.(*os.PathError); !ok {
err = &os.PathError{"ReadDir", path, err}
err = NewPathErrorErr("ReadDir", path, err)
}
}
return f, err
}
// Remove removes a remote file
func (c *Client) Remove(path string) error {
return c.RemoveAll(path)
}
// RemoveAll removes remote files
func (c *Client) RemoveAll(path string) error {
rs, err := c.req("DELETE", path, nil, nil)
if err != nil {
return newPathError("Remove", path, 400)
return NewPathError("Remove", path, 400)
}
err = rs.Body.Close()
if err != nil {
return err
}
rs.Body.Close()
if rs.StatusCode == 200 || rs.StatusCode == 404 {
if rs.StatusCode == 200 || rs.StatusCode == 204 || rs.StatusCode == 404 {
return nil
} else {
return newPathError("Remove", path, rs.StatusCode)
}
return NewPathError("Remove", path, rs.StatusCode)
}
func (c *Client) Mkdir(path string, _ os.FileMode) error {
// Mkdir makes a directory
func (c *Client) Mkdir(path string, _ os.FileMode) (err error) {
path = FixSlashes(path)
status := c.mkcol(path)
status, err := c.mkcol(path)
if err != nil {
return
}
if status == 201 {
return nil
} else {
return newPathError("Mkdir", path, status)
}
return NewPathError("Mkdir", path, status)
}
func (c *Client) MkdirAll(path string, _ os.FileMode) error {
// MkdirAll like mkdir -p, but for webdav
func (c *Client) MkdirAll(path string, _ os.FileMode) (err error) {
path = FixSlashes(path)
status := c.mkcol(path)
status, err := c.mkcol(path)
if err != nil {
return
}
if status == 201 {
return nil
} else if status == 409 {
}
if status == 409 {
paths := strings.Split(path, "/")
sub := "/"
for _, e := range paths {
@ -222,75 +287,152 @@ func (c *Client) MkdirAll(path string, _ os.FileMode) error {
continue
}
sub += e + "/"
status = c.mkcol(sub)
status, err = c.mkcol(sub)
if err != nil {
return
}
if status != 201 {
return newPathError("MkdirAll", sub, status)
return NewPathError("MkdirAll", sub, status)
}
}
return nil
}
return newPathError("MkdirAll", path, status)
return NewPathError("MkdirAll", path, status)
}
func (c *Client) Rename(oldpath string, newpath string, overwrite bool) error {
// Rename moves a file from A to B
func (c *Client) Rename(oldpath, newpath string, overwrite bool) error {
return c.copymove("MOVE", oldpath, newpath, overwrite)
}
func (c *Client) Copy(oldpath string, newpath string, overwrite bool) error {
// Copy copies a file from A to B
func (c *Client) Copy(oldpath, newpath string, overwrite bool) error {
return c.copymove("COPY", oldpath, newpath, overwrite)
}
// Read reads the contents of a remote file
func (c *Client) Read(path string) ([]byte, error) {
if stream, err := c.ReadStream(path); err == nil {
defer stream.Close()
buf := new(bytes.Buffer)
buf.ReadFrom(stream)
return buf.Bytes(), nil
} else {
var stream io.ReadCloser
var err error
if stream, err = c.ReadStream(path); err != nil {
return nil, err
}
defer stream.Close()
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(stream)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// ReadStream reads the stream for a given path
func (c *Client) ReadStream(path string) (io.ReadCloser, error) {
rs, err := c.req("GET", path, nil, nil)
if err != nil {
return nil, newPathErrorErr("ReadStream", path, err)
return nil, NewPathErrorErr("ReadStream", path, err)
}
return rs.Body, nil
if rs.StatusCode == 200 {
return rs.Body, nil
}
rs.Body.Close()
return nil, NewPathError("ReadStream", path, rs.StatusCode)
}
func (c *Client) Write(path string, data []byte, _ os.FileMode) error {
s := c.put(path, bytes.NewReader(data))
// ReadStreamRange reads the stream representing a subset of bytes for a given path,
// utilizing HTTP Range Requests if the server supports it.
// The range is expressed as offset from the start of the file and length, for example
// offset=10, length=10 will return bytes 10 through 19.
//
// If the server does not support partial content requests and returns full content instead,
// this function will emulate the behavior by skipping `offset` bytes and limiting the result
// to `length`.
func (c *Client) ReadStreamRange(path string, offset, length int64) (io.ReadCloser, error) {
rs, err := c.req("GET", path, nil, func(r *http.Request) {
if length > 0 {
r.Header.Add("Range", fmt.Sprintf("bytes=%d-%d", offset, offset+length-1))
} else {
r.Header.Add("Range", fmt.Sprintf("bytes=%d-", offset))
}
})
if err != nil {
return nil, NewPathErrorErr("ReadStreamRange", path, err)
}
if rs.StatusCode == http.StatusPartialContent {
// server supported partial content, return as-is.
return rs.Body, nil
}
// server returned success, but did not support partial content, so we have the whole
// stream in rs.Body
if rs.StatusCode == 200 {
// discard first 'offset' bytes.
if _, err := io.Copy(io.Discard, io.LimitReader(rs.Body, offset)); err != nil {
return nil, NewPathErrorErr("ReadStreamRange", path, err)
}
// return a io.ReadCloser that is limited to `length` bytes.
return &limitedReadCloser{rc: rs.Body, remaining: int(length)}, nil
}
rs.Body.Close()
return nil, NewPathError("ReadStream", path, rs.StatusCode)
}
// Write writes data to a given path
func (c *Client) Write(path string, data []byte, _ os.FileMode) (err error) {
s, err := c.put(path, bytes.NewReader(data))
if err != nil {
return
}
switch s {
case 200, 201:
case 200, 201, 204:
return nil
case 409:
if idx := strings.LastIndex(path, "/"); idx == -1 {
// faulty root
return newPathError("Write", path, 500)
} else {
if err := c.MkdirAll(path[0:idx+1], 0755); err == nil {
s = c.put(path, bytes.NewReader(data))
if s == 200 || s == 201 {
return nil
}
}
case 404, 409:
err = c.createParentCollection(path)
if err != nil {
return
}
s, err = c.put(path, bytes.NewReader(data))
if err != nil {
return
}
if s == 200 || s == 201 || s == 204 {
return
}
}
return newPathError("Write", path, s)
return NewPathError("Write", path, s)
}
func (c *Client) WriteStream(path string, stream io.Reader, _ os.FileMode) error {
// TODO check if parent collection exists
s := c.put(path, stream)
// WriteStream writes a stream
func (c *Client) WriteStream(path string, stream io.Reader, _ os.FileMode) (err error) {
err = c.createParentCollection(path)
if err != nil {
return err
}
s, err := c.put(path, stream)
if err != nil {
return err
}
switch s {
case 200, 201:
case 200, 201, 204:
return nil
default:
return newPathError("WriteStream", path, s)
return NewPathError("WriteStream", path, s)
}
}

574
client_test.go Normal file
View File

@ -0,0 +1,574 @@
package gowebdav
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"golang.org/x/net/webdav"
)
func noAuthHndl(h http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.ServeHTTP(w, r)
}
}
func basicAuth(h http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if user, passwd, ok := r.BasicAuth(); ok {
if user == "user" && passwd == "password" {
h.ServeHTTP(w, r)
return
}
http.Error(w, "not authorized", 403)
} else {
w.Header().Set("WWW-Authenticate", `Basic realm="x"`)
w.WriteHeader(401)
}
}
}
func multipleAuth(h http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
notAuthed := false
if r.Header.Get("Authorization") == "" {
notAuthed = true
} else if user, passwd, ok := r.BasicAuth(); ok {
if user == "user" && passwd == "password" {
h.ServeHTTP(w, r)
return
}
notAuthed = true
} else if strings.HasPrefix(r.Header.Get("Authorization"), "Digest ") {
pairs := strings.TrimPrefix(r.Header.Get("Authorization"), "Digest ")
digestParts := make(map[string]string)
for _, pair := range strings.Split(pairs, ",") {
kv := strings.SplitN(strings.TrimSpace(pair), "=", 2)
key, value := kv[0], kv[1]
value = strings.Trim(value, `"`)
digestParts[key] = value
}
if digestParts["qop"] == "" {
digestParts["qop"] = "auth"
}
ha1 := getMD5(fmt.Sprint(digestParts["username"], ":", digestParts["realm"], ":", "digestPW"))
ha2 := getMD5(fmt.Sprint(r.Method, ":", digestParts["uri"]))
expected := getMD5(fmt.Sprint(ha1,
":", digestParts["nonce"],
":", digestParts["nc"],
":", digestParts["cnonce"],
":", digestParts["qop"],
":", ha2))
if expected == digestParts["response"] {
h.ServeHTTP(w, r)
return
}
notAuthed = true
}
if notAuthed {
w.Header().Add("WWW-Authenticate", `Digest realm="testrealm@host.com", qop="auth,auth-int",nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",opaque="5ccc069c403ebaf9f0171e9517f40e41"`)
w.Header().Add("WWW-Authenticate", `Basic realm="x"`)
w.WriteHeader(401)
}
}
}
func fillFs(t *testing.T, fs webdav.FileSystem) context.Context {
ctx := context.Background()
f, err := fs.OpenFile(ctx, "hello.txt", os.O_CREATE, 0644)
if err != nil {
t.Errorf("fail to crate file: %v", err)
}
f.Write([]byte("hello gowebdav\n"))
f.Close()
err = fs.Mkdir(ctx, "/test", 0755)
if err != nil {
t.Errorf("fail to crate directory: %v", err)
}
f, err = fs.OpenFile(ctx, "/test/test.txt", os.O_CREATE, 0644)
if err != nil {
t.Errorf("fail to crate file: %v", err)
}
f.Write([]byte("test test gowebdav\n"))
f.Close()
return ctx
}
func newServer(t *testing.T) (*Client, *httptest.Server, webdav.FileSystem, context.Context) {
return newAuthServer(t, basicAuth)
}
func newAuthServer(t *testing.T, auth func(h http.Handler) http.HandlerFunc) (*Client, *httptest.Server, webdav.FileSystem, context.Context) {
srv, fs, ctx := newAuthSrv(t, auth)
cli := NewClient(srv.URL, "user", "password")
return cli, srv, fs, ctx
}
func newAuthSrv(t *testing.T, auth func(h http.Handler) http.HandlerFunc) (*httptest.Server, webdav.FileSystem, context.Context) {
mux := http.NewServeMux()
fs := webdav.NewMemFS()
ctx := fillFs(t, fs)
mux.HandleFunc("/", auth(&webdav.Handler{
FileSystem: fs,
LockSystem: webdav.NewMemLS(),
}))
srv := httptest.NewServer(mux)
return srv, fs, ctx
}
func TestConnect(t *testing.T) {
cli, srv, _, _ := newServer(t)
defer srv.Close()
if err := cli.Connect(); err != nil {
t.Fatalf("got error: %v, want nil", err)
}
cli = NewClient(srv.URL, "no", "no")
if err := cli.Connect(); err == nil {
t.Fatalf("got nil, want error: %v", err)
}
}
func TestConnectMultipleAuth(t *testing.T) {
cli, srv, _, _ := newAuthServer(t, multipleAuth)
defer srv.Close()
if err := cli.Connect(); err != nil {
t.Fatalf("got error: %v, want nil", err)
}
cli = NewClient(srv.URL, "digestUser", "digestPW")
if err := cli.Connect(); err != nil {
t.Fatalf("got nil, want error: %v", err)
}
cli = NewClient(srv.URL, "no", "no")
if err := cli.Connect(); err == nil {
t.Fatalf("got nil, want error: %v", err)
}
}
func TestConnectMultiAuthII(t *testing.T) {
cli, srv, _, _ := newAuthServer(t, func(h http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if user, passwd, ok := r.BasicAuth(); ok {
if user == "user" && passwd == "password" {
h.ServeHTTP(w, r)
return
}
http.Error(w, "not authorized", 403)
} else {
w.Header().Add("WWW-Authenticate", `FooAuth`)
w.Header().Add("WWW-Authenticate", `BazAuth`)
w.Header().Add("WWW-Authenticate", `BarAuth`)
w.Header().Add("WWW-Authenticate", `Basic realm="x"`)
w.WriteHeader(401)
}
}
})
defer srv.Close()
if err := cli.Connect(); err != nil {
t.Fatalf("got error: %v, want nil", err)
}
cli = NewClient(srv.URL, "no", "no")
if err := cli.Connect(); err == nil {
t.Fatalf("got nil, want error: %v", err)
}
}
func TestReadDirConcurrent(t *testing.T) {
cli, srv, _, _ := newServer(t)
defer srv.Close()
var wg sync.WaitGroup
errs := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
f, err := cli.ReadDir("/")
if err != nil {
errs <- errors.New(fmt.Sprintf("got error: %v, want file listing: %v", err, f))
}
if len(f) != 2 {
errs <- errors.New(fmt.Sprintf("f: %v err: %v", f, err))
}
if f[0].Name() != "hello.txt" && f[1].Name() != "hello.txt" {
errs <- errors.New(fmt.Sprintf("got: %v, want file: %s", f, "hello.txt"))
}
if f[0].Name() != "test" && f[1].Name() != "test" {
errs <- errors.New(fmt.Sprintf("got: %v, want directory: %s", f, "test"))
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
}
func TestRead(t *testing.T) {
cli, srv, _, _ := newServer(t)
defer srv.Close()
data, err := cli.Read("/hello.txt")
if err != nil || bytes.Compare(data, []byte("hello gowebdav\n")) != 0 {
t.Fatalf("got: %v, want data: %s", err, []byte("hello gowebdav\n"))
}
data, err = cli.Read("/404.txt")
if err == nil {
t.Fatalf("got: %v, want error: %v", data, err)
}
if !IsErrNotFound(err) {
t.Fatalf("got: %v, want 404 error", err)
}
}
func TestReadNoAuth(t *testing.T) {
cli, srv, _, _ := newAuthServer(t, noAuthHndl)
defer srv.Close()
data, err := cli.Read("/hello.txt")
if err != nil || bytes.Compare(data, []byte("hello gowebdav\n")) != 0 {
t.Fatalf("got: %v, want data: %s", err, []byte("hello gowebdav\n"))
}
data, err = cli.Read("/404.txt")
if err == nil {
t.Fatalf("got: %v, want error: %v", data, err)
}
if !IsErrNotFound(err) {
t.Fatalf("got: %v, want 404 error", err)
}
}
func TestReadStream(t *testing.T) {
cli, srv, _, _ := newServer(t)
defer srv.Close()
stream, err := cli.ReadStream("/hello.txt")
if err != nil {
t.Fatalf("got: %v, want data: %v", err, stream)
}
buf := new(bytes.Buffer)
buf.ReadFrom(stream)
if buf.String() != "hello gowebdav\n" {
t.Fatalf("got: %v, want stream: hello gowebdav", buf.String())
}
stream, err = cli.ReadStream("/404/hello.txt")
if err == nil {
t.Fatalf("got: %v, want error: %v", stream, err)
}
}
func TestReadStreamRange(t *testing.T) {
cli, srv, _, _ := newServer(t)
defer srv.Close()
stream, err := cli.ReadStreamRange("/hello.txt", 4, 4)
if err != nil {
t.Fatalf("got: %v, want data: %v", err, stream)
}
buf := new(bytes.Buffer)
buf.ReadFrom(stream)
if buf.String() != "o go" {
t.Fatalf("got: %v, want stream: o go", buf.String())
}
stream, err = cli.ReadStream("/404/hello.txt")
if err == nil {
t.Fatalf("got: %v, want error: %v", stream, err)
}
}
func TestReadStreamRangeUnkownLength(t *testing.T) {
cli, srv, _, _ := newServer(t)
defer srv.Close()
stream, err := cli.ReadStreamRange("/hello.txt", 6, 0)
if err != nil {
t.Fatalf("got: %v, want data: %v", err, stream)
}
buf := new(bytes.Buffer)
buf.ReadFrom(stream)
if buf.String() != "gowebdav\n" {
t.Fatalf("got: %v, want stream: gowebdav\n", buf.String())
}
stream, err = cli.ReadStream("/404/hello.txt")
if err == nil {
t.Fatalf("got: %v, want error: %v", stream, err)
}
}
func TestStat(t *testing.T) {
cli, srv, _, _ := newServer(t)
defer srv.Close()
info, err := cli.Stat("/hello.txt")
if err != nil {
t.Fatalf("got: %v, want os.Info: %v", err, info)
}
if info.Name() != "hello.txt" {
t.Fatalf("got: %v, want file hello.txt", info)
}
info, err = cli.Stat("/404.txt")
if err == nil {
t.Fatalf("got: %v, want error: %v", info, err)
}
if !IsErrNotFound(err) {
t.Fatalf("got: %v, want 404 error", err)
}
}
func TestMkdir(t *testing.T) {
cli, srv, fs, ctx := newServer(t)
defer srv.Close()
info, err := cli.Stat("/newdir")
if err == nil {
t.Fatalf("got: %v, want error: %v", info, err)
}
if err := cli.Mkdir("/newdir", 0755); err != nil {
t.Fatalf("got: %v, want mkdir /newdir", err)
}
if err := cli.Mkdir("/newdir", 0755); err != nil {
t.Fatalf("got: %v, want mkdir /newdir", err)
}
info, err = fs.Stat(ctx, "/newdir")
if err != nil {
t.Fatalf("got: %v, want dir info: %v", err, info)
}
if err := cli.Mkdir("/404/newdir", 0755); err == nil {
t.Fatalf("expected Mkdir error due to missing parent directory")
}
}
func TestMkdirAll(t *testing.T) {
cli, srv, fs, ctx := newServer(t)
defer srv.Close()
if err := cli.MkdirAll("/dir/dir/dir", 0755); err != nil {
t.Fatalf("got: %v, want mkdirAll /dir/dir/dir", err)
}
info, err := fs.Stat(ctx, "/dir/dir/dir")
if err != nil {
t.Fatalf("got: %v, want dir info: %v", err, info)
}
}
func TestCopy(t *testing.T) {
cli, srv, fs, ctx := newServer(t)
defer srv.Close()
info, err := fs.Stat(ctx, "/copy.txt")
if err == nil {
t.Fatalf("got: %v, want error: %v", info, err)
}
if err := cli.Copy("/hello.txt", "/copy.txt", false); err != nil {
t.Fatalf("got: %v, want copy /hello.txt to /copy.txt", err)
}
info, err = fs.Stat(ctx, "/copy.txt")
if err != nil {
t.Fatalf("got: %v, want file info: %v", err, info)
}
if info.Size() != 15 {
t.Fatalf("got: %v, want file size: %d bytes", info.Size(), 15)
}
info, err = fs.Stat(ctx, "/hello.txt")
if err != nil {
t.Fatalf("got: %v, want file info: %v", err, info)
}
if info.Size() != 15 {
t.Fatalf("got: %v, want file size: %d bytes", info.Size(), 15)
}
if err := cli.Copy("/hello.txt", "/copy.txt", false); err == nil {
t.Fatalf("expected copy error due to overwrite false")
}
if err := cli.Copy("/hello.txt", "/copy.txt", true); err != nil {
t.Fatalf("got: %v, want overwrite /copy.txt with /hello.txt", err)
}
}
func TestRename(t *testing.T) {
cli, srv, fs, ctx := newServer(t)
defer srv.Close()
info, err := fs.Stat(ctx, "/copy.txt")
if err == nil {
t.Fatalf("got: %v, want error: %v", info, err)
}
if err := cli.Rename("/hello.txt", "/copy.txt", false); err != nil {
t.Fatalf("got: %v, want mv /hello.txt to /copy.txt", err)
}
info, err = fs.Stat(ctx, "/copy.txt")
if err != nil {
t.Fatalf("got: %v, want file info: %v", err, info)
}
if info.Size() != 15 {
t.Fatalf("got: %v, want file size: %d bytes", info.Size(), 15)
}
if info, err = fs.Stat(ctx, "/hello.txt"); err == nil {
t.Fatalf("got: %v, want error: %v", info, err)
}
if err := cli.Rename("/test/test.txt", "/copy.txt", true); err != nil {
t.Fatalf("got: %v, want overwrite /copy.txt with /hello.txt", err)
}
info, err = fs.Stat(ctx, "/copy.txt")
if err != nil {
t.Fatalf("got: %v, want file info: %v", err, info)
}
if info.Size() != 19 {
t.Fatalf("got: %v, want file size: %d bytes", info.Size(), 19)
}
}
func TestRemove(t *testing.T) {
cli, srv, fs, ctx := newServer(t)
defer srv.Close()
if err := cli.Remove("/hello.txt"); err != nil {
t.Fatalf("got: %v, want nil", err)
}
if info, err := fs.Stat(ctx, "/hello.txt"); err == nil {
t.Fatalf("got: %v, want error: %v", info, err)
}
if err := cli.Remove("/404.txt"); err != nil {
t.Fatalf("got: %v, want nil", err)
}
}
func TestRemoveAll(t *testing.T) {
cli, srv, fs, ctx := newServer(t)
defer srv.Close()
if err := cli.RemoveAll("/test/test.txt"); err != nil {
t.Fatalf("got: %v, want nil", err)
}
if info, err := fs.Stat(ctx, "/test/test.txt"); err == nil {
t.Fatalf("got: %v, want error: %v", info, err)
}
if err := cli.RemoveAll("/404.txt"); err != nil {
t.Fatalf("got: %v, want nil", err)
}
if err := cli.RemoveAll("/404/404/404.txt"); err != nil {
t.Fatalf("got: %v, want nil", err)
}
}
func TestWrite(t *testing.T) {
cli, srv, fs, ctx := newServer(t)
defer srv.Close()
if err := cli.Write("/newfile.txt", []byte("foo bar\n"), 0660); err != nil {
t.Fatalf("got: %v, want nil", err)
}
info, err := fs.Stat(ctx, "/newfile.txt")
if err != nil {
t.Fatalf("got: %v, want file info: %v", err, info)
}
if info.Size() != 8 {
t.Fatalf("got: %v, want file size: %d bytes", info.Size(), 8)
}
if err := cli.Write("/404/newfile.txt", []byte("foo bar\n"), 0660); err != nil {
t.Fatalf("got: %v, want nil", err)
}
}
func TestWriteStream(t *testing.T) {
cli, srv, fs, ctx := newServer(t)
defer srv.Close()
if err := cli.WriteStream("/newfile.txt", strings.NewReader("foo bar\n"), 0660); err != nil {
t.Fatalf("got: %v, want nil", err)
}
info, err := fs.Stat(ctx, "/newfile.txt")
if err != nil {
t.Fatalf("got: %v, want file info: %v", err, info)
}
if info.Size() != 8 {
t.Fatalf("got: %v, want file size: %d bytes", info.Size(), 8)
}
if err := cli.WriteStream("/404/works.txt", strings.NewReader("foo bar\n"), 0660); err != nil {
t.Fatalf("got: %v, want nil", err)
}
if info, err := fs.Stat(ctx, "/404/works.txt"); err != nil {
t.Fatalf("got: %v, want file info: %v", err, info)
}
}
func TestWriteStreamFromPipe(t *testing.T) {
cli, srv, fs, ctx := newServer(t)
defer srv.Close()
r, w := io.Pipe()
go func() {
defer w.Close()
fmt.Fprint(w, "foo")
time.Sleep(1 * time.Second)
fmt.Fprint(w, " ")
time.Sleep(1 * time.Second)
fmt.Fprint(w, "bar\n")
}()
if err := cli.WriteStream("/newfile.txt", r, 0660); err != nil {
t.Fatalf("got: %v, want nil", err)
}
info, err := fs.Stat(ctx, "/newfile.txt")
if err != nil {
t.Fatalf("got: %v, want file info: %v", err, info)
}
if info.Size() != 8 {
t.Fatalf("got: %v, want file size: %d bytes", info.Size(), 8)
}
}

103
cmd/gowebdav/README.md Normal file
View File

@ -0,0 +1,103 @@
# Description
Command line tool for [gowebdav](https://github.com/studio-b12/gowebdav) library.
# Prerequisites
## Software
* **OS**: all, which are supported by `Golang`
* **Golang**: version 1.x
* **Git**: version 2.14.2 at higher (required to install via `go get`)
# Install
```sh
go get -u github.com/studio-b12/gowebdav/cmd/gowebdav
```
# Usage
It is recommended to set following environment variables to improve your experience with this tool:
* `ROOT` is an URL of target WebDAV server (e.g. `https://webdav.mydomain.me/user_root_folder`)
* `USER` is a login to connect to specified server (e.g. `user`)
* `PASSWORD` is a password to connect to specified server (e.g. `p@s$w0rD`)
In following examples we suppose that:
* environment variable `ROOT` is set to `https://webdav.mydomain.me/ufolder`
* environment variable `USER` is set to `user`
* environment variable `PASSWORD` is set `p@s$w0rD`
* folder `/ufolder/temp` exists on the server
* file `/ufolder/temp/file.txt` exists on the server
* file `/ufolder/temp/document.rtf` exists on the server
* file `/tmp/webdav/to_upload.txt` exists on the local machine
* folder `/tmp/webdav/` is used to download files from the server
## Examples
#### Get content of specified folder
```sh
gowebdav -X LS temp
```
#### Get info about file/folder
```sh
gowebdav -X STAT temp
gowebdav -X STAT temp/file.txt
```
#### Create folder on the remote server
```sh
gowebdav -X MKDIR temp2
gowebdav -X MKDIRALL all/folders/which-you-want/to_create
```
#### Download file
```sh
gowebdav -X GET temp/document.rtf /tmp/webdav/document.rtf
```
You may do not specify target local path, in this case file will be downloaded to the current folder with the
#### Upload file
```sh
gowebdav -X PUT temp/uploaded.txt /tmp/webdav/to_upload.txt
```
#### Move file on the remote server
```sh
gowebdav -X MV temp/file.txt temp/moved_file.txt
```
#### Copy file to another location
```sh
gowebdav -X MV temp/file.txt temp/file-copy.txt
```
#### Delete file from the remote server
```sh
gowebdav -X DEL temp/file.txt
```
# Wrapper script
You can create wrapper script for your server (via `$EDITOR ./dav && chmod a+x ./dav`) and add following content to it:
```sh
#!/bin/sh
ROOT="https://my.dav.server/" \
USER="foo" \
PASSWORD="$(pass dav/foo@my.dav.server)" \
gowebdav $@
```
It allows you to use [pass](https://www.passwordstore.org/ "the standard unix password manager") or similar tools to retrieve the password.
## Examples
Using the `dav` wrapper:
```sh
$ ./dav -X LS /
$ echo hi dav! > hello && ./dav -X PUT /hello
$ ./dav -X STAT /hello
$ ./dav -X PUT /hello_dav hello
$ ./dav -X GET /hello_dav
$ ./dav -X GET /hello_dav hello.txt
```

267
cmd/gowebdav/main.go Normal file
View File

@ -0,0 +1,267 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"io/fs"
"os"
"os/user"
"path"
"path/filepath"
"runtime"
"strings"
d "git.siteop.biz/shoopea/gowebdav"
)
func main() {
root := flag.String("root", os.Getenv("ROOT"), "WebDAV Endpoint [ENV.ROOT]")
user := flag.String("user", os.Getenv("USER"), "User [ENV.USER]")
password := flag.String("pw", os.Getenv("PASSWORD"), "Password [ENV.PASSWORD]")
netrc := flag.String("netrc-file", filepath.Join(getHome(), ".netrc"), "read login from netrc file")
method := flag.String("X", "", `Method:
LS <PATH>
STAT <PATH>
MKDIR <PATH>
MKDIRALL <PATH>
GET <PATH> [<FILE>]
PUT <PATH> [<FILE>]
MV <OLD> <NEW>
CP <OLD> <NEW>
DEL <PATH>
`)
flag.Parse()
if *root == "" {
fail("Set WebDAV ROOT")
}
if argsLength := len(flag.Args()); argsLength == 0 || argsLength > 2 {
fail("Unsupported arguments")
}
if *password == "" {
if u, p := d.ReadConfig(*root, *netrc); u != "" && p != "" {
user = &u
password = &p
}
}
c := d.NewClient(*root, *user, *password)
if e := c.Connect(); e != nil {
panic(e)
}
cmd := getCmd(*method)
if e := cmd(c, flag.Arg(0), flag.Arg(1)); e != nil {
fail(e)
}
}
func fail(err interface{}) {
if err != nil {
fmt.Println(err)
}
os.Exit(-1)
}
func getHome() string {
u, e := user.Current()
if e != nil {
return os.Getenv("HOME")
}
if u != nil {
return u.HomeDir
}
switch runtime.GOOS {
case "windows":
return ""
default:
return "~/"
}
}
func getCmd(method string) func(c *d.Client, p0, p1 string) error {
switch strings.ToUpper(method) {
case "LS", "LIST", "PROPFIND":
return cmdLs
case "STAT":
return cmdStat
case "GET", "PULL", "READ":
return cmdGet
case "DELETE", "RM", "DEL":
return cmdRm
case "MKCOL", "MKDIR":
return cmdMkdir
case "MKCOLALL", "MKDIRALL", "MKDIRP":
return cmdMkdirAll
case "RENAME", "MV", "MOVE":
return cmdMv
case "COPY", "CP":
return cmdCp
case "PUT", "PUSH", "WRITE":
return cmdPut
default:
return func(c *d.Client, p0, p1 string) (err error) {
return errors.New("Unsupported method: " + method)
}
}
}
func cmdLs(c *d.Client, p0, _ string) (err error) {
files, err := c.ReadDir(p0)
if err == nil {
fmt.Println(fmt.Sprintf("ReadDir: '%s' entries: %d ", p0, len(files)))
for _, f := range files {
fmt.Println(f)
}
}
return
}
func cmdStat(c *d.Client, p0, _ string) (err error) {
file, err := c.Stat(p0)
if err == nil {
fmt.Println(file)
}
return
}
func cmdGet(c *d.Client, p0, p1 string) (err error) {
bytes, err := c.Read(p0)
if err == nil {
if p1 == "" {
p1 = filepath.Join(".", p0)
}
err = writeFile(p1, bytes, 0644)
if err == nil {
fmt.Println(fmt.Sprintf("Written %d bytes to: %s", len(bytes), p1))
}
}
return
}
func cmdRm(c *d.Client, p0, _ string) (err error) {
if err = c.Remove(p0); err == nil {
fmt.Println("Remove: " + p0)
}
return
}
func cmdMkdir(c *d.Client, p0, _ string) (err error) {
if err = c.Mkdir(p0, 0755); err == nil {
fmt.Println("Mkdir: " + p0)
}
return
}
func cmdMkdirAll(c *d.Client, p0, _ string) (err error) {
if err = c.MkdirAll(p0, 0755); err == nil {
fmt.Println("MkdirAll: " + p0)
}
return
}
func cmdMv(c *d.Client, p0, p1 string) (err error) {
if err = c.Rename(p0, p1, true); err == nil {
fmt.Println("Rename: " + p0 + " -> " + p1)
}
return
}
func cmdCp(c *d.Client, p0, p1 string) (err error) {
if err = c.Copy(p0, p1, true); err == nil {
fmt.Println("Copy: " + p0 + " -> " + p1)
}
return
}
func cmdPut(c *d.Client, p0, p1 string) (err error) {
if p1 == "" {
p1 = path.Join(".", p0)
} else {
var fi fs.FileInfo
fi, err = c.Stat(p0)
if err != nil && !d.IsErrNotFound(err) {
return
}
if !d.IsErrNotFound(err) && fi.IsDir() {
p0 = path.Join(p0, p1)
}
}
stream, err := getStream(p1)
if err != nil {
return
}
defer stream.Close()
if err = c.WriteStream(p0, stream, 0644); err == nil {
fmt.Println("Put: " + p1 + " -> " + p0)
}
return
}
func writeFile(path string, bytes []byte, mode os.FileMode) error {
parent := filepath.Dir(path)
if _, e := os.Stat(parent); os.IsNotExist(e) {
if e := os.MkdirAll(parent, os.ModePerm); e != nil {
return e
}
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(bytes)
return err
}
func getStream(pathOrString string) (io.ReadCloser, error) {
fi, err := os.Stat(pathOrString)
if err != nil {
return nil, err
}
if fi.IsDir() {
return nil, &os.PathError{
Op: "Open",
Path: pathOrString,
Err: errors.New("Path: '" + pathOrString + "' is a directory"),
}
}
f, err := os.Open(pathOrString)
if err == nil {
return f, nil
}
return nil, &os.PathError{
Op: "Open",
Path: pathOrString,
Err: err,
}
}

164
digestAuth.go Normal file
View File

@ -0,0 +1,164 @@
package gowebdav
import (
"crypto/md5"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
)
// DigestAuth structure holds our credentials
type DigestAuth struct {
user string
pw string
digestParts map[string]string
}
// NewDigestAuth creates a new instance of our Digest Authenticator
func NewDigestAuth(login, secret string, rs *http.Response) (Authenticator, error) {
return &DigestAuth{user: login, pw: secret, digestParts: digestParts(rs)}, nil
}
// Authorize the current request
func (d *DigestAuth) Authorize(c *http.Client, rq *http.Request, path string) error {
d.digestParts["uri"] = path
d.digestParts["method"] = rq.Method
d.digestParts["username"] = d.user
d.digestParts["password"] = d.pw
rq.Header.Set("Authorization", getDigestAuthorization(d.digestParts))
return nil
}
// Verify checks for authentication issues and may trigger a re-authentication
func (d *DigestAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) {
if rs.StatusCode == 401 {
err = NewPathError("Authorize", path, rs.StatusCode)
}
return
}
// Close cleans up all resources
func (d *DigestAuth) Close() error {
return nil
}
// Clone creates a copy of itself
func (d *DigestAuth) Clone() Authenticator {
parts := make(map[string]string, len(d.digestParts))
for k, v := range d.digestParts {
parts[k] = v
}
return &DigestAuth{user: d.user, pw: d.pw, digestParts: parts}
}
// String toString
func (d *DigestAuth) String() string {
return fmt.Sprintf("DigestAuth login: %s", d.user)
}
func digestParts(resp *http.Response) map[string]string {
result := map[string]string{}
if len(resp.Header["Www-Authenticate"]) > 0 {
wantedHeaders := []string{"nonce", "realm", "qop", "opaque", "algorithm", "entityBody"}
responseHeaders := strings.Split(resp.Header["Www-Authenticate"][0], ",")
for _, r := range responseHeaders {
for _, w := range wantedHeaders {
if strings.Contains(r, w) {
result[w] = strings.Trim(
strings.SplitN(r, `=`, 2)[1],
`"`,
)
}
}
}
}
return result
}
func getMD5(text string) string {
hasher := md5.New()
hasher.Write([]byte(text))
return hex.EncodeToString(hasher.Sum(nil))
}
func getCnonce() string {
b := make([]byte, 8)
io.ReadFull(rand.Reader, b)
return fmt.Sprintf("%x", b)[:16]
}
func getDigestAuthorization(digestParts map[string]string) string {
d := digestParts
// These are the correct ha1 and ha2 for qop=auth. We should probably check for other types of qop.
var (
ha1 string
ha2 string
nonceCount = 00000001
cnonce = getCnonce()
response string
)
// 'ha1' value depends on value of "algorithm" field
switch d["algorithm"] {
case "MD5", "":
ha1 = getMD5(d["username"] + ":" + d["realm"] + ":" + d["password"])
case "MD5-sess":
ha1 = getMD5(
fmt.Sprintf("%s:%v:%s",
getMD5(d["username"]+":"+d["realm"]+":"+d["password"]),
nonceCount,
cnonce,
),
)
}
// 'ha2' value depends on value of "qop" field
switch d["qop"] {
case "auth", "":
ha2 = getMD5(d["method"] + ":" + d["uri"])
case "auth-int":
if d["entityBody"] != "" {
ha2 = getMD5(d["method"] + ":" + d["uri"] + ":" + getMD5(d["entityBody"]))
}
}
// 'response' value depends on value of "qop" field
switch d["qop"] {
case "":
response = getMD5(
fmt.Sprintf("%s:%s:%s",
ha1,
d["nonce"],
ha2,
),
)
case "auth", "auth-int":
response = getMD5(
fmt.Sprintf("%s:%s:%v:%s:%s:%s",
ha1,
d["nonce"],
nonceCount,
cnonce,
d["qop"],
ha2,
),
)
}
authorization := fmt.Sprintf(`Digest username="%s", realm="%s", nonce="%s", uri="%s", nc=%v, cnonce="%s", response="%s"`,
d["username"], d["realm"], d["nonce"], d["uri"], nonceCount, cnonce, response)
if d["qop"] != "" {
authorization += fmt.Sprintf(`, qop=%s`, d["qop"])
}
if d["opaque"] != "" {
authorization += fmt.Sprintf(`, opaque="%s"`, d["opaque"])
}
return authorization
}

35
digestAuth_test.go Normal file
View File

@ -0,0 +1,35 @@
package gowebdav
import (
"net/http"
"strings"
"testing"
)
func TestNewDigestAuth(t *testing.T) {
a := &DigestAuth{user: "user", pw: "password", digestParts: make(map[string]string, 0)}
ex := "DigestAuth login: user"
if a.String() != ex {
t.Error("expected: " + ex + " got: " + a.String())
}
if a.Clone() == a {
t.Error("expected a different instance")
}
if a.Close() != nil {
t.Error("expected close without errors")
}
}
func TestDigestAuthAuthorize(t *testing.T) {
a := &DigestAuth{user: "user", pw: "password", digestParts: make(map[string]string, 0)}
rq, _ := http.NewRequest("GET", "http://localhost/", nil)
a.Authorize(nil, rq, "/")
// TODO this is a very lazy test it cuts of cnonce
ex := `Digest username="user", realm="", nonce="", uri="/", nc=1, cnonce="`
if strings.Index(rq.Header.Get("Authorization"), ex) != 0 {
t.Error("got wrong Authorization header: " + rq.Header.Get("Authorization"))
}
}

3
doc.go Normal file
View File

@ -0,0 +1,3 @@
// Package gowebdav is a WebDAV client library with a command line tool
// included.
package gowebdav

57
errors.go Normal file
View File

@ -0,0 +1,57 @@
package gowebdav
import (
"errors"
"fmt"
"os"
)
// ErrAuthChanged must be returned from the Verify method as an error
// to trigger a re-authentication / negotiation with a new authenticator.
var ErrAuthChanged = errors.New("authentication failed, change algorithm")
// ErrTooManyRedirects will be used as return error if a request exceeds 10 redirects.
var ErrTooManyRedirects = errors.New("stopped after 10 redirects")
// StatusError implements error and wraps
// an erroneous status code.
type StatusError struct {
Status int
}
func (se StatusError) Error() string {
return fmt.Sprintf("%d", se.Status)
}
// IsErrCode returns true if the given error
// is an os.PathError wrapping a StatusError
// with the given status code.
func IsErrCode(err error, code int) bool {
if pe, ok := err.(*os.PathError); ok {
se, ok := pe.Err.(StatusError)
return ok && se.Status == code
}
return false
}
// IsErrNotFound is shorthand for IsErrCode
// for status 404.
func IsErrNotFound(err error) bool {
return IsErrCode(err, 404)
}
func NewPathError(op string, path string, statusCode int) error {
return &os.PathError{
Op: op,
Path: path,
Err: StatusError{statusCode},
}
}
func NewPathErrorErr(op string, path string, err error) error {
return &os.PathError{
Op: op,
Path: path,
Err: err,
}
}

43
file.go
View File

@ -6,47 +6,72 @@ import (
"time"
)
// File is our structure for a given file
type File struct {
path string
name string
size int64
modified time.Time
isdir bool
path string
name string
contentType string
size int64
modified time.Time
etag string
isdir bool
}
// Path returns the full path of a file
func (f File) Path() string {
return f.path
}
// Name returns the name of a file
func (f File) Name() string {
return f.name
}
// ContentType returns the content type of a file
func (f File) ContentType() string {
return f.contentType
}
// Size returns the size of a file
func (f File) Size() int64 {
return f.size
}
// Mode will return the mode of a given file
func (f File) Mode() os.FileMode {
// TODO check webdav perms
if f.isdir {
return 0775 | os.ModeDir
} else {
return 0664
}
return 0664
}
// ModTime returns the modified time of a file
func (f File) ModTime() time.Time {
return f.modified
}
// ETag returns the ETag of a file
func (f File) ETag() string {
return f.etag
}
// IsDir let us see if a given file is a directory or not
func (f File) IsDir() bool {
return f.isdir
}
// Sys ????
func (f File) Sys() interface{} {
return nil
}
// String lets us see file information
func (f File) String() string {
if f.isdir {
return fmt.Sprintf("Dir : '%s' - '%s'", f.path, f.name)
} else {
return fmt.Sprintf("File: '%s' SIZE: %d MODIFIED: %s", f.path, f.size, f.modified.String())
}
return fmt.Sprintf("File: '%s' SIZE: %d MODIFIED: %s ETAG: %s CTYPE: %s", f.path, f.size, f.modified.String(), f.etag, f.contentType)
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module git.siteop.biz/shoopea/gowebdav
go 1.17

5
go_test.mod Normal file
View File

@ -0,0 +1,5 @@
module github.com/studio-b12/gowebdav
go 1.17
require golang.org/x/net v0.0.0-20221014081412-f15817d10f9b

7
go_test.sum Normal file
View File

@ -0,0 +1,7 @@
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b h1:tvrvnPFcdzp294diPnrdZZZ8XUt2Tyj7svb7X52iDuU=
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@ -1,181 +0,0 @@
package main
import (
"errors"
"flag"
"fmt"
d "gowebdav"
"io"
"os"
"strings"
)
func Fail(err interface{}) {
if err != nil {
fmt.Println(err)
} else {
fmt.Println("Usage: client FLAGS ARGS")
fmt.Println("Flags:")
flag.PrintDefaults()
fmt.Println("Method <ARGS>")
fmt.Println(" LS | LIST | PROPFIND <PATH>")
fmt.Println(" RM | DELETE | DEL <PATH>")
fmt.Println(" MKDIR | MKCOL <PATH>")
fmt.Println(" MKDIRALL | MKCOLALL <PATH>")
fmt.Println(" MV | MOVE | RENAME <OLD_PATH> <NEW_PATH>")
fmt.Println(" CP | COPY <OLD_PATH> <NEW_PATH>")
fmt.Println(" GET | PULL | READ <PATH>")
fmt.Println(" PUT | PUSH | WRITE <PATH> <FILE>")
fmt.Println(" STAT <PATH>")
}
os.Exit(-1)
}
func writeFile(path string, bytes []byte, mode os.FileMode) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(bytes)
return err
}
func main() {
root := flag.String("root", "URL", "WebDAV Endpoint")
usr := flag.String("user", "", "user")
pw := flag.String("pw", "", "password")
m := flag.String("X", "GET", "Method ...")
flag.Parse()
if *root == "URL" {
Fail(nil)
}
M := strings.ToUpper(*m)
m = &M
c := d.NewClient(*root, *usr, *pw)
if err := c.Connect(); err != nil {
Fail(fmt.Sprintf("Failed to connect due to: %s", err.Error()))
}
alen := len(flag.Args())
if alen == 1 {
path := flag.Args()[0]
switch *m {
case "LS", "LIST", "PROPFIND":
if files, err := c.ReadDir(path); err == nil {
fmt.Println(fmt.Sprintf("ReadDir: '%s' entries: %d ", path, len(files)))
for _, f := range files {
fmt.Println(f)
}
} else {
fmt.Println(err)
}
case "STAT":
if file, err := c.Stat(path); err == nil {
fmt.Println(file)
} else {
fmt.Println(err)
}
case "GET", "PULL", "READ":
if bytes, err := c.Read(path); err == nil {
if lidx := strings.LastIndex(path, "/"); lidx != -1 {
path = path[lidx+1:]
}
if err := writeFile(path, bytes, 0644); err != nil {
fmt.Println(err)
} else {
fmt.Println(fmt.Sprintf("Written %d bytes to: %s", len(bytes), path))
}
} else {
fmt.Println(err)
}
case "DELETE", "RM", "DEL":
if err := c.Remove(path); err != nil {
fmt.Println(err)
} else {
fmt.Println("Remove: " + path)
}
case "MKCOL", "MKDIR":
if err := c.Mkdir(path, 0); err != nil {
fmt.Println(err)
} else {
fmt.Println("MkDir: " + path)
}
case "MKCOLALL", "MKDIRALL":
if err := c.MkdirAll(path, 0); err != nil {
fmt.Println(err)
} else {
fmt.Println("MkDirAll: " + path)
}
default:
Fail(nil)
}
} else if alen == 2 {
a0 := flag.Args()[0]
a1 := flag.Args()[1]
switch *m {
case "RENAME", "MV", "MOVE":
if err := c.Rename(a0, a1, true); err != nil {
fmt.Println(err)
} else {
fmt.Println("Rename: " + a0 + " -> " + a1)
}
case "COPY", "CP":
if err := c.Copy(a0, a1, true); err != nil {
fmt.Println(err)
} else {
fmt.Println("Copy: " + a0 + " -> " + a1)
}
case "PUT", "PUSH", "WRITE":
stream, err := getStream(a1)
if err != nil {
Fail(err)
}
if err := c.WriteStream(a0, stream, 0644); err != nil {
fmt.Println(err)
} else {
fmt.Println(fmt.Sprintf("Written: '%s' -> %s", a1, a0))
}
default:
Fail(nil)
}
} else {
Fail(nil)
}
}
func getStream(pathOrString string) (io.ReadCloser, error) {
fi, err := os.Stat(pathOrString)
if err == nil {
if fi.IsDir() {
return nil, &os.PathError{"Open", pathOrString, errors.New("Path: '" + pathOrString + "' is a directory")}
}
f, err := os.Open(pathOrString)
if err == nil {
return f, nil
}
return nil, &os.PathError{"Open", pathOrString, err}
} else {
return nopCloser{strings.NewReader(pathOrString)}, nil
}
}
type nopCloser struct {
io.Reader
}
func (nopCloser) Close() error {
return nil
}

54
netrc.go Normal file
View File

@ -0,0 +1,54 @@
package gowebdav
import (
"bufio"
"fmt"
"net/url"
"os"
"regexp"
"strings"
)
func parseLine(s string) (login, pass string) {
fields := strings.Fields(s)
for i, f := range fields {
if f == "login" {
login = fields[i+1]
}
if f == "password" {
pass = fields[i+1]
}
}
return login, pass
}
// ReadConfig reads login and password configuration from ~/.netrc
// machine foo.com login username password 123456
func ReadConfig(uri, netrc string) (string, string) {
u, err := url.Parse(uri)
if err != nil {
return "", ""
}
file, err := os.Open(netrc)
if err != nil {
return "", ""
}
defer file.Close()
re := fmt.Sprintf(`^.*machine %s.*$`, u.Host)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
s := scanner.Text()
matched, err := regexp.MatchString(re, s)
if err != nil {
return "", ""
}
if matched {
return parseLine(s)
}
}
return "", ""
}

181
passportAuth.go Normal file
View File

@ -0,0 +1,181 @@
package gowebdav
import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// PassportAuth structure holds our credentials
type PassportAuth struct {
user string
pw string
cookies []http.Cookie
inhibitRedirect bool
}
// constructor for PassportAuth creates a new PassportAuth object and
// automatically authenticates against the given partnerURL
func NewPassportAuth(c *http.Client, user, pw, partnerURL string, header *http.Header) (Authenticator, error) {
p := &PassportAuth{
user: user,
pw: pw,
inhibitRedirect: true,
}
err := p.genCookies(c, partnerURL, header)
return p, err
}
// Authorize the current request
func (p *PassportAuth) Authorize(c *http.Client, rq *http.Request, path string) error {
// prevent redirects to detect subsequent authentication requests
if p.inhibitRedirect {
rq.Header.Set(XInhibitRedirect, "1")
} else {
p.inhibitRedirect = true
}
for _, cookie := range p.cookies {
rq.AddCookie(&cookie)
}
return nil
}
// Verify verifies if the authentication is good
func (p *PassportAuth) Verify(c *http.Client, rs *http.Response, path string) (redo bool, err error) {
switch rs.StatusCode {
case 301, 302, 307, 308:
redo = true
if rs.Header.Get("Www-Authenticate") != "" {
// re-authentication required as we are redirected to the login page
err = p.genCookies(c, rs.Request.URL.String(), &rs.Header)
} else {
// just a redirect, follow it
p.inhibitRedirect = false
}
case 401:
err = NewPathError("Authorize", path, rs.StatusCode)
}
return
}
// Close cleans up all resources
func (p *PassportAuth) Close() error {
return nil
}
// Clone creates a Copy of itself
func (p *PassportAuth) Clone() Authenticator {
// create a copy to allow independent cookie updates
clonedCookies := make([]http.Cookie, len(p.cookies))
copy(clonedCookies, p.cookies)
return &PassportAuth{
user: p.user,
pw: p.pw,
cookies: clonedCookies,
inhibitRedirect: true,
}
}
// String toString
func (p *PassportAuth) String() string {
return fmt.Sprintf("PassportAuth login: %s", p.user)
}
func (p *PassportAuth) genCookies(c *http.Client, partnerUrl string, header *http.Header) error {
// For more details refer to:
// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-pass/2c80637d-438c-4d4b-adc5-903170a779f3
// Skipping step 1 and 2 as we already have the partner server challenge
baseAuthenticationServer := header.Get("Location")
baseAuthenticationServerURL, err := url.Parse(baseAuthenticationServer)
if err != nil {
return err
}
// Skipping step 3 and 4 as we already know that we need and have the user's credentials
// Step 5 (Sign-in request)
authenticationServerUrl := url.URL{
Scheme: baseAuthenticationServerURL.Scheme,
Host: baseAuthenticationServerURL.Host,
Path: "/login2.srf",
}
partnerServerChallenge := strings.Split(header.Get("Www-Authenticate"), " ")[1]
req := http.Request{
Method: "GET",
URL: &authenticationServerUrl,
Header: http.Header{
"Authorization": []string{"Passport1.4 sign-in=" + url.QueryEscape(p.user) + ",pwd=" + url.QueryEscape(p.pw) + ",OrgVerb=GET,OrgUrl=" + partnerUrl + "," + partnerServerChallenge},
},
}
rs, err := c.Do(&req)
if err != nil {
return err
}
io.Copy(io.Discard, rs.Body)
rs.Body.Close()
if rs.StatusCode != 200 {
return NewPathError("Authorize", "/", rs.StatusCode)
}
// Step 6 (Token Response from Authentication Server)
tokenResponseHeader := rs.Header.Get("Authentication-Info")
if tokenResponseHeader == "" {
return NewPathError("Authorize", "/", 401)
}
tokenResponseHeaderList := strings.Split(tokenResponseHeader, ",")
token := ""
for _, tokenResponseHeader := range tokenResponseHeaderList {
if strings.HasPrefix(tokenResponseHeader, "from-PP='") {
token = tokenResponseHeader
break
}
}
if token == "" {
return NewPathError("Authorize", "/", 401)
}
// Step 7 (First Authentication Request to Partner Server)
origUrl, err := url.Parse(partnerUrl)
if err != nil {
return err
}
req = http.Request{
Method: "GET",
URL: origUrl,
Header: http.Header{
"Authorization": []string{"Passport1.4 " + token},
},
}
rs, err = c.Do(&req)
if err != nil {
return err
}
io.Copy(io.Discard, rs.Body)
rs.Body.Close()
if rs.StatusCode != 200 && rs.StatusCode != 302 {
return NewPathError("Authorize", "/", rs.StatusCode)
}
// Step 8 (Set Token Message from Partner Server)
cookies := rs.Header.Values("Set-Cookie")
p.cookies = make([]http.Cookie, len(cookies))
for i, cookie := range cookies {
cookieParts := strings.Split(cookie, ";")
cookieName := strings.Split(cookieParts[0], "=")[0]
cookieValue := strings.Split(cookieParts[0], "=")[1]
p.cookies[i] = http.Cookie{
Name: cookieName,
Value: cookieValue,
}
}
return nil
}

66
passportAuth_test.go Normal file
View File

@ -0,0 +1,66 @@
package gowebdav
import (
"bytes"
"net/http"
"net/url"
"regexp"
"testing"
)
// testing the creation is enough as it handles the authorization during init
func TestNewPassportAuth(t *testing.T) {
user := "user"
pass := "password"
p1 := "some,comma,separated,values"
token := "from-PP='token'"
authHandler := func(h http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
reg, err := regexp.Compile("Passport1\\.4 sign-in=" + url.QueryEscape(user) + ",pwd=" + url.QueryEscape(pass) + ",OrgVerb=GET,OrgUrl=.*," + p1)
if err != nil {
t.Error(err)
}
if reg.MatchString(r.Header.Get("Authorization")) {
w.Header().Set("Authentication-Info", token)
w.WriteHeader(200)
return
}
}
}
authsrv, _, _ := newAuthSrv(t, authHandler)
defer authsrv.Close()
dataHandler := func(h http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
reg, err := regexp.Compile("Passport1\\.4 " + token)
if err != nil {
t.Error(err)
}
if reg.MatchString(r.Header.Get("Authorization")) {
w.Header().Set("Set-Cookie", "Pass=port")
h.ServeHTTP(w, r)
return
}
for _, c := range r.Cookies() {
if c.Name == "Pass" && c.Value == "port" {
h.ServeHTTP(w, r)
return
}
}
w.Header().Set("Www-Authenticate", "Passport1.4 "+p1)
http.Redirect(w, r, authsrv.URL+"/", 302)
}
}
srv, _, _ := newAuthSrv(t, dataHandler)
defer srv.Close()
cli := NewClient(srv.URL, user, pass)
data, err := cli.Read("/hello.txt")
if err != nil {
t.Errorf("got error=%v; want nil", err)
}
if !bytes.Equal(data, []byte("hello gowebdav\n")) {
t.Logf("got data=%v; want=hello gowebdav", data)
}
}

View File

@ -1,43 +1,77 @@
package gowebdav
import (
"errors"
"fmt"
"io"
"log"
"net/http"
"path"
"strings"
)
func (c *Client) req(method string, path string, body io.Reader, intercept func(*http.Request)) (req *http.Response, err error) {
r, err := http.NewRequest(method, Join(c.root, path), body)
if err != nil {
return nil, err
}
for k, vals := range c.headers {
for _, v := range vals {
r.Header.Add(k, v)
func (c *Client) req(method, path string, body io.Reader, intercept func(*http.Request)) (rs *http.Response, err error) {
var redo bool
var r *http.Request
var uri = PathEscape(Join(c.root, path))
auth, body := c.auth.NewAuthenticator(body)
defer auth.Close()
for { // TODO auth.continue() strategy(true|n times|until)?
if r, err = http.NewRequest(method, uri, body); err != nil {
return
}
for k, vals := range c.headers {
for _, v := range vals {
r.Header.Add(k, v)
}
}
if err = auth.Authorize(c.c, r, path); err != nil {
return
}
if intercept != nil {
intercept(r)
}
if c.interceptor != nil {
c.interceptor(method, r)
}
if rs, err = c.c.Do(r); err != nil {
return
}
if redo, err = auth.Verify(c.c, rs, path); err != nil {
rs.Body.Close()
return nil, err
}
if redo {
rs.Body.Close()
if body, err = r.GetBody(); err != nil {
return nil, err
}
continue
}
break
}
if intercept != nil {
intercept(r)
}
return c.c.Do(r)
return rs, err
}
func (c *Client) mkcol(path string) int {
func (c *Client) mkcol(path string) (status int, err error) {
rs, err := c.req("MKCOL", path, nil, nil)
if err != nil {
return 400
return
}
rs.Body.Close()
defer rs.Body.Close()
if rs.StatusCode == 201 || rs.StatusCode == 405 {
return 201
status = rs.StatusCode
if status == 405 {
status = 201
}
return rs.StatusCode
return
}
func (c *Client) options(path string) (*http.Response, error) {
@ -53,7 +87,7 @@ func (c *Client) propfind(path string, self bool, body string, resp interface{},
} else {
rq.Header.Add("Depth", "1")
}
rq.Header.Add("Content-Type", "text/xml;charset=UTF-8")
rq.Header.Add("Content-Type", "application/xml;charset=UTF-8")
rq.Header.Add("Accept", "application/xml,text/xml")
rq.Header.Add("Accept-Charset", "utf-8")
// TODO add support for 'gzip,deflate;q=0.8,q=0.7'
@ -65,15 +99,24 @@ func (c *Client) propfind(path string, self bool, body string, resp interface{},
defer rs.Body.Close()
if rs.StatusCode != 207 {
return errors.New(fmt.Sprintf("%s - %s %s", rs.Status, "PROPFIND", path))
return NewPathError("PROPFIND", path, rs.StatusCode)
}
return parseXML(rs.Body, resp, parse)
}
func (c *Client) doCopyMove(method string, oldpath string, newpath string, overwrite bool) (int, io.ReadCloser) {
func (c *Client) doCopyMove(
method string,
oldpath string,
newpath string,
overwrite bool,
) (
status int,
r io.ReadCloser,
err error,
) {
rs, err := c.req(method, oldpath, nil, func(rq *http.Request) {
rq.Header.Add("Destination", Join(c.root, newpath))
rq.Header.Add("Destination", PathEscape(Join(c.root, newpath)))
if overwrite {
rq.Header.Add("Overwrite", "T")
} else {
@ -81,13 +124,18 @@ func (c *Client) doCopyMove(method string, oldpath string, newpath string, overw
}
})
if err != nil {
return 400, nil
return
}
return rs.StatusCode, rs.Body
status = rs.StatusCode
r = rs.Body
return
}
func (c *Client) copymove(method string, oldpath string, newpath string, overwrite bool) error {
s, data := c.doCopyMove(method, oldpath, newpath, overwrite)
func (c *Client) copymove(method string, oldpath string, newpath string, overwrite bool) (err error) {
s, data, err := c.doCopyMove(method, oldpath, newpath, overwrite)
if err != nil {
return
}
if data != nil {
defer data.Close()
}
@ -98,20 +146,36 @@ func (c *Client) copymove(method string, oldpath string, newpath string, overwri
case 207:
// TODO handle multistat errors, worst case ...
log(fmt.Sprintf(" TODO handle %s - %s multistatus result %s", method, oldpath, String(data)))
log.Printf("TODO handle %s - %s multistatus result %s\n", method, oldpath, String(data))
case 409:
// TODO create dst path
err := c.createParentCollection(newpath)
if err != nil {
return err
}
return c.copymove(method, oldpath, newpath, overwrite)
}
return newPathError(method, oldpath, s)
return NewPathError(method, oldpath, s)
}
func (c *Client) put(path string, stream io.Reader) int {
func (c *Client) put(path string, stream io.Reader) (status int, err error) {
rs, err := c.req("PUT", path, stream, nil)
if err != nil {
return 400
return
}
rs.Body.Close()
return rs.StatusCode
defer rs.Body.Close()
status = rs.StatusCode
return
}
func (c *Client) createParentCollection(itemPath string) (err error) {
parentPath := path.Dir(itemPath)
if parentPath == "." || parentPath == "/" {
return nil
}
return c.MkdirAll(parentPath, 0755)
}

View File

@ -3,27 +3,23 @@ package gowebdav
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"os"
"net/url"
"strconv"
"strings"
"time"
)
func log(msg interface{}) {
fmt.Println(msg)
}
func newPathError(op string, path string, statusCode int) error {
return &os.PathError{op, path, errors.New(fmt.Sprintf("%d", statusCode))}
}
func newPathErrorErr(op string, path string, err error) error {
return &os.PathError{op, path, err}
// PathEscape escapes all segments of a given path
func PathEscape(path string) string {
s := strings.Split(path, "/")
for i, e := range s {
s[i] = url.PathEscape(e)
}
return strings.Join(s, "/")
}
// FixSlash appends a trailing / to our string
func FixSlash(s string) string {
if !strings.HasSuffix(s, "/") {
s += "/"
@ -31,20 +27,25 @@ func FixSlash(s string) string {
return s
}
// FixSlashes appends and prepends a / if they are missing
func FixSlashes(s string) string {
if s[0] != '/' {
if !strings.HasPrefix(s, "/") {
s = "/" + s
}
return FixSlash(s)
}
// Join joins two paths
func Join(path0 string, path1 string) string {
return strings.TrimSuffix(path0, "/") + "/" + strings.TrimPrefix(path1, "/")
}
// String pulls a string out of our io.Reader
func String(r io.Reader) string {
buf := new(bytes.Buffer)
buf.ReadFrom(r)
// TODO - make String return an error as well
_, _ = buf.ReadFrom(r)
return buf.String()
}
@ -85,3 +86,28 @@ func parseXML(data io.Reader, resp interface{}, parse func(resp interface{}) err
}
return nil
}
// limitedReadCloser wraps a io.ReadCloser and limits the number of bytes that can be read from it.
type limitedReadCloser struct {
rc io.ReadCloser
remaining int
}
func (l *limitedReadCloser) Read(buf []byte) (int, error) {
if l.remaining <= 0 {
return 0, io.EOF
}
if len(buf) > l.remaining {
buf = buf[0:l.remaining]
}
n, err := l.rc.Read(buf)
l.remaining -= n
return n, err
}
func (l *limitedReadCloser) Close() error {
return l.rc.Close()
}

View File

@ -1,6 +1,10 @@
package gowebdav
import "testing"
import (
"fmt"
"net/url"
"testing"
)
func TestJoin(t *testing.T) {
eq(t, "/", "", "")
@ -16,3 +20,48 @@ func eq(t *testing.T, expected string, s0 string, s1 string) {
t.Error("For", "'"+s0+"','"+s1+"'", "expeted", "'"+expected+"'", "got", "'"+s+"'")
}
}
func ExamplePathEscape() {
fmt.Println(PathEscape(""))
fmt.Println(PathEscape("/"))
fmt.Println(PathEscape("/web"))
fmt.Println(PathEscape("/web/"))
fmt.Println(PathEscape("/w e b/d a v/s%u&c#k:s/"))
// Output:
//
// /
// /web
// /web/
// /w%20e%20b/d%20a%20v/s%25u&c%23k:s/
}
func TestEscapeURL(t *testing.T) {
ex := "https://foo.com/w%20e%20b/d%20a%20v/s%25u&c%23k:s/"
u, _ := url.Parse("https://foo.com" + PathEscape("/w e b/d a v/s%u&c#k:s/"))
if ex != u.String() {
t.Error("expected: " + ex + " got: " + u.String())
}
}
func TestFixSlashes(t *testing.T) {
expected := "/"
if got := FixSlashes(""); got != expected {
t.Errorf("expected: %q, got: %q", expected, got)
}
expected = "/path/"
if got := FixSlashes("path"); got != expected {
t.Errorf("expected: %q, got: %q", expected, got)
}
if got := FixSlashes("/path"); got != expected {
t.Errorf("expected: %q, got: %q", expected, got)
}
if got := FixSlashes("path/"); got != expected {
t.Errorf("expected: %q, got: %q", expected, got)
}
}