mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-22 03:20:41 +08:00
refactor: code cleanup, stricter config validation (#155)
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
name: Build
|
name: Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
@@ -6,6 +7,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
name: Lint
|
name: Lint
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
@@ -6,6 +7,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.22.x"
|
||||||
|
- name: Run test with coverage
|
||||||
|
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
> ⚠️ Disclaimer: this repository is not actively maintained. If you are interested in maintaining it, please [contact me](https://github.com/hacdias/webdav/issues/144).
|
|
||||||
|
|
||||||
# webdav
|
# webdav
|
||||||
|
|
||||||

|
|
||||||
[](https://goreportcard.com/report/hacdias/webdav)
|
[](https://goreportcard.com/report/hacdias/webdav)
|
||||||
[](https://github.com/hacdias/webdav/releases/latest)
|
[](https://github.com/hacdias/webdav/releases/latest)
|
||||||
[](https://hub.docker.com/r/hacdias/webdav)
|
[](https://hub.docker.com/r/hacdias/webdav)
|
||||||
|
|
||||||
|
A simple and standalone [WebDAV](https://en.wikipedia.org/wiki/WebDAV) server.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -13,7 +12,7 @@ Please refer to the [Releases page](https://github.com/hacdias/webdav/releases)
|
|||||||
|
|
||||||
## 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:
|
`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:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Server related settings
|
# Server related settings
|
||||||
@@ -55,7 +54,7 @@ users:
|
|||||||
password: "{env}ENV_PASSWORD"
|
password: "{env}ENV_PASSWORD"
|
||||||
- username: basic
|
- username: basic
|
||||||
password: basic
|
password: basic
|
||||||
modify: false
|
modify: false
|
||||||
rules:
|
rules:
|
||||||
- regex: false
|
- regex: false
|
||||||
allow: false
|
allow: false
|
||||||
@@ -79,17 +78,22 @@ The `allowed_*` properties are optional, the default value for each of them will
|
|||||||
|
|
||||||
### 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 you use a reverse proxy implementation like `Nginx` or `Apache`, please note the following fields to avoid causing `502` errors
|
||||||
```text
|
|
||||||
|
```nginx
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header REMOTE-HOST $remote_addr;
|
proxy_set_header REMOTE-HOST $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Feel free to open an issue or a pull request.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT © [Henrique Dias](https://hacdias.com)
|
[MIT License](LICENSE) © [Henrique Dias](https://hacdias.com)
|
||||||
-224
@@ -1,224 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/hacdias/webdav/v4/lib"
|
|
||||||
"github.com/spf13/pflag"
|
|
||||||
v "github.com/spf13/viper"
|
|
||||||
"golang.org/x/net/webdav"
|
|
||||||
)
|
|
||||||
|
|
||||||
func parseRules(raw []interface{}, defaultModify bool) []*lib.Rule {
|
|
||||||
rules := []*lib.Rule{}
|
|
||||||
|
|
||||||
for _, v := range raw {
|
|
||||||
if r, ok := v.(map[interface{}]interface{}); ok {
|
|
||||||
rule := &lib.Rule{
|
|
||||||
Regex: false,
|
|
||||||
Allow: false,
|
|
||||||
Modify: defaultModify,
|
|
||||||
Path: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
if regex, ok := r["regex"].(bool); ok {
|
|
||||||
rule.Regex = regex
|
|
||||||
}
|
|
||||||
|
|
||||||
if allow, ok := r["allow"].(bool); ok {
|
|
||||||
rule.Allow = allow
|
|
||||||
}
|
|
||||||
|
|
||||||
if modify, ok := r["modify"].(bool); ok {
|
|
||||||
rule.Modify = modify
|
|
||||||
if modify {
|
|
||||||
rule.Allow = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
path, ok := r["path"].(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if rule.Regex {
|
|
||||||
rule.Regexp = regexp.MustCompile(path)
|
|
||||||
} else {
|
|
||||||
rule.Path = path
|
|
||||||
}
|
|
||||||
|
|
||||||
rules = append(rules, rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rules
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadFromEnv(v string) (string, error) {
|
|
||||||
v = strings.TrimPrefix(v, "{env}")
|
|
||||||
if v == "" {
|
|
||||||
return "", errors.New("no environment variable specified")
|
|
||||||
}
|
|
||||||
|
|
||||||
v = os.Getenv(v)
|
|
||||||
if v == "" {
|
|
||||||
return "", errors.New("the environment variable is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseUsers(raw []interface{}, c *lib.Config) {
|
|
||||||
var err error
|
|
||||||
for _, v := range raw {
|
|
||||||
if u, ok := v.(map[interface{}]interface{}); ok {
|
|
||||||
username, ok := u["username"].(string)
|
|
||||||
if !ok {
|
|
||||||
log.Fatal("user needs an username")
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(username, "{env}") {
|
|
||||||
username, err = loadFromEnv(username)
|
|
||||||
checkErr(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
password, ok := u["password"].(string)
|
|
||||||
if !ok {
|
|
||||||
password = ""
|
|
||||||
|
|
||||||
if numPwd, ok := u["password"].(int); ok {
|
|
||||||
password = strconv.Itoa(numPwd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(password, "{env}") {
|
|
||||||
password, err = loadFromEnv(password)
|
|
||||||
checkErr(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user := &lib.User{
|
|
||||||
Username: username,
|
|
||||||
Password: password,
|
|
||||||
Scope: c.User.Scope,
|
|
||||||
Modify: c.User.Modify,
|
|
||||||
Rules: c.User.Rules,
|
|
||||||
}
|
|
||||||
|
|
||||||
if scope, ok := u["scope"].(string); ok {
|
|
||||||
user.Scope = scope
|
|
||||||
}
|
|
||||||
|
|
||||||
if modify, ok := u["modify"].(bool); ok {
|
|
||||||
user.Modify = modify
|
|
||||||
}
|
|
||||||
|
|
||||||
if rules, ok := u["rules"].([]interface{}); ok {
|
|
||||||
user.Rules = append(c.User.Rules, parseRules(rules, user.Modify)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Handler = &webdav.Handler{
|
|
||||||
Prefix: c.User.Handler.Prefix,
|
|
||||||
FileSystem: lib.WebDavDir{
|
|
||||||
Dir: webdav.Dir(user.Scope),
|
|
||||||
NoSniff: c.NoSniff,
|
|
||||||
},
|
|
||||||
LockSystem: webdav.NewMemLS(),
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Users[username] = user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCors(cfg map[string]interface{}, c *lib.Config) {
|
|
||||||
cors := lib.CorsCfg{
|
|
||||||
Enabled: cfg["enabled"].(bool),
|
|
||||||
Credentials: cfg["credentials"].(bool),
|
|
||||||
}
|
|
||||||
|
|
||||||
cors.AllowedHeaders = corsProperty("allowed_headers", cfg)
|
|
||||||
cors.AllowedHosts = corsProperty("allowed_hosts", cfg)
|
|
||||||
cors.AllowedMethods = corsProperty("allowed_methods", cfg)
|
|
||||||
cors.ExposedHeaders = corsProperty("exposed_headers", cfg)
|
|
||||||
|
|
||||||
c.Cors = cors
|
|
||||||
}
|
|
||||||
|
|
||||||
func corsProperty(property string, cfg map[string]interface{}) []string {
|
|
||||||
var def []string
|
|
||||||
|
|
||||||
if property == "exposed_headers" {
|
|
||||||
def = []string{}
|
|
||||||
} else {
|
|
||||||
def = []string{"*"}
|
|
||||||
}
|
|
||||||
|
|
||||||
if allowed, ok := cfg[property].([]interface{}); ok {
|
|
||||||
items := make([]string, len(allowed))
|
|
||||||
|
|
||||||
for idx, a := range allowed {
|
|
||||||
items[idx] = a.(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) == 0 {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
func readConfig(flags *pflag.FlagSet) *lib.Config {
|
|
||||||
cfg := &lib.Config{
|
|
||||||
User: &lib.User{
|
|
||||||
Scope: getOpt(flags, "scope"),
|
|
||||||
Modify: getOptB(flags, "modify"),
|
|
||||||
Rules: []*lib.Rule{},
|
|
||||||
Handler: &webdav.Handler{
|
|
||||||
Prefix: getOpt(flags, "prefix"),
|
|
||||||
FileSystem: lib.WebDavDir{
|
|
||||||
Dir: webdav.Dir(getOpt(flags, "scope")),
|
|
||||||
NoSniff: getOptB(flags, "nosniff"),
|
|
||||||
},
|
|
||||||
LockSystem: webdav.NewMemLS(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Debug: getOptB(flags, "debug"),
|
|
||||||
Auth: getOptB(flags, "auth"),
|
|
||||||
NoSniff: getOptB(flags, "nosniff"),
|
|
||||||
Cors: lib.CorsCfg{
|
|
||||||
Enabled: false,
|
|
||||||
Credentials: false,
|
|
||||||
},
|
|
||||||
Users: map[string]*lib.User{},
|
|
||||||
LogFormat: getOpt(flags, "log_format"),
|
|
||||||
}
|
|
||||||
|
|
||||||
rawRules := v.Get("rules")
|
|
||||||
if rules, ok := rawRules.([]interface{}); ok {
|
|
||||||
cfg.User.Rules = parseRules(rules, cfg.User.Modify)
|
|
||||||
}
|
|
||||||
|
|
||||||
rawUsers := v.Get("users")
|
|
||||||
if users, ok := rawUsers.([]interface{}); ok {
|
|
||||||
parseUsers(users, cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
rawCors := v.Get("cors")
|
|
||||||
if cors, ok := rawCors.(map[string]interface{}); ok {
|
|
||||||
parseCors(cors, cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cfg.Users) != 0 && !cfg.Auth {
|
|
||||||
log.Print("Users will be ignored due to auth=false")
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
+84
-82
@@ -1,7 +1,8 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -9,27 +10,21 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/hacdias/webdav/v4/lib"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
v "github.com/spf13/viper"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"go.uber.org/zap/zapcore"
|
"go.uber.org/zap/zapcore"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
cfgFile string
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
cobra.OnInitialize(initConfig)
|
|
||||||
|
|
||||||
flags := rootCmd.Flags()
|
flags := rootCmd.Flags()
|
||||||
flags.StringVarP(&cfgFile, "config", "c", "", "config file path")
|
flags.StringP("config", "c", "", "config file path")
|
||||||
flags.BoolP("tls", "t", false, "enable tls")
|
flags.BoolP("tls", "t", false, "enable TLS")
|
||||||
flags.Bool("auth", true, "enable auth")
|
flags.Bool("auth", false, "enable authentication")
|
||||||
flags.String("cert", "cert.pem", "TLS certificate")
|
flags.String("cert", "cert.pem", "path to TLS certificate")
|
||||||
flags.String("key", "key.pem", "TLS key")
|
flags.String("key", "key.pem", "path to TLS key")
|
||||||
flags.StringP("address", "a", "0.0.0.0", "address to listen to")
|
flags.StringP("address", "a", "0.0.0.0", "address to listen on")
|
||||||
flags.StringP("port", "p", "0", "port to listen to")
|
flags.StringP("port", "p", "0", "port to listen on")
|
||||||
flags.StringP("prefix", "P", "/", "URL path prefix")
|
flags.StringP("prefix", "P", "/", "URL path prefix")
|
||||||
flags.String("log_format", "console", "logging format")
|
flags.String("log_format", "console", "logging format")
|
||||||
}
|
}
|
||||||
@@ -53,91 +48,98 @@ The precedence of the configuration values are as follows:
|
|||||||
The environment variables are prefixed by "WD_" followed by the option
|
The environment variables are prefixed by "WD_" followed by the option
|
||||||
name in caps. So to set "cert" via an env variable, you should
|
name in caps. So to set "cert" via an env variable, you should
|
||||||
set WD_CERT.`,
|
set WD_CERT.`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
flags := cmd.Flags()
|
flags := cmd.Flags()
|
||||||
|
|
||||||
cfg := readConfig(flags)
|
cfgFilename, _ := flags.GetString("config")
|
||||||
|
|
||||||
// Build address and listener
|
|
||||||
laddr := getOpt(flags, "address")
|
|
||||||
var lnet string
|
|
||||||
if strings.HasPrefix(laddr, "unix:") {
|
|
||||||
laddr = laddr[5:]
|
|
||||||
lnet = "unix"
|
|
||||||
} else {
|
|
||||||
laddr = laddr + ":" + getOpt(flags, "port")
|
|
||||||
lnet = "tcp"
|
|
||||||
}
|
|
||||||
listener, err := net.Listen(lnet, laddr)
|
|
||||||
|
|
||||||
sigc := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
|
|
||||||
|
|
||||||
go func(c chan os.Signal) {
|
|
||||||
// Wait for a SIGINT or SIGKILL:
|
|
||||||
sig := <-c
|
|
||||||
log.Printf("Caught signal %s: shutting down.", sig)
|
|
||||||
// Stop listening (and unlink the socket if unix type):
|
|
||||||
listener.Close()
|
|
||||||
// And we're done:
|
|
||||||
os.Exit(0)
|
|
||||||
}(sigc)
|
|
||||||
|
|
||||||
|
cfg, err := lib.ParseConfig(cfgFilename, flags)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
loggerConfig := zap.NewProductionConfig()
|
|
||||||
loggerConfig.DisableCaller = true
|
// Create HTTP handler from the config
|
||||||
if cfg.Debug {
|
handler, err := lib.NewHandler(cfg)
|
||||||
loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
|
||||||
}
|
|
||||||
loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
|
||||||
loggerConfig.Encoding = cfg.LogFormat
|
|
||||||
logger, err := loggerConfig.Build()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// if we fail to configure proper logging, then the user has deliberately
|
return err
|
||||||
// misconfigured the logger. Abort.
|
|
||||||
panic(err)
|
|
||||||
}
|
}
|
||||||
zap.ReplaceGlobals(logger)
|
|
||||||
|
// Setup the logger based on the configuration
|
||||||
|
err = setupLogger(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
// Flush the logger at the end
|
||||||
_ = zap.L().Sync()
|
_ = zap.L().Sync()
|
||||||
}()
|
}()
|
||||||
// Tell the user the port in which is listening.
|
|
||||||
zap.L().Info("Listening", zap.String("address", listener.Addr().String()))
|
|
||||||
|
|
||||||
// Starts the server.
|
// Build listener
|
||||||
if getOptB(flags, "tls") {
|
listener, err := getListener(cfg)
|
||||||
if err := http.ServeTLS(listener, cfg, getOpt(flags, "cert"), getOpt(flags, "key")); err != nil {
|
if err != nil {
|
||||||
zap.L().Fatal("shutting server", zap.Error(err))
|
return err
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := http.Serve(listener, cfg); err != nil {
|
|
||||||
zap.L().Fatal("shutting server", zap.Error(err))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trap exiting signals
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
zap.L().Info("listening", zap.String("address", listener.Addr().String()))
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if cfg.TLS {
|
||||||
|
err = http.ServeTLS(listener, handler, cfg.Cert, cfg.Key)
|
||||||
|
} else {
|
||||||
|
err = http.Serve(listener, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
zap.L().Error("failed to start server", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
quit <- os.Interrupt
|
||||||
|
}()
|
||||||
|
|
||||||
|
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||||
|
signal := <-quit
|
||||||
|
|
||||||
|
zap.L().Info("caught signal, shutting down", zap.Stringer("signal", signal))
|
||||||
|
_ = listener.Close()
|
||||||
|
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func initConfig() {
|
func getListener(cfg *lib.Config) (net.Listener, error) {
|
||||||
if cfgFile == "" {
|
var (
|
||||||
v.AddConfigPath(".")
|
address string
|
||||||
v.AddConfigPath("/etc/webdav/")
|
network string
|
||||||
v.SetConfigName("config")
|
)
|
||||||
|
|
||||||
|
if strings.HasPrefix(cfg.Address, "unix:") {
|
||||||
|
address = cfg.Address[5:]
|
||||||
|
network = "unix"
|
||||||
} else {
|
} else {
|
||||||
v.SetConfigFile(cfgFile)
|
address = fmt.Sprintf("%s:%d", cfg.Address, cfg.Port)
|
||||||
|
network = "tcp"
|
||||||
}
|
}
|
||||||
|
|
||||||
v.SetEnvPrefix("WD")
|
return net.Listen(network, address)
|
||||||
v.AutomaticEnv()
|
}
|
||||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
|
||||||
|
|
||||||
if err := v.ReadInConfig(); err != nil {
|
func setupLogger(cfg *lib.Config) error {
|
||||||
if _, ok := err.(v.ConfigParseError); ok {
|
loggerConfig := zap.NewProductionConfig()
|
||||||
panic(err)
|
loggerConfig.DisableCaller = true
|
||||||
}
|
if cfg.Debug {
|
||||||
cfgFile = "No config file used"
|
loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
||||||
} else {
|
|
||||||
cfgFile = "Using config file: " + v.ConfigFileUsed()
|
|
||||||
}
|
}
|
||||||
|
loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||||
|
loggerConfig.Encoding = cfg.LogFormat
|
||||||
|
logger, err := loggerConfig.Build()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
zap.ReplaceGlobals(logger)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ func init() {
|
|||||||
Use: "version",
|
Use: "version",
|
||||||
Short: "Print the version number",
|
Short: "Print the version number",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
fmt.Printf("WebDAV version: %q", version)
|
fmt.Printf("WebDAV version: %s\n", version)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/spf13/pflag"
|
|
||||||
v "github.com/spf13/viper"
|
|
||||||
)
|
|
||||||
|
|
||||||
// getOption returns a parameter as a string.
|
|
||||||
//
|
|
||||||
// NOTE: we could simply bind the flags to viper and use IsSet.
|
|
||||||
// Although there is a bug on Viper that always returns true on IsSet
|
|
||||||
// if a flag is binded. Our alternative way is to manually check
|
|
||||||
// the flag and then the value from env/config/gotten by viper.
|
|
||||||
// https://github.com/spf13/viper/pull/331
|
|
||||||
func getOpt(flags *pflag.FlagSet, key string) string {
|
|
||||||
value, _ := flags.GetString(key)
|
|
||||||
|
|
||||||
// If set on Flags, use it.
|
|
||||||
if flags.Changed(key) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
// If set through viper (env, config), return it.
|
|
||||||
if v.IsSet(key) {
|
|
||||||
return v.GetString(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise use default value on flags.
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOptB(flags *pflag.FlagSet, key string) bool {
|
|
||||||
value, _ := flags.GetBool(key)
|
|
||||||
|
|
||||||
// If set on Flags, use it.
|
|
||||||
if flags.Changed(key) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
// If set through viper (env, config), return it.
|
|
||||||
if v.IsSet(key) {
|
|
||||||
return v.GetBool(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise use default value on flags.
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkErr(err error) {
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,21 +3,25 @@ module github.com/hacdias/webdav/v4
|
|||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
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
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.6.0 // indirect
|
github.com/sagikazarmark/locafero v0.6.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
|
||||||
|
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk=
|
github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk=
|
||||||
github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0=
|
github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0=
|
||||||
|
|||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Permissions `mapstructure:",squash"`
|
||||||
|
Debug bool
|
||||||
|
Address string
|
||||||
|
Port int
|
||||||
|
TLS bool
|
||||||
|
Cert string
|
||||||
|
Key string
|
||||||
|
Prefix string
|
||||||
|
NoSniff bool
|
||||||
|
LogFormat string
|
||||||
|
Auth bool
|
||||||
|
CORS CORS
|
||||||
|
Users []User
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||||
|
v := viper.New()
|
||||||
|
|
||||||
|
// Configure flags bindings
|
||||||
|
if flags != nil {
|
||||||
|
err := v.BindPFlags(flags)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = v.BindPFlag("LogFormat", flags.Lookup("log_format"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration file settings
|
||||||
|
v.AddConfigPath(".")
|
||||||
|
v.AddConfigPath("/etc/webdav/")
|
||||||
|
v.SetConfigName("config")
|
||||||
|
if filename != "" {
|
||||||
|
v.SetConfigFile(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment settings
|
||||||
|
v.SetEnvPrefix("wd")
|
||||||
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||||
|
v.AutomaticEnv()
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
v.SetDefault("CORS.AllowedHeaders", []string{"*"})
|
||||||
|
v.SetDefault("CORS.AllowedHosts", []string{"*"})
|
||||||
|
v.SetDefault("CORS.AllowedMethods", []string{"*"})
|
||||||
|
|
||||||
|
// Read and unmarshal configuration
|
||||||
|
err := v.ReadInConfig()
|
||||||
|
if err != nil {
|
||||||
|
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{}
|
||||||
|
err = v.Unmarshal(cfg)
|
||||||
|
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.Modify", i)) {
|
||||||
|
cfg.Users[i].Modify = cfg.Modify
|
||||||
|
}
|
||||||
|
|
||||||
|
if !v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) {
|
||||||
|
cfg.Users[i].Rules = cfg.Rules
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.TLS {
|
||||||
|
if c.Cert == "" {
|
||||||
|
return errors.New("invalid config: Cert must be defined if TLS is activated")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Key == "" {
|
||||||
|
return errors.New("invalid config: Key must be defined if TLS is activated")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Cert, err = filepath.Abs(c.Cert)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Key, err = filepath.Abs(c.Key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.Permissions.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range c.Users {
|
||||||
|
err := u.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type CORS struct {
|
||||||
|
Enabled bool
|
||||||
|
Credentials bool
|
||||||
|
AllowedHeaders []string
|
||||||
|
AllowedHosts []string
|
||||||
|
AllowedMethods []string
|
||||||
|
ExposedHeaders []string
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeAndParseConfig(t *testing.T, content string) *Config {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(tmpFile, []byte(content), 0666)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg, err := ParseConfig(tmpFile, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := writeAndParseConfig(t, "")
|
||||||
|
require.NoError(t, cfg.Validate())
|
||||||
|
|
||||||
|
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHeaders)
|
||||||
|
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
|
||||||
|
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedMethods)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigCascade(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
content := `
|
||||||
|
auth: true
|
||||||
|
scope: /
|
||||||
|
modify: true
|
||||||
|
rules:
|
||||||
|
- path: /public/access/
|
||||||
|
modify: true
|
||||||
|
|
||||||
|
users:
|
||||||
|
- username: admin
|
||||||
|
password: admin
|
||||||
|
- username: basic
|
||||||
|
password: basic
|
||||||
|
scope: /basic
|
||||||
|
modify: false
|
||||||
|
rules: []`
|
||||||
|
|
||||||
|
cfg := writeAndParseConfig(t, content)
|
||||||
|
require.NoError(t, cfg.Validate())
|
||||||
|
|
||||||
|
require.True(t, cfg.Modify)
|
||||||
|
require.Equal(t, "/", cfg.Scope)
|
||||||
|
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.Len(t, cfg.Users[0].Rules, 1)
|
||||||
|
|
||||||
|
require.False(t, cfg.Users[1].Modify)
|
||||||
|
require.Equal(t, "/basic", cfg.Users[1].Scope)
|
||||||
|
require.Len(t, cfg.Users[1].Rules, 0)
|
||||||
|
}
|
||||||
+40
-41
@@ -9,12 +9,44 @@ import (
|
|||||||
"golang.org/x/net/webdav"
|
"golang.org/x/net/webdav"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NoSniffFileInfo wraps any generic FileInfo interface and bypasses mime type sniffing.
|
type Dir struct {
|
||||||
type NoSniffFileInfo struct {
|
webdav.Dir
|
||||||
|
noSniff bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Dir) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
||||||
|
// Skip wrapping if NoSniff is off
|
||||||
|
if !d.noSniff {
|
||||||
|
return d.Dir.Stat(ctx, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := d.Dir.Stat(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return noSniffFileInfo{info}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Dir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||||
|
// Skip wrapping if NoSniff is off
|
||||||
|
if !d.noSniff {
|
||||||
|
return d.Dir.OpenFile(ctx, name, flag, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := d.Dir.OpenFile(ctx, name, flag, perm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return noSniffFile{File: file}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type noSniffFileInfo struct {
|
||||||
os.FileInfo
|
os.FileInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w NoSniffFileInfo) ContentType(ctx context.Context) (contentType string, err error) {
|
func (w noSniffFileInfo) ContentType(ctx context.Context) (contentType string, err error) {
|
||||||
if mimeType := mime.TypeByExtension(path.Ext(w.FileInfo.Name())); mimeType != "" {
|
if mimeType := mime.TypeByExtension(path.Ext(w.FileInfo.Name())); mimeType != "" {
|
||||||
// We can figure out the mime from the extension.
|
// We can figure out the mime from the extension.
|
||||||
return mimeType, nil
|
return mimeType, nil
|
||||||
@@ -24,60 +56,27 @@ func (w NoSniffFileInfo) ContentType(ctx context.Context) (contentType string, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebDavDir struct {
|
type noSniffFile struct {
|
||||||
webdav.Dir
|
|
||||||
NoSniff bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d WebDavDir) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
|
||||||
// Skip wrapping if NoSniff is off
|
|
||||||
if !d.NoSniff {
|
|
||||||
return d.Dir.Stat(ctx, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := d.Dir.Stat(ctx, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return NoSniffFileInfo{info}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d WebDavDir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
|
||||||
// Skip wrapping if NoSniff is off
|
|
||||||
if !d.NoSniff {
|
|
||||||
return d.Dir.OpenFile(ctx, name, flag, perm)
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := d.Dir.OpenFile(ctx, name, flag, perm)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return WebDavFile{File: file}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type WebDavFile struct {
|
|
||||||
webdav.File
|
webdav.File
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f WebDavFile) Stat() (os.FileInfo, error) {
|
func (f noSniffFile) Stat() (os.FileInfo, error) {
|
||||||
info, err := f.File.Stat()
|
info, err := f.File.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return NoSniffFileInfo{info}, nil
|
return noSniffFileInfo{info}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f WebDavFile) Readdir(count int) (fis []os.FileInfo, err error) {
|
func (f noSniffFile) Readdir(count int) (fis []os.FileInfo, err error) {
|
||||||
fis, err = f.File.Readdir(count)
|
fis, err = f.File.Readdir(count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range fis {
|
for i := range fis {
|
||||||
fis[i] = NoSniffFileInfo{fis[i]}
|
fis[i] = noSniffFileInfo{fis[i]}
|
||||||
}
|
}
|
||||||
return fis, nil
|
return fis, nil
|
||||||
}
|
}
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rs/cors"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/net/webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
type handlerUser struct {
|
||||||
|
User
|
||||||
|
webdav.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
*Config
|
||||||
|
user *handlerUser
|
||||||
|
users map[string]*handlerUser
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(c *Config) (http.Handler, error) {
|
||||||
|
h := &Handler{
|
||||||
|
user: &handlerUser{
|
||||||
|
User: User{
|
||||||
|
Permissions: c.Permissions,
|
||||||
|
},
|
||||||
|
Handler: webdav.Handler{
|
||||||
|
Prefix: c.Prefix,
|
||||||
|
FileSystem: Dir{
|
||||||
|
Dir: webdav.Dir(c.Scope),
|
||||||
|
noSniff: c.NoSniff,
|
||||||
|
},
|
||||||
|
LockSystem: webdav.NewMemLS(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: map[string]*handlerUser{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range c.Users {
|
||||||
|
h.users[u.Username] = &handlerUser{
|
||||||
|
User: u,
|
||||||
|
Handler: webdav.Handler{
|
||||||
|
Prefix: c.Prefix,
|
||||||
|
FileSystem: Dir{
|
||||||
|
Dir: webdav.Dir(u.Scope),
|
||||||
|
noSniff: c.NoSniff,
|
||||||
|
},
|
||||||
|
LockSystem: webdav.NewMemLS(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CORS.Enabled {
|
||||||
|
return cors.New(cors.Options{
|
||||||
|
AllowCredentials: c.CORS.Credentials,
|
||||||
|
AllowedOrigins: c.CORS.AllowedHosts,
|
||||||
|
AllowedMethods: c.CORS.AllowedMethods,
|
||||||
|
AllowedHeaders: c.CORS.AllowedHeaders,
|
||||||
|
OptionsPassthrough: false,
|
||||||
|
}).Handler(h), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := h.user
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
if h.Auth {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||||
|
|
||||||
|
// Gets the correct user for this request.
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
zap.L().Info("login attempt", zap.String("username", username), zap.String("remote_address", r.RemoteAddr))
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, ok = h.users[username]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !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
|
||||||
|
}
|
||||||
|
|
||||||
|
zap.L().Info("user authorized", zap.String("username", username))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks for user permissions relatively to this PATH.
|
||||||
|
allowed := user.Allowed(r)
|
||||||
|
|
||||||
|
zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
|
||||||
|
|
||||||
|
if !allowed {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "HEAD" {
|
||||||
|
w = newResponseWriterNoBody(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excerpt from RFC4918, section 9.4:
|
||||||
|
//
|
||||||
|
// GET, when applied to a collection, may return the contents of an
|
||||||
|
// "index.html" resource, a human-readable view of the contents of
|
||||||
|
// the collection, or something else altogether.
|
||||||
|
//
|
||||||
|
// Get, when applied to collection, will return the same as PROPFIND method.
|
||||||
|
if r.Method == "GET" && strings.HasPrefix(r.URL.Path, user.Prefix) {
|
||||||
|
info, err := user.FileSystem.Stat(r.Context(), strings.TrimPrefix(r.URL.Path, user.Prefix))
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
r.Method = "PROPFIND"
|
||||||
|
|
||||||
|
if r.Header.Get("Depth") == "" {
|
||||||
|
r.Header.Add("Depth", "1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs the WebDAV.
|
||||||
|
user.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"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:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches checks if [Rule] matches the given path.
|
||||||
|
func (r *Rule) Matches(path string) bool {
|
||||||
|
if r.Regex {
|
||||||
|
return r.Regexp.MatchString(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.HasPrefix(path, r.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Permissions struct {
|
||||||
|
Scope string
|
||||||
|
Modify bool
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go through rules beginning from the last one.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return readRequest || p.Modify
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Permissions) Validate() error {
|
||||||
|
for _, r := range p.Rules {
|
||||||
|
if err := r.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid permissions: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
Executable → Regular
+39
-37
@@ -1,50 +1,52 @@
|
|||||||
package lib
|
package lib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/net/webdav"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rule is a disallow/allow rule.
|
|
||||||
type Rule struct {
|
|
||||||
Regex bool
|
|
||||||
Allow bool
|
|
||||||
Modify bool
|
|
||||||
Path string
|
|
||||||
Regexp *regexp.Regexp
|
|
||||||
}
|
|
||||||
|
|
||||||
// User contains the settings of each user.
|
|
||||||
type User struct {
|
type User struct {
|
||||||
Username string
|
Permissions `mapstructure:",squash"`
|
||||||
Password string
|
Username string
|
||||||
Scope string
|
Password string
|
||||||
Modify bool
|
|
||||||
Rules []*Rule
|
|
||||||
Handler *webdav.Handler
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allowed checks if the user has permission to access a directory/file
|
func (u User) checkPassword(input string) bool {
|
||||||
func (u User) Allowed(url string, noModification bool) bool {
|
if strings.HasPrefix(u.Password, "{bcrypt}") {
|
||||||
var rule *Rule
|
savedPassword := strings.TrimPrefix(u.Password, "{bcrypt}")
|
||||||
i := len(u.Rules) - 1
|
return bcrypt.CompareHashAndPassword([]byte(savedPassword), []byte(input)) == nil
|
||||||
|
|
||||||
for i >= 0 {
|
|
||||||
rule = u.Rules[i]
|
|
||||||
|
|
||||||
isAllowed := rule.Allow && (noModification || rule.Modify)
|
|
||||||
if rule.Regex {
|
|
||||||
if rule.Regexp.MatchString(url) {
|
|
||||||
return isAllowed
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(url, rule.Path) {
|
|
||||||
return isAllowed
|
|
||||||
}
|
|
||||||
|
|
||||||
i--
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return noModification || u.Modify
|
return u.Password == input
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) Validate() error {
|
||||||
|
if u.Username == "" {
|
||||||
|
return errors.New("invalid user: username must be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Password == "" {
|
||||||
|
return fmt.Errorf("invalid user %q: password must be set", u.Username)
|
||||||
|
} else if strings.HasPrefix(u.Password, "{env}") {
|
||||||
|
|
||||||
|
env := strings.TrimPrefix(u.Password, "{env}")
|
||||||
|
if env == "" {
|
||||||
|
return fmt.Errorf("invalid user %q: password environment variable not set", u.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Password = os.Getenv(env)
|
||||||
|
if u.Password == "" {
|
||||||
|
return fmt.Errorf("invalid user %q: password environment variable is empty", u.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.Permissions.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid user %q: %w", u.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package lib
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func checkPassword(saved, input string) bool {
|
|
||||||
if strings.HasPrefix(saved, "{bcrypt}") {
|
|
||||||
savedPassword := strings.TrimPrefix(saved, "{bcrypt}")
|
|
||||||
return bcrypt.CompareHashAndPassword([]byte(savedPassword), []byte(input)) == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return saved == input
|
|
||||||
}
|
|
||||||
|
|
||||||
func isAllowedHost(allowedHosts []string, origin string) bool {
|
|
||||||
for _, host := range allowedHosts {
|
|
||||||
if host == origin {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
-174
@@ -1,174 +0,0 @@
|
|||||||
package lib
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CorsCfg is the CORS config.
|
|
||||||
type CorsCfg struct {
|
|
||||||
Enabled bool
|
|
||||||
Credentials bool
|
|
||||||
AllowedHeaders []string
|
|
||||||
AllowedHosts []string
|
|
||||||
AllowedMethods []string
|
|
||||||
ExposedHeaders []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config is the configuration of a WebDAV instance.
|
|
||||||
type Config struct {
|
|
||||||
*User
|
|
||||||
Auth bool
|
|
||||||
Debug bool
|
|
||||||
NoSniff bool
|
|
||||||
Cors CorsCfg
|
|
||||||
Users map[string]*User
|
|
||||||
LogFormat string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
|
|
||||||
func (c *Config) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
u := c.User
|
|
||||||
requestOrigin := r.Header.Get("Origin")
|
|
||||||
|
|
||||||
// Add CORS headers before any operation so even on a 401 unauthorized status, CORS will work.
|
|
||||||
if c.Cors.Enabled && requestOrigin != "" {
|
|
||||||
headers := w.Header()
|
|
||||||
|
|
||||||
allowedHeaders := strings.Join(c.Cors.AllowedHeaders, ", ")
|
|
||||||
allowedMethods := strings.Join(c.Cors.AllowedMethods, ", ")
|
|
||||||
exposedHeaders := strings.Join(c.Cors.ExposedHeaders, ", ")
|
|
||||||
|
|
||||||
allowAllHosts := len(c.Cors.AllowedHosts) == 1 && c.Cors.AllowedHosts[0] == "*"
|
|
||||||
allowedHost := isAllowedHost(c.Cors.AllowedHosts, requestOrigin)
|
|
||||||
|
|
||||||
if allowAllHosts {
|
|
||||||
headers.Set("Access-Control-Allow-Origin", "*")
|
|
||||||
} else if allowedHost {
|
|
||||||
headers.Set("Access-Control-Allow-Origin", requestOrigin)
|
|
||||||
}
|
|
||||||
|
|
||||||
if allowAllHosts || allowedHost {
|
|
||||||
headers.Set("Access-Control-Allow-Headers", allowedHeaders)
|
|
||||||
headers.Set("Access-Control-Allow-Methods", allowedMethods)
|
|
||||||
|
|
||||||
if c.Cors.Credentials {
|
|
||||||
headers.Set("Access-Control-Allow-Credentials", "true")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.Cors.ExposedHeaders) > 0 {
|
|
||||||
headers.Set("Access-Control-Expose-Headers", exposedHeaders)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == "OPTIONS" && c.Cors.Enabled && requestOrigin != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authentication
|
|
||||||
if c.Auth {
|
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
|
||||||
|
|
||||||
// Gets the correct user for this request.
|
|
||||||
username, password, ok := r.BasicAuth()
|
|
||||||
zap.L().Info("login attempt", zap.String("username", username), zap.String("remote_address", r.RemoteAddr))
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, ok := c.Users[username]
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !checkPassword(user.Password, password) {
|
|
||||||
zap.L().Info("invalid password", zap.String("username", username), zap.String("remote_address", r.RemoteAddr))
|
|
||||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u = user
|
|
||||||
zap.L().Info("user authorized", zap.String("username", username))
|
|
||||||
} else {
|
|
||||||
// Even if Auth is disabled, we might want to get
|
|
||||||
// the user from the Basic Auth header. Useful for Caddy
|
|
||||||
// plugin implementation.
|
|
||||||
username, _, ok := r.BasicAuth()
|
|
||||||
if ok {
|
|
||||||
if user, ok := c.Users[username]; ok {
|
|
||||||
u = user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checks for user permissions relatively to this PATH.
|
|
||||||
noModification := r.Method == "GET" || r.Method == "HEAD" ||
|
|
||||||
r.Method == "OPTIONS" || r.Method == "PROPFIND"
|
|
||||||
|
|
||||||
allowed := u.Allowed(r.URL.Path, noModification)
|
|
||||||
|
|
||||||
zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
|
|
||||||
|
|
||||||
if !allowed {
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == "HEAD" {
|
|
||||||
w = newResponseWriterNoBody(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Excerpt from RFC4918, section 9.4:
|
|
||||||
//
|
|
||||||
// GET, when applied to a collection, may return the contents of an
|
|
||||||
// "index.html" resource, a human-readable view of the contents of
|
|
||||||
// the collection, or something else altogether.
|
|
||||||
//
|
|
||||||
// Get, when applied to collection, will return the same as PROPFIND method.
|
|
||||||
if r.Method == "GET" && strings.HasPrefix(r.URL.Path, u.Handler.Prefix) {
|
|
||||||
info, err := u.Handler.FileSystem.Stat(context.TODO(), strings.TrimPrefix(r.URL.Path, u.Handler.Prefix))
|
|
||||||
if err == nil && info.IsDir() {
|
|
||||||
r.Method = "PROPFIND"
|
|
||||||
|
|
||||||
if r.Header.Get("Depth") == "" {
|
|
||||||
r.Header.Add("Depth", "1")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs the WebDAV.
|
|
||||||
//u.Handler.LockSystem = webdav.NewMemLS()
|
|
||||||
u.Handler.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// responseWriterNoBody is a wrapper used to suprress 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}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Header executes the Header method from the http.ResponseWriter.
|
|
||||||
func (w responseWriterNoBody) Header() http.Header {
|
|
||||||
return w.ResponseWriter.Header()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write suprresses 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.ResponseWriter.WriteHeader(statusCode)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user