Compare commits

...
48 Commits
Author SHA1 Message Date
renovate[bot] ca617862a5 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to cdce021 (#341) 2026-07-09 14:24:45 +02:00
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
Henrique Dias 4ac9a1041c fix: remove broken targets 2026-02-15 14:16:59 +01:00
Henrique Dias 27aad9d403 fix: test 2026-02-15 14:05:07 +01:00
Henrique Dias e06f26b095 feat: update default CORS settings 2026-02-15 14:02:31 +01:00
Legendary4226 04070a8ae8 docs: add compose.yml and clarifications 2026-02-15 13:57:49 +01:00
renovate[bot] bd0667aac1 chore(deps): update all non-major dependencies 2026-02-15 13:57:03 +01:00
renovate[bot] b620cd5c88 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to a408498 2026-02-15 13:54:43 +01:00
renovate[bot] c4c58fe420 chore(deps): update module github.com/coreos/go-systemd/v22 to v22.7.0 2026-02-01 13:41:40 +01:00
renovate[bot] 0ff71ca067 chore(deps): update module github.com/studio-b12/gowebdav to v0.12.0 2026-01-24 14:31:55 +01:00
renovate[bot] efdaa784b9 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 7d0074c 2026-01-18 08:34:19 +01:00
renovate[bot] 7662d629e3 chore(deps): update all non-major dependencies 2026-01-17 22:45:01 +01:00
renovate[bot] 010ca576fb chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 7dacc38 2025-12-13 09:10:00 +01:00
renovate[bot] 7b8aa1c422 chore(deps): update all non-major dependencies 2025-12-13 09:05:43 +01:00
Henrique Dias 2f98af7d1e ci: run renovate on weekends 2025-12-05 15:10:32 +01:00
renovate[bot] 50e1828b60 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 3a1c6b4 2025-12-05 14:34:40 +01:00
renovate[bot] e78d59e374 chore(deps): update module github.com/spf13/cobra to v1.10.2 2025-12-05 14:31:30 +01:00
renovate[bot] de0a102ed8 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to f4602e4 2025-12-03 08:46:31 +01:00
20 changed files with 1100 additions and 95 deletions
+2 -2
View File
@@ -12,10 +12,10 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: "1.25.x"
go-version: "1.26.x"
- run: go build .
env:
CGO_ENABLED: '0'
+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
+2 -2
View File
@@ -12,10 +12,10 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: "1.25.x"
go-version: "1.26.x"
- uses: golangci/golangci-lint-action@v9
with:
version: "latest"
+3 -3
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.25.x"
- uses: goreleaser/goreleaser-action@v6
go-version: "1.26.x"
- uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: '~> v2'
+2 -2
View File
@@ -13,9 +13,9 @@ 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.25.x"
go-version: "1.26.x"
- name: Run test with coverage
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
+1 -1
View File
@@ -1,2 +1,2 @@
dist/
webdav
webdav
+3 -1
View File
@@ -38,9 +38,11 @@ builds:
- goos: openbsd
goarch: arm
goarm: 6
# Broken as of Go 1.24, deprecated as of Go 1.26
- goos: freebsd
goarch: arm
goarm: 6
- goos: windows
goarch: arm
archives:
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.25-alpine3.22 AS build
FROM golang:1.26-alpine3.22 AS build
ARG VERSION="untracked"
+79 -37
View File
@@ -38,34 +38,45 @@ docker pull hacdias/webdav:latest
For usage information regarding the CLI, run `webdav --help`.
### Docker
### Container
To use with Docker, you need to provide a configuration file and mount the data directories. For example, let's take the following configuration file that simply sets the port to `6060` and the directory to `/data`.
To run the container, you can refer to the `compose.yml` file which provides a minimal setup. Additionally, you also need to create a configuration file, as [explained below](#configuration).
```yaml
port: 6060
directory: /data
```
You can now run with the following Docker command, where you mount the configuration file inside the container, and the data directory too, as well as forwarding the port 6060. You will need to change this to match your own configuration.
The equivalent Docker command to the aforementioned compose file would be as follows:
```bash
docker run \
-p 6060:6060 \
-v $(pwd)/config.yml:/config.yml:ro \
-v $(pwd)/data:/data \
-p 6065:6065 \
-v ./config.yml:/config.yml:ro \
-v ./data:/data \
ghcr.io/hacdias/webdav -c /config.yml
```
If you are using [fail2ban](#fail2ban-setup), it would be helpful to add the parameters listed below. They will assist in analyzing the log.
If you are using [fail2ban](#fail2ban-setup), it would be helpful to add the parameters listed below. They will assist in analyzing
the log.
```bash
--log-driver journald \
--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 all the options available, as well as what they mean.
The configuration can be provided as a YAML, JSON or TOML file. Below is an example of a YAML configuration file with
all the options available, as well as what they mean.
```yaml
address: 0.0.0.0
@@ -92,8 +103,9 @@ behindProxy: false
# The directory that will be able to be accessed by the users when connecting.
# This directory will be used by users unless they have their own 'directory' defined.
# Default is '.' (current directory).
directory: .
# By default it points to the working directory. In the case of the compose file above,
# that is /data.
directory: /data
# The default permissions for users. This is a case insensitive option. Possible
# permissions: C (Create), R (Read), U (Update), D (Delete). You can combine multiple
@@ -124,48 +136,67 @@ log:
colors: true
# Logging outputs. You can have more than one output. Default is only 'stderr'.
outputs:
- stderr
- stderr
# CORS configuration
cors:
# Whether or not CORS configuration should be applied. Default is 'false'.
enabled: true
credentials: true
allowed_headers:
- Depth
# The following are the default CORS settings when it is enabled.
allowed_hosts:
- http://localhost:8080
allowed_methods:
- GET
exposed_headers:
- Content-Length
- '*'
allowed_headers:
- Authorization
- Content-Type
- Content-Range
- Depth
- Destination
- If
- Lock-Token
- Overwrite
- TimeOut
- Translate
- X-Update-Range
allowed_methods:
- COPY
- DELETE
- GET
- HEAD
- LOCK
- UNLOCK
- MKCOL
- MOVE
- OPTIONS
- PATCH
- POST
- PROPFIND
- PROPPATCH
- PUT
exposed_headers: []
# The list of users. If the list is empty, then there will be no authentication.
# Otherwise, basic authentication will automatically be configured.
#
# If you're delegating the authentication to a different service, you can proxy
# the username using basic authentication, and then disable webdav's password
# check using the option:
#
# noPassword: true
# You define here the list of users.
# Basic authentication is automatically be configured when users are detected
# below, else there will be no authentication.
# Customize to your needs and don't forget to comment out the users you don't need.
users:
# Example 'admin' user with plaintext password.
- username: admin
password: admin
# Example 'john' user with bcrypt encrypted password, with custom directory.
# You can generate a bcrypt-encrypted password by using the 'webdav bcrypt'
# command lint utility.
# Tip: you can generate a bcrypt-encrypted password by using the 'webdav bcrypt'
# command lint utility, or htpasswd on Linux.
- username: john
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
directory: /another/path
directory: /data/john
# Example user whose details will be picked up from the environment.
- username: "{env}ENV_USERNAME"
password: "{env}ENV_PASSWORD"
# Example user with advanced control over his permissions
- username: basic
password: basic
# Override default permissions.
permissions: CRUD
permissions: CRUD # Override default permissions.
rules:
# With this rule, the user CANNOT access {user directory}/some/files.
- path: /some/file
@@ -178,6 +209,16 @@ users:
# It uses a regular expression.
- regex: "^.+.js$"
permissions: RU
# Example user for android SeedVault backuping
- username: android
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
directory: /data/android
permissions: CRUD
# If you're delegating the authentication to a different service, you can proxy
# the username using basic authentication, and then disable webdav's password
# check using the option:
# noPassword: true
```
### CORS
@@ -225,7 +266,8 @@ example.com {
@hasDest header_regexp dest ^https?://[^/]+(.*)$
header @hasDest Destination {re.dest.1}
reverse_proxy 127.0.0.1:6065 { # if running on the same network in docker you can just point to the service name e.g. webdav:6065
# if running on the same network in docker you can just point to the service name e.g. webdav:6065
reverse_proxy 127.0.0.1:6065 {
header_up X-Real-IP {remote_host}
header_up REMOTE-HOST {remote_host}
}
+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
},
+14
View File
@@ -0,0 +1,14 @@
name: webdav
services:
app:
container_name: webdav
image: 'webdav:test'
ports:
- '6065:6065'
security_opt:
- label=type:container_runtime_t
restart: always
volumes:
- ./data:/data
- ./config.yml:/config.yml:ro
+10 -10
View File
@@ -3,18 +3,18 @@ module github.com/hacdias/webdav/v5
go 1.25.0
require (
github.com/coreos/go-systemd/v22 v22.6.0
github.com/go-viper/mapstructure/v2 v2.4.0
github.com/coreos/go-systemd/v22 v22.7.0
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/rs/cors v1.11.1
github.com/spf13/cobra v1.10.1
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/studio-b12/gowebdav v0.11.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.45.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20251119195548-4e0068c0098b
golang.org/x/net v0.47.0
github.com/studio-b12/gowebdav v0.12.0
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.53.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20260708182226-cdce021fa6c7
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.38.0 // indirect
golang.org/x/text v0.31.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
)
+20 -20
View File
@@ -1,5 +1,5 @@
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -7,8 +7,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -34,8 +34,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -43,28 +43,28 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/studio-b12/gowebdav v0.11.0 h1:qbQzq4USxY28ZYsGJUfO5jR+xkFtcnwWgitp4Zp1irU=
github.com/studio-b12/gowebdav v0.11.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto/x509roots/fallback v0.0.0-20251119195548-4e0068c0098b h1:VI77LRI9gm150dbLwyi9yxd2VxVCm4mFzrZqkz7ahFo=
golang.org/x/crypto/x509roots/fallback v0.0.0-20251119195548-4e0068c0098b/go.mod h1:MEIPiCnxvQEjA4astfaKItNwEVZA5Ki+3+nyGbJ5N18=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
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-20260708182226-cdce021fa6c7 h1:XWbcG8uLNW2EmI8DBzFXp7XwIxmrYK6s7ME2iyyq7Cg=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260708182226-cdce021fa6c7/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
@@ -86,9 +86,9 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
v.SetDefault("Log.Format", "console")
v.SetDefault("Log.Outputs", []string{"stderr"})
v.SetDefault("Log.Colors", true)
v.SetDefault("CORS.Allowed_Headers", []string{"*"})
v.SetDefault("CORS.Allowed_Hosts", []string{"*"})
v.SetDefault("CORS.Allowed_Methods", []string{"*"})
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
@@ -51,9 +51,9 @@ func TestConfigDefaults(t *testing.T) {
require.NoError(t, err)
require.Equal(t, dir, cfg.Directory)
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHeaders)
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
require.EqualValues(t, []string{"*"}, 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 {
+2 -1
View File
@@ -10,5 +10,6 @@
"postUpdateOptions": [
"gomodUpdateImportPaths",
"gomodTidy"
]
],
"schedule": "* * * * 0,6"
}