mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-22 03:20:41 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dceeb296a | ||
|
|
2bf7130f56 | ||
|
|
7ea4cec229 | ||
|
|
de2ac9d327 | ||
|
|
ca617862a5 | ||
|
|
3ddd9182f9 | ||
|
|
71fce1a29e | ||
|
|
db385c76b2 | ||
|
|
f9c771fdad | ||
|
|
7cc3f8b4ae | ||
|
|
36063ce391 | ||
|
|
6c3258a469 | ||
|
|
3ef6dd5ba5 | ||
|
|
d57f57f3a0 | ||
|
|
c3cfaf18f5 | ||
|
|
0128eed63d | ||
|
|
f051809a5f | ||
|
|
f13c884b75 | ||
|
|
cef2e3b673 | ||
|
|
4fe4703e02 | ||
|
|
882c2200eb | ||
|
|
4f4045dbca | ||
|
|
20606cd795 | ||
|
|
c9ca42d89a | ||
|
|
8217bc0816 | ||
|
|
98f8f93ebb | ||
|
|
66049d9c08 | ||
|
|
0406449b05 | ||
|
|
d7745cff34 | ||
|
|
d8558a77ff | ||
|
|
ab5cf175ad | ||
|
|
f225b0418f | ||
|
|
d9600ee82d | ||
|
|
6359115c2d | ||
|
|
8574b461a9 | ||
|
|
1196ad506d |
@@ -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,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
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# webdav
|
||||
|
||||
[](https://goreportcard.com/report/hacdias/webdav)
|
||||
[](https://github.com/hacdias/webdav/releases/latest)
|
||||
[](https://hub.docker.com/r/hacdias/webdav)
|
||||
|
||||
@@ -60,6 +59,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
|
||||
@@ -94,6 +106,16 @@ behindProxy: false
|
||||
# that is /data.
|
||||
directory: /data
|
||||
|
||||
# Alternatively, replace 'directory' with 'directories' to expose multiple
|
||||
# directories as virtual root entries. This option is mutually exclusive with
|
||||
# 'directory' in the same scope. Rules should include the virtual mount name,
|
||||
# such as /media/public/access/.
|
||||
# directories:
|
||||
# - media: /data/media
|
||||
# - /data/archive
|
||||
# - name: backups
|
||||
# path: /data/backups
|
||||
|
||||
# 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
|
||||
# permissions. For example, to allow to read and create, set "RC". Default is "R".
|
||||
@@ -132,10 +154,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 +166,7 @@ cors:
|
||||
- Overwrite
|
||||
- TimeOut
|
||||
- Translate
|
||||
- X-Update-Range
|
||||
allowed_methods:
|
||||
- COPY
|
||||
- DELETE
|
||||
@@ -153,6 +177,7 @@ cors:
|
||||
- MKCOL
|
||||
- MOVE
|
||||
- OPTIONS
|
||||
- PATCH
|
||||
- POST
|
||||
- PROPFIND
|
||||
- PROPPATCH
|
||||
|
||||
+6
-3
@@ -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
|
||||
},
|
||||
|
||||
@@ -12,4 +12,3 @@ services:
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./config.yml:/config.yml:ro
|
||||
command: ['-c', '/config.yml']
|
||||
|
||||
@@ -10,11 +10,11 @@ require (
|
||||
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.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
|
||||
github.com/studio-b12/gowebdav v0.13.0
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260709184058-243e02a382f8
|
||||
golang.org/x/net v0.57.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.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -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.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
|
||||
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
|
||||
github.com/studio-b12/gowebdav v0.13.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.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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260709184058-243e02a382f8 h1:OZy0hsjD/gbnVKENxnVK6I3e4bdKwYy4R3dfdKosYyA=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260709184058-243e02a382f8/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
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=
|
||||
|
||||
+176
-3
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
@@ -23,6 +24,8 @@ const (
|
||||
DefaultPrefix = "/"
|
||||
)
|
||||
|
||||
var errDirectoryConflict = errors.New("directory and directories cannot both be defined")
|
||||
|
||||
type Config struct {
|
||||
UserPermissions `mapstructure:",squash"`
|
||||
Debug bool
|
||||
@@ -87,8 +90,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()
|
||||
@@ -100,6 +103,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
|
||||
cfg := &Config{}
|
||||
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||
directoryMountsDecodeHook(),
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
mapstructure.TextUnmarshallerHookFunc(),
|
||||
@@ -108,12 +112,28 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = applyDirectoryConfig(v, flags, &cfg.UserPermissions, "directory", "directories", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
// Cascade user settings
|
||||
for i := range cfg.Users {
|
||||
if !v.IsSet(fmt.Sprintf("Users.%d.Directory", i)) {
|
||||
userDirectoryKey := fmt.Sprintf("Users.%d.Directory", i)
|
||||
userDirectoriesKey := fmt.Sprintf("Users.%d.Directories", i)
|
||||
|
||||
if !v.IsSet(userDirectoryKey) {
|
||||
cfg.Users[i].Directory = cfg.Directory
|
||||
}
|
||||
|
||||
err := applyDirectoryConfig(v, flags, &cfg.Users[i].UserPermissions, userDirectoryKey, userDirectoriesKey, &cfg.UserPermissions)
|
||||
if err != nil {
|
||||
if errors.Is(err, errDirectoryConflict) {
|
||||
return nil, fmt.Errorf("invalid config: user %q cannot define both directory and directories", cfg.Users[i].Username)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid config: user %q: %w", cfg.Users[i].Username, err)
|
||||
}
|
||||
|
||||
if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
|
||||
cfg.Users[i].Permissions = cfg.Permissions
|
||||
}
|
||||
@@ -145,6 +165,46 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyDirectoryConfig(v *viper.Viper, flags *pflag.FlagSet, permissions *UserPermissions, directoryKey, directoriesKey string, inherited *UserPermissions) error {
|
||||
permissions.directoryExplicit = isExplicitlySet(v, flags, directoryKey)
|
||||
permissions.directoriesExplicit = isExplicitlySet(v, flags, directoriesKey)
|
||||
if permissions.directoryExplicit && permissions.directoriesExplicit {
|
||||
return errDirectoryConflict
|
||||
}
|
||||
|
||||
switch {
|
||||
case permissions.directoryExplicit:
|
||||
permissions.Directory = v.GetString(directoryKey)
|
||||
permissions.useDirectories = false
|
||||
case permissions.directoriesExplicit:
|
||||
directories, err := getDirectoryMounts(v, directoriesKey, permissions.Directories)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissions.Directories = directories
|
||||
permissions.useDirectories = true
|
||||
case inherited != nil:
|
||||
permissions.Directories = append(DirectoryMounts{}, inherited.Directories...)
|
||||
permissions.useDirectories = inherited.useDirectories
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isExplicitlySet(v *viper.Viper, flags *pflag.FlagSet, key string) bool {
|
||||
if flags != nil && flags.Changed(key) {
|
||||
return true
|
||||
}
|
||||
|
||||
if v.InConfig(key) {
|
||||
return true
|
||||
}
|
||||
|
||||
envKey := "WD_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
|
||||
value, ok := os.LookupEnv(envKey)
|
||||
return ok && value != ""
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
var err error
|
||||
|
||||
@@ -188,6 +248,119 @@ func (c *Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func directoryMountsDecodeHook() mapstructure.DecodeHookFunc {
|
||||
mountsType := reflect.TypeOf(DirectoryMounts{})
|
||||
|
||||
return func(from reflect.Type, to reflect.Type, data any) (any, error) {
|
||||
if to != mountsType {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
return decodeDirectoryMounts(data)
|
||||
}
|
||||
}
|
||||
|
||||
func getDirectoryMounts(v *viper.Viper, key string, fallback DirectoryMounts) (DirectoryMounts, error) {
|
||||
value := v.Get(key)
|
||||
if value == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
return decodeDirectoryMounts(value)
|
||||
}
|
||||
|
||||
func decodeDirectoryMounts(data any) (DirectoryMounts, error) {
|
||||
switch value := data.(type) {
|
||||
case nil:
|
||||
return DirectoryMounts{}, nil
|
||||
case DirectoryMounts:
|
||||
return value, nil
|
||||
case []DirectoryMount:
|
||||
return DirectoryMounts(value), nil
|
||||
case string:
|
||||
if value == "" {
|
||||
return DirectoryMounts{}, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(value, ",")
|
||||
mounts := make(DirectoryMounts, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
mounts = append(mounts, DirectoryMount{Path: part})
|
||||
}
|
||||
return mounts, nil
|
||||
case []any:
|
||||
mounts := make(DirectoryMounts, 0, len(value))
|
||||
for _, item := range value {
|
||||
mount, err := decodeDirectoryMount(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mounts = append(mounts, mount)
|
||||
}
|
||||
return mounts, nil
|
||||
case []string:
|
||||
mounts := make(DirectoryMounts, 0, len(value))
|
||||
for _, item := range value {
|
||||
mounts = append(mounts, DirectoryMount{Path: item})
|
||||
}
|
||||
return mounts, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid directories: unsupported value %T", data)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeDirectoryMount(data any) (DirectoryMount, error) {
|
||||
switch value := data.(type) {
|
||||
case string:
|
||||
return DirectoryMount{Path: value}, nil
|
||||
case map[string]any:
|
||||
return decodeDirectoryMountMap(value)
|
||||
case map[any]any:
|
||||
m := map[string]any{}
|
||||
for key, value := range value {
|
||||
keyString, ok := key.(string)
|
||||
if !ok {
|
||||
return DirectoryMount{}, errors.New("invalid directories: mount keys must be strings")
|
||||
}
|
||||
m[keyString] = value
|
||||
}
|
||||
return decodeDirectoryMountMap(m)
|
||||
default:
|
||||
return DirectoryMount{}, fmt.Errorf("invalid directories: unsupported mount entry %T", data)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeDirectoryMountMap(data map[string]any) (DirectoryMount, error) {
|
||||
_, hasName := data["name"]
|
||||
_, hasPath := data["path"]
|
||||
if hasName || hasPath {
|
||||
name, nameOK := data["name"].(string)
|
||||
path, pathOK := data["path"].(string)
|
||||
if !nameOK || !pathOK || len(data) != 2 {
|
||||
return DirectoryMount{}, errors.New("invalid directories: explicit mount objects must define name and path")
|
||||
}
|
||||
return DirectoryMount{Name: name, Path: path}, nil
|
||||
}
|
||||
|
||||
if len(data) != 1 {
|
||||
return DirectoryMount{}, errors.New("invalid directories: mapped mount entries must have exactly one key")
|
||||
}
|
||||
|
||||
for name, path := range data {
|
||||
pathString, ok := path.(string)
|
||||
if !ok {
|
||||
return DirectoryMount{}, errors.New("invalid directories: mapped mount paths must be strings")
|
||||
}
|
||||
return DirectoryMount{Name: name, Path: pathString}, nil
|
||||
}
|
||||
|
||||
return DirectoryMount{}, errors.New("invalid directories: empty mount entry")
|
||||
}
|
||||
|
||||
func (cfg *Config) GetLogger() (*zap.Logger, error) {
|
||||
loggerConfig := zap.NewProductionConfig()
|
||||
loggerConfig.DisableCaller = true
|
||||
|
||||
+179
-2
@@ -3,6 +3,7 @@ package lib
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -52,8 +53,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) {
|
||||
@@ -165,6 +166,182 @@ rules = []
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Mixed Entries", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirC := t.TempDir()
|
||||
dirD := t.TempDir()
|
||||
dirE := t.TempDir()
|
||||
|
||||
cfg := writeAndParseConfig(t, `
|
||||
directories:
|
||||
- `+dirC+`
|
||||
- d2: `+dirD+`
|
||||
- name: archive
|
||||
path: `+dirE+`
|
||||
`, ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.Equal(t, filepath.Base(dirC), cfg.Directories[0].Name)
|
||||
require.Equal(t, dirC, cfg.Directories[0].Path)
|
||||
require.Equal(t, "d2", cfg.Directories[1].Name)
|
||||
require.Equal(t, dirD, cfg.Directories[1].Path)
|
||||
require.Equal(t, "archive", cfg.Directories[2].Name)
|
||||
require.Equal(t, dirE, cfg.Directories[2].Path)
|
||||
})
|
||||
|
||||
t.Run("JSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirC := t.TempDir()
|
||||
dirD := t.TempDir()
|
||||
dirE := t.TempDir()
|
||||
|
||||
cfg := writeAndParseConfig(t, `{
|
||||
"directories": [
|
||||
`+strconv.Quote(dirC)+`,
|
||||
{ "d2": `+strconv.Quote(dirD)+` },
|
||||
{ "name": "archive", "path": `+strconv.Quote(dirE)+` }
|
||||
]
|
||||
}`, ".json")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.Equal(t, filepath.Base(dirC), cfg.Directories[0].Name)
|
||||
require.Equal(t, dirC, cfg.Directories[0].Path)
|
||||
require.Equal(t, "d2", cfg.Directories[1].Name)
|
||||
require.Equal(t, dirD, cfg.Directories[1].Path)
|
||||
require.Equal(t, "archive", cfg.Directories[2].Name)
|
||||
require.Equal(t, dirE, cfg.Directories[2].Path)
|
||||
})
|
||||
|
||||
t.Run("TOML", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirD := t.TempDir()
|
||||
dirE := t.TempDir()
|
||||
|
||||
cfg := writeAndParseConfig(t, `
|
||||
[[directories]]
|
||||
d2 = `+strconv.Quote(dirD)+`
|
||||
|
||||
[[directories]]
|
||||
name = "archive"
|
||||
path = `+strconv.Quote(dirE)+`
|
||||
`, ".toml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.Equal(t, "d2", cfg.Directories[0].Name)
|
||||
require.Equal(t, dirD, cfg.Directories[0].Path)
|
||||
require.Equal(t, "archive", cfg.Directories[1].Name)
|
||||
require.Equal(t, dirE, cfg.Directories[1].Path)
|
||||
})
|
||||
|
||||
t.Run("Mutually Exclusive Global Directory Fields", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writeAndParseConfigWithError(t, `
|
||||
directory: /tmp
|
||||
directories:
|
||||
- /tmp
|
||||
`, ".yml", "directory and directories cannot both be defined")
|
||||
})
|
||||
|
||||
t.Run("Mutually Exclusive User Directory Fields", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writeAndParseConfigWithError(t, `
|
||||
users:
|
||||
- username: basic
|
||||
password: basic
|
||||
directory: /tmp
|
||||
directories:
|
||||
- /tmp
|
||||
`, ".yml", "cannot define both directory and directories")
|
||||
})
|
||||
|
||||
t.Run("Duplicate Mount Names", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parent := t.TempDir()
|
||||
dir := filepath.Join(parent, "dup")
|
||||
require.NoError(t, os.Mkdir(dir, 0775))
|
||||
|
||||
writeAndParseConfigWithError(t, `
|
||||
directories:
|
||||
- `+dir+`
|
||||
- dup: /tmp
|
||||
`, ".yml", "duplicate mount name")
|
||||
})
|
||||
|
||||
t.Run("Cascade Mode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
global := t.TempDir()
|
||||
single := t.TempDir()
|
||||
userMulti := t.TempDir()
|
||||
|
||||
cfg := writeAndParseConfig(t, `
|
||||
directories:
|
||||
- global: `+global+`
|
||||
users:
|
||||
- username: inherited
|
||||
password: inherited
|
||||
- username: single
|
||||
password: single
|
||||
directory: `+single+`
|
||||
- username: multi
|
||||
password: multi
|
||||
directories:
|
||||
- owned: `+userMulti+`
|
||||
`, ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.True(t, cfg.Users[0].useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: "global", Path: global}}, cfg.Users[0].Directories)
|
||||
require.False(t, cfg.Users[1].useDirectories)
|
||||
require.Equal(t, single, cfg.Users[1].Directory)
|
||||
require.True(t, cfg.Users[2].useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: "owned", Path: userMulti}}, cfg.Users[2].Directories)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigDirectoriesEnvOverrides(t *testing.T) {
|
||||
global := t.TempDir()
|
||||
single := t.TempDir()
|
||||
userMulti := t.TempDir()
|
||||
|
||||
t.Setenv("WD_DIRECTORIES", global)
|
||||
t.Setenv("WD_USERS_1_DIRECTORY", single)
|
||||
t.Setenv("WD_USERS_2_DIRECTORIES", userMulti)
|
||||
|
||||
cfg := writeAndParseConfig(t, `
|
||||
users:
|
||||
- username: inherited
|
||||
password: inherited
|
||||
- username: single
|
||||
password: single
|
||||
- username: multi
|
||||
password: multi
|
||||
`, ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: filepath.Base(global), Path: global}}, cfg.Directories)
|
||||
require.True(t, cfg.Users[0].useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: filepath.Base(global), Path: global}}, cfg.Users[0].Directories)
|
||||
require.False(t, cfg.Users[1].useDirectories)
|
||||
require.Equal(t, single, cfg.Users[1].Directory)
|
||||
require.True(t, cfg.Users[2].useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: filepath.Base(userMulti), Path: userMulti}}, cfg.Users[2].Directories)
|
||||
}
|
||||
|
||||
func TestConfigKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+40
-28
@@ -34,40 +34,16 @@ func NewHandler(c *Config) (http.Handler, error) {
|
||||
noPassword: c.NoPassword,
|
||||
behindProxy: c.BehindProxy,
|
||||
user: &handlerUser{
|
||||
User: User{
|
||||
UserPermissions: c.UserPermissions,
|
||||
},
|
||||
Handler: webdav.Handler{
|
||||
Prefix: c.Prefix,
|
||||
FileSystem: Dir{
|
||||
Dir: webdav.Dir(c.Directory),
|
||||
noSniff: c.NoSniff,
|
||||
},
|
||||
LockSystem: &lockSystem{
|
||||
LockSystem: ls,
|
||||
directory: c.Directory,
|
||||
},
|
||||
Logger: logFunc,
|
||||
},
|
||||
User: User{UserPermissions: c.UserPermissions},
|
||||
Handler: buildWebdavHandler(c.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
|
||||
},
|
||||
users: map[string]*handlerUser{},
|
||||
}
|
||||
|
||||
for _, u := range c.Users {
|
||||
h.users[u.Username] = &handlerUser{
|
||||
User: u,
|
||||
Handler: webdav.Handler{
|
||||
Prefix: c.Prefix,
|
||||
FileSystem: Dir{
|
||||
Dir: webdav.Dir(u.Directory),
|
||||
noSniff: c.NoSniff,
|
||||
},
|
||||
LockSystem: &lockSystem{
|
||||
LockSystem: ls,
|
||||
directory: u.Directory,
|
||||
},
|
||||
Logger: logFunc,
|
||||
},
|
||||
User: u,
|
||||
Handler: buildWebdavHandler(u.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +69,32 @@ func NewHandler(c *Config) (http.Handler, error) {
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// buildWebdavHandler creates the [webdav.Handler] for a set of user permissions,
|
||||
// selecting between single-directory and multi-directory backing depending on
|
||||
// whether directories are configured.
|
||||
func buildWebdavHandler(p UserPermissions, prefix string, noSniff bool, ls webdav.LockSystem, logFunc func(*http.Request, error)) webdav.Handler {
|
||||
h := webdav.Handler{
|
||||
Prefix: prefix,
|
||||
Logger: logFunc,
|
||||
}
|
||||
|
||||
if p.useDirectories {
|
||||
h.FileSystem = multiDir{
|
||||
mounts: p.Directories,
|
||||
noSniff: noSniff,
|
||||
}
|
||||
h.LockSystem = newMultiDirLockSystem(ls, p.Directories)
|
||||
} else {
|
||||
h.FileSystem = Dir{
|
||||
Dir: webdav.Dir(p.Directory),
|
||||
noSniff: noSniff,
|
||||
}
|
||||
h.LockSystem = newLockSystem(ls, p.Directory)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := h.user
|
||||
@@ -176,6 +178,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()
|
||||
|
||||
@@ -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) {
|
||||
@@ -413,6 +820,129 @@ users:
|
||||
require.ErrorContains(t, err, "403")
|
||||
}
|
||||
|
||||
func TestServerMultiDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirC := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
"folder/nested.txt": []byte("nested"),
|
||||
"public/access/ok.txt": []byte("ok"),
|
||||
})
|
||||
dirD := makeTestDirectory(t, map[string][]byte{
|
||||
"bar.txt": []byte("bar"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
permissions: CRUD
|
||||
directories:
|
||||
- c: %s
|
||||
- d: %s
|
||||
`, dirC, dirD))
|
||||
client := gowebdav.NewClient(srv.URL, "", "")
|
||||
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 2)
|
||||
require.Equal(t, "c", files[0].Name())
|
||||
require.Equal(t, "d", files[1].Name())
|
||||
|
||||
data, err := client.Read("/c/foo.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo"), data)
|
||||
|
||||
data, err = client.Read("/d/bar.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("bar"), data)
|
||||
|
||||
err = client.Copy("/c/foo.txt", "/d/copied.txt", false)
|
||||
require.NoError(t, err)
|
||||
data, err = os.ReadFile(filepath.Join(dirD, "copied.txt"))
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo"), data)
|
||||
|
||||
err = client.Rename("/c/foo.txt", "/d/moved.txt", false)
|
||||
require.NoError(t, err)
|
||||
require.NoFileExists(t, filepath.Join(dirC, "foo.txt"))
|
||||
data, err = os.ReadFile(filepath.Join(dirD, "moved.txt"))
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo"), data)
|
||||
|
||||
err = client.Rename("/d/bar.txt", "/d/renamed.txt", false)
|
||||
require.NoError(t, err)
|
||||
require.NoFileExists(t, filepath.Join(dirD, "bar.txt"))
|
||||
data, err = os.ReadFile(filepath.Join(dirD, "renamed.txt"))
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("bar"), data)
|
||||
|
||||
err = client.Rename("/c/folder", "/d/folder", false)
|
||||
require.NoError(t, err)
|
||||
require.NoDirExists(t, filepath.Join(dirC, "folder"))
|
||||
data, err = os.ReadFile(filepath.Join(dirD, "folder", "nested.txt"))
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("nested"), data)
|
||||
|
||||
require.ErrorContains(t, client.Remove("/c"), "405")
|
||||
require.Error(t, client.Write("/c", []byte("blocked"), 0666))
|
||||
require.ErrorContains(t, client.Rename("/d", "/c/d", false), "403")
|
||||
}
|
||||
|
||||
func TestServerMultiDirectoriesRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirC := makeTestDirectory(t, map[string][]byte{
|
||||
"public/access/ok.txt": []byte("ok"),
|
||||
})
|
||||
dirD := makeTestDirectory(t, map[string][]byte{
|
||||
"public/access/no.txt": []byte("no"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
permissions: none
|
||||
directories:
|
||||
- c: %s
|
||||
- d: %s
|
||||
rules:
|
||||
- path: /c/public/access/
|
||||
permissions: R
|
||||
`, dirC, dirD))
|
||||
client := gowebdav.NewClient(srv.URL, "", "")
|
||||
|
||||
data, err := client.Read("/c/public/access/ok.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("ok"), data)
|
||||
|
||||
_, err = client.Read("/d/public/access/no.txt")
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
_, err = client.Read("/public/access/ok.txt")
|
||||
require.ErrorContains(t, err, "403")
|
||||
}
|
||||
|
||||
func TestServerMultiDirectoriesPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirC := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
permissions: R
|
||||
prefix: /prefix
|
||||
directories:
|
||||
- c: %s
|
||||
`, dirC))
|
||||
client := gowebdav.NewClient(srv.URL, "", "")
|
||||
|
||||
files, err := client.ReadDir("/prefix")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1)
|
||||
require.Equal(t, "c", files[0].Name())
|
||||
|
||||
data, err := client.Read("/prefix/c/foo.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo"), data)
|
||||
}
|
||||
|
||||
func TestServerPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+48
-7
@@ -9,27 +9,68 @@ import (
|
||||
|
||||
var _ webdav.LockSystem = &lockSystem{}
|
||||
|
||||
// LockSystem wraps a [webdav.LockSystem] with a root directory, allowing
|
||||
// to reuse the same [webdav.LockSystem] for multiple users with different base
|
||||
// directories, meaning we can correctly lock the files across different users.
|
||||
// lockSystem wraps a [webdav.LockSystem], mapping virtual request names to the
|
||||
// real backing paths via resolve. This allows reusing the same
|
||||
// [webdav.LockSystem] for multiple users with different base directories,
|
||||
// meaning we can correctly lock the files across different users.
|
||||
type lockSystem struct {
|
||||
webdav.LockSystem
|
||||
directory string
|
||||
resolve func(name string) (string, error)
|
||||
}
|
||||
|
||||
// newLockSystem returns a lockSystem for a single-directory user, resolving
|
||||
// names relative to directory.
|
||||
func newLockSystem(ls webdav.LockSystem, directory string) *lockSystem {
|
||||
return &lockSystem{
|
||||
LockSystem: ls,
|
||||
resolve: func(name string) (string, error) {
|
||||
return filepath.Join(directory, name), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// newMultiDirLockSystem returns a lockSystem for a multi-directory user,
|
||||
// resolving names against the real backing path of each mount.
|
||||
func newMultiDirLockSystem(ls webdav.LockSystem, mounts DirectoryMounts) *lockSystem {
|
||||
return &lockSystem{
|
||||
LockSystem: ls,
|
||||
resolve: func(name string) (string, error) {
|
||||
if cleanName(name) == "/" {
|
||||
return "/", nil
|
||||
}
|
||||
|
||||
mount, rest, err := multiDir{mounts: mounts}.resolve(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return mount.filePath(rest), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lockSystem) Confirm(now time.Time, name0, name1 string, conditions ...webdav.Condition) (release func(), err error) {
|
||||
if name0 != "" {
|
||||
name0 = filepath.Join(l.directory, name0)
|
||||
name0, err = l.resolve(name0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if name1 != "" {
|
||||
name1 = filepath.Join(l.directory, name1)
|
||||
name1, err = l.resolve(name1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return l.LockSystem.Confirm(now, name0, name1, conditions...)
|
||||
}
|
||||
|
||||
func (l *lockSystem) Create(now time.Time, details webdav.LockDetails) (token string, err error) {
|
||||
details.Root = filepath.Join(l.directory, details.Root)
|
||||
details.Root, err = l.resolve(details.Root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return l.LockSystem.Create(now, details)
|
||||
}
|
||||
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
var _ webdav.FileSystem = multiDir{}
|
||||
|
||||
const windowsErrorNotSameDevice = syscall.Errno(17)
|
||||
|
||||
type multiDir struct {
|
||||
mounts DirectoryMounts
|
||||
noSniff bool
|
||||
}
|
||||
|
||||
func (m multiDir) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
|
||||
mount, rest, err := m.resolve(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rest == "/" {
|
||||
return os.ErrExist
|
||||
}
|
||||
|
||||
return mount.dir(m.noSniff).Mkdir(ctx, rest, perm)
|
||||
}
|
||||
|
||||
func (m multiDir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
if cleanName(name) == "/" {
|
||||
if writeFlag(flag) {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
return &multiDirRootFile{
|
||||
entries: m.rootEntries(ctx),
|
||||
info: virtualDirInfo{name: "/"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
mount, rest, err := m.resolve(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rest == "/" && writeFlag(flag) {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
file, err := mount.dir(m.noSniff).OpenFile(ctx, rest, flag, perm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if rest == "/" {
|
||||
return mountRootFile{File: file, name: mount.Name}, nil
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (m multiDir) RemoveAll(ctx context.Context, name string) error {
|
||||
if cleanName(name) == "/" {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
mount, rest, err := m.resolve(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rest == "/" {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
return mount.dir(m.noSniff).RemoveAll(ctx, rest)
|
||||
}
|
||||
|
||||
func (m multiDir) Rename(ctx context.Context, oldName, newName string) error {
|
||||
oldMount, oldRest, err := m.resolve(oldName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newMount, newRest, err := m.resolve(newName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if oldRest == "/" || newRest == "/" {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
if oldMount.Name == newMount.Name {
|
||||
return oldMount.dir(m.noSniff).Rename(ctx, oldRest, newRest)
|
||||
}
|
||||
|
||||
oldPath := oldMount.filePath(oldRest)
|
||||
newPath := newMount.filePath(newRest)
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
if isCrossDeviceError(err) {
|
||||
return renameAcrossMount(oldPath, newPath)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renameAcrossMount(oldPath, newPath string) error {
|
||||
info, err := os.Lstat(oldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
target, err := os.Readlink(oldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(target, newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(oldPath)
|
||||
}
|
||||
|
||||
if info.Mode().IsRegular() {
|
||||
if err := copyRegularFile(oldPath, newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
source, err := fs.Sub(os.DirFS(filepath.Dir(oldPath)), filepath.Base(oldPath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.CopyFS(newPath, source); err != nil {
|
||||
_ = os.RemoveAll(newPath)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := copyMetadata(oldPath, newPath); err != nil {
|
||||
_ = os.RemoveAll(newPath)
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(oldPath)
|
||||
}
|
||||
|
||||
func copyRegularFile(oldPath, newPath string) error {
|
||||
source, err := os.Open(oldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := os.OpenFile(newPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
_ = source.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
_, copyErr := io.Copy(target, source)
|
||||
copyErr = errors.Join(copyErr, target.Close(), source.Close())
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(newPath)
|
||||
return copyErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMetadata(oldPath, newPath string) error {
|
||||
return filepath.Walk(oldPath, func(name string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.Mode()&os.ModeSymlink != 0 {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(oldPath, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newName := filepath.Join(newPath, rel)
|
||||
if err := os.Chmod(newName, info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chtimes(newName, info.ModTime(), info.ModTime())
|
||||
})
|
||||
}
|
||||
|
||||
func isCrossDeviceError(err error) bool {
|
||||
return errors.Is(err, syscall.EXDEV) || runtime.GOOS == "windows" && errors.Is(err, windowsErrorNotSameDevice)
|
||||
}
|
||||
|
||||
func (m multiDir) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
||||
if cleanName(name) == "/" {
|
||||
return virtualDirInfo{name: "/"}, nil
|
||||
}
|
||||
|
||||
mount, rest, err := m.resolve(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := mount.dir(m.noSniff).Stat(ctx, rest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rest == "/" {
|
||||
return namedFileInfo{FileInfo: info, name: mount.Name}, nil
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (m multiDir) resolve(name string) (DirectoryMount, string, error) {
|
||||
name = cleanName(name)
|
||||
if name == "/" {
|
||||
return DirectoryMount{}, "", os.ErrInvalid
|
||||
}
|
||||
|
||||
trimmed := strings.TrimPrefix(name, "/")
|
||||
mountName, rest, _ := strings.Cut(trimmed, "/")
|
||||
for _, mount := range m.mounts {
|
||||
if mount.Name == mountName {
|
||||
if rest == "" {
|
||||
return mount, "/", nil
|
||||
}
|
||||
return mount, "/" + rest, nil
|
||||
}
|
||||
}
|
||||
|
||||
return DirectoryMount{}, "", os.ErrNotExist
|
||||
}
|
||||
|
||||
func (m multiDir) rootEntries(ctx context.Context) []os.FileInfo {
|
||||
entries := make([]os.FileInfo, 0, len(m.mounts))
|
||||
for _, mount := range m.mounts {
|
||||
info, err := mount.dir(m.noSniff).Stat(ctx, "/")
|
||||
if err != nil {
|
||||
entries = append(entries, virtualDirInfo{name: mount.Name})
|
||||
continue
|
||||
}
|
||||
entries = append(entries, namedFileInfo{FileInfo: info, name: mount.Name})
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Name() < entries[j].Name()
|
||||
})
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
func (d DirectoryMount) dir(noSniff bool) Dir {
|
||||
return Dir{
|
||||
Dir: webdav.Dir(d.Path),
|
||||
noSniff: noSniff,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DirectoryMount) filePath(name string) string {
|
||||
return filepath.Join(d.Path, filepath.FromSlash(strings.TrimPrefix(name, "/")))
|
||||
}
|
||||
|
||||
func cleanName(name string) string {
|
||||
if name == "" || !strings.HasPrefix(name, "/") {
|
||||
name = "/" + name
|
||||
}
|
||||
return path.Clean(name)
|
||||
}
|
||||
|
||||
func writeFlag(flag int) bool {
|
||||
return flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_TRUNC|os.O_APPEND) != 0
|
||||
}
|
||||
|
||||
type multiDirRootFile struct {
|
||||
entries []os.FileInfo
|
||||
info os.FileInfo
|
||||
offset int
|
||||
}
|
||||
|
||||
func (f *multiDirRootFile) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *multiDirRootFile) Read([]byte) (int, error) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (f *multiDirRootFile) Seek(offset int64, whence int) (int64, error) {
|
||||
var next int64
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
next = offset
|
||||
case io.SeekCurrent:
|
||||
next = int64(f.offset) + offset
|
||||
case io.SeekEnd:
|
||||
next = int64(len(f.entries)) + offset
|
||||
default:
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
if next < 0 {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
f.offset = int(next)
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (f *multiDirRootFile) Readdir(count int) ([]os.FileInfo, error) {
|
||||
if count <= 0 {
|
||||
entries := f.entries[f.offset:]
|
||||
f.offset = len(f.entries)
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
if f.offset >= len(f.entries) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
end := f.offset + count
|
||||
if end > len(f.entries) {
|
||||
end = len(f.entries)
|
||||
}
|
||||
entries := f.entries[f.offset:end]
|
||||
f.offset = end
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (f *multiDirRootFile) Stat() (os.FileInfo, error) {
|
||||
return f.info, nil
|
||||
}
|
||||
|
||||
func (f *multiDirRootFile) Write([]byte) (int, error) {
|
||||
return 0, os.ErrPermission
|
||||
}
|
||||
|
||||
type mountRootFile struct {
|
||||
webdav.File
|
||||
name string
|
||||
}
|
||||
|
||||
func (f mountRootFile) Stat() (os.FileInfo, error) {
|
||||
info, err := f.File.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return namedFileInfo{FileInfo: info, name: f.name}, nil
|
||||
}
|
||||
|
||||
type namedFileInfo struct {
|
||||
os.FileInfo
|
||||
name string
|
||||
}
|
||||
|
||||
func (i namedFileInfo) Name() string {
|
||||
return i.name
|
||||
}
|
||||
|
||||
type virtualDirInfo struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (i virtualDirInfo) Name() string {
|
||||
return i.name
|
||||
}
|
||||
|
||||
func (i virtualDirInfo) Size() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (i virtualDirInfo) Mode() os.FileMode {
|
||||
return os.ModeDir | 0555
|
||||
}
|
||||
|
||||
func (i virtualDirInfo) ModTime() time.Time {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (i virtualDirInfo) IsDir() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (i virtualDirInfo) Sys() any {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRenameAcrossMount(t *testing.T) {
|
||||
source := makeTestDirectory(t, map[string][]byte{
|
||||
"file.txt": []byte("cross mount"),
|
||||
"folder/empty": nil,
|
||||
"folder/nested/file.txt": []byte("nested"),
|
||||
})
|
||||
target := t.TempDir()
|
||||
sourceFile := filepath.Join(source, "file.txt")
|
||||
modTime := time.Date(2020, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
require.NoError(t, os.Chmod(sourceFile, 0600))
|
||||
require.NoError(t, os.Chtimes(sourceFile, modTime, modTime))
|
||||
|
||||
require.NoError(t, renameAcrossMount(sourceFile, filepath.Join(target, "file.txt")))
|
||||
require.NoFileExists(t, filepath.Join(source, "file.txt"))
|
||||
data, err := os.ReadFile(filepath.Join(target, "file.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("cross mount"), data)
|
||||
info, err := os.Stat(filepath.Join(target, "file.txt"))
|
||||
require.NoError(t, err)
|
||||
if runtime.GOOS != "windows" {
|
||||
require.Equal(t, os.FileMode(0600), info.Mode().Perm())
|
||||
}
|
||||
require.WithinDuration(t, modTime, info.ModTime(), time.Second)
|
||||
|
||||
require.NoError(t, renameAcrossMount(filepath.Join(source, "folder"), filepath.Join(target, "folder")))
|
||||
require.NoDirExists(t, filepath.Join(source, "folder"))
|
||||
require.DirExists(t, filepath.Join(target, "folder", "empty"))
|
||||
data, err = os.ReadFile(filepath.Join(target, "folder", "nested", "file.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("nested"), data)
|
||||
}
|
||||
|
||||
func TestRenameAcrossMountPreservesSymlink(t *testing.T) {
|
||||
source := makeTestDirectory(t, map[string][]byte{
|
||||
"file.txt": []byte("target"),
|
||||
})
|
||||
oldPath := filepath.Join(source, "link.txt")
|
||||
if err := os.Symlink("file.txt", oldPath); err != nil {
|
||||
t.Skipf("symbolic links are unavailable: %v", err)
|
||||
}
|
||||
newPath := filepath.Join(t.TempDir(), "link.txt")
|
||||
|
||||
require.NoError(t, renameAcrossMount(oldPath, newPath))
|
||||
info, err := os.Lstat(newPath)
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, info.Mode()&os.ModeSymlink)
|
||||
target, err := os.Readlink(newPath)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "file.txt", target)
|
||||
}
|
||||
|
||||
func TestIsCrossDeviceError(t *testing.T) {
|
||||
err := syscall.EXDEV
|
||||
if runtime.GOOS == "windows" {
|
||||
err = windowsErrorNotSameDevice
|
||||
}
|
||||
require.True(t, isCrossDeviceError(err))
|
||||
require.False(t, isCrossDeviceError(os.ErrPermission))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+59
-1
@@ -44,11 +44,23 @@ const (
|
||||
|
||||
type UserPermissions struct {
|
||||
Directory string
|
||||
Directories DirectoryMounts
|
||||
Permissions Permissions
|
||||
Rules []*Rule
|
||||
RulesBehavior RulesBehavior
|
||||
|
||||
directoryExplicit bool
|
||||
directoriesExplicit bool
|
||||
useDirectories bool
|
||||
}
|
||||
|
||||
type DirectoryMount struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
type DirectoryMounts []DirectoryMount
|
||||
|
||||
// Allowed checks if the user has permission to access a directory/file
|
||||
func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool {
|
||||
// For COPY and MOVE requests, we first check the permissions for the destination
|
||||
@@ -94,6 +106,12 @@ func (p *UserPermissions) Validate() error {
|
||||
return fmt.Errorf("invalid permissions: %w", err)
|
||||
}
|
||||
|
||||
if p.useDirectories || len(p.Directories) > 0 {
|
||||
if err := (&p.Directories).Validate(); err != nil {
|
||||
return fmt.Errorf("invalid permissions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range p.Rules {
|
||||
if err := r.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid permissions: %w", err)
|
||||
@@ -110,6 +128,46 @@ func (p *UserPermissions) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DirectoryMounts) Validate() error {
|
||||
names := map[string]struct{}{}
|
||||
|
||||
for i := range *d {
|
||||
mount := &(*d)[i]
|
||||
if mount.Path == "" {
|
||||
return errors.New("invalid directories: path must be defined")
|
||||
}
|
||||
|
||||
path, err := filepath.Abs(mount.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid directories: %w", err)
|
||||
}
|
||||
mount.Path = path
|
||||
|
||||
if mount.Name == "" {
|
||||
mount.Name = filepath.Base(path)
|
||||
}
|
||||
|
||||
if !validDirectoryMountName(mount.Name) {
|
||||
return fmt.Errorf("invalid directories: invalid mount name %q", mount.Name)
|
||||
}
|
||||
|
||||
if _, ok := names[mount.Name]; ok {
|
||||
return fmt.Errorf("invalid directories: duplicate mount name %q", mount.Name)
|
||||
}
|
||||
names[mount.Name] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validDirectoryMountName(name string) bool {
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return false
|
||||
}
|
||||
|
||||
return !strings.ContainsAny(name, `/\`)
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
Create bool
|
||||
Read bool
|
||||
@@ -152,7 +210,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