Compare commits

..
21 Commits
Author SHA1 Message Date
Henrique Dias aad8e563a7 feat: add executable's directory as config path 2025-05-20 18:09:52 +02:00
Henrique Dias 8446f9bdae docs: add nginx note about copy/move 2025-05-20 18:06:26 +02:00
Henrique Dias 5226853fca fix: lock files across different users
Fixes #66.
2025-05-17 10:53:07 +02:00
Henrique Dias e350dc989a docs(readme): make it clear that path.rule is relative to user's directory 2025-05-17 10:47:57 +02:00
Henrique Dias 65de8e8578 chore: update dependencies 2025-05-17 09:14:37 +02:00
Henrique Dias f0b5a39d80 ci: go 1.24 2025-03-18 07:53:28 +01:00
Henrique Dias dbd708a76e chore: update dependencies 2025-03-18 07:53:28 +01:00
Henrique Dias f61b7cab3b docs: cleanup comments 2025-02-12 07:42:15 +01:00
Henrique Dias 3cfaa0da3e fix: return correct amount of written bytes 2025-02-12 07:41:21 +01:00
networkException f7b78cd834 fix: support HEAD method on collections 2025-02-12 07:41:09 +01:00
Henrique Dias 4b4e555ed5 fix: bring back the prefix test 2025-01-16 07:10:07 +01:00
Henrique Dias 6aeb3f8a30 fix: refactoring previous fix in attempt to address ongoing issues
See #211 and #215
2025-01-15 19:35:03 +01:00
Henrique Dias 59c5f1343f Revert "fix: rename/copy when using prefix"
This reverts commit 617496c019.
2025-01-15 19:18:40 +01:00
Henrique Dias 617496c019 fix: rename/copy when using prefix 2025-01-04 18:45:20 +01:00
Andrey Denisov 3790d7de08 docs(readme): improve fail2ban description 2024-12-30 20:10:07 +01:00
Henrique Dias eaf42b03e9 feat: add bcrypt command 2024-12-22 10:28:49 +01:00
Henrique Dias dce6010b61 chore: update dependencies 2024-12-21 08:04:50 +01:00
Henrique Dias 88863d7242 ci: update golangci-lint 2024-12-15 18:30:50 +01:00
Henrique Dias 2c96db92aa chore: update dependencies 2024-12-15 18:30:28 +01:00
networkException 79bc17afab feat: add support for systemd listener activation sockets
To support starting a network service on demand and to
support a "least privilege-approach" with regards to the
permission a network service process needs to have, systemd
supports opening a network socket on behalf of the service
and passing it as an open file descriptor.

The service gets notified about open file descriptors for
this purpose as well as metadata such as named listeners
via environment variables.

This patch adds support for prefixing the listen address
passed with --address with "sd-listen-fd:" to access these
file descriptors, taking either a listener name passed using
the `LISTEN_FDNAMES` environment variable or `LISTEN_FD_$n`
for unnamed file descriptiors where `n` is the id of the
descriptor starting at 3 (LISTEN_FD_3).

See sd_listen_fds(3)
2024-12-11 17:29:03 +01:00
Henrique Dias 51b101d3d8 feat: rules behavior 2024-12-07 12:07:12 +01:00
16 changed files with 461 additions and 112 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: "1.23.x" go-version: "1.24.x"
- run: go build . - run: go build .
env: env:
CGO_ENABLED: '0' CGO_ENABLED: '0'
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: "1.23.x" go-version: "1.24.x"
- uses: golangci/golangci-lint-action@v6 - uses: golangci/golangci-lint-action@v6
with: with:
version: "v1.60" version: "v1.64"
+1 -1
View File
@@ -16,6 +16,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: "1.23.x" go-version: "1.24.x"
- name: Run test with coverage - name: Run test with coverage
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.23-alpine3.20 AS build FROM golang:1.24-alpine3.20 AS build
ARG VERSION="untracked" ARG VERSION="untracked"
+39 -9
View File
@@ -49,6 +49,12 @@ docker run \
ghcr.io/hacdias/webdav -c /config.yml ghcr.io/hacdias/webdav -c /config.yml
``` ```
If you are using [fail2ban](#fail2ban-setup), it would be helpful to add the parameters listed below. They will assist in analyzing the log.
```bash
--log-driver journald \
--name webdav \
```
## Configuration ## 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. 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.
@@ -86,9 +92,22 @@ directory: .
# permissions. For example, to allow to read and create, set "RC". Default is "R". # permissions. For example, to allow to read and create, set "RC". Default is "R".
permissions: R permissions: R
# The default permissions rules for users. Default is none. # The default permissions rules for users. Default is none. Rules are applied
# from last to first, that is, the first rule that matches the request, starting
# from the end, will be applied to the request. Rule paths are always relative to
# the user's directory.
rules: [] rules: []
# The behavior of redefining the rules for users. It can be:
# - overwrite: when a user has rules defined, these will overwrite any global
# rules already defined. That is, the global rules are not applicable to the
# user.
# - append: when a user has rules defined, these will be appended to the global
# rules already defined. That is, for this user, their own specific rules will
# be checked first, and then the global rules.
# Default is 'overwrite'.
rulesBehavior: overwrite
# Logging configuration # Logging configuration
log: log:
# Logging format ('console', 'json'). Default is 'console'. # Logging format ('console', 'json'). Default is 'console'.
@@ -127,6 +146,8 @@ users:
- username: admin - username: admin
password: admin password: admin
# Example 'john' user with bcrypt encrypted password, with custom directory. # Example 'john' user with bcrypt encrypted password, with custom directory.
# You can generate a bcrypt-encrypted password by using the 'webdav bcrypt'
# command lint utility.
- username: john - username: john
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi" password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
directory: /another/path directory: /another/path
@@ -138,14 +159,15 @@ users:
# Override default permissions. # Override default permissions.
permissions: CRUD permissions: CRUD
rules: rules:
# With this rule, the user CANNOT access /some/files. # With this rule, the user CANNOT access {user directory}/some/files.
- path: /some/file - path: /some/file
permissions: none permissions: none
# With this rule, the user CAN create, read, update and delete within /public/access. # With this rule, the user CAN create, read, update and delete within
# {user directory}/public/access.
- path: /public/access/ - path: /public/access/
permissions: CRUD permissions: CRUD
# With this rule, the user CAN read and update all files ending with .js. It uses # With this rule, the user CAN read and update all files ending with .js.
# a regular expression. # It uses a regular expression.
- regex: "^.+.js$" - regex: "^.+.js$"
permissions: RU permissions: RU
``` ```
@@ -171,6 +193,14 @@ location / {
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 $host; proxy_set_header Host $host;
proxy_redirect off; proxy_redirect off;
# Ensure COPY and MOVE commands work. Change https://example.com to the
# correct address where the WebDAV server will be deployed at.
set $dest $http_destination;
if ($http_destination ~ "^https://example.com(?<path>(.+))") {
set $dest /$path;
}
proxy_set_header Destination $dest;
} }
``` ```
@@ -209,10 +239,8 @@ before = common.conf
[Definition] [Definition]
# Failregex to match "invalid password" and extract remote_address only # Failregex to match "invalid password" and extract remote_address only
failregex = ^.*invalid password\s*\{.*"remote_address":\s*"<HOST>"\s*\} failregex = ^.*invalid password\s*\{.*"remote_address":\s*"<HOST>:\d+"\s*\}
^.*invalid username\s*\{.*"remote_address":\s*"<HOST>:\d+"\s*\}
# Failregex to match "invalid username" and extract remote_address only (if applicable)
failregex += ^.*invalid username\s*\{.*"remote_address":\s*"<HOST>"\s*\}
ignoreregex = ignoreregex =
``` ```
@@ -237,6 +265,8 @@ ignoreself = false
- Replace `[your_port]` with the port your WebDAV server is running on. - Replace `[your_port]` with the port your WebDAV server is running on.
- Replace `[your_log_path]` with the path to your WebDAV log file. - Replace `[your_log_path]` with the path to your WebDAV log file.
If you use it with Docker and `--log-driver journald`, replace `logpath` with `journalmatch = CONTAINER_NAME=[your_container_name]`
#### Final Steps #### Final Steps
1. Restart Fail2Ban to apply these configurations: 1. Restart Fail2Ban to apply these configurations:
+49
View File
@@ -0,0 +1,49 @@
package cmd
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
)
func init() {
flags := bcryptCmd.Flags()
flags.IntP("cost", "c", bcrypt.DefaultCost, "cost used to generate password, higher cost leads to slower verification times")
rootCmd.AddCommand(bcryptCmd)
}
var bcryptCmd = &cobra.Command{
Use: "bcrypt",
Short: "Generate a bcrypt encrypted password",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cost, err := cmd.Flags().GetInt("cost")
if err != nil {
return err
}
if cost < bcrypt.MinCost {
return fmt.Errorf("given cost cannot be under minimum cost of %d", bcrypt.MinCost)
}
if cost > bcrypt.MaxCost {
return fmt.Errorf("given cost cannot be over maximum cost of %d", bcrypt.MaxCost)
}
pwd := args[0]
if pwd == "" {
return errors.New("password argument must not be empty")
}
hash, err := bcrypt.GenerateFromPassword([]byte(pwd), cost)
if err != nil {
return err
}
fmt.Println(string(hash))
return nil
},
}
+16 -1
View File
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"syscall" "syscall"
"github.com/coreos/go-systemd/v22/activation"
"github.com/hacdias/webdav/v5/lib" "github.com/hacdias/webdav/v5/lib"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"go.uber.org/zap" "go.uber.org/zap"
@@ -115,7 +116,21 @@ func getListener(cfg *lib.Config) (net.Listener, error) {
network string network string
) )
if strings.HasPrefix(cfg.Address, "unix:") { if strings.HasPrefix(cfg.Address, "sd-listen-fd:") {
listeners, err := activation.ListenersWithNames()
if err != nil {
return nil, err
}
address := cfg.Address[13:]
listener, ok := listeners[address]
if !ok || len(listener) < 1 {
return nil, errors.New("unknown sd-listen-fd address '" + address + "'")
}
return listener[0], nil
} else if strings.HasPrefix(cfg.Address, "unix:") {
address = cfg.Address[5:] address = cfg.Address[5:]
network = "unix" network = "unix"
} else { } else {
+15 -20
View File
@@ -1,39 +1,34 @@
module github.com/hacdias/webdav/v5 module github.com/hacdias/webdav/v5
go 1.23 go 1.24.0
require ( require (
github.com/coreos/go-systemd/v22 v22.5.0
github.com/go-viper/mapstructure/v2 v2.2.1 github.com/go-viper/mapstructure/v2 v2.2.1
github.com/rs/cors v1.11.1 github.com/rs/cors v1.11.1
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.6
github.com/spf13/viper v1.19.0 github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.10.0
github.com/studio-b12/gowebdav v0.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.29.0 golang.org/x/crypto v0.38.0
golang.org/x/net v0.31.0 golang.org/x/net v0.40.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fsnotify/fsnotify v1.9.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/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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.9.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
github.com/spf13/afero v1.11.0 // indirect github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cast v1.8.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.25.0 // indirect
golang.org/x/text v0.20.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
) )
+30 -40
View File
@@ -1,29 +1,25 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
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.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
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=
@@ -31,24 +27,22 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/cors v1.11.1/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.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.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 h1:1j1sc9gQnNxbXXM4M/CebPOX4aXYtr7MojAVcN4dHjU=
github.com/studio-b12/gowebdav v0.9.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= 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=
@@ -59,20 +53,16 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+22 -5
View File
@@ -3,6 +3,7 @@ package lib
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -40,7 +41,7 @@ type Config struct {
} }
func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) { func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
v := viper.New() v := viper.NewWithOptions(viper.ExperimentalBindStruct())
// Configure flags bindings // Configure flags bindings
if flags != nil { if flags != nil {
@@ -53,6 +54,10 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
// Configuration file settings // Configuration file settings
v.AddConfigPath(".") v.AddConfigPath(".")
v.AddConfigPath("/etc/webdav/") v.AddConfigPath("/etc/webdav/")
if path, err := os.Executable(); err == nil {
v.AddConfigPath(filepath.Dir(path))
}
v.SetConfigName("config") v.SetConfigName("config")
if filename != "" { if filename != "" {
v.SetConfigFile(filename) v.SetConfigFile(filename)
@@ -62,9 +67,6 @@ 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 shared with flags // Defaults shared with flags
v.SetDefault("TLS", DefaultTLS) v.SetDefault("TLS", DefaultTLS)
@@ -75,6 +77,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
v.SetDefault("Prefix", DefaultPrefix) v.SetDefault("Prefix", DefaultPrefix)
// Other defaults // Other defaults
v.SetDefault("RulesBehavior", RulesOverwrite)
v.SetDefault("Directory", ".") v.SetDefault("Directory", ".")
v.SetDefault("Permissions", "R") v.SetDefault("Permissions", "R")
v.SetDefault("Debug", false) v.SetDefault("Debug", false)
@@ -115,7 +118,21 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
cfg.Users[i].Permissions = cfg.Permissions cfg.Users[i].Permissions = cfg.Permissions
} }
if !v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) { if !v.IsSet(fmt.Sprintf("Users.%d.RulesBehavior", i)) {
cfg.Users[i].RulesBehavior = cfg.RulesBehavior
}
if v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) {
switch cfg.Users[i].RulesBehavior {
case RulesOverwrite:
// Do nothing
case RulesAppend:
rules := append([]*Rule{}, cfg.Rules...)
rules = append(rules, cfg.Users[i].Rules...)
cfg.Users[i].Rules = rules
}
} else {
cfg.Users[i].Rules = cfg.Rules cfg.Users[i].Rules = cfg.Rules
} }
} }
+65
View File
@@ -232,6 +232,71 @@ rules:
require.Nil(t, cfg.Rules[1].Regex) require.Nil(t, cfg.Rules[1].Regex)
}) })
t.Run("Rules Behavior (Default: Overwrite)", func(t *testing.T) {
content := `
directory: /
rules:
- regex: '^.+\.js$'
- path: /public/access/
users:
- username: foo
password: bar
rules:
- path: /private/access/`
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
require.Len(t, cfg.Rules, 2)
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.EqualValues(t, "/public/access/", cfg.Rules[1].Path)
require.Nil(t, cfg.Rules[1].Regex)
require.Len(t, cfg.Users, 1)
require.Len(t, cfg.Users[0].Rules, 1)
require.EqualValues(t, "/private/access/", cfg.Users[0].Rules[0].Path)
})
t.Run("Rules Behavior (Append)", func(t *testing.T) {
content := `
directory: /
rules:
- regex: '^.+\.js$'
- path: /public/access/
rulesBehavior: append
users:
- username: foo
password: bar
rules:
- path: /private/access/`
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
require.Len(t, cfg.Rules, 2)
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.EqualValues(t, "/public/access/", cfg.Rules[1].Path)
require.Nil(t, cfg.Rules[1].Regex)
require.Len(t, cfg.Users, 1)
require.Len(t, cfg.Users[0].Rules, 3)
require.EqualValues(t, cfg.Rules[0], cfg.Users[0].Rules[0])
require.EqualValues(t, cfg.Rules[1], cfg.Users[0].Rules[1])
require.EqualValues(t, "/private/access/", cfg.Users[0].Rules[2].Path)
})
} }
func TestConfigEnv(t *testing.T) { func TestConfigEnv(t *testing.T) {
+24 -18
View File
@@ -2,7 +2,6 @@ package lib
import ( import (
"net/http" "net/http"
"net/url"
"os" "os"
"strings" "strings"
@@ -24,6 +23,8 @@ type Handler struct {
} }
func NewHandler(c *Config) (http.Handler, error) { func NewHandler(c *Config) (http.Handler, error) {
ls := webdav.NewMemLS()
h := &Handler{ h := &Handler{
noPassword: c.NoPassword, noPassword: c.NoPassword,
behindProxy: c.BehindProxy, behindProxy: c.BehindProxy,
@@ -37,7 +38,10 @@ func NewHandler(c *Config) (http.Handler, error) {
Dir: webdav.Dir(c.Directory), Dir: webdav.Dir(c.Directory),
noSniff: c.NoSniff, noSniff: c.NoSniff,
}, },
LockSystem: webdav.NewMemLS(), LockSystem: &lockSystem{
LockSystem: ls,
directory: c.Directory,
},
}, },
}, },
users: map[string]*handlerUser{}, users: map[string]*handlerUser{},
@@ -52,7 +56,10 @@ func NewHandler(c *Config) (http.Handler, error) {
Dir: webdav.Dir(u.Directory), Dir: webdav.Dir(u.Directory),
noSniff: c.NoSniff, noSniff: c.NoSniff,
}, },
LockSystem: webdav.NewMemLS(), LockSystem: &lockSystem{
LockSystem: ls,
directory: u.Directory,
},
}, },
} }
} }
@@ -116,21 +123,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
zap.L().Info("user authorized", zap.String("username", username), zap.String("remote_address", remoteAddr)) zap.L().Info("user authorized", zap.String("username", username), zap.String("remote_address", remoteAddr))
} }
// Cleanup destination header if it's present by stripping out the prefix // Convert the HTTP request into an internal request type
// and only keeping the path. req, err := newRequest(r, h.user.Prefix)
if destination := r.Header.Get("Destination"); destination != "" { if err != nil {
u, err := url.Parse(destination) zap.L().Info("invalid request path or destination", zap.Error(err))
if err == nil { http.Error(w, "Invalid request path or destination", http.StatusBadRequest)
destination = strings.TrimPrefix(u.Path, user.Prefix) return
if !strings.HasPrefix(destination, "/") {
destination = "/" + destination
}
r.Header.Set("Destination", destination)
}
} }
// Checks for user permissions relatively to this PATH. // Checks for user permissions relatively to this PATH.
allowed := user.Allowed(r, func(filename string) bool { allowed := user.Allowed(req, func(filename string) bool {
_, err := user.FileSystem.Stat(r.Context(), filename) _, err := user.FileSystem.Stat(r.Context(), filename)
return !os.IsNotExist(err) return !os.IsNotExist(err)
}) })
@@ -152,8 +154,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// "index.html" resource, a human-readable view of the contents of // "index.html" resource, a human-readable view of the contents of
// the collection, or something else altogether. // the collection, or something else altogether.
// //
// Get, when applied to collection, will return the same as PROPFIND method. // Similarly, since the definition of HEAD is a GET without a response
if r.Method == "GET" && strings.HasPrefix(r.URL.Path, user.Prefix) { // message body, the semantics of HEAD are unmodified when applied to
// collection resources.
//
// GET (or HEAD), when applied to collection, will return the same as PROPFIND method.
if (r.Method == "GET" || r.Method == "HEAD") && strings.HasPrefix(r.URL.Path, user.Prefix) {
info, err := user.FileSystem.Stat(r.Context(), strings.TrimPrefix(r.URL.Path, user.Prefix)) info, err := user.FileSystem.Stat(r.Context(), strings.TrimPrefix(r.URL.Path, user.Prefix))
if err == nil && info.IsDir() { if err == nil && info.IsDir() {
r.Method = "PROPFIND" r.Method = "PROPFIND"
@@ -183,5 +189,5 @@ type responseWriterNoBody struct {
} }
func (w responseWriterNoBody) Write(data []byte) (int, error) { func (w responseWriterNoBody) Write(data []byte) (int, error) {
return 0, nil return len(data), nil
} }
+76
View File
@@ -292,6 +292,82 @@ users:
require.ErrorContains(t, err, "403") require.ErrorContains(t, err, "403")
} }
func TestServerRulesPrefix(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
"bar.js": []byte("foo js"),
"a/foo.js": []byte("foo js"),
"a/foo.txt": []byte("foo txt"),
"b/foo.txt": []byte("foo b"),
"c/a.txt": []byte("b"),
"c/b.txt": []byte("b"),
"c/c.txt": []byte("b"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
prefix: /prefix
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("/prefix")
require.NoError(t, err)
require.Len(t, files, 5)
err = client.Write("/prefix/foo.txt", []byte("new"), 0666)
require.NoError(t, err)
err = client.Write("/prefix/new.txt", []byte("new"), 0666)
require.NoError(t, err)
err = client.Copy("/prefix/bar.js", "/prefix/b/bar.js", false)
require.ErrorContains(t, err, "403")
err = client.Copy("/prefix/bar.js", "/prefix/bar.jsx", false)
require.NoError(t, err)
err = client.Copy("/prefix/b/foo.txt", "/prefix/foo1.txt", false)
require.NoError(t, err)
err = client.Rename("/prefix/b/foo.txt", "/prefix/foo2.txt", false)
require.ErrorContains(t, err, "403")
_, err = client.Read("/prefix/a/foo.txt")
require.ErrorContains(t, err, "403")
err = client.Write("/prefix/a/foo.js", []byte("new"), 0666)
require.ErrorContains(t, err, "403")
err = client.Write("/prefix/b/foo.txt", []byte("new"), 0666)
require.ErrorContains(t, err, "403")
_, err = client.ReadDir("/prefix/c")
require.ErrorContains(t, err, "403")
_, err = client.Read("/prefix/c/a.txt")
require.ErrorContains(t, err, "403")
err = client.Write("/prefix/c/b.txt", []byte("new"), 0666)
require.ErrorContains(t, err, "403")
}
func TestServerPermissions(t *testing.T) { func TestServerPermissions(t *testing.T) {
t.Parallel() t.Parallel()
+35
View File
@@ -0,0 +1,35 @@
package lib
import (
"path/filepath"
"time"
"golang.org/x/net/webdav"
)
var _ webdav.LockSystem = &lockSystem{}
// LockSystem wraps a [webdav.LockSystem] with a root directory, allowing
// to reuse the same [webdav.LockSystem] for multiple users with different base
// directories, meaning we can correctly lock the files across different users.
type lockSystem struct {
webdav.LockSystem
directory string
}
func (l *lockSystem) Confirm(now time.Time, name0, name1 string, conditions ...webdav.Condition) (release func(), err error) {
if name0 != "" {
name0 = filepath.Join(l.directory, name0)
}
if name1 != "" {
name1 = filepath.Join(l.directory, name1)
}
return l.LockSystem.Confirm(now, name0, name1, conditions...)
}
func (l *lockSystem) Create(now time.Time, details webdav.LockDetails) (token string, err error) {
details.Root = filepath.Join(l.directory, details.Root)
return l.LockSystem.Create(now, details)
}
+28 -14
View File
@@ -3,7 +3,6 @@ package lib
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/http"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -36,19 +35,27 @@ func (r *Rule) Matches(path string) bool {
return strings.HasPrefix(path, r.Path) return strings.HasPrefix(path, r.Path)
} }
type RulesBehavior string
const (
RulesOverwrite RulesBehavior = "overwrite"
RulesAppend RulesBehavior = "append"
)
type UserPermissions struct { type UserPermissions struct {
Directory string Directory string
Permissions Permissions Permissions Permissions
Rules []*Rule Rules []*Rule
RulesBehavior RulesBehavior
} }
// Allowed checks if the user has permission to access a directory/file // Allowed checks if the user has permission to access a directory/file
func (p UserPermissions) Allowed(r *http.Request, fileExists func(string) bool) bool { func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool {
// For COPY and MOVE requests, we first check the permissions for the destination // For COPY and MOVE requests, we first check the permissions for the destination
// path. As soon as a rule matches and does not allow the operation at the destination, // path. As soon as a rule matches and does not allow the operation at the destination,
// we fail immediately. If no rule matches, we check the global permissions. // we fail immediately. If no rule matches, we check the global permissions.
if r.Method == "COPY" || r.Method == "MOVE" { if r.method == "COPY" || r.method == "MOVE" {
dst := r.Header.Get("Destination") dst := r.destination
for i := len(p.Rules) - 1; i >= 0; i-- { for i := len(p.Rules) - 1; i >= 0; i-- {
if p.Rules[i].Matches(dst) { if p.Rules[i].Matches(dst) {
@@ -69,7 +76,7 @@ func (p UserPermissions) Allowed(r *http.Request, fileExists func(string) bool)
// Go through rules beginning from the last one, and check the permissions at // Go through rules beginning from the last one, and check the permissions at
// the source. The first matched rule returns. // the source. The first matched rule returns.
for i := len(p.Rules) - 1; i >= 0; i-- { for i := len(p.Rules) - 1; i >= 0; i-- {
if p.Rules[i].Matches(r.URL.Path) { if p.Rules[i].Matches(r.path) {
return p.Rules[i].Permissions.Allowed(r, fileExists) return p.Rules[i].Permissions.Allowed(r, fileExists)
} }
} }
@@ -91,6 +98,13 @@ func (p *UserPermissions) Validate() error {
} }
} }
switch p.RulesBehavior {
case RulesAppend, RulesOverwrite:
// Good to go
default:
return fmt.Errorf("invalid rule behavior: %s", p.RulesBehavior)
}
return nil return nil
} }
@@ -127,8 +141,8 @@ func (p *Permissions) UnmarshalText(data []byte) error {
// Allowed returns whether this permission set has permissions to execute this // Allowed returns whether this permission set has permissions to execute this
// request in the source directory. This applies to all requests with all methods. // request in the source directory. This applies to all requests with all methods.
func (p Permissions) Allowed(r *http.Request, fileExists func(string) bool) bool { func (p Permissions) Allowed(r *request, fileExists func(string) bool) bool {
switch r.Method { switch r.method {
case "GET", "HEAD", "OPTIONS", "POST", "PROPFIND": case "GET", "HEAD", "OPTIONS", "POST", "PROPFIND":
// Note: POST backend implementation just returns the same thing as GET. // Note: POST backend implementation just returns the same thing as GET.
return p.Read return p.Read
@@ -137,7 +151,7 @@ func (p Permissions) Allowed(r *http.Request, fileExists func(string) bool) bool
case "PROPPATCH": case "PROPPATCH":
return p.Update return p.Update
case "PUT": case "PUT":
if fileExists(r.URL.Path) { if fileExists(r.path) {
return p.Update return p.Update
} else { } else {
return p.Create return p.Create
@@ -157,10 +171,10 @@ func (p Permissions) Allowed(r *http.Request, fileExists func(string) bool) bool
// AllowedDestination returns whether this permissions set has permissions to execute this // AllowedDestination returns whether this permissions set has permissions to execute this
// request in the destination directory. This only applies for COPY and MOVE requests. // request in the destination directory. This only applies for COPY and MOVE requests.
func (p Permissions) AllowedDestination(r *http.Request, fileExists func(string) bool) bool { func (p Permissions) AllowedDestination(r *request, fileExists func(string) bool) bool {
switch r.Method { switch r.method {
case "COPY", "MOVE": case "COPY", "MOVE":
if fileExists(r.Header.Get("Destination")) { if fileExists(r.destination) {
return p.Update return p.Update
} else { } else {
return p.Create return p.Create
+57
View File
@@ -0,0 +1,57 @@
package lib
import (
"errors"
"net/http"
"net/url"
"strings"
)
type request struct {
method string
path string
destination string
}
func newRequest(r *http.Request, prefix string) (*request, error) {
ctx := &request{
method: r.Method,
}
if destination := r.Header.Get("Destination"); destination != "" {
u, err := url.Parse(destination)
if err != nil {
return nil, errors.New("invalid destination header")
}
if prefix != "" {
destination = strings.TrimPrefix(u.Path, prefix)
if len(destination) >= len(u.Path) {
return nil, errors.New("invalid url prefix")
}
}
if !strings.HasPrefix(destination, "/") {
destination = "/" + destination
}
ctx.destination = destination
}
path := r.URL.Path
if prefix != "" {
path = strings.TrimPrefix(r.URL.Path, prefix)
if len(path) >= len(r.URL.Path) {
return nil, errors.New("invalid url prefix")
}
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
ctx.path = path
return ctx, nil
}