mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-24 04:01:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
390fe21ed9 | ||
|
|
44e5e02dd3 | ||
|
|
d59dd02f96 | ||
|
|
10183d09bc | ||
|
|
1dceeb296a | ||
|
|
2bf7130f56 | ||
|
|
7ea4cec229 | ||
|
|
de2ac9d327 | ||
|
|
ca617862a5 |
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.26.x"
|
||||||
- run: go build .
|
- run: go build .
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.26.x"
|
||||||
- uses: golangci/golangci-lint-action@v9
|
- uses: golangci/golangci-lint-action@v9
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.26.x"
|
||||||
- uses: goreleaser/goreleaser-action@v7
|
- uses: goreleaser/goreleaser-action@v7
|
||||||
|
|||||||
@@ -10,12 +10,21 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: test
|
name: test (${{ matrix.os }})
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os:
|
||||||
|
- ubuntu-latest
|
||||||
|
- windows-latest
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.26.x"
|
||||||
- name: Run test with coverage
|
- name: Run test with race detector and coverage
|
||||||
|
if: runner.os != 'Windows'
|
||||||
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||||
|
- name: Run test with coverage
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
run: go test "-coverprofile=coverage.txt" -covermode=atomic ./...
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# webdav
|
# webdav
|
||||||
|
|
||||||
[](https://goreportcard.com/report/hacdias/webdav)
|
|
||||||
[](https://github.com/hacdias/webdav/releases/latest)
|
[](https://github.com/hacdias/webdav/releases/latest)
|
||||||
[](https://hub.docker.com/r/hacdias/webdav)
|
[](https://hub.docker.com/r/hacdias/webdav)
|
||||||
|
|
||||||
@@ -107,6 +106,16 @@ behindProxy: false
|
|||||||
# that is /data.
|
# that is /data.
|
||||||
directory: /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
|
# 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: 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".
|
# permissions. For example, to allow to read and create, set "RC". Default is "R".
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ require (
|
|||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/studio-b12/gowebdav v0.12.0
|
github.com/studio-b12/gowebdav v0.13.0
|
||||||
go.uber.org/zap v1.28.0
|
go.uber.org/zap v1.28.0
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.54.0
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260626155920-5b7f84159940
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e
|
||||||
golang.org/x/net v0.56.0
|
golang.org/x/net v0.57.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -30,7 +30,7 @@ require (
|
|||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ 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/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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
|
||||||
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
@@ -55,16 +55,16 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
|||||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
go.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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260626155920-5b7f84159940 h1:WD+hrfJy2TRwCU7dnWiqw/dGb9jNdEzy2uR+jtpYJYk=
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e h1:9TjMDOuGaMMTP5f7GXeHeA0JvFqGmv4DYRIWkzoePGI=
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260626155920-5b7f84159940/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
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 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
+174
-1
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
@@ -23,6 +24,8 @@ const (
|
|||||||
DefaultPrefix = "/"
|
DefaultPrefix = "/"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errDirectoryConflict = errors.New("directory and directories cannot both be defined")
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
UserPermissions `mapstructure:",squash"`
|
UserPermissions `mapstructure:",squash"`
|
||||||
Debug bool
|
Debug bool
|
||||||
@@ -100,6 +103,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
|||||||
|
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||||
|
directoryMountsDecodeHook(),
|
||||||
mapstructure.StringToTimeDurationHookFunc(),
|
mapstructure.StringToTimeDurationHookFunc(),
|
||||||
mapstructure.StringToSliceHookFunc(","),
|
mapstructure.StringToSliceHookFunc(","),
|
||||||
mapstructure.TextUnmarshallerHookFunc(),
|
mapstructure.TextUnmarshallerHookFunc(),
|
||||||
@@ -108,12 +112,28 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
|||||||
return nil, err
|
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
|
// Cascade user settings
|
||||||
for i := range cfg.Users {
|
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
|
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)) {
|
if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
|
||||||
cfg.Users[i].Permissions = cfg.Permissions
|
cfg.Users[i].Permissions = cfg.Permissions
|
||||||
}
|
}
|
||||||
@@ -145,6 +165,46 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
|||||||
return cfg, nil
|
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 {
|
func (c *Config) Validate() error {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -188,6 +248,119 @@ func (c *Config) Validate() error {
|
|||||||
return nil
|
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) {
|
func (cfg *Config) GetLogger() (*zap.Logger, error) {
|
||||||
loggerConfig := zap.NewProductionConfig()
|
loggerConfig := zap.NewProductionConfig()
|
||||||
loggerConfig.DisableCaller = true
|
loggerConfig.DisableCaller = true
|
||||||
|
|||||||
+191
-4
@@ -3,6 +3,7 @@ package lib
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -59,12 +60,19 @@ func TestConfigDefaults(t *testing.T) {
|
|||||||
func TestConfigCascade(t *testing.T) {
|
func TestConfigCascade(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
// Directories are resolved to absolute paths, which differ by platform
|
||||||
|
// (for example "/" becomes the current drive root on Windows).
|
||||||
|
rootDirectory, err := filepath.Abs("/")
|
||||||
|
require.NoError(t, err)
|
||||||
|
basicDirectory, err := filepath.Abs("/basic")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
check := func(t *testing.T, cfg *Config) {
|
check := func(t *testing.T, cfg *Config) {
|
||||||
require.True(t, cfg.Permissions.Read)
|
require.True(t, cfg.Permissions.Read)
|
||||||
require.True(t, cfg.Permissions.Create)
|
require.True(t, cfg.Permissions.Create)
|
||||||
require.False(t, cfg.Permissions.Delete)
|
require.False(t, cfg.Permissions.Delete)
|
||||||
require.False(t, cfg.Permissions.Update)
|
require.False(t, cfg.Permissions.Update)
|
||||||
require.Equal(t, "/", cfg.Directory)
|
require.Equal(t, rootDirectory, cfg.Directory)
|
||||||
require.Len(t, cfg.Rules, 1)
|
require.Len(t, cfg.Rules, 1)
|
||||||
|
|
||||||
require.Len(t, cfg.Users, 2)
|
require.Len(t, cfg.Users, 2)
|
||||||
@@ -72,14 +80,14 @@ func TestConfigCascade(t *testing.T) {
|
|||||||
require.True(t, cfg.Users[0].Permissions.Create)
|
require.True(t, cfg.Users[0].Permissions.Create)
|
||||||
require.False(t, cfg.Users[0].Permissions.Delete)
|
require.False(t, cfg.Users[0].Permissions.Delete)
|
||||||
require.False(t, cfg.Users[0].Permissions.Update)
|
require.False(t, cfg.Users[0].Permissions.Update)
|
||||||
require.Equal(t, "/", cfg.Users[0].Directory)
|
require.Equal(t, rootDirectory, cfg.Users[0].Directory)
|
||||||
require.Len(t, cfg.Users[0].Rules, 1)
|
require.Len(t, cfg.Users[0].Rules, 1)
|
||||||
|
|
||||||
require.True(t, cfg.Users[1].Permissions.Read)
|
require.True(t, cfg.Users[1].Permissions.Read)
|
||||||
require.False(t, cfg.Users[1].Permissions.Create)
|
require.False(t, cfg.Users[1].Permissions.Create)
|
||||||
require.False(t, cfg.Users[1].Permissions.Delete)
|
require.False(t, cfg.Users[1].Permissions.Delete)
|
||||||
require.False(t, cfg.Users[1].Permissions.Update)
|
require.False(t, cfg.Users[1].Permissions.Update)
|
||||||
require.Equal(t, "/basic", cfg.Users[1].Directory)
|
require.Equal(t, basicDirectory, cfg.Users[1].Directory)
|
||||||
require.Len(t, cfg.Users[1].Rules, 0)
|
require.Len(t, cfg.Users[1].Rules, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +173,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) {
|
func TestConfigKeys(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -308,8 +492,11 @@ func TestConfigEnv(t *testing.T) {
|
|||||||
cfg, err := ParseConfig("", nil)
|
cfg, err := ParseConfig("", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expectedDirectory, err := filepath.Abs("/test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 1234, cfg.Port)
|
assert.Equal(t, 1234, cfg.Port)
|
||||||
assert.Equal(t, "/test", cfg.Directory)
|
assert.Equal(t, expectedDirectory, cfg.Directory)
|
||||||
assert.Equal(t, true, cfg.Debug)
|
assert.Equal(t, true, cfg.Debug)
|
||||||
require.True(t, cfg.Permissions.Read)
|
require.True(t, cfg.Permissions.Read)
|
||||||
require.True(t, cfg.Permissions.Create)
|
require.True(t, cfg.Permissions.Create)
|
||||||
|
|||||||
+30
-28
@@ -34,40 +34,16 @@ func NewHandler(c *Config) (http.Handler, error) {
|
|||||||
noPassword: c.NoPassword,
|
noPassword: c.NoPassword,
|
||||||
behindProxy: c.BehindProxy,
|
behindProxy: c.BehindProxy,
|
||||||
user: &handlerUser{
|
user: &handlerUser{
|
||||||
User: User{
|
User: User{UserPermissions: c.UserPermissions},
|
||||||
UserPermissions: c.UserPermissions,
|
Handler: buildWebdavHandler(c.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
users: map[string]*handlerUser{},
|
users: map[string]*handlerUser{},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range c.Users {
|
for _, u := range c.Users {
|
||||||
h.users[u.Username] = &handlerUser{
|
h.users[u.Username] = &handlerUser{
|
||||||
User: u,
|
User: u,
|
||||||
Handler: webdav.Handler{
|
Handler: buildWebdavHandler(u.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
|
||||||
Prefix: c.Prefix,
|
|
||||||
FileSystem: Dir{
|
|
||||||
Dir: webdav.Dir(u.Directory),
|
|
||||||
noSniff: c.NoSniff,
|
|
||||||
},
|
|
||||||
LockSystem: &lockSystem{
|
|
||||||
LockSystem: ls,
|
|
||||||
directory: u.Directory,
|
|
||||||
},
|
|
||||||
Logger: logFunc,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +69,32 @@ func NewHandler(c *Config) (http.Handler, error) {
|
|||||||
return h, nil
|
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.
|
// 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) {
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
user := h.user
|
user := h.user
|
||||||
|
|||||||
+145
-20
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -486,13 +487,21 @@ func TestServerPartialUpdateHonorsLocks(t *testing.T) {
|
|||||||
func TestServerListingCharacters(t *testing.T) {
|
func TestServerListingCharacters(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
dir := makeTestDirectory(t, map[string][]byte{
|
contents := map[string][]byte{
|
||||||
"富/foo.txt": []byte("foo"),
|
"富/foo.txt": []byte("foo"),
|
||||||
"你好.txt": []byte("bar"),
|
"你好.txt": []byte("bar"),
|
||||||
"z*.txt": []byte("zbar"),
|
|
||||||
"foo.txt": []byte("foo"),
|
"foo.txt": []byte("foo"),
|
||||||
"🌹.txt": []byte("foo"),
|
"🌹.txt": []byte("foo"),
|
||||||
})
|
}
|
||||||
|
expectedNames := []string{"foo.txt", "你好.txt", "富", "🌹.txt"}
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
// Asterisks are invalid in Windows filenames.
|
||||||
|
contents["z*.txt"] = []byte("zbar")
|
||||||
|
expectedNames = append(expectedNames, "z*.txt")
|
||||||
|
}
|
||||||
|
sort.Strings(expectedNames)
|
||||||
|
|
||||||
|
dir := makeTestDirectory(t, contents)
|
||||||
|
|
||||||
srv := makeTestServer(t, "directory: "+dir)
|
srv := makeTestServer(t, "directory: "+dir)
|
||||||
client := gowebdav.NewClient(srv.URL, "", "")
|
client := gowebdav.NewClient(srv.URL, "", "")
|
||||||
@@ -500,28 +509,21 @@ func TestServerListingCharacters(t *testing.T) {
|
|||||||
// By default, reading permissions.
|
// By default, reading permissions.
|
||||||
files, err := client.ReadDir("/")
|
files, err := client.ReadDir("/")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, files, 5)
|
require.Len(t, files, len(expectedNames))
|
||||||
|
|
||||||
names := []string{
|
names := make([]string, len(files))
|
||||||
files[0].Name(),
|
for i, file := range files {
|
||||||
files[1].Name(),
|
names[i] = file.Name()
|
||||||
files[2].Name(),
|
|
||||||
files[3].Name(),
|
|
||||||
files[4].Name(),
|
|
||||||
}
|
}
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
|
|
||||||
require.Equal(t, []string{
|
require.Equal(t, expectedNames, names)
|
||||||
"foo.txt",
|
|
||||||
"z*.txt",
|
|
||||||
"你好.txt",
|
|
||||||
"富",
|
|
||||||
"🌹.txt",
|
|
||||||
}, names)
|
|
||||||
|
|
||||||
data, err := client.Read("/z*.txt")
|
if runtime.GOOS != "windows" {
|
||||||
require.NoError(t, err)
|
data, err := client.Read("/z*.txt")
|
||||||
require.EqualValues(t, []byte("zbar"), data)
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, []byte("zbar"), data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerAuthentication(t *testing.T) {
|
func TestServerAuthentication(t *testing.T) {
|
||||||
@@ -820,6 +822,129 @@ users:
|
|||||||
require.ErrorContains(t, err, "403")
|
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) {
|
func TestServerPermissions(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+55
-7
@@ -1,6 +1,7 @@
|
|||||||
package lib
|
package lib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -9,27 +10,74 @@ import (
|
|||||||
|
|
||||||
var _ webdav.LockSystem = &lockSystem{}
|
var _ webdav.LockSystem = &lockSystem{}
|
||||||
|
|
||||||
// LockSystem wraps a [webdav.LockSystem] with a root directory, allowing
|
// lockSystem wraps a [webdav.LockSystem], mapping virtual request names to the
|
||||||
// to reuse the same [webdav.LockSystem] for multiple users with different base
|
// real backing paths via resolve. This allows reusing the same
|
||||||
// directories, meaning we can correctly lock the files across different users.
|
// [webdav.LockSystem] for multiple users with different base directories,
|
||||||
|
// meaning we can correctly lock the files across different users.
|
||||||
type lockSystem struct {
|
type lockSystem struct {
|
||||||
webdav.LockSystem
|
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) {
|
||||||
|
// Lock names share a slash-separated namespace across users, even
|
||||||
|
// on Windows where filepath.Join would emit backslashes and break
|
||||||
|
// descendant-lock matching in the underlying LockSystem.
|
||||||
|
return path.Join(filepath.ToSlash(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
|
||||||
|
}
|
||||||
|
|
||||||
|
// filePath returns an OS-native path for real file operations; the
|
||||||
|
// lock namespace must stay slash-separated so descendant locks match
|
||||||
|
// on Windows.
|
||||||
|
return filepath.ToSlash(mount.filePath(rest)), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lockSystem) Confirm(now time.Time, name0, name1 string, conditions ...webdav.Condition) (release func(), err error) {
|
func (l *lockSystem) Confirm(now time.Time, name0, name1 string, conditions ...webdav.Condition) (release func(), err error) {
|
||||||
if name0 != "" {
|
if name0 != "" {
|
||||||
name0 = filepath.Join(l.directory, name0)
|
name0, err = l.resolve(name0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if name1 != "" {
|
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...)
|
return l.LockSystem.Confirm(now, name0, name1, conditions...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lockSystem) Create(now time.Time, details webdav.LockDetails) (token string, err error) {
|
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)
|
return l.LockSystem.Create(now, details)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/net/webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLockSystemRootLockProtectsDescendants(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
locks := newLockSystem(webdav.NewMemLS(), filepath.Join(t.TempDir(), "nested"))
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
token, err := locks.Create(now, webdav.LockDetails{
|
||||||
|
Root: "/",
|
||||||
|
Duration: time.Minute,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, locks.Unlock(time.Now(), token))
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err = locks.Create(now, webdav.LockDetails{
|
||||||
|
Root: "/child.txt",
|
||||||
|
Duration: time.Minute,
|
||||||
|
ZeroDepth: true,
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, webdav.ErrLocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockSystemSharesLocksAcrossNestedUserDirectories(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
shared := webdav.NewMemLS()
|
||||||
|
parentDirectory := t.TempDir()
|
||||||
|
childDirectory := filepath.Join(parentDirectory, "child")
|
||||||
|
parent := newLockSystem(shared, parentDirectory)
|
||||||
|
child := newLockSystem(shared, childDirectory)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
token, err := parent.Create(now, webdav.LockDetails{
|
||||||
|
Root: "/",
|
||||||
|
Duration: time.Minute,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, parent.Unlock(time.Now(), token))
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err = child.Create(now, webdav.LockDetails{
|
||||||
|
Root: "/file.txt",
|
||||||
|
Duration: time.Minute,
|
||||||
|
ZeroDepth: true,
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, webdav.ErrLocked)
|
||||||
|
|
||||||
|
// The lock key is slash-separated on every platform, so the child's file
|
||||||
|
// nests under the parent's root lock rather than diverging on Windows.
|
||||||
|
key, err := child.resolve("/file.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, path.Join(filepath.ToSlash(childDirectory), "file.txt"), key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiDirLockSystemUsesSlashSeparatedKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mounts := DirectoryMounts{{Name: "docs", Path: filepath.Join(t.TempDir(), "docs")}}
|
||||||
|
locks := newMultiDirLockSystem(webdav.NewMemLS(), mounts)
|
||||||
|
|
||||||
|
key, err := locks.resolve("/docs/report.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, path.Join(filepath.ToSlash(mounts[0].Path), "report.txt"), key)
|
||||||
|
require.NotContains(t, key, "\\")
|
||||||
|
}
|
||||||
+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))
|
||||||
|
}
|
||||||
@@ -44,11 +44,23 @@ const (
|
|||||||
|
|
||||||
type UserPermissions struct {
|
type UserPermissions struct {
|
||||||
Directory string
|
Directory string
|
||||||
|
Directories DirectoryMounts
|
||||||
Permissions Permissions
|
Permissions Permissions
|
||||||
Rules []*Rule
|
Rules []*Rule
|
||||||
RulesBehavior RulesBehavior
|
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
|
// Allowed checks if the user has permission to access a directory/file
|
||||||
func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool {
|
func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool {
|
||||||
// For COPY and MOVE requests, we first check the permissions for the destination
|
// 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)
|
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 {
|
for _, r := range p.Rules {
|
||||||
if err := r.Validate(); err != nil {
|
if err := r.Validate(); err != nil {
|
||||||
return fmt.Errorf("invalid permissions: %w", err)
|
return fmt.Errorf("invalid permissions: %w", err)
|
||||||
@@ -110,6 +128,46 @@ func (p *UserPermissions) Validate() error {
|
|||||||
return nil
|
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 {
|
type Permissions struct {
|
||||||
Create bool
|
Create bool
|
||||||
Read bool
|
Read bool
|
||||||
|
|||||||
Reference in New Issue
Block a user