Compare commits

...
25 Commits
Author SHA1 Message Date
Henrique Dias 000f404f7a docs: remove dev version note 2024-07-31 10:07:23 +01:00
Henrique Dias e4a8622c1e docs: remove outdated SECURITY.md 2024-07-31 10:07:01 +01:00
Henrique Dias b5a3d07f5c feat!: fine-grained permissions 2024-07-31 11:06:34 +02:00
Henrique Dias f4de82cfd1 feat: add test for server listing characters 2024-07-30 15:01:17 +02:00
Henrique Dias ebcf500d5e docs: cleanup readme 2024-07-29 09:13:46 +01:00
Henrique Dias d7faa1f887 feat!: further log customizations 2024-07-29 10:11:02 +02:00
Henrique Dias d5e5052f63 feat!: rename 'scope' to 'directory'
Directory makes it more clear of what it is. In addition, this will make it easier when
allowing for multiple directories in the future, since we can just name it 'directories', which is more clear than 'scopes'.
2024-07-29 10:11:02 +02:00
Henrique Dias a255fb51e2 feat!: remove Auth option 2024-07-29 10:11:02 +02:00
Henrique Dias ed23ca1820 feat!: change default port and scope
BREAKING CHANGE: the default port is no longer random, but 6065. The default scope is now the current directory instead of the root directory.
2024-07-29 10:11:02 +02:00
Henrique Dias e7e9c3176d feat!: simplified rule with regex instead of boolean
BREAKING CHANGE: the "regex" field in the rule is now a regular expression instead of a boolean.
2024-07-29 10:11:02 +02:00
Henrique Dias d3732322bc chore: bump version to v5 2024-07-29 10:10:19 +02:00
Henrique Dias f708664906 feat: permissions, auth, rules basic tests 2024-07-26 17:18:46 +02:00
Henrique Dias 814462bed1 fix: environment variable parsing
This is more of a workaround than the correct solution. It only fixes top-level ENV variables parsing.
2024-07-25 22:46:34 +02:00
Henrique Dias f6a0707fe6 refactor: shorten response writer code 2024-07-22 22:28:56 +02:00
Henrique Dias 947b163ea7 fix: rules parsing 2024-07-22 22:25:50 +02:00
Henrique Dias 732cf5eff5 docs: fix readme highlighting 2024-07-22 19:22:25 +02:00
Henrique Dias 1e87b21bb1 docs: improve configuration section 2024-07-22 18:55:04 +02:00
Henrique Dias 6166061f20 docs: install, docker, systemd instructions 2024-07-22 18:55:04 +02:00
Henrique Dias 4f8eab48ab fix: config parsing keys 2024-07-22 18:36:58 +02:00
Henrique Dias 7542860a47 fix: panic when getting requests 2024-07-22 18:32:57 +02:00
Henrique Dias 3688420246 feat: centrally defined defaults 2024-07-22 17:52:56 +02:00
Henrique Dias 47e3f6de6f fix: remove 'v' from version name 2024-07-21 21:43:53 +02:00
Henrique Dias 356edb8b93 feat: add tests for json and toml config 2024-07-21 21:41:16 +02:00
Henrique Dias b16c041d0c fix: add 'v' prefix to version 2024-07-21 21:31:52 +02:00
Henrique Dias dc45f32af8 fix: dockerfile build version 2024-07-21 21:25:49 +02:00
17 changed files with 830 additions and 219 deletions
+2 -1
View File
@@ -64,6 +64,7 @@ jobs:
sbom: true sbom: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ steps.meta.outputs.args }} build-args: |
VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
+1 -1
View File
@@ -11,7 +11,7 @@ builds:
flags: flags:
- '-trimpath' - '-trimpath'
ldflags: ldflags:
- '-X github.com/hacdias/webdav/v4/cmd.version={{.Version}}' - '-X github.com/hacdias/webdav/v5/cmd.version={{.Version}}'
goos: goos:
- darwin - darwin
- linux - linux
+3 -4
View File
@@ -1,6 +1,6 @@
FROM golang:1.22-alpine3.20 AS build FROM golang:1.22-alpine3.20 AS build
ARG DOCKER_META_VERSION="untracked" ARG VERSION="untracked"
RUN apk --update add ca-certificates RUN apk --update add ca-certificates
@@ -11,14 +11,13 @@ COPY ./go.sum ./
RUN go mod download RUN go mod download
COPY . /webdav/ COPY . /webdav/
RUN go build -o main -ldflags="-X 'github.com/hacdias/webdav/v4/cmd.version=$DOCKER_META_VERSION'" . RUN go build -o main -ldflags="-X 'github.com/hacdias/webdav/v5/cmd.version=$VERSION'" .
FROM scratch FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /webdav/main /bin/webdav COPY --from=build /webdav/main /bin/webdav
EXPOSE 80 EXPOSE 6065
ENTRYPOINT [ "webdav" ] ENTRYPOINT [ "webdav" ]
CMD [ "-p", "80" ]
+112 -22
View File
@@ -8,30 +8,92 @@ A simple and standalone [WebDAV](https://en.wikipedia.org/wiki/WebDAV) server.
## Install ## Install
Please refer to the [Releases page](https://github.com/hacdias/webdav/releases) for more information. There, you can either download the binaries or find the Docker commands to install WebDAV. 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/v5@latest
```
### Docker
Docker images are provided on both [GitHub's registry](https://github.com/hacdias/webdav/pkgs/container/webdav) and [Docker Hub](https://hub.docker.com/r/hacdias/webdav). You can pull the images using one of the following two commands. Note that this commands pull the latest released version. You can use specific tags to pin specific versions, or use `main` for the development branch.
```bash
# GitHub Registry
docker pull ghcr.io/hacdias/webdav:latest
# Docker Hub
docker pull hacdias/webdav:latest
```
## Usage ## Usage
`webdav` command line interface is really easy to use so you can easily create a WebDAV server for your own user. By default, it runs on a random free port and supports JSON, YAML and TOML configuration. An example of a YAML configuration with the default configurations: 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 directory to `/data`.
```yaml
port: 6060
directory: /data
```
You can now run with the following Docker command, where you mount the configuration file inside the container, and the data directory too, as well as forwarding the port 6060. You will need to change this to match your own configuration.
```bash
docker run \
-p 6060:6060 \
-v $(pwd)/config.yml:/config.yml:ro \
-v $(pwd)/data:/data \
ghcr.io/hacdias/webdav -c /config.yml
```
## Configuration
The configuration can be provided as a YAML, JSON or TOML file. Below is an example of a YAML configuration file with all the options available, as well as what they mean.
```yaml ```yaml
# Server related settings
address: 0.0.0.0 address: 0.0.0.0
port: 0 port: 6065
auth: true
# TLS-related settings if you want to enable TLS directly.
tls: false tls: false
cert: cert.pem cert: cert.pem
key: key.pem key: key.pem
# Prefix to apply to the WebDAV path-ing. Default is '/'.
prefix: / prefix: /
# Enable or disable debug logging. Default is 'false'.
debug: false debug: false
# Default user settings (will be merged) # The directory that will be able to be accessed by the users when connecting.
scope: . # This directory will be used by users unless they have their own 'directory' defined.
modify: true # Default is '.' (current directory).
directory: .
# 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
# The default permissions rules for users. Default is none.
rules: [] rules: []
# 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 configuration
cors: cors:
# Whether or not CORS configuration should be applied. Default is 'false'.
enabled: true enabled: true
credentials: true credentials: true
allowed_headers: allowed_headers:
@@ -44,31 +106,35 @@ cors:
- Content-Length - Content-Length
- Content-Range - Content-Range
# The list of users. If users is empty, then there will be no authentication.
users: users:
# Example 'admin' user with plaintext password.
- username: admin - username: admin
password: admin password: admin
scope: /a/different/path # Example 'john' user with bcrypt encrypted password, with custom directory.
- username: encrypted - username: john
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi" 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" - username: "{env}ENV_USERNAME"
password: "{env}ENV_PASSWORD" password: "{env}ENV_PASSWORD"
- username: basic - username: basic
password: basic password: basic
modify: false # Override default permissions.
permissions: CRUD
rules: rules:
- regex: false # With this rule, the user CANNOT access /some/files.
allow: false - path: /some/file
path: /some/file permissions: none
# With this rule, the user CAN create, read, update and delete within /public/access.
- path: /public/access/ - path: /public/access/
modify: true 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
``` ```
There are more ways to customize how you run WebDAV through flags and environment variables. Please run `webdav --help` for more information on that.
### Systemd
An example of how to use this with `systemd` is on [webdav.service.example](/webdav.service.example).
### CORS ### CORS
The `allowed_*` properties are optional, the default value for each of them will be `*`. `exposed_headers` is optional as well, but is not set if not defined. Setting `credentials` to `true` will allow you to: The `allowed_*` properties are optional, the default value for each of them will be `*`. `exposed_headers` is optional as well, but is not set if not defined. Setting `credentials` to `true` will allow you to:
@@ -76,8 +142,11 @@ The `allowed_*` properties are optional, the default value for each of them will
1. Use `withCredentials = true` in javascript. 1. Use `withCredentials = true` in javascript.
2. Use the `username:password@host` syntax. 2. Use the `username:password@host` syntax.
## Caveats
### Reverse Proxy Service ### Reverse Proxy Service
When you use a reverse proxy implementation like `Nginx` or `Apache`, please note the following fields to avoid causing `502` errors
When using a reverse proxy implementation, like Caddy, Nginx, or Apache, note that you need to forward the correct headers in order to avoid 502 errors. Here's a Nginx configuration example:
```nginx ```nginx
location / { location / {
@@ -90,6 +159,27 @@ location / {
} }
``` ```
## Examples
### Systemd
Example configuration of a [`systemd`](https://en.wikipedia.org/wiki/Systemd) service:
```conf
[Unit]
Description=WebDAV
After=network.target
[Service]
Type=simple
User=root
ExecStart=/usr/bin/webdav --config /opt/webdav.yml
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
## Contributing ## Contributing
Feel free to open an issue or a pull request. Feel free to open an issue or a pull request.
-8
View File
@@ -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]
+12 -30
View File
@@ -10,23 +10,20 @@ import (
"strings" "strings"
"syscall" "syscall"
"github.com/hacdias/webdav/v4/lib" "github.com/hacdias/webdav/v5/lib"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore"
) )
func init() { func init() {
flags := rootCmd.Flags() flags := rootCmd.Flags()
flags.StringP("config", "c", "", "config file path") flags.StringP("config", "c", "", "config file path")
flags.BoolP("tls", "t", false, "enable TLS") flags.StringP("address", "a", lib.DefaultAddress, "address to listen on")
flags.Bool("auth", false, "enable authentication") flags.IntP("port", "p", lib.DefaultPort, "port to listen on")
flags.String("cert", "cert.pem", "path to TLS certificate") flags.BoolP("tls", "t", lib.DefaultTLS, "enable TLS")
flags.String("key", "key.pem", "path to TLS key") flags.String("cert", lib.DefaultCert, "path to TLS certificate")
flags.StringP("address", "a", "0.0.0.0", "address to listen on") flags.String("key", lib.DefaultKey, "path to TLS key")
flags.StringP("port", "p", "0", "port to listen on") flags.StringP("prefix", "P", lib.DefaultPrefix, "URL path prefix")
flags.StringP("prefix", "P", "/", "URL path prefix")
flags.String("log_format", "console", "logging format")
} }
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@@ -58,14 +55,15 @@ set WD_CERT.`,
return err return err
} }
// Create HTTP handler from the config // Setup the logger based on the configuration
handler, err := lib.NewHandler(cfg) logger, err := cfg.GetLogger()
if err != nil { if err != nil {
return err return err
} }
zap.ReplaceGlobals(logger)
// Setup the logger based on the configuration // Create HTTP handler from the config
err = setupLogger(cfg) handler, err := lib.NewHandler(cfg)
if err != nil { if err != nil {
return err return err
} }
@@ -127,19 +125,3 @@ func getListener(cfg *lib.Config) (net.Listener, error) {
return net.Listen(network, address) 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
}
+3 -3
View File
@@ -1,13 +1,15 @@
module github.com/hacdias/webdav/v4 module github.com/hacdias/webdav/v5
go 1.22 go 1.22
require ( require (
github.com/go-viper/mapstructure/v2 v2.0.0
github.com/rs/cors v1.11.0 github.com/rs/cors v1.11.0
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.19.0 github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
github.com/studio-b12/gowebdav v0.9.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.25.0 golang.org/x/crypto v0.25.0
golang.org/x/net v0.27.0 golang.org/x/net v0.27.0
@@ -35,5 +37,3 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
retract v4.1.0
+4
View File
@@ -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/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 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAprG2mQfMfc=
github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 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/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
@@ -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.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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 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=
+84 -38
View File
@@ -6,24 +6,35 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/go-viper/mapstructure/v2"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
const (
DefaultTLS = false
DefaultCert = "cert.pem"
DefaultKey = "key.pem"
DefaultAddress = "0.0.0.0"
DefaultPort = 6065
DefaultPrefix = "/"
) )
type Config struct { type Config struct {
Permissions `mapstructure:",squash"` UserPermissions `mapstructure:",squash"`
Debug bool Debug bool
Address string Address string
Port int Port int
TLS bool TLS bool
Cert string Cert string
Key string Key string
Prefix string Prefix string
NoSniff bool NoSniff bool
LogFormat string Log Log
Auth bool CORS CORS
CORS CORS Users []User
Users []User
} }
func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) { func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
@@ -35,11 +46,6 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = v.BindPFlag("LogFormat", flags.Lookup("log_format"))
if err != nil {
return nil, err
}
} }
// Configuration file settings // Configuration file settings
@@ -54,11 +60,29 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
v.SetEnvPrefix("wd") v.SetEnvPrefix("wd")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv() 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 // Defaults shared with flags
v.SetDefault("CORS.AllowedHeaders", []string{"*"}) v.SetDefault("TLS", DefaultTLS)
v.SetDefault("CORS.AllowedHosts", []string{"*"}) v.SetDefault("Cert", DefaultCert)
v.SetDefault("CORS.AllowedMethods", []string{"*"}) v.SetDefault("Key", DefaultKey)
v.SetDefault("Address", DefaultAddress)
v.SetDefault("Port", DefaultPort)
v.SetDefault("Prefix", DefaultPrefix)
// Other defaults
v.SetDefault("Directory", ".")
v.SetDefault("Permissions", "R")
v.SetDefault("Debug", false)
v.SetDefault("NoSniff", 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{"*"})
// Read and unmarshal configuration // Read and unmarshal configuration
err := v.ReadInConfig() err := v.ReadInConfig()
@@ -69,19 +93,23 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
} }
cfg := &Config{} cfg := &Config{}
err = v.Unmarshal(cfg) err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
mapstructure.TextUnmarshallerHookFunc(),
)))
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Cascade user settings // Cascade user settings
for i := range cfg.Users { for i := range cfg.Users {
if !v.IsSet(fmt.Sprintf("Users.%d.Scope", i)) { if !v.IsSet(fmt.Sprintf("Users.%d.Directory", i)) {
cfg.Users[i].Scope = cfg.Scope cfg.Users[i].Directory = cfg.Directory
} }
if !v.IsSet(fmt.Sprintf("Users.%d.Modify", i)) { if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
cfg.Users[i].Modify = cfg.Modify cfg.Users[i].Permissions = cfg.Permissions
} }
if !v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) { if !v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) {
@@ -100,12 +128,9 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
func (c *Config) Validate() error { func (c *Config) Validate() error {
var err error var err error
if c.Auth && len(c.Users) == 0 { c.Directory, err = filepath.Abs(c.Directory)
return errors.New("invalid config: auth cannot be enabled without users") if err != nil {
} return fmt.Errorf("invalid config: %w", err)
if !c.Auth && len(c.Users) != 0 {
return errors.New("invalid config: auth cannot be disabled with users defined")
} }
if c.TLS { if c.TLS {
@@ -128,7 +153,7 @@ func (c *Config) Validate() error {
} }
} }
err = c.Permissions.Validate() err = c.UserPermissions.Validate()
if err != nil { if err != nil {
return fmt.Errorf("invalid config: %w", err) return fmt.Errorf("invalid config: %w", err)
} }
@@ -143,11 +168,32 @@ func (c *Config) Validate() error {
return nil 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 { type CORS struct {
Enabled bool Enabled bool
Credentials bool Credentials bool
AllowedHeaders []string AllowedHeaders []string `mapstructure:"allowed_headers"`
AllowedHosts []string AllowedHosts []string `mapstructure:"allowed_hosts"`
AllowedMethods []string AllowedMethods []string `mapstructure:"allowed_methods"`
ExposedHeaders []string ExposedHeaders []string `mapstructure:"exposed_headers"`
} }
+176 -21
View File
@@ -5,12 +5,13 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func writeAndParseConfig(t *testing.T, content string) *Config { func writeAndParseConfig(t *testing.T, content, extension string) *Config {
tmpDir := t.TempDir() tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "config.yml") tmpFile := filepath.Join(tmpDir, "config"+extension)
err := os.WriteFile(tmpFile, []byte(content), 0666) err := os.WriteFile(tmpFile, []byte(content), 0666)
require.NoError(t, err) require.NoError(t, err)
@@ -24,9 +25,21 @@ func writeAndParseConfig(t *testing.T, content string) *Config {
func TestConfigDefaults(t *testing.T) { func TestConfigDefaults(t *testing.T) {
t.Parallel() t.Parallel()
cfg := writeAndParseConfig(t, "") cfg := writeAndParseConfig(t, "", ".yml")
require.NoError(t, cfg.Validate()) require.NoError(t, cfg.Validate())
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, "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.AllowedHeaders)
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts) require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedMethods) require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedMethods)
@@ -35,37 +48,179 @@ func TestConfigDefaults(t *testing.T) {
func TestConfigCascade(t *testing.T) { func TestConfigCascade(t *testing.T) {
t.Parallel() t.Parallel()
content := ` check := func(t *testing.T, cfg *Config) {
auth: true require.True(t, cfg.Permissions.Read)
scope: / require.True(t, cfg.Permissions.Create)
modify: true 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].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.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 := `
directory: /
permissions: CR
rules: rules:
- path: /public/access/ - path: /public/access/
modify: true permissions: R
users: users:
- username: admin - username: admin
password: admin password: admin
- username: basic - username: basic
password: basic password: basic
scope: /basic directory: /basic
modify: false permissions: R
rules: []` rules: []`
cfg := writeAndParseConfig(t, content) cfg := writeAndParseConfig(t, content, ".yml")
require.NoError(t, cfg.Validate())
check(t, cfg)
})
t.Run("JSON", func(t *testing.T) {
content := `{
"directory": "/",
"permissions": "CR",
"rules": [
{
"path": "/public/access/",
"permissions": "R"
}
],
"users": [
{
"username": "admin",
"password": "admin"
},
{
"username": "basic",
"password": "basic",
"directory": "/basic",
"permissions": "R",
"rules": []
}
]
}`
cfg := writeAndParseConfig(t, content, ".json")
require.NoError(t, cfg.Validate())
check(t, cfg)
})
t.Run("`TOML", func(t *testing.T) {
content := `
directory = "/"
permissions = "CR"
[[rules]]
path = "/public/access/"
permissions = "R"
[[users]]
username = "admin"
password = "admin"
[[users]]
username = "basic"
password = "basic"
directory = "/basic"
permissions = "R"
rules = []
`
cfg := writeAndParseConfig(t, content, ".toml")
require.NoError(t, cfg.Validate())
check(t, cfg)
})
}
func TestConfigKeys(t *testing.T) {
t.Parallel()
cfg := writeAndParseConfig(t, `
cors:
enabled: true
credentials: true
allowed_headers:
- Depth
allowed_hosts:
- http://localhost:8080
allowed_methods:
- GET
exposed_headers:
- Content-Length
- Content-Range`, ".yml")
require.NoError(t, cfg.Validate()) require.NoError(t, cfg.Validate())
require.True(t, cfg.Modify) require.True(t, cfg.CORS.Enabled)
require.Equal(t, "/", cfg.Scope) require.True(t, cfg.CORS.Credentials)
require.Len(t, cfg.Rules, 1) require.EqualValues(t, []string{"Content-Length", "Content-Range"}, cfg.CORS.ExposedHeaders)
require.EqualValues(t, []string{"Depth"}, cfg.CORS.AllowedHeaders)
require.EqualValues(t, []string{"http://localhost:8080"}, cfg.CORS.AllowedHosts)
require.EqualValues(t, []string{"GET"}, cfg.CORS.AllowedMethods)
}
require.Len(t, cfg.Users, 2) func TestConfigRules(t *testing.T) {
content := `
directory: /
rules:
- regex: '^.+\.js$'
- path: /public/access/`
require.True(t, cfg.Users[0].Modify) cfg := writeAndParseConfig(t, content, ".yaml")
require.Equal(t, "/", cfg.Users[0].Scope) require.NoError(t, cfg.Validate())
require.Len(t, cfg.Users[0].Rules, 1)
require.False(t, cfg.Users[1].Modify) require.Len(t, cfg.Rules, 2)
require.Equal(t, "/basic", cfg.Users[1].Scope)
require.Len(t, cfg.Users[1].Rules, 0) require.Empty(t, cfg.Rules[0].Path)
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].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", ""))
} }
+28 -7
View File
@@ -2,6 +2,8 @@ package lib
import ( import (
"net/http" "net/http"
"net/url"
"os"
"strings" "strings"
"github.com/rs/cors" "github.com/rs/cors"
@@ -15,7 +17,6 @@ type handlerUser struct {
} }
type Handler struct { type Handler struct {
*Config
user *handlerUser user *handlerUser
users map[string]*handlerUser users map[string]*handlerUser
} }
@@ -24,12 +25,12 @@ func NewHandler(c *Config) (http.Handler, error) {
h := &Handler{ h := &Handler{
user: &handlerUser{ user: &handlerUser{
User: User{ User: User{
Permissions: c.Permissions, UserPermissions: c.UserPermissions,
}, },
Handler: webdav.Handler{ Handler: webdav.Handler{
Prefix: c.Prefix, Prefix: c.Prefix,
FileSystem: Dir{ FileSystem: Dir{
Dir: webdav.Dir(c.Scope), Dir: webdav.Dir(c.Directory),
noSniff: c.NoSniff, noSniff: c.NoSniff,
}, },
LockSystem: webdav.NewMemLS(), LockSystem: webdav.NewMemLS(),
@@ -44,7 +45,7 @@ func NewHandler(c *Config) (http.Handler, error) {
Handler: webdav.Handler{ Handler: webdav.Handler{
Prefix: c.Prefix, Prefix: c.Prefix,
FileSystem: Dir{ FileSystem: Dir{
Dir: webdav.Dir(u.Scope), Dir: webdav.Dir(u.Directory),
noSniff: c.NoSniff, noSniff: c.NoSniff,
}, },
LockSystem: webdav.NewMemLS(), LockSystem: webdav.NewMemLS(),
@@ -62,6 +63,10 @@ func NewHandler(c *Config) (http.Handler, error) {
}).Handler(h), nil }).Handler(h), nil
} }
if len(c.Users) == 0 {
zap.L().Warn("unprotected config: no users have been set, so no authentication will be used")
}
return h, nil return h, nil
} }
@@ -70,7 +75,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := h.user user := h.user
// Authentication // Authentication
if h.Auth { if len(h.users) > 0 {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
// Gets the correct user for this request. // Gets the correct user for this request.
@@ -97,7 +102,15 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
// Checks for user permissions relatively to this PATH. // Checks for user permissions relatively to this PATH.
allowed := user.Allowed(r) allowed := user.Allowed(r, func(destination string) bool {
u, err := url.Parse(destination)
if err != nil {
return false
}
path := strings.TrimPrefix(u.Path, user.Prefix)
_, err = user.FileSystem.Stat(r.Context(), path)
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)) zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
@@ -107,7 +120,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
if r.Method == "HEAD" { if r.Method == "HEAD" {
w = newResponseWriterNoBody(w) w = responseWriterNoBody{w}
} }
// Excerpt from RFC4918, section 9.4: // Excerpt from RFC4918, section 9.4:
@@ -131,3 +144,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Runs the WebDAV. // Runs the WebDAV.
user.ServeHTTP(w, r) user.ServeHTTP(w, r)
} }
type responseWriterNoBody struct {
http.ResponseWriter
}
func (w responseWriterNoBody) Write(data []byte) (int, error) {
return 0, nil
}
+314
View File
@@ -0,0 +1,314 @@
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 TestServerRules(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
"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, 4)
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.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")
})
}
+85 -39
View File
@@ -1,36 +1,23 @@
package lib package lib
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
) )
var readMethods = []string{
http.MethodGet,
http.MethodHead,
http.MethodOptions,
"PROPFIND",
}
type Rule struct { type Rule struct {
Regex bool Permissions Permissions
Allow bool Path string
Modify bool Regex *regexp.Regexp
Path string
// TODO: remove Regex and replace by this. It encodes
Regexp *regexp.Regexp `mapstructure:"-"`
} }
func (r *Rule) Validate() error { func (r *Rule) Validate() error {
if r.Regex { if r.Regex != nil && r.Path != "" {
rp, err := regexp.Compile(r.Path) return errors.New("invalid rule: cannot define both regex and path")
if err != nil {
return fmt.Errorf("invalid rule: %w", err)
}
r.Regexp = rp
r.Path = ""
} }
return nil return nil
@@ -38,43 +25,41 @@ func (r *Rule) Validate() error {
// Matches checks if [Rule] matches the given path. // Matches checks if [Rule] matches the given path.
func (r *Rule) Matches(path string) bool { func (r *Rule) Matches(path string) bool {
if r.Regex { if r.Regex != nil {
return r.Regexp.MatchString(path) return r.Regex.MatchString(path)
} }
return strings.HasPrefix(path, r.Path) return strings.HasPrefix(path, r.Path)
} }
type Permissions struct { type UserPermissions struct {
Scope string Directory string
Modify bool Permissions Permissions
Rules []*Rule Rules []*Rule
} }
// 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 Permissions) Allowed(r *http.Request) bool { func (p UserPermissions) Allowed(r *http.Request, destinationExists func(string) bool) 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
}
}
// Go through rules beginning from the last one. // Go through rules beginning from the last one.
for i := len(p.Rules) - 1; i >= 0; i-- { for i := len(p.Rules) - 1; i >= 0; i-- {
rule := p.Rules[i] rule := p.Rules[i]
if rule.Matches(r.URL.Path) { if rule.Matches(r.URL.Path) {
return rule.Allow && (readRequest || rule.Modify) return rule.Permissions.Allowed(r, destinationExists)
} }
} }
return readRequest || p.Modify return p.Permissions.Allowed(r, destinationExists)
} }
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 { 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)
@@ -83,3 +68,64 @@ func (p *Permissions) Validate() error {
return nil 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
}
func (p Permissions) Allowed(r *http.Request, destinationExists 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 destinationExists(r.URL.Path) {
return p.Update
} else {
return p.Create
}
case "COPY", "MOVE":
if destinationExists(r.Header.Get("Destination")) {
return p.Update
} else {
return p.Create
}
case "DELETE":
return p.Delete
case "LOCK", "UNLOCK":
return p.Create || p.Read || p.Update || p.Delete
default:
return false
}
}
-27
View File
@@ -1,27 +0,0 @@
package lib
import "net/http"
var _ http.ResponseWriter = responseWriterNoBody{}
// responseWriterNoBody is a wrapper used to suppress the body of the response
// to a request. Mainly used for HEAD requests.
type responseWriterNoBody struct {
http.ResponseWriter
}
// newResponseWriterNoBody creates a new responseWriterNoBody.
func newResponseWriterNoBody(w http.ResponseWriter) *responseWriterNoBody {
return &responseWriterNoBody{w}
}
// Write suppress the body.
func (w responseWriterNoBody) Write(data []byte) (int, error) {
return 0, nil
}
// WriteHeader writes the header to the http.ResponseWriter.
func (w responseWriterNoBody) WriteHeader(statusCode int) {
w.Header().Del("Content-Length")
w.ResponseWriter.WriteHeader(statusCode)
}
+4 -4
View File
@@ -10,9 +10,9 @@ import (
) )
type User struct { type User struct {
Permissions `mapstructure:",squash"` UserPermissions `mapstructure:",squash"`
Username string Username string
Password string Password string
} }
func (u User) checkPassword(input string) bool { func (u User) checkPassword(input string) bool {
@@ -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) return fmt.Errorf("invalid user %q: %w", u.Username, err)
} }
+1 -1
View File
@@ -1,7 +1,7 @@
package main package main
import ( import (
"github.com/hacdias/webdav/v4/cmd" "github.com/hacdias/webdav/v5/cmd"
) )
func main() { func main() {
-12
View File
@@ -1,12 +0,0 @@
[Unit]
Description=WebDAV server
After=network.target
[Service]
Type=simple
User=root
ExecStart=/usr/bin/webdav --config /opt/webdav.config
Restart=on-failure
[Install]
WantedBy=multi-user.target