Compare commits

...
31 Commits
Author SHA1 Message Date
Janlay WuandHenrique Dias 3ddd9182f9 feat: add partial update support (#331)
Co-authored-by: Henrique Dias <[email protected]>
2026-06-27 10:00:57 +02:00
renovate[bot] 71fce1a29e chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 5b7f841 (#340) 2026-06-27 09:41:45 +02:00
renovate[bot] db385c76b2 chore(deps): update all digest updates (#338) 2026-06-24 08:51:24 +02:00
renovate[bot] f9c771fdad chore(deps): update actions/checkout action to v7 (#339) 2026-06-24 08:51:15 +02:00
renovate[bot] 7cc3f8b4ae chore(deps): update all non-major dependencies (#336) 2026-06-13 07:11:02 +02:00
renovate[bot] 36063ce391 chore(deps): update all digest updates to 5f2de1a (#334) 2026-06-11 10:57:08 +02:00
renovate[bot] 6c3258a469 chore(deps): update all digest updates to e2ffffe (#332) 2026-06-03 14:15:33 +02:00
renovate[bot] 3ef6dd5ba5 chore(deps): update all non-major dependencies (#333) 2026-06-03 13:55:28 +02:00
renovate[bot] d57f57f3a0 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 44decbf (#328) 2026-05-14 20:21:59 +02:00
renovate[bot] c3cfaf18f5 chore(deps): update all non-major dependencies (#329) 2026-05-14 20:10:16 +02:00
Bingshen Wang 0128eed63d docs: fix CORS allowed_hosts example 2026-05-14 20:09:47 +02:00
renovate[bot] f051809a5f chore(deps): update golang.org/x/crypto/x509roots/fallback digest to fd0b90d 2026-05-05 07:42:30 +02:00
renovate[bot] f13c884b75 chore(deps): update module go.uber.org/zap to v1.28.0 2026-05-05 07:42:16 +02:00
renovate[bot] cef2e3b673 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to b9e5359 2026-04-28 07:20:05 +02:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 4fe4703e02 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to a8e9237 (#321)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-18 07:53:04 +02:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 882c2200eb chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 03ca0dc (#317)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-11 07:42:03 +02:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 4f4045dbca chore(deps): update all non-major dependencies (#318)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-11 07:40:06 +02:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 20606cd795 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 8400f4a (#314)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-29 08:47:47 +02:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> c9ca42d89a chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 81c6cb3 (#313)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-21 07:12:15 +01:00
Henrique Dias 8217bc0816 refactor: use http.Server 2026-03-14 07:46:25 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 98f8f93ebb chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 982eaa6 (#311)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-14 07:42:38 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 66049d9c08 chore(deps): update all non-major dependencies (#312)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-14 07:40:45 +01:00
Henrique Dias 0406449b05 chore: add additional test 2026-03-09 11:25:59 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> d7745cff34 chore(deps): update docker/setup-qemu-action action to v4 (#309)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 07:40:17 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> d8558a77ff chore(deps): update docker/build-push-action action to v7 (#305)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 07:39:47 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> ab5cf175ad chore(deps): update docker/login-action action to v4 (#306)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 07:39:41 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> f225b0418f chore(deps): update docker/metadata-action action to v6 (#307)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 07:39:35 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> d9600ee82d chore(deps): update docker/setup-buildx-action action to v4 (#308)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 07:39:27 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 6359115c2d chore(deps): update module golang.org/x/net to v0.51.0 (#304)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-28 11:09:54 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 8574b461a9 chore(deps): update goreleaser/goreleaser-action action to v7 (#302)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-21 18:13:54 +01:00
Henrique Dias 1196ad506d fix: revert Dockerfile changes 2026-02-16 17:13:56 +01:00
17 changed files with 1001 additions and 42 deletions
+1 -1
View File
@@ -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"
+7 -7
View File
@@ -18,22 +18,22 @@ 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@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -41,7 +41,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
hacdias/webdav
@@ -55,7 +55,7 @@ jobs:
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -12,13 +12,13 @@ jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: "1.26.x"
- uses: goreleaser/goreleaser-action@v6
- uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: '~> v2'
+1 -1
View File
@@ -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"
-2
View File
@@ -13,8 +13,6 @@ RUN go build -o main -trimpath -ldflags="-s -w -X 'github.com/hacdias/webdav/v5/
FROM scratch
WORKDIR /data
COPY --from=build /webdav/main /bin/webdav
EXPOSE 6065
+17 -1
View File
@@ -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
@@ -132,10 +145,11 @@ cors:
credentials: true
# The following are the default CORS settings when it is enabled.
allowed_hosts:
- *
- '*'
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
+6 -3
View File
@@ -1,6 +1,7 @@
package cmd
import (
"context"
"errors"
"fmt"
"net"
@@ -80,6 +81,8 @@ set WD_CERT.`,
return err
}
server := &http.Server{Handler: handler}
// Trap exiting signals
quit := make(chan os.Signal, 1)
@@ -88,9 +91,9 @@ set WD_CERT.`,
var err error
if cfg.TLS {
err = http.ServeTLS(listener, handler, cfg.Cert, cfg.Key)
err = server.ServeTLS(listener, cfg.Cert, cfg.Key)
} else {
err = http.Serve(listener, handler)
err = server.Serve(listener)
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -104,7 +107,7 @@ set WD_CERT.`,
signal := <-quit
zap.L().Info("caught signal, shutting down", zap.Stringer("signal", signal))
_ = listener.Close()
_ = server.Shutdown(context.Background())
return nil
},
-1
View File
@@ -12,4 +12,3 @@ services:
volumes:
- ./data:/data
- ./config.yml:/config.yml:ro
command: ['-c', '/config.yml']
+6 -6
View File
@@ -11,10 +11,10 @@ require (
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/studio-b12/gowebdav v0.12.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.48.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541
golang.org/x/net v0.50.0
go.uber.org/zap v1.28.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.41.0 // indirect
golang.org/x/text v0.34.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
)
+12 -12
View File
@@ -51,20 +51,20 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 h1:FmKxj9ocLKn45jiR2jQMwCVhDvaK7fKQFzfuT9GvyK8=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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
View File
@@ -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
View File
@@ -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) {
+10
View File
@@ -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)
}
+407
View File
@@ -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()
@@ -290,6 +691,12 @@ users:
err = client.Write("/c/b.txt", []byte("new"), 0666)
require.ErrorContains(t, err, "403")
err = client.MkdirAll("/d/foo/bar", 0666)
require.NoError(t, err)
err = client.Write("/d/foo/bar/test.txt", []byte("test"), 0666)
require.NoError(t, err)
}
func TestServerRulesAdditive(t *testing.T) {
+526
View File
@@ -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
View File
@@ -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 {