mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-22 03:20:41 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ddd9182f9 | ||
|
|
71fce1a29e | ||
|
|
db385c76b2 | ||
|
|
f9c771fdad | ||
|
|
7cc3f8b4ae | ||
|
|
36063ce391 |
@@ -12,7 +12,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v6
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
|
||||
@@ -60,6 +60,19 @@ the log.
|
||||
--name webdav \
|
||||
```
|
||||
|
||||
### Partial updates
|
||||
|
||||
This server supports partial file updates compatible with SabreDAV's `PATCH` extension. This is not an official WebDAV specification. Requests must use the `application/x-sabredav-partialupdate` content type, include `Content-Length`, and provide the target range in `X-Update-Range`.
|
||||
|
||||
Supported `X-Update-Range` values are:
|
||||
|
||||
- `bytes=start-end`
|
||||
- `bytes=start-`
|
||||
- `bytes=-N`
|
||||
- `append`
|
||||
|
||||
For clients that use it, the server also supports partial `PUT` requests with `Content-Range`, for example `Content-Range: bytes 6-8/*`. This is an extra compatibility path and should be treated as a client/server agreement.
|
||||
|
||||
## Configuration
|
||||
|
||||
The configuration can be provided as a YAML, JSON or TOML file. Below is an example of a YAML configuration file with
|
||||
@@ -136,6 +149,7 @@ cors:
|
||||
allowed_headers:
|
||||
- Authorization
|
||||
- Content-Type
|
||||
- Content-Range
|
||||
- Depth
|
||||
- Destination
|
||||
- If
|
||||
@@ -143,6 +157,7 @@ cors:
|
||||
- Overwrite
|
||||
- TimeOut
|
||||
- Translate
|
||||
- X-Update-Range
|
||||
allowed_methods:
|
||||
- COPY
|
||||
- DELETE
|
||||
@@ -153,6 +168,7 @@ cors:
|
||||
- MKCOL
|
||||
- MOVE
|
||||
- OPTIONS
|
||||
- PATCH
|
||||
- POST
|
||||
- PROPFIND
|
||||
- PROPPATCH
|
||||
|
||||
@@ -12,9 +12,9 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/studio-b12/gowebdav v0.12.0
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260602072539-e2ffffe738fb
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260626155920-5b7f84159940
|
||||
golang.org/x/net v0.56.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -30,7 +30,7 @@ require (
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -55,16 +55,16 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260602072539-e2ffffe738fb h1:CwwbfJyWu/4JFx3SUseZXBthp02ezLIBochvBIBfwr8=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260602072539-e2ffffe738fb/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260626155920-5b7f84159940 h1:WD+hrfJy2TRwCU7dnWiqw/dGb9jNdEzy2uR+jtpYJYk=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260626155920-5b7f84159940/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+2
-2
@@ -87,8 +87,8 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
v.SetDefault("Log.Outputs", []string{"stderr"})
|
||||
v.SetDefault("Log.Colors", true)
|
||||
v.SetDefault("CORS.Allowed_Hosts", []string{"*"})
|
||||
v.SetDefault("CORS.Allowed_Headers", []string{"Authorization", "Content-Type", "Depth", "Destination", "If", "Lock-Token", "Overwrite"})
|
||||
v.SetDefault("CORS.Allowed_Methods", []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"})
|
||||
v.SetDefault("CORS.Allowed_Headers", []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"})
|
||||
v.SetDefault("CORS.Allowed_Methods", []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"})
|
||||
|
||||
// Read and unmarshal configuration
|
||||
err := v.ReadInConfig()
|
||||
|
||||
+2
-2
@@ -52,8 +52,8 @@ func TestConfigDefaults(t *testing.T) {
|
||||
require.Equal(t, dir, cfg.Directory)
|
||||
|
||||
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
|
||||
require.EqualValues(t, []string{"Authorization", "Content-Type", "Depth", "Destination", "If", "Lock-Token", "Overwrite"}, cfg.CORS.AllowedHeaders)
|
||||
require.EqualValues(t, []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"}, cfg.CORS.AllowedMethods)
|
||||
require.EqualValues(t, []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"}, cfg.CORS.AllowedHeaders)
|
||||
require.EqualValues(t, []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"}, cfg.CORS.AllowedMethods)
|
||||
}
|
||||
|
||||
func TestConfigCascade(t *testing.T) {
|
||||
|
||||
@@ -176,6 +176,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
user.handleOptions(w, r, req.path)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "PATCH" || (r.Method == "PUT" && r.Header.Get("Content-Range") != "") {
|
||||
user.handlePartialUpdate(w, r, req.path)
|
||||
return
|
||||
}
|
||||
|
||||
// Runs the WebDAV.
|
||||
user.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -82,6 +85,404 @@ func TestServerDefaults(t *testing.T) {
|
||||
require.ErrorContains(t, client.Write("/foo.txt", []byte("hello world 2"), 0666), "403")
|
||||
}
|
||||
|
||||
func TestServerPartialUpdateOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("hello world"),
|
||||
})
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodOptions, srv.URL+"/foo.txt", nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Contains(t, resp.Header.Get("DAV"), "sabredav-partialupdate")
|
||||
require.Contains(t, resp.Header.Get("Allow"), "PATCH")
|
||||
require.Equal(t, partialUpdateContentType, resp.Header.Get("Accept-Patch"))
|
||||
}
|
||||
|
||||
func TestServerPatchPartialUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
initialData string
|
||||
body string
|
||||
updateRange string
|
||||
wantData string
|
||||
}{{
|
||||
name: "start",
|
||||
initialData: "hello world",
|
||||
body: "DAV",
|
||||
updateRange: "bytes=6-",
|
||||
wantData: "hello DAVld",
|
||||
}, {
|
||||
name: "suffix",
|
||||
initialData: "hello world",
|
||||
body: "DAV",
|
||||
updateRange: "bytes=-5",
|
||||
wantData: "hello DAVld",
|
||||
}, {
|
||||
name: "append",
|
||||
initialData: "hello",
|
||||
body: " world",
|
||||
updateRange: "append",
|
||||
wantData: "hello world",
|
||||
}, {
|
||||
name: "suffix_zero",
|
||||
initialData: "hello",
|
||||
body: " world",
|
||||
updateRange: "bytes=-0",
|
||||
wantData: "hello world",
|
||||
}}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte(tc.initialData),
|
||||
})
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest("PATCH", srv.URL+"/foo.txt", strings.NewReader(tc.body))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", partialUpdateContentType)
|
||||
req.Header.Set("X-Update-Range", tc.updateRange)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantData, string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPatchPartialUpdateCreatesSparseFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, nil)
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest("PATCH", srv.URL+"/new.bin", strings.NewReader("x"))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", partialUpdateContentType)
|
||||
req.Header.Set("X-Update-Range", "bytes=3-")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "new.bin"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{0, 0, 0, 'x'}, data)
|
||||
}
|
||||
|
||||
func TestServerPutContentRangePartialUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("hello world"),
|
||||
})
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut, srv.URL+"/foo.txt", strings.NewReader("DAV"))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Range", "bytes 6-8/*")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello DAVld", string(data))
|
||||
}
|
||||
|
||||
func TestServerPartialUpdateErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
method string
|
||||
body string
|
||||
contentLength int64
|
||||
path string
|
||||
headerName string
|
||||
updateRange string
|
||||
contentRange string
|
||||
wantStatus int
|
||||
}{{
|
||||
name: "patch_missing_content_length",
|
||||
method: "PATCH",
|
||||
body: "DAV",
|
||||
contentLength: -1,
|
||||
updateRange: "bytes=6-8",
|
||||
wantStatus: http.StatusLengthRequired,
|
||||
}, {
|
||||
name: "patch_invalid_range",
|
||||
method: "PATCH",
|
||||
body: "DAV",
|
||||
updateRange: "bytes=8-6",
|
||||
wantStatus: http.StatusRequestedRangeNotSatisfiable,
|
||||
}, {
|
||||
name: "patch_length_mismatch",
|
||||
method: "PATCH",
|
||||
body: "TOOLONG",
|
||||
updateRange: "bytes=6-8",
|
||||
wantStatus: http.StatusRequestedRangeNotSatisfiable,
|
||||
}, {
|
||||
name: "put_content_range_length_mismatch",
|
||||
method: http.MethodPut,
|
||||
body: "TOOLONG",
|
||||
contentLength: -1,
|
||||
contentRange: "bytes 6-8/*",
|
||||
wantStatus: http.StatusRequestedRangeNotSatisfiable,
|
||||
}, {
|
||||
name: "if_none_match",
|
||||
method: "PATCH",
|
||||
body: "DAV",
|
||||
headerName: "If-None-Match",
|
||||
updateRange: "bytes=0-2",
|
||||
wantStatus: http.StatusPreconditionFailed,
|
||||
}, {
|
||||
name: "if_match",
|
||||
method: "PATCH",
|
||||
path: "/missing.txt",
|
||||
body: "DAV",
|
||||
headerName: "If-Match",
|
||||
updateRange: "bytes=0-2",
|
||||
wantStatus: http.StatusPreconditionFailed,
|
||||
}}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("hello world"),
|
||||
})
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
var body io.Reader = strings.NewReader(tc.body)
|
||||
if tc.contentLength < 0 {
|
||||
body = io.NopCloser(strings.NewReader(tc.body))
|
||||
}
|
||||
path := tc.path
|
||||
if path == "" {
|
||||
path = "/foo.txt"
|
||||
}
|
||||
req, err := http.NewRequest(tc.method, srv.URL+path, body)
|
||||
require.NoError(t, err)
|
||||
if tc.contentLength < 0 {
|
||||
req.ContentLength = tc.contentLength
|
||||
}
|
||||
if tc.method == "PATCH" {
|
||||
req.Header.Set("Content-Type", partialUpdateContentType)
|
||||
req.Header.Set("X-Update-Range", tc.updateRange)
|
||||
}
|
||||
if tc.contentRange != "" {
|
||||
req.Header.Set("Content-Range", tc.contentRange)
|
||||
}
|
||||
if tc.headerName != "" {
|
||||
req.Header.Set(tc.headerName, "*")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, tc.wantStatus, resp.StatusCode)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello world", string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPartialUpdateETagPreconditions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
method string
|
||||
headerName string
|
||||
headerValue func(string) string
|
||||
contentRange string
|
||||
wantStatus int
|
||||
wantData string
|
||||
}{{
|
||||
name: "if_match_matches",
|
||||
method: "PATCH",
|
||||
headerName: "If-Match",
|
||||
headerValue: func(etag string) string { return etag },
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantData: "hello DAVld",
|
||||
}, {
|
||||
name: "if_match_mismatch",
|
||||
method: "PATCH",
|
||||
headerName: "If-Match",
|
||||
headerValue: func(string) string { return `"definitely-wrong"` },
|
||||
wantStatus: http.StatusPreconditionFailed,
|
||||
wantData: "hello world",
|
||||
}, {
|
||||
name: "if_match_list_matches",
|
||||
method: "PATCH",
|
||||
headerName: "If-Match",
|
||||
headerValue: func(etag string) string { return `"definitely-wrong", ` + etag },
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantData: "hello DAVld",
|
||||
}, {
|
||||
name: "if_none_match_matches",
|
||||
method: "PATCH",
|
||||
headerName: "If-None-Match",
|
||||
headerValue: func(etag string) string { return etag },
|
||||
wantStatus: http.StatusPreconditionFailed,
|
||||
wantData: "hello world",
|
||||
}, {
|
||||
name: "if_none_match_mismatch",
|
||||
method: "PATCH",
|
||||
headerName: "If-None-Match",
|
||||
headerValue: func(string) string { return `"definitely-wrong"` },
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantData: "hello DAVld",
|
||||
}, {
|
||||
name: "put_content_range_if_match_mismatch",
|
||||
method: http.MethodPut,
|
||||
headerName: "If-Match",
|
||||
headerValue: func(string) string { return `"definitely-wrong"` },
|
||||
contentRange: "bytes 6-8/*",
|
||||
wantStatus: http.StatusPreconditionFailed,
|
||||
wantData: "hello world",
|
||||
}}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("hello world"),
|
||||
})
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodHead, srv.URL+"/foo.txt", nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
etag := resp.Header.Get("ETag")
|
||||
require.NotEmpty(t, etag)
|
||||
|
||||
req, err = http.NewRequest(tc.method, srv.URL+"/foo.txt", strings.NewReader("DAV"))
|
||||
require.NoError(t, err)
|
||||
if tc.method == "PATCH" {
|
||||
req.Header.Set("Content-Type", partialUpdateContentType)
|
||||
req.Header.Set("X-Update-Range", "bytes=6-8")
|
||||
}
|
||||
if tc.contentRange != "" {
|
||||
req.Header.Set("Content-Range", tc.contentRange)
|
||||
}
|
||||
req.Header.Set(tc.headerName, tc.headerValue(etag))
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, tc.wantStatus, resp.StatusCode)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantData, string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPartialUpdateHonorsLocks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const createLockBody = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:lockinfo xmlns:D='DAV:'>
|
||||
<D:lockscope><D:exclusive/></D:lockscope>
|
||||
<D:locktype><D:write/></D:locktype>
|
||||
<D:owner>test</D:owner>
|
||||
</D:lockinfo>`
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
lockPath string
|
||||
depth string
|
||||
ifPath string
|
||||
}{{
|
||||
name: "file",
|
||||
lockPath: "/foo.txt",
|
||||
depth: "0",
|
||||
ifPath: "/foo.txt",
|
||||
}, {
|
||||
name: "root",
|
||||
lockPath: "/",
|
||||
depth: "infinity",
|
||||
ifPath: "/",
|
||||
}}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("hello world"),
|
||||
})
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest("LOCK", srv.URL+tc.lockPath, strings.NewReader(createLockBody))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
req.Header.Set("Depth", tc.depth)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
lockToken := resp.Header.Get("Lock-Token")
|
||||
|
||||
req, err = http.NewRequest("PATCH", srv.URL+"/foo.txt", strings.NewReader("DAV"))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", partialUpdateContentType)
|
||||
req.Header.Set("X-Update-Range", "bytes=6-8")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, 423, resp.StatusCode)
|
||||
|
||||
req, err = http.NewRequest("PATCH", srv.URL+"/foo.txt", strings.NewReader("DAV"))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", partialUpdateContentType)
|
||||
req.Header.Set("X-Update-Range", "bytes=6-8")
|
||||
req.Header.Set("If", fmt.Sprintf("<%s%s> (%s)", srv.URL, tc.ifPath, lockToken))
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello DAVld", string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerListingCharacters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
const partialUpdateContentType = "application/x-sabredav-partialupdate"
|
||||
|
||||
type updateRange struct {
|
||||
offset int64
|
||||
end int64
|
||||
hasEnd bool
|
||||
append bool
|
||||
}
|
||||
|
||||
type partialUpdateError struct {
|
||||
status int
|
||||
err error
|
||||
}
|
||||
|
||||
func (e partialUpdateError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func newPartialUpdateError(status int, message string) error {
|
||||
return partialUpdateError{status: status, err: errors.New(message)}
|
||||
}
|
||||
|
||||
func writePartialUpdateError(w http.ResponseWriter, err error, fallbackStatus int) {
|
||||
var httpErr partialUpdateError
|
||||
if errors.As(err, &httpErr) {
|
||||
fallbackStatus = httpErr.status
|
||||
}
|
||||
http.Error(w, err.Error(), fallbackStatus)
|
||||
}
|
||||
|
||||
func (u *handlerUser) handleOptions(w http.ResponseWriter, r *http.Request, reqPath string) {
|
||||
allow := "OPTIONS, LOCK, PUT, MKCOL, PATCH"
|
||||
if fi, err := u.FileSystem.Stat(r.Context(), reqPath); err == nil {
|
||||
if fi.IsDir() {
|
||||
allow = "OPTIONS, LOCK, DELETE, PROPPATCH, COPY, MOVE, UNLOCK, PROPFIND"
|
||||
} else {
|
||||
allow = "OPTIONS, LOCK, GET, HEAD, POST, DELETE, PROPPATCH, COPY, MOVE, UNLOCK, PROPFIND, PUT, PATCH"
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Allow", allow)
|
||||
w.Header().Set("DAV", "1, 2, sabredav-partialupdate")
|
||||
w.Header().Set("MS-Author-Via", "DAV")
|
||||
w.Header().Set("Accept-Patch", partialUpdateContentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (u *handlerUser) handlePartialUpdate(w http.ResponseWriter, r *http.Request, reqPath string) {
|
||||
contentRange := r.Header.Get("Content-Range")
|
||||
isContentRangePut := r.Method == "PUT" && contentRange != ""
|
||||
|
||||
var (
|
||||
updateRange updateRange
|
||||
err error
|
||||
)
|
||||
if isContentRangePut {
|
||||
updateRange, err = parseContentRange(contentRange)
|
||||
} else {
|
||||
if err := checkPartialUpdateContentType(r.Header.Get("Content-Type")); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
updateRange, err = parseUpdateRange(r.Header.Get("X-Update-Range"))
|
||||
}
|
||||
if err != nil {
|
||||
writePartialUpdateError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Method == "PATCH" && r.ContentLength < 0 {
|
||||
http.Error(w, "missing content length", http.StatusLengthRequired)
|
||||
return
|
||||
}
|
||||
|
||||
release, status, err := u.confirmPartialUpdateLocks(r, reqPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
ctx := r.Context()
|
||||
fi, statErr := u.FileSystem.Stat(ctx, reqPath)
|
||||
exists := statErr == nil
|
||||
if statErr != nil && !os.IsNotExist(statErr) {
|
||||
http.Error(w, statErr.Error(), http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if exists && fi.IsDir() {
|
||||
http.Error(w, "cannot update a collection", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
etag, status, err := u.checkPartialUpdatePreconditions(r, exists, fi)
|
||||
if err != nil {
|
||||
if etag != "" {
|
||||
w.Header().Set("ETag", etag)
|
||||
}
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
|
||||
currentSize := int64(0)
|
||||
if exists {
|
||||
currentSize = fi.Size()
|
||||
}
|
||||
if updateRange.append {
|
||||
updateRange.offset = currentSize
|
||||
} else if updateRange.offset < 0 {
|
||||
updateRange.offset += currentSize
|
||||
if updateRange.offset < 0 {
|
||||
updateRange.offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
if updateRange.hasEnd {
|
||||
expected := updateRange.end - updateRange.offset + 1
|
||||
if expected < 0 {
|
||||
http.Error(w, "invalid byte range", http.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
if r.ContentLength >= 0 && r.ContentLength != expected {
|
||||
http.Error(w, "content length does not match byte range", http.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body := io.Reader(r.Body)
|
||||
var cleanup func()
|
||||
if updateRange.hasEnd {
|
||||
body, cleanup, err = spoolBoundedBody(r.Body, updateRange.end-updateRange.offset+1)
|
||||
if err != nil {
|
||||
writePartialUpdateError(w, err, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
defer cleanup()
|
||||
}
|
||||
|
||||
flag := os.O_RDWR
|
||||
if !exists {
|
||||
flag |= os.O_CREATE
|
||||
}
|
||||
f, err := u.FileSystem.OpenFile(ctx, reqPath, flag, 0666)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
if _, err := f.Seek(updateRange.offset, io.SeekStart); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(f, body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !exists {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func checkPartialUpdateContentType(contentType string) error {
|
||||
if contentType == "" {
|
||||
return errors.New("missing content type")
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mediaType != partialUpdateContentType {
|
||||
return fmt.Errorf("unsupported content type %q", mediaType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *handlerUser) checkPartialUpdatePreconditions(r *http.Request, exists bool, fi os.FileInfo) (etag string, status int, err error) {
|
||||
ifMatch := r.Header.Get("If-Match")
|
||||
ifNoneMatch := r.Header.Get("If-None-Match")
|
||||
if ifMatch == "" && ifNoneMatch == "" {
|
||||
return "", 0, nil
|
||||
}
|
||||
|
||||
if ifMatch != "" && !exists {
|
||||
return "", http.StatusPreconditionFailed, errors.New("resource does not exist")
|
||||
}
|
||||
|
||||
if exists {
|
||||
etag, err = findPartialETag(r.Context(), fi)
|
||||
if err != nil {
|
||||
return "", http.StatusInternalServerError, err
|
||||
}
|
||||
}
|
||||
|
||||
if ifMatch != "" && !partialETagHeaderMatches(ifMatch, etag, exists) {
|
||||
return etag, http.StatusPreconditionFailed, errors.New("etag does not match")
|
||||
}
|
||||
|
||||
if ifNoneMatch != "" && exists && partialETagHeaderMatches(ifNoneMatch, etag, true) {
|
||||
return etag, http.StatusPreconditionFailed, errors.New("etag matches")
|
||||
}
|
||||
|
||||
return etag, 0, nil
|
||||
}
|
||||
|
||||
func findPartialETag(ctx context.Context, fi os.FileInfo) (string, error) {
|
||||
if etager, ok := fi.(webdav.ETager); ok {
|
||||
etag, err := etager.ETag(ctx)
|
||||
if !errors.Is(err, webdav.ErrNotImplemented) {
|
||||
return etag, err
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(`"%x%x"`, fi.ModTime().UnixNano(), fi.Size()), nil
|
||||
}
|
||||
|
||||
func partialETagHeaderMatches(header, etag string, exists bool) bool {
|
||||
for _, item := range strings.Split(header, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "*" {
|
||||
return exists
|
||||
}
|
||||
if item == etag || strings.ReplaceAll(item, `\"`, `"`) == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseUpdateRange(header string) (updateRange, error) {
|
||||
if header == "" {
|
||||
return updateRange{}, errors.New("missing X-Update-Range header")
|
||||
}
|
||||
if header == "append" {
|
||||
return updateRange{append: true}, nil
|
||||
}
|
||||
if !strings.HasPrefix(header, "bytes=") {
|
||||
return updateRange{}, errors.New("invalid X-Update-Range header")
|
||||
}
|
||||
return parseByteRange(strings.TrimPrefix(header, "bytes="), true)
|
||||
}
|
||||
|
||||
func parseContentRange(header string) (updateRange, error) {
|
||||
if !strings.HasPrefix(header, "bytes ") {
|
||||
return updateRange{}, errors.New("invalid Content-Range header")
|
||||
}
|
||||
spec, _, ok := strings.Cut(strings.TrimPrefix(header, "bytes "), "/")
|
||||
if !ok {
|
||||
return updateRange{}, errors.New("invalid Content-Range header")
|
||||
}
|
||||
return parseByteRange(spec, false)
|
||||
}
|
||||
|
||||
func parseByteRange(spec string, allowNegativeStart bool) (updateRange, error) {
|
||||
if strings.HasPrefix(spec, "-") {
|
||||
if !allowNegativeStart {
|
||||
return updateRange{}, errors.New("invalid byte range start")
|
||||
}
|
||||
start, err := strconv.ParseInt(strings.TrimPrefix(spec, "-"), 10, 64)
|
||||
if err != nil || start < 0 {
|
||||
return updateRange{}, errors.New("invalid byte range start")
|
||||
}
|
||||
if start == 0 {
|
||||
return updateRange{append: true}, nil
|
||||
}
|
||||
return updateRange{offset: -start}, nil
|
||||
}
|
||||
|
||||
startText, endText, ok := strings.Cut(spec, "-")
|
||||
if !ok || startText == "" {
|
||||
return updateRange{}, errors.New("invalid byte range")
|
||||
}
|
||||
|
||||
start, err := strconv.ParseInt(startText, 10, 64)
|
||||
if err != nil {
|
||||
return updateRange{}, errors.New("invalid byte range start")
|
||||
}
|
||||
if start < 0 && !allowNegativeStart {
|
||||
return updateRange{}, errors.New("invalid byte range start")
|
||||
}
|
||||
|
||||
r := updateRange{offset: start}
|
||||
if endText == "" {
|
||||
return r, nil
|
||||
}
|
||||
if start < 0 {
|
||||
return updateRange{}, errors.New("negative byte range cannot include an end")
|
||||
}
|
||||
|
||||
end, err := strconv.ParseInt(endText, 10, 64)
|
||||
if err != nil {
|
||||
return updateRange{}, errors.New("invalid byte range end")
|
||||
}
|
||||
if end < start {
|
||||
return updateRange{}, newPartialUpdateError(http.StatusRequestedRangeNotSatisfiable, "invalid byte range")
|
||||
}
|
||||
r.end = end
|
||||
r.hasEnd = true
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func spoolBoundedBody(body io.Reader, expected int64) (io.Reader, func(), error) {
|
||||
tmp, err := os.CreateTemp("", "webdav-partial-update-*")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cleanup := func() {
|
||||
name := tmp.Name()
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(name)
|
||||
}
|
||||
cleanupOnError := true
|
||||
defer func() {
|
||||
if cleanupOnError {
|
||||
cleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
n, err := io.Copy(tmp, io.LimitReader(body, expected+1))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if n != expected {
|
||||
return nil, nil, newPartialUpdateError(http.StatusRequestedRangeNotSatisfiable, "body length does not match byte range")
|
||||
}
|
||||
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cleanupOnError = false
|
||||
return tmp, cleanup, nil
|
||||
}
|
||||
|
||||
// confirmPartialUpdateLocks mirrors the unexported confirmLocks helper from
|
||||
// golang.org/x/net/webdav so that partial updates honor WebDAV locks the same
|
||||
// way regular PUT requests do. Keep it in sync if the upstream behavior changes.
|
||||
func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (release func(), status int, err error) {
|
||||
hdr := r.Header.Get("If")
|
||||
if hdr == "" {
|
||||
now := time.Now()
|
||||
token, err := u.LockSystem.Create(now, webdav.LockDetails{
|
||||
Root: src,
|
||||
Duration: -1,
|
||||
ZeroDepth: true,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, webdav.ErrLocked) {
|
||||
return nil, webdav.StatusLocked, err
|
||||
}
|
||||
return nil, http.StatusInternalServerError, err
|
||||
}
|
||||
return func() {
|
||||
_ = u.LockSystem.Unlock(now, token)
|
||||
}, 0, nil
|
||||
}
|
||||
|
||||
ifLists, ok := parsePartialIfHeader(hdr)
|
||||
if !ok {
|
||||
return nil, http.StatusBadRequest, errors.New("webdav: invalid If header")
|
||||
}
|
||||
for _, l := range ifLists {
|
||||
lsrc := l.resourceTag
|
||||
if lsrc == "" {
|
||||
lsrc = src
|
||||
} else {
|
||||
parsedURL, err := url.Parse(lsrc)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if parsedURL.Host != r.Host {
|
||||
continue
|
||||
}
|
||||
lsrc, err = stripPartialPrefix(parsedURL.Path, u.Prefix)
|
||||
if err != nil {
|
||||
return nil, http.StatusNotFound, err
|
||||
}
|
||||
if lsrc == "" {
|
||||
lsrc = src
|
||||
}
|
||||
}
|
||||
release, err = u.LockSystem.Confirm(time.Now(), lsrc, "", l.conditions...)
|
||||
if errors.Is(err, webdav.ErrConfirmationFailed) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, err
|
||||
}
|
||||
return release, 0, nil
|
||||
}
|
||||
return nil, http.StatusPreconditionFailed, webdav.ErrLocked
|
||||
}
|
||||
|
||||
type partialIfList struct {
|
||||
resourceTag string
|
||||
conditions []webdav.Condition
|
||||
}
|
||||
|
||||
// parsePartialIfHeader, parsePartialIfConditions and cutPartialIfToken
|
||||
// reimplement the unexported If-header parser from golang.org/x/net/webdav,
|
||||
// which is not accessible from outside that package. Keep them in sync with the
|
||||
// upstream parseIfHeader if it changes.
|
||||
func parsePartialIfHeader(header string) ([]partialIfList, bool) {
|
||||
s := strings.TrimSpace(header)
|
||||
tagged := strings.HasPrefix(s, "<")
|
||||
var lists []partialIfList
|
||||
for s != "" {
|
||||
resourceTag := ""
|
||||
if strings.HasPrefix(s, "<") {
|
||||
if !tagged {
|
||||
return nil, false
|
||||
}
|
||||
var ok bool
|
||||
resourceTag, s, ok = cutPartialIfToken(s, '<', '>')
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "(") {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
for strings.HasPrefix(s, "(") {
|
||||
body, rest, ok := cutPartialIfToken(s, '(', ')')
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
conditions, ok := parsePartialIfConditions(body)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
lists = append(lists, partialIfList{resourceTag: resourceTag, conditions: conditions})
|
||||
s = strings.TrimSpace(rest)
|
||||
}
|
||||
if s != "" && !strings.HasPrefix(s, "<") {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return lists, len(lists) > 0
|
||||
}
|
||||
|
||||
func parsePartialIfConditions(s string) ([]webdav.Condition, bool) {
|
||||
var conditions []webdav.Condition
|
||||
for {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return conditions, len(conditions) > 0
|
||||
}
|
||||
not := false
|
||||
if strings.HasPrefix(s, "Not ") || strings.HasPrefix(s, "Not\t") {
|
||||
not = true
|
||||
s = strings.TrimSpace(s[3:])
|
||||
}
|
||||
if s == "" {
|
||||
return nil, false
|
||||
}
|
||||
var token string
|
||||
switch s[0] {
|
||||
case '<':
|
||||
var ok bool
|
||||
token, s, ok = cutPartialIfToken(s, '<', '>')
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
conditions = append(conditions, webdav.Condition{Not: not, Token: token})
|
||||
case '[':
|
||||
var ok bool
|
||||
token, s, ok = cutPartialIfToken(s, '[', ']')
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
conditions = append(conditions, webdav.Condition{Not: not, ETag: token})
|
||||
default:
|
||||
i := strings.IndexAny(s, " \t")
|
||||
if i < 0 {
|
||||
token, s = s, ""
|
||||
} else {
|
||||
token, s = s[:i], s[i:]
|
||||
}
|
||||
if token == "" || strings.ContainsAny(token, "()<>[]") {
|
||||
return nil, false
|
||||
}
|
||||
conditions = append(conditions, webdav.Condition{Not: not, Token: token})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cutPartialIfToken(s string, open, close byte) (string, string, bool) {
|
||||
if s == "" || s[0] != open {
|
||||
return "", "", false
|
||||
}
|
||||
token, rest, ok := strings.Cut(s[1:], string(close))
|
||||
return token, rest, ok
|
||||
}
|
||||
|
||||
func stripPartialPrefix(p, prefix string) (string, error) {
|
||||
if prefix == "" {
|
||||
return p, nil
|
||||
}
|
||||
if stripped := strings.TrimPrefix(p, prefix); len(stripped) < len(p) {
|
||||
return stripped, nil
|
||||
}
|
||||
return "", errors.New("webdav: prefix mismatch")
|
||||
}
|
||||
+1
-1
@@ -152,7 +152,7 @@ func (p Permissions) Allowed(r *request, fileExists func(string) bool) bool {
|
||||
return p.Create
|
||||
case "PROPPATCH":
|
||||
return p.Update
|
||||
case "PUT":
|
||||
case "PUT", "PATCH":
|
||||
if fileExists(r.path) {
|
||||
return p.Update
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user