mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-22 03:20:41 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e87e6a613 | ||
|
|
63449f1636 | ||
|
|
4ad26dad35 | ||
|
|
623bbc9a70 | ||
|
|
feeb33d249 | ||
|
|
d3bee98000 | ||
|
|
373b2ec931 | ||
|
|
000f404f7a | ||
|
|
e4a8622c1e | ||
|
|
b5a3d07f5c | ||
|
|
f4de82cfd1 | ||
|
|
ebcf500d5e | ||
|
|
d7faa1f887 | ||
|
|
d5e5052f63 | ||
|
|
a255fb51e2 | ||
|
|
ed23ca1820 | ||
|
|
e7e9c3176d | ||
|
|
d3732322bc | ||
|
|
f708664906 | ||
|
|
814462bed1 |
@@ -15,5 +15,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.23.x"
|
||||
- run: go build .
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.23.x"
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: "v1.59"
|
||||
version: "v1.60"
|
||||
|
||||
@@ -16,6 +16,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.23.x"
|
||||
- name: Run test with coverage
|
||||
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
|
||||
+3
-1
@@ -8,10 +8,12 @@ before:
|
||||
builds:
|
||||
- main: main.go
|
||||
binary: webdav
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- '-trimpath'
|
||||
ldflags:
|
||||
- '-X github.com/hacdias/webdav/v4/cmd.version={{.Version}}'
|
||||
- '-X github.com/hacdias/webdav/v5/cmd.version={{.Version}}'
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
|
||||
+3
-4
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.22-alpine3.20 AS build
|
||||
FROM golang:1.23-alpine3.20 AS build
|
||||
|
||||
ARG VERSION="untracked"
|
||||
|
||||
@@ -11,14 +11,13 @@ COPY ./go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . /webdav/
|
||||
RUN go build -o main -ldflags="-X 'github.com/hacdias/webdav/v4/cmd.version=$VERSION'" .
|
||||
RUN go build -o main -ldflags="-X 'github.com/hacdias/webdav/v5/cmd.version=$VERSION'" .
|
||||
|
||||
FROM scratch
|
||||
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=build /webdav/main /bin/webdav
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 6065
|
||||
|
||||
ENTRYPOINT [ "webdav" ]
|
||||
CMD [ "-p", "80" ]
|
||||
|
||||
@@ -11,7 +11,7 @@ A simple and standalone [WebDAV](https://en.wikipedia.org/wiki/WebDAV) server.
|
||||
For a manual install, please refer to the [releases](https://github.com/hacdias/webdav/releases) page and download the correct binary for your system. Alternatively, you can build or install it from source using the Go toolchain. You can either clone the repository and execute `go build`, or directly install it, using:
|
||||
|
||||
```
|
||||
go install github.com/hacdias/webdav/v4@latest
|
||||
go install github.com/hacdias/webdav/v5@latest
|
||||
```
|
||||
|
||||
### Docker
|
||||
@@ -32,11 +32,11 @@ For usage information regarding the CLI, run `webdav --help`.
|
||||
|
||||
### Docker
|
||||
|
||||
To use with Docker, you need to provide a configuration file and mount the data directories. For example, let's take the following configuration file that simply sets the port to `6060` and the scope to `/data`.
|
||||
To use with Docker, you need to provide a configuration file and mount the data directories. For example, let's take the following configuration file that simply sets the port to `6060` and the directory to `/data`.
|
||||
|
||||
```yaml
|
||||
port: 6060
|
||||
scope: /data
|
||||
directory: /data
|
||||
```
|
||||
|
||||
You can now run with the following Docker command, where you mount the configuration file inside the container, and the data directory too, as well as forwarding the port 6060. You will need to change this to match your own configuration.
|
||||
@@ -55,65 +55,48 @@ The configuration can be provided as a YAML, JSON or TOML file. Below is an exam
|
||||
|
||||
```yaml
|
||||
address: 0.0.0.0
|
||||
port: 0
|
||||
port: 6065
|
||||
|
||||
# TLS-related settings if you want to enable TLS directly.
|
||||
tls: false
|
||||
cert: cert.pem
|
||||
key: key.pem
|
||||
|
||||
# Prefix to apply to the WebDAV path-ing. Default is "/".
|
||||
# Prefix to apply to the WebDAV path-ing. Default is '/'.
|
||||
prefix: /
|
||||
|
||||
# Enable or disable debug logging. Default is false.
|
||||
# Enable or disable debug logging. Default is 'false'.
|
||||
debug: false
|
||||
|
||||
# Whether or not to have authentication. With authentication on, you need to
|
||||
# define one or more users. Default is false.
|
||||
auth: true
|
||||
# Disable sniffing the files to detect their content type. Default is 'false'.
|
||||
noSniff: false
|
||||
|
||||
# The directory that will be able to be accessed by the users when connecting.
|
||||
# This directory will be used by users unless they have their own 'scope' defined.
|
||||
# Default is "/".
|
||||
scope: /
|
||||
# This directory will be used by users unless they have their own 'directory' defined.
|
||||
# Default is '.' (current directory).
|
||||
directory: .
|
||||
|
||||
# Whether the users can, by default, modify the contents. Default is false.
|
||||
modify: true
|
||||
# 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".
|
||||
permissions: R
|
||||
|
||||
# Default permissions rules to apply at the paths.
|
||||
# The default permissions rules for users. Default is none.
|
||||
rules: []
|
||||
|
||||
# The list of users. Must be defined if auth is set to true.
|
||||
users:
|
||||
# Example 'admin' user with plaintext password.
|
||||
- username: admin
|
||||
password: admin
|
||||
# Example 'john' user with bcrypt encrypted password, with custom scope.
|
||||
- username: john
|
||||
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
|
||||
scope: /another/path
|
||||
# Example user whose details will be picked up from the environment.
|
||||
- username: "{env}ENV_USERNAME"
|
||||
password: "{env}ENV_PASSWORD"
|
||||
- username: basic
|
||||
password: basic
|
||||
# Override default modify.
|
||||
modify: false
|
||||
rules:
|
||||
# With this rule, the user CANNOT access /some/files.
|
||||
- path: /some/file
|
||||
allow: false
|
||||
# With this rule, the user CAN modify /public/access.
|
||||
- path: /public/access/
|
||||
modify: true
|
||||
# With this rule, the user CAN modify all files ending with .js. It uses
|
||||
# a regular expression.
|
||||
- path: "^*.js$"
|
||||
regex: true
|
||||
modify: true
|
||||
# Logging configuration
|
||||
log:
|
||||
# Logging format ('console', 'json'). Default is 'console'.
|
||||
format: console
|
||||
# Enable or disable colors. Default is 'true'. Only applied if format is 'console'.
|
||||
colors: true
|
||||
# Logging outputs. You can have more than one output. Default is only 'stderr'.
|
||||
outputs:
|
||||
- stderr
|
||||
|
||||
# CORS configuration
|
||||
cors:
|
||||
# Whether or not CORS configuration should be applied. Default is 'false'.
|
||||
enabled: true
|
||||
credentials: true
|
||||
allowed_headers:
|
||||
@@ -125,6 +108,41 @@ cors:
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- Content-Range
|
||||
|
||||
# The list of users. If the list is empty, then there will be no authentication.
|
||||
# Otherwise, basic authentication will automatically be configured.
|
||||
#
|
||||
# If you're delegating the authentication to a different service, you can proxy
|
||||
# the username using basic authentication, and then disable webdav's password
|
||||
# check using the option:
|
||||
#
|
||||
# noPassword: true
|
||||
users:
|
||||
# Example 'admin' user with plaintext password.
|
||||
- username: admin
|
||||
password: admin
|
||||
# Example 'john' user with bcrypt encrypted password, with custom directory.
|
||||
- username: john
|
||||
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
|
||||
directory: /another/path
|
||||
# Example user whose details will be picked up from the environment.
|
||||
- username: "{env}ENV_USERNAME"
|
||||
password: "{env}ENV_PASSWORD"
|
||||
- username: basic
|
||||
password: basic
|
||||
# Override default permissions.
|
||||
permissions: CRUD
|
||||
rules:
|
||||
# With this rule, the user CANNOT access /some/files.
|
||||
- path: /some/file
|
||||
permissions: none
|
||||
# With this rule, the user CAN create, read, update and delete within /public/access.
|
||||
- path: /public/access/
|
||||
permissions: CRUD
|
||||
# With this rule, the user CAN read and update all files ending with .js. It uses
|
||||
# a regular expression.
|
||||
- regex: "^.+.js$"
|
||||
permissions: RU
|
||||
```
|
||||
|
||||
### CORS
|
||||
@@ -146,7 +164,7 @@ location / {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header REMOTE-HOST $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Host $host;
|
||||
proxy_redirect off;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security issues to:
|
||||
msaa1990 [at] gmail [dot com]
|
||||
|
||||
cc: hacdias [at] gmail [dot com]
|
||||
+9
-27
@@ -10,23 +10,20 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/hacdias/webdav/v4/lib"
|
||||
"github.com/hacdias/webdav/v5/lib"
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func init() {
|
||||
flags := rootCmd.Flags()
|
||||
flags.StringP("config", "c", "", "config file path")
|
||||
flags.BoolP("tls", "t", lib.DefaultTLS, "enable TLS")
|
||||
flags.Bool("auth", lib.DefaultAuth, "enable authentication")
|
||||
flags.String("cert", lib.DefaultCert, "path to TLS certificate")
|
||||
flags.String("key", lib.DefaultKey, "path to TLS key")
|
||||
flags.StringP("address", "a", lib.DefaultAddress, "address to listen on")
|
||||
flags.IntP("port", "p", lib.DefaultPort, "port to listen on")
|
||||
flags.BoolP("tls", "t", lib.DefaultTLS, "enable TLS")
|
||||
flags.String("cert", lib.DefaultCert, "path to TLS certificate")
|
||||
flags.String("key", lib.DefaultKey, "path to TLS key")
|
||||
flags.StringP("prefix", "P", lib.DefaultPrefix, "URL path prefix")
|
||||
flags.String("log_format", lib.DefaultLogFormat, "logging format")
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
@@ -58,14 +55,15 @@ set WD_CERT.`,
|
||||
return err
|
||||
}
|
||||
|
||||
// Create HTTP handler from the config
|
||||
handler, err := lib.NewHandler(cfg)
|
||||
// Setup the logger based on the configuration
|
||||
logger, err := cfg.GetLogger()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zap.ReplaceGlobals(logger)
|
||||
|
||||
// Setup the logger based on the configuration
|
||||
err = setupLogger(cfg)
|
||||
// Create HTTP handler from the config
|
||||
handler, err := lib.NewHandler(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,19 +125,3 @@ func getListener(cfg *lib.Config) (net.Listener, error) {
|
||||
|
||||
return net.Listen(network, address)
|
||||
}
|
||||
|
||||
func setupLogger(cfg *lib.Config) error {
|
||||
loggerConfig := zap.NewProductionConfig()
|
||||
loggerConfig.DisableCaller = true
|
||||
if cfg.Debug {
|
||||
loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
||||
}
|
||||
loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
loggerConfig.Encoding = cfg.LogFormat
|
||||
logger, err := loggerConfig.Build()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zap.ReplaceGlobals(logger)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
module github.com/hacdias/webdav/v4
|
||||
module github.com/hacdias/webdav/v5
|
||||
|
||||
go 1.22
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/go-viper/mapstructure/v2 v2.1.0
|
||||
github.com/rs/cors v1.11.0
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/studio-b12/gowebdav v0.9.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.25.0
|
||||
golang.org/x/net v0.27.0
|
||||
golang.org/x/crypto v0.26.0
|
||||
golang.org/x/net v0.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -26,14 +28,12 @@ require (
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect
|
||||
golang.org/x/sys v0.24.0 // indirect
|
||||
golang.org/x/text v0.17.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
retract v4.1.0
|
||||
|
||||
@@ -7,6 +7,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-viper/mapstructure/v2 v2.1.0 h1:gHnMa2Y/pIxElCH2GlZZ1lZSsn6XMtufpGyP1XxdC/w=
|
||||
github.com/go-viper/mapstructure/v2 v2.1.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
@@ -39,8 +41,8 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
@@ -56,6 +58,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/studio-b12/gowebdav v0.9.0 h1:1j1sc9gQnNxbXXM4M/CebPOX4aXYtr7MojAVcN4dHjU=
|
||||
github.com/studio-b12/gowebdav v0.9.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=
|
||||
@@ -64,16 +68,16 @@ 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.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7 h1:wDLEX9a7YQoKdKNQt88rtydkqDxeGaBUTnIYc3iG/mA=
|
||||
golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI=
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
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=
|
||||
|
||||
+66
-44
@@ -6,35 +6,36 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultTLS = false
|
||||
DefaultAuth = false
|
||||
DefaultCert = "cert.pem"
|
||||
DefaultKey = "key.pem"
|
||||
DefaultAddress = "0.0.0.0"
|
||||
DefaultPort = 0
|
||||
DefaultPrefix = "/"
|
||||
DefaultLogFormat = "console"
|
||||
DefaultTLS = false
|
||||
DefaultCert = "cert.pem"
|
||||
DefaultKey = "key.pem"
|
||||
DefaultAddress = "0.0.0.0"
|
||||
DefaultPort = 6065
|
||||
DefaultPrefix = "/"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Permissions `mapstructure:",squash"`
|
||||
Debug bool
|
||||
Address string
|
||||
Port int
|
||||
TLS bool
|
||||
Cert string
|
||||
Key string
|
||||
Prefix string
|
||||
NoSniff bool
|
||||
LogFormat string `mapstructure:"log_format"`
|
||||
Auth bool
|
||||
CORS CORS
|
||||
Users []User
|
||||
UserPermissions `mapstructure:",squash"`
|
||||
Debug bool
|
||||
Address string
|
||||
Port int
|
||||
TLS bool
|
||||
Cert string
|
||||
Key string
|
||||
Prefix string
|
||||
NoSniff bool
|
||||
NoPassword bool
|
||||
Log Log
|
||||
CORS CORS
|
||||
Users []User
|
||||
}
|
||||
|
||||
func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
@@ -46,11 +47,6 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = v.BindPFlag("LogFormat", flags.Lookup("log_format"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration file settings
|
||||
@@ -65,6 +61,9 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
v.SetEnvPrefix("wd")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
// TODO: use new env struct bind feature when it's released in viper.
|
||||
// This should make it redundant to set defaults for things that are
|
||||
// empty or false.
|
||||
|
||||
// Defaults shared with flags
|
||||
v.SetDefault("TLS", DefaultTLS)
|
||||
@@ -72,11 +71,17 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
v.SetDefault("Key", DefaultKey)
|
||||
v.SetDefault("Address", DefaultAddress)
|
||||
v.SetDefault("Port", DefaultPort)
|
||||
v.SetDefault("Auth", DefaultAuth)
|
||||
v.SetDefault("Prefix", DefaultPrefix)
|
||||
v.SetDefault("Log_Format", DefaultLogFormat)
|
||||
|
||||
// Other defaults
|
||||
v.SetDefault("Directory", ".")
|
||||
v.SetDefault("Permissions", "R")
|
||||
v.SetDefault("Debug", false)
|
||||
v.SetDefault("NoSniff", false)
|
||||
v.SetDefault("NoPassword", false)
|
||||
v.SetDefault("Log.Format", "console")
|
||||
v.SetDefault("Log.Outputs", []string{"stderr"})
|
||||
v.SetDefault("Log.Colors", true)
|
||||
v.SetDefault("CORS.Allowed_Headers", []string{"*"})
|
||||
v.SetDefault("CORS.Allowed_Hosts", []string{"*"})
|
||||
v.SetDefault("CORS.Allowed_Methods", []string{"*"})
|
||||
@@ -90,19 +95,23 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
err = v.Unmarshal(cfg)
|
||||
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
mapstructure.TextUnmarshallerHookFunc(),
|
||||
)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cascade user settings
|
||||
for i := range cfg.Users {
|
||||
if !v.IsSet(fmt.Sprintf("Users.%d.Scope", i)) {
|
||||
cfg.Users[i].Scope = cfg.Scope
|
||||
if !v.IsSet(fmt.Sprintf("Users.%d.Directory", i)) {
|
||||
cfg.Users[i].Directory = cfg.Directory
|
||||
}
|
||||
|
||||
if !v.IsSet(fmt.Sprintf("Users.%d.Modify", i)) {
|
||||
cfg.Users[i].Modify = cfg.Modify
|
||||
if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
|
||||
cfg.Users[i].Permissions = cfg.Permissions
|
||||
}
|
||||
|
||||
if !v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) {
|
||||
@@ -121,15 +130,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
func (c *Config) Validate() error {
|
||||
var err error
|
||||
|
||||
if c.Auth && len(c.Users) == 0 {
|
||||
return errors.New("invalid config: auth cannot be enabled without users")
|
||||
}
|
||||
|
||||
if !c.Auth && len(c.Users) != 0 {
|
||||
return errors.New("invalid config: auth cannot be disabled with users defined")
|
||||
}
|
||||
|
||||
c.Scope, err = filepath.Abs(c.Scope)
|
||||
c.Directory, err = filepath.Abs(c.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
@@ -154,13 +155,13 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
err = c.Permissions.Validate()
|
||||
err = c.UserPermissions.Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
for _, u := range c.Users {
|
||||
err := u.Validate()
|
||||
err := u.Validate(c.NoPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
@@ -169,6 +170,27 @@ func (c *Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) GetLogger() (*zap.Logger, error) {
|
||||
loggerConfig := zap.NewProductionConfig()
|
||||
loggerConfig.DisableCaller = true
|
||||
if cfg.Debug {
|
||||
loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
||||
}
|
||||
if cfg.Log.Colors && cfg.Log.Format != "json" {
|
||||
loggerConfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
}
|
||||
loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
loggerConfig.Encoding = cfg.Log.Format
|
||||
loggerConfig.OutputPaths = cfg.Log.Outputs
|
||||
return loggerConfig.Build()
|
||||
}
|
||||
|
||||
type Log struct {
|
||||
Format string
|
||||
Colors bool
|
||||
Outputs []string
|
||||
}
|
||||
|
||||
type CORS struct {
|
||||
Enabled bool
|
||||
Credentials bool
|
||||
|
||||
+70
-41
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -27,13 +28,17 @@ func TestConfigDefaults(t *testing.T) {
|
||||
cfg := writeAndParseConfig(t, "", ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.EqualValues(t, DefaultAuth, cfg.Auth)
|
||||
require.EqualValues(t, DefaultTLS, cfg.TLS)
|
||||
require.EqualValues(t, DefaultAddress, cfg.Address)
|
||||
require.EqualValues(t, DefaultPort, cfg.Port)
|
||||
require.EqualValues(t, DefaultPrefix, cfg.Prefix)
|
||||
require.EqualValues(t, DefaultLogFormat, cfg.LogFormat)
|
||||
require.NotEmpty(t, cfg.Scope)
|
||||
require.EqualValues(t, "console", cfg.Log.Format)
|
||||
require.EqualValues(t, true, cfg.Log.Colors)
|
||||
require.EqualValues(t, []string{"stderr"}, cfg.Log.Outputs)
|
||||
|
||||
dir, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, dir, cfg.Directory)
|
||||
|
||||
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHeaders)
|
||||
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
|
||||
@@ -44,37 +49,44 @@ func TestConfigCascade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
check := func(t *testing.T, cfg *Config) {
|
||||
require.True(t, cfg.Modify)
|
||||
require.Equal(t, "/", cfg.Scope)
|
||||
require.True(t, cfg.Permissions.Read)
|
||||
require.True(t, cfg.Permissions.Create)
|
||||
require.False(t, cfg.Permissions.Delete)
|
||||
require.False(t, cfg.Permissions.Update)
|
||||
require.Equal(t, "/", cfg.Directory)
|
||||
require.Len(t, cfg.Rules, 1)
|
||||
|
||||
require.Len(t, cfg.Users, 2)
|
||||
|
||||
require.True(t, cfg.Users[0].Modify)
|
||||
require.Equal(t, "/", cfg.Users[0].Scope)
|
||||
require.True(t, cfg.Users[0].Permissions.Read)
|
||||
require.True(t, cfg.Users[0].Permissions.Create)
|
||||
require.False(t, cfg.Users[0].Permissions.Delete)
|
||||
require.False(t, cfg.Users[0].Permissions.Update)
|
||||
require.Equal(t, "/", cfg.Users[0].Directory)
|
||||
require.Len(t, cfg.Users[0].Rules, 1)
|
||||
|
||||
require.False(t, cfg.Users[1].Modify)
|
||||
require.Equal(t, "/basic", cfg.Users[1].Scope)
|
||||
require.True(t, cfg.Users[1].Permissions.Read)
|
||||
require.False(t, cfg.Users[1].Permissions.Create)
|
||||
require.False(t, cfg.Users[1].Permissions.Delete)
|
||||
require.False(t, cfg.Users[1].Permissions.Update)
|
||||
require.Equal(t, "/basic", cfg.Users[1].Directory)
|
||||
require.Len(t, cfg.Users[1].Rules, 0)
|
||||
}
|
||||
|
||||
t.Run("YAML", func(t *testing.T) {
|
||||
content := `
|
||||
auth: true
|
||||
scope: /
|
||||
modify: true
|
||||
directory: /
|
||||
permissions: CR
|
||||
rules:
|
||||
- path: /public/access/
|
||||
modify: true
|
||||
permissions: R
|
||||
|
||||
users:
|
||||
- username: admin
|
||||
password: admin
|
||||
- username: basic
|
||||
password: basic
|
||||
scope: /basic
|
||||
modify: false
|
||||
directory: /basic
|
||||
permissions: R
|
||||
rules: []`
|
||||
|
||||
cfg := writeAndParseConfig(t, content, ".yml")
|
||||
@@ -85,13 +97,12 @@ users:
|
||||
|
||||
t.Run("JSON", func(t *testing.T) {
|
||||
content := `{
|
||||
"auth": true,
|
||||
"scope": "/",
|
||||
"modify": true,
|
||||
"directory": "/",
|
||||
"permissions": "CR",
|
||||
"rules": [
|
||||
{
|
||||
"path": "/public/access/",
|
||||
"modify": true
|
||||
"permissions": "R"
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
@@ -102,8 +113,8 @@ users:
|
||||
{
|
||||
"username": "basic",
|
||||
"password": "basic",
|
||||
"scope": "/basic",
|
||||
"modify": false,
|
||||
"directory": "/basic",
|
||||
"permissions": "R",
|
||||
"rules": []
|
||||
}
|
||||
]
|
||||
@@ -116,13 +127,13 @@ users:
|
||||
})
|
||||
|
||||
t.Run("`TOML", func(t *testing.T) {
|
||||
content := `auth = true
|
||||
scope = "/"
|
||||
modify = true
|
||||
content := `
|
||||
directory = "/"
|
||||
permissions = "CR"
|
||||
|
||||
[[rules]]
|
||||
path = "/public/access/"
|
||||
modify = true
|
||||
permissions = "R"
|
||||
|
||||
[[users]]
|
||||
username = "admin"
|
||||
@@ -131,8 +142,8 @@ password = "admin"
|
||||
[[users]]
|
||||
username = "basic"
|
||||
password = "basic"
|
||||
scope = "/basic"
|
||||
modify = false
|
||||
directory = "/basic"
|
||||
permissions = "R"
|
||||
rules = []
|
||||
`
|
||||
|
||||
@@ -171,16 +182,10 @@ cors:
|
||||
|
||||
func TestConfigRules(t *testing.T) {
|
||||
content := `
|
||||
auth: false
|
||||
scope: /
|
||||
modify: true
|
||||
directory: /
|
||||
rules:
|
||||
- path: '^.+\.js$'
|
||||
regex: true
|
||||
modify: true
|
||||
- path: /public/access/
|
||||
regex: false
|
||||
modify: true`
|
||||
- regex: '^.+\.js$'
|
||||
- path: /public/access/`
|
||||
|
||||
cfg := writeAndParseConfig(t, content, ".yaml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
@@ -188,10 +193,34 @@ rules:
|
||||
require.Len(t, cfg.Rules, 2)
|
||||
|
||||
require.Empty(t, cfg.Rules[0].Path)
|
||||
require.NotNil(t, cfg.Rules[0].Regexp)
|
||||
require.True(t, cfg.Rules[0].Regexp.MatchString("/my/path/to/file.js"))
|
||||
require.False(t, cfg.Rules[0].Regexp.MatchString("/my/path/to/file.ts"))
|
||||
require.NotNil(t, cfg.Rules[0].Regex)
|
||||
require.True(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.js"))
|
||||
require.False(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.ts"))
|
||||
|
||||
require.NotEmpty(t, cfg.Rules[1].Path)
|
||||
require.Nil(t, cfg.Rules[1].Regexp)
|
||||
require.Nil(t, cfg.Rules[1].Regex)
|
||||
}
|
||||
|
||||
func TestConfigEnv(t *testing.T) {
|
||||
require.NoError(t, os.Setenv("WD_PORT", "1234"))
|
||||
require.NoError(t, os.Setenv("WD_DEBUG", "true"))
|
||||
require.NoError(t, os.Setenv("WD_PERMISSIONS", "CRUD"))
|
||||
require.NoError(t, os.Setenv("WD_DIRECTORY", "/test"))
|
||||
|
||||
cfg, err := ParseConfig("", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1234, cfg.Port)
|
||||
assert.Equal(t, "/test", cfg.Directory)
|
||||
assert.Equal(t, true, cfg.Debug)
|
||||
require.True(t, cfg.Permissions.Read)
|
||||
require.True(t, cfg.Permissions.Create)
|
||||
require.True(t, cfg.Permissions.Delete)
|
||||
require.True(t, cfg.Permissions.Update)
|
||||
|
||||
// Reset
|
||||
require.NoError(t, os.Setenv("WD_PORT", ""))
|
||||
require.NoError(t, os.Setenv("WD_DEBUG", ""))
|
||||
require.NoError(t, os.Setenv("WD_PERMISSIONS", ""))
|
||||
require.NoError(t, os.Setenv("WD_DIRECTORY", ""))
|
||||
}
|
||||
|
||||
+35
-7
@@ -2,6 +2,8 @@ package lib
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/cors"
|
||||
@@ -15,20 +17,22 @@ type handlerUser struct {
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
user *handlerUser
|
||||
users map[string]*handlerUser
|
||||
noPassword bool
|
||||
user *handlerUser
|
||||
users map[string]*handlerUser
|
||||
}
|
||||
|
||||
func NewHandler(c *Config) (http.Handler, error) {
|
||||
h := &Handler{
|
||||
noPassword: c.NoPassword,
|
||||
user: &handlerUser{
|
||||
User: User{
|
||||
Permissions: c.Permissions,
|
||||
UserPermissions: c.UserPermissions,
|
||||
},
|
||||
Handler: webdav.Handler{
|
||||
Prefix: c.Prefix,
|
||||
FileSystem: Dir{
|
||||
Dir: webdav.Dir(c.Scope),
|
||||
Dir: webdav.Dir(c.Directory),
|
||||
noSniff: c.NoSniff,
|
||||
},
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
@@ -43,7 +47,7 @@ func NewHandler(c *Config) (http.Handler, error) {
|
||||
Handler: webdav.Handler{
|
||||
Prefix: c.Prefix,
|
||||
FileSystem: Dir{
|
||||
Dir: webdav.Dir(u.Scope),
|
||||
Dir: webdav.Dir(u.Directory),
|
||||
noSniff: c.NoSniff,
|
||||
},
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
@@ -61,6 +65,14 @@ func NewHandler(c *Config) (http.Handler, error) {
|
||||
}).Handler(h), nil
|
||||
}
|
||||
|
||||
if len(c.Users) == 0 {
|
||||
zap.L().Warn("unprotected config: no users have been set, so no authentication will be used")
|
||||
}
|
||||
|
||||
if c.NoPassword {
|
||||
zap.L().Warn("unprotected config: password check is disabled, only intended when delegating authentication to another service")
|
||||
}
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
@@ -86,7 +98,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !user.checkPassword(password) {
|
||||
if !h.noPassword && !user.checkPassword(password) {
|
||||
zap.L().Info("invalid password", zap.String("username", username), zap.String("remote_address", r.RemoteAddr))
|
||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -95,8 +107,24 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
zap.L().Info("user authorized", zap.String("username", username))
|
||||
}
|
||||
|
||||
// Cleanup destination header if it's present by stripping out the prefix
|
||||
// and only keeping the path.
|
||||
if destination := r.Header.Get("Destination"); destination != "" {
|
||||
u, err := url.Parse(destination)
|
||||
if err == nil {
|
||||
destination = strings.TrimPrefix(u.Path, user.Prefix)
|
||||
if !strings.HasPrefix(destination, "/") {
|
||||
destination = "/" + destination
|
||||
}
|
||||
r.Header.Set("Destination", destination)
|
||||
}
|
||||
}
|
||||
|
||||
// Checks for user permissions relatively to this PATH.
|
||||
allowed := user.Allowed(r)
|
||||
allowed := user.Allowed(r, func(filename string) bool {
|
||||
_, err := user.FileSystem.Stat(r.Context(), filename)
|
||||
return !os.IsNotExist(err)
|
||||
})
|
||||
|
||||
zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
|
||||
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/studio-b12/gowebdav"
|
||||
)
|
||||
|
||||
func makeTestDirectory(t *testing.T, m map[string][]byte) string {
|
||||
dir := t.TempDir()
|
||||
|
||||
for path, data := range m {
|
||||
filename := filepath.Join(dir, path)
|
||||
|
||||
if data == nil {
|
||||
err := os.MkdirAll(filename, 0775)
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
err := os.MkdirAll(filepath.Dir(filename), 0775)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filename, data, 0664)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func makeTestServer(t *testing.T, yamlConfig string) *httptest.Server {
|
||||
cfg := writeAndParseConfig(t, yamlConfig, ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
handler, err := NewHandler(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func TestServerDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
"sub/bar.txt": []byte("bar"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, "directory: "+dir)
|
||||
client := gowebdav.NewClient(srv.URL, "", "")
|
||||
|
||||
// By default, reading permissions.
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 2)
|
||||
|
||||
data, err := client.Read("/foo.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo"), data)
|
||||
|
||||
files, err = client.ReadDir("/sub")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1)
|
||||
require.Equal(t, "bar.txt", files[0].Name())
|
||||
|
||||
data, err = client.Read("/sub/bar.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("bar"), data)
|
||||
|
||||
// By default, no modification permissions.
|
||||
require.ErrorContains(t, client.Mkdir("/dir", 0666), "403")
|
||||
require.ErrorContains(t, client.MkdirAll("/dir/path", 0666), "403")
|
||||
require.ErrorContains(t, client.Remove("/foo.txt"), "403")
|
||||
require.ErrorContains(t, client.RemoveAll("/foo.txt"), "403")
|
||||
require.ErrorContains(t, client.Rename("/foo.txt", "/file2.txt", false), "403")
|
||||
require.ErrorContains(t, client.Copy("/foo.txt", "/file2.txt", false), "403")
|
||||
require.ErrorContains(t, client.Write("/foo.txt", []byte("hello world 2"), 0666), "403")
|
||||
}
|
||||
|
||||
func TestServerListingCharacters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"富/foo.txt": []byte("foo"),
|
||||
"你好.txt": []byte("bar"),
|
||||
"z*.txt": []byte("zbar"),
|
||||
"foo.txt": []byte("foo"),
|
||||
"🌹.txt": []byte("foo"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, "directory: "+dir)
|
||||
client := gowebdav.NewClient(srv.URL, "", "")
|
||||
|
||||
// By default, reading permissions.
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 5)
|
||||
|
||||
names := []string{
|
||||
files[0].Name(),
|
||||
files[1].Name(),
|
||||
files[2].Name(),
|
||||
files[3].Name(),
|
||||
files[4].Name(),
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
require.Equal(t, []string{
|
||||
"foo.txt",
|
||||
"z*.txt",
|
||||
"你好.txt",
|
||||
"富",
|
||||
"🌹.txt",
|
||||
}, names)
|
||||
|
||||
data, err := client.Read("/z*.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("zbar"), data)
|
||||
}
|
||||
|
||||
func TestServerAuthentication(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
"sub/bar.txt": []byte("bar"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: CRUD
|
||||
|
||||
users:
|
||||
- username: basic
|
||||
password: basic
|
||||
- username: bcrypt
|
||||
password: "{bcrypt}$2a$12$222dfz8Nweoyvy8OwI8.me9nfaRfuz8lqGkiiYSMH1lLMHO26qWom"
|
||||
`, dir))
|
||||
|
||||
t.Run("Basic Auth (Plaintext)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "basic", "basic")
|
||||
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 2)
|
||||
})
|
||||
|
||||
t.Run("Basic Auth (BCrypt)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := gowebdav.NewClient(srv.URL, "bcrypt", "bcrypt")
|
||||
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 2)
|
||||
})
|
||||
|
||||
t.Run("Unauthorized (No Credentials)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := gowebdav.NewClient(srv.URL, "", "")
|
||||
_, err := client.ReadDir("/")
|
||||
require.ErrorContains(t, err, "401")
|
||||
})
|
||||
|
||||
t.Run("Unauthorized (Wrong User)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := gowebdav.NewClient(srv.URL, "wrong", "basic")
|
||||
_, err := client.ReadDir("/")
|
||||
require.ErrorContains(t, err, "401")
|
||||
})
|
||||
|
||||
t.Run("Unauthorized (Wrong Password)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := gowebdav.NewClient(srv.URL, "basic", "wrong")
|
||||
_, err := client.ReadDir("/")
|
||||
require.ErrorContains(t, err, "401")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerAuthenticationNoPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
"sub/bar.txt": []byte("bar"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
noPassword: true
|
||||
permissions: CRUD
|
||||
|
||||
users:
|
||||
- username: basic
|
||||
`, dir))
|
||||
|
||||
t.Run("Basic Auth", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "basic", "")
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 2)
|
||||
})
|
||||
|
||||
t.Run("Unauthorized Wrong User", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := gowebdav.NewClient(srv.URL, "wrong", "")
|
||||
_, err := client.ReadDir("/")
|
||||
require.ErrorContains(t, err, "401")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
"bar.js": []byte("foo js"),
|
||||
"a/foo.js": []byte("foo js"),
|
||||
"a/foo.txt": []byte("foo txt"),
|
||||
"b/foo.txt": []byte("foo b"),
|
||||
"c/a.txt": []byte("b"),
|
||||
"c/b.txt": []byte("b"),
|
||||
"c/c.txt": []byte("b"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: CRUD
|
||||
|
||||
users:
|
||||
- username: basic
|
||||
password: basic
|
||||
rules:
|
||||
- regex: "^.+.js$"
|
||||
permissions: R
|
||||
- path: "/b/"
|
||||
permissions: R
|
||||
- path: "/a/foo.txt"
|
||||
permissions: none
|
||||
- path: "/c/"
|
||||
permissions: none
|
||||
`, dir))
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "basic", "basic")
|
||||
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 5)
|
||||
|
||||
err = client.Write("/foo.txt", []byte("new"), 0666)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Write("/new.txt", []byte("new"), 0666)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Copy("/bar.js", "/b/bar.js", false)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Copy("/bar.js", "/bar.jsx", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Copy("/b/foo.txt", "/foo1.txt", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Rename("/b/foo.txt", "/foo2.txt", false)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
_, err = client.Read("/a/foo.txt")
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Write("/a/foo.js", []byte("new"), 0666)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Write("/b/foo.txt", []byte("new"), 0666)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
_, err = client.ReadDir("/c")
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
_, err = client.Read("/c/a.txt")
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Write("/c/b.txt", []byte("new"), 0666)
|
||||
require.ErrorContains(t, err, "403")
|
||||
}
|
||||
|
||||
func TestServerPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
"a/foo.txt": []byte("foo a"),
|
||||
"b/foo.txt": []byte("foo b"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: CR
|
||||
|
||||
users:
|
||||
- username: a
|
||||
password: a
|
||||
directory: %s/a
|
||||
- username: b
|
||||
password: b
|
||||
directory: %s/b
|
||||
permissions: R
|
||||
`, dir, dir, dir))
|
||||
|
||||
t.Run("User A", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "a", "a")
|
||||
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1)
|
||||
|
||||
data, err := client.Read("/foo.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo a"), data)
|
||||
|
||||
err = client.Copy("/foo.txt", "/copy.txt", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Copy("/foo.txt", "/copy.txt", true)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Rename("/foo.txt", "/copy.txt", true)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
data, err = client.Read("/copy.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo a"), data)
|
||||
})
|
||||
|
||||
t.Run("User B", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "b", "b")
|
||||
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1)
|
||||
|
||||
data, err := client.Read("/foo.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo b"), data)
|
||||
|
||||
err = client.Copy("/foo.txt", "/copy.txt", false)
|
||||
require.ErrorContains(t, err, "403")
|
||||
})
|
||||
}
|
||||
+122
-41
@@ -1,37 +1,23 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var readMethods = []string{
|
||||
http.MethodGet,
|
||||
http.MethodHead,
|
||||
http.MethodOptions,
|
||||
"PROPFIND",
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
Regex bool
|
||||
Allow bool
|
||||
Modify bool
|
||||
Path string
|
||||
// TODO: remove Regex and replace by this. It encodes
|
||||
Regexp *regexp.Regexp `mapstructure:"-"`
|
||||
Permissions Permissions
|
||||
Path string
|
||||
Regex *regexp.Regexp
|
||||
}
|
||||
|
||||
func (r *Rule) Validate() error {
|
||||
if r.Regex {
|
||||
rp, err := regexp.Compile(r.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid rule: %w", err)
|
||||
}
|
||||
r.Regexp = rp
|
||||
r.Path = ""
|
||||
r.Regex = false
|
||||
if r.Regex != nil && r.Path != "" {
|
||||
return errors.New("invalid rule: cannot define both regex and path")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -39,43 +25,62 @@ func (r *Rule) Validate() error {
|
||||
|
||||
// Matches checks if [Rule] matches the given path.
|
||||
func (r *Rule) Matches(path string) bool {
|
||||
if r.Regexp != nil {
|
||||
return r.Regexp.MatchString(path)
|
||||
if r.Regex != nil {
|
||||
return r.Regex.MatchString(path)
|
||||
}
|
||||
|
||||
return strings.HasPrefix(path, r.Path)
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
Scope string
|
||||
Modify bool
|
||||
Rules []*Rule
|
||||
type UserPermissions struct {
|
||||
Directory string
|
||||
Permissions Permissions
|
||||
Rules []*Rule
|
||||
}
|
||||
|
||||
// Allowed checks if the user has permission to access a directory/file
|
||||
func (p Permissions) Allowed(r *http.Request) bool {
|
||||
// Determine whether or not it is a read or write request.
|
||||
readRequest := false
|
||||
for _, method := range readMethods {
|
||||
if r.Method == method {
|
||||
readRequest = true
|
||||
break
|
||||
func (p UserPermissions) Allowed(r *http.Request, fileExists func(string) bool) bool {
|
||||
// For COPY and MOVE requests, we first check the permissions for the destination
|
||||
// path. As soon as a rule matches and does not allow the operation at the destination,
|
||||
// we fail immediately. If no rule matches, we check the global permissions.
|
||||
if r.Method == "COPY" || r.Method == "MOVE" {
|
||||
dst := r.Header.Get("Destination")
|
||||
|
||||
for i := len(p.Rules) - 1; i >= 0; i-- {
|
||||
if p.Rules[i].Matches(dst) {
|
||||
if !p.Rules[i].Permissions.AllowedDestination(r, fileExists) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Only check the first rule that matches, similarly to the source rules.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !p.Permissions.AllowedDestination(r, fileExists) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Go through rules beginning from the last one.
|
||||
// Go through rules beginning from the last one, and check the permissions at
|
||||
// the source. The first matched rule returns.
|
||||
for i := len(p.Rules) - 1; i >= 0; i-- {
|
||||
rule := p.Rules[i]
|
||||
|
||||
if rule.Matches(r.URL.Path) {
|
||||
return rule.Allow && (readRequest || rule.Modify)
|
||||
if p.Rules[i].Matches(r.URL.Path) {
|
||||
return p.Rules[i].Permissions.Allowed(r, fileExists)
|
||||
}
|
||||
}
|
||||
|
||||
return readRequest || p.Modify
|
||||
return p.Permissions.Allowed(r, fileExists)
|
||||
}
|
||||
|
||||
func (p *Permissions) Validate() error {
|
||||
func (p *UserPermissions) Validate() error {
|
||||
var err error
|
||||
|
||||
p.Directory, err = filepath.Abs(p.Directory)
|
||||
if 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)
|
||||
@@ -84,3 +89,79 @@ func (p *Permissions) Validate() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
Create bool
|
||||
Read bool
|
||||
Update bool
|
||||
Delete bool
|
||||
}
|
||||
|
||||
func (p *Permissions) UnmarshalText(data []byte) error {
|
||||
text := strings.ToLower(string(data))
|
||||
if text == "none" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, c := range text {
|
||||
switch c {
|
||||
case 'c':
|
||||
p.Create = true
|
||||
case 'r':
|
||||
p.Read = true
|
||||
case 'u':
|
||||
p.Update = true
|
||||
case 'd':
|
||||
p.Delete = true
|
||||
default:
|
||||
return fmt.Errorf("invalid permission: %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Allowed returns whether this permission set has permissions to execute this
|
||||
// request in the source directory. This applies to all requests with all methods.
|
||||
func (p Permissions) Allowed(r *http.Request, fileExists func(string) bool) bool {
|
||||
switch r.Method {
|
||||
case "GET", "HEAD", "OPTIONS", "POST", "PROPFIND":
|
||||
// Note: POST backend implementation just returns the same thing as GET.
|
||||
return p.Read
|
||||
case "MKCOL":
|
||||
return p.Create
|
||||
case "PROPPATCH":
|
||||
return p.Update
|
||||
case "PUT":
|
||||
if fileExists(r.URL.Path) {
|
||||
return p.Update
|
||||
} else {
|
||||
return p.Create
|
||||
}
|
||||
case "COPY":
|
||||
return p.Read
|
||||
case "MOVE":
|
||||
return p.Read && p.Delete
|
||||
case "DELETE":
|
||||
return p.Delete
|
||||
case "LOCK", "UNLOCK":
|
||||
return p.Create || p.Read || p.Update || p.Delete
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AllowedDestination returns whether this permissions set has permissions to execute this
|
||||
// request in the destination directory. This only applies for COPY and MOVE requests.
|
||||
func (p Permissions) AllowedDestination(r *http.Request, fileExists func(string) bool) bool {
|
||||
switch r.Method {
|
||||
case "COPY", "MOVE":
|
||||
if fileExists(r.Header.Get("Destination")) {
|
||||
return p.Update
|
||||
} else {
|
||||
return p.Create
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -10,9 +10,9 @@ import (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Permissions `mapstructure:",squash"`
|
||||
Username string
|
||||
Password string
|
||||
UserPermissions `mapstructure:",squash"`
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func (u User) checkPassword(input string) bool {
|
||||
@@ -24,12 +24,12 @@ func (u User) checkPassword(input string) bool {
|
||||
return u.Password == input
|
||||
}
|
||||
|
||||
func (u *User) Validate() error {
|
||||
func (u *User) Validate(noPassword bool) error {
|
||||
if u.Username == "" {
|
||||
return errors.New("invalid user: username must be set")
|
||||
}
|
||||
|
||||
if u.Password == "" {
|
||||
if u.Password == "" && !noPassword {
|
||||
return fmt.Errorf("invalid user %q: password must be set", u.Username)
|
||||
} else if strings.HasPrefix(u.Password, "{env}") {
|
||||
|
||||
@@ -44,7 +44,7 @@ func (u *User) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := u.Permissions.Validate(); err != nil {
|
||||
if err := u.UserPermissions.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid user %q: %w", u.Username, err)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user