mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-22 03:20:41 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
390fe21ed9 | ||
|
|
44e5e02dd3 | ||
|
|
d59dd02f96 | ||
|
|
10183d09bc | ||
|
|
1dceeb296a | ||
|
|
2bf7130f56 | ||
|
|
7ea4cec229 | ||
|
|
de2ac9d327 | ||
|
|
ca617862a5 | ||
|
|
3ddd9182f9 | ||
|
|
71fce1a29e | ||
|
|
db385c76b2 | ||
|
|
f9c771fdad | ||
|
|
7cc3f8b4ae | ||
|
|
36063ce391 | ||
|
|
6c3258a469 | ||
|
|
3ef6dd5ba5 | ||
|
|
d57f57f3a0 | ||
|
|
c3cfaf18f5 | ||
|
|
0128eed63d | ||
|
|
f051809a5f | ||
|
|
f13c884b75 | ||
|
|
cef2e3b673 | ||
|
|
4fe4703e02 | ||
|
|
882c2200eb | ||
|
|
4f4045dbca | ||
|
|
20606cd795 | ||
|
|
c9ca42d89a | ||
|
|
8217bc0816 | ||
|
|
98f8f93ebb | ||
|
|
66049d9c08 | ||
|
|
0406449b05 | ||
|
|
d7745cff34 | ||
|
|
d8558a77ff | ||
|
|
ab5cf175ad | ||
|
|
f225b0418f | ||
|
|
d9600ee82d | ||
|
|
6359115c2d | ||
|
|
8574b461a9 | ||
|
|
1196ad506d | ||
|
|
4ac9a1041c | ||
|
|
27aad9d403 | ||
|
|
e06f26b095 | ||
|
|
04070a8ae8 | ||
|
|
bd0667aac1 | ||
|
|
b620cd5c88 | ||
|
|
c4c58fe420 | ||
|
|
0ff71ca067 | ||
|
|
efdaa784b9 | ||
|
|
7662d629e3 | ||
|
|
010ca576fb | ||
|
|
7b8aa1c422 | ||
|
|
2f98af7d1e | ||
|
|
50e1828b60 | ||
|
|
e78d59e374 | ||
|
|
de0a102ed8 | ||
|
|
bdfa98d00d | ||
|
|
f0e7e23ea8 | ||
|
|
065991a195 | ||
|
|
bb85514c8f | ||
|
|
c0af549a1f | ||
|
|
685d89162a | ||
|
|
a5496c9516 | ||
|
|
24abd52e23 | ||
|
|
1af4c723f4 | ||
|
|
2406836223 | ||
|
|
7ca822bcaf | ||
|
|
a98d0c2616 | ||
|
|
98e92d0c13 | ||
|
|
53416e57da | ||
|
|
ff6637f396 | ||
|
|
15fbcd391b | ||
|
|
70b3630d08 | ||
|
|
aa014b092b | ||
|
|
b4553710eb | ||
|
|
e07e55d97a | ||
|
|
cfee0f9f0b | ||
|
|
79eb02aba4 | ||
|
|
7bbe36e4c1 | ||
|
|
520b7df04d | ||
|
|
8f3a3298a8 | ||
|
|
1753aa2945 | ||
|
|
4645f6f46f | ||
|
|
e998e2a838 | ||
|
|
3b3dedc4eb | ||
|
|
8706802dac | ||
|
|
5676a1c382 | ||
|
|
c938367dc0 | ||
|
|
758410ba64 | ||
|
|
1bbf52e475 | ||
|
|
2c2a325215 | ||
|
|
f7e006b449 | ||
|
|
49f61eab79 | ||
|
|
5d1a68c4f2 | ||
|
|
04cee682fb | ||
|
|
19d128cfbd | ||
|
|
118a0dda88 | ||
|
|
aad8e563a7 | ||
|
|
8446f9bdae |
@@ -12,10 +12,10 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.24.x"
|
||||
go-version: "1.26.x"
|
||||
- run: go build .
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
|
||||
@@ -18,22 +18,22 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
hacdias/webdav
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -12,10 +12,10 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.24.x"
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
go-version: "1.26.x"
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: "v1.64"
|
||||
version: "latest"
|
||||
|
||||
@@ -12,11 +12,13 @@ jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: goreleaser/goreleaser-action@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
- uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: '~> v2'
|
||||
|
||||
@@ -10,12 +10,21 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test
|
||||
runs-on: ubuntu-latest
|
||||
name: test (${{ matrix.os }})
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.24.x"
|
||||
- name: Run test with coverage
|
||||
go-version: "1.26.x"
|
||||
- name: Run test with race detector and coverage
|
||||
if: runner.os != 'Windows'
|
||||
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
- name: Run test with coverage
|
||||
if: runner.os == 'Windows'
|
||||
run: go test "-coverprofile=coverage.txt" -covermode=atomic ./...
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
dist/
|
||||
webdav
|
||||
webdav
|
||||
|
||||
+7
-3
@@ -38,16 +38,20 @@ builds:
|
||||
- goos: openbsd
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
# Broken as of Go 1.24, deprecated as of Go 1.26
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
|
||||
archives:
|
||||
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
|
||||
format: tar.gz
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
formats:
|
||||
- zip
|
||||
|
||||
release:
|
||||
github:
|
||||
|
||||
+1
-4
@@ -1,9 +1,7 @@
|
||||
FROM golang:1.24-alpine3.20 AS build
|
||||
FROM golang:1.26-alpine3.22 AS build
|
||||
|
||||
ARG VERSION="untracked"
|
||||
|
||||
RUN apk --update add ca-certificates
|
||||
|
||||
WORKDIR /webdav/
|
||||
|
||||
COPY ./go.mod ./
|
||||
@@ -15,7 +13,6 @@ RUN go build -o main -trimpath -ldflags="-s -w -X 'github.com/hacdias/webdav/v5/
|
||||
|
||||
FROM scratch
|
||||
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=build /webdav/main /bin/webdav
|
||||
|
||||
EXPOSE 6065
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# webdav
|
||||
|
||||
[](https://goreportcard.com/report/hacdias/webdav)
|
||||
[](https://github.com/hacdias/webdav/releases/latest)
|
||||
[](https://hub.docker.com/r/hacdias/webdav)
|
||||
|
||||
@@ -14,6 +13,14 @@ For a manual install, please refer to the [releases](https://github.com/hacdias/
|
||||
go install github.com/hacdias/webdav/v5@latest
|
||||
```
|
||||
|
||||
### Homebrew
|
||||
|
||||
If you have Homebrew available on your system, you can also install `webdav` using it:
|
||||
|
||||
```
|
||||
brew install webdav
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
Docker images are provided on both [GitHub's registry](https://github.com/hacdias/webdav/pkgs/container/webdav) and [Docker Hub](https://hub.docker.com/r/hacdias/webdav). You can pull the images using one of the following two commands. Note that this commands pull the latest released version. You can use specific tags to pin specific versions, or use `main` for the development branch.
|
||||
@@ -30,34 +37,45 @@ docker pull hacdias/webdav:latest
|
||||
|
||||
For usage information regarding the CLI, run `webdav --help`.
|
||||
|
||||
### Docker
|
||||
### Container
|
||||
|
||||
To use with Docker, you need to provide a configuration file and mount the data directories. For example, let's take the following configuration file that simply sets the port to `6060` and the directory to `/data`.
|
||||
To run the container, you can refer to the `compose.yml` file which provides a minimal setup. Additionally, you also need to create a configuration file, as [explained below](#configuration).
|
||||
|
||||
```yaml
|
||||
port: 6060
|
||||
directory: /data
|
||||
```
|
||||
|
||||
You can now run with the following Docker command, where you mount the configuration file inside the container, and the data directory too, as well as forwarding the port 6060. You will need to change this to match your own configuration.
|
||||
The equivalent Docker command to the aforementioned compose file would be as follows:
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-p 6060:6060 \
|
||||
-v $(pwd)/config.yml:/config.yml:ro \
|
||||
-v $(pwd)/data:/data \
|
||||
-p 6065:6065 \
|
||||
-v ./config.yml:/config.yml:ro \
|
||||
-v ./data:/data \
|
||||
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.
|
||||
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 \
|
||||
```
|
||||
|
||||
### Partial updates
|
||||
|
||||
This server supports partial file updates compatible with SabreDAV's `PATCH` extension. This is not an official WebDAV specification. Requests must use the `application/x-sabredav-partialupdate` content type, include `Content-Length`, and provide the target range in `X-Update-Range`.
|
||||
|
||||
Supported `X-Update-Range` values are:
|
||||
|
||||
- `bytes=start-end`
|
||||
- `bytes=start-`
|
||||
- `bytes=-N`
|
||||
- `append`
|
||||
|
||||
For clients that use it, the server also supports partial `PUT` requests with `Content-Range`, for example `Content-Range: bytes 6-8/*`. This is an extra compatibility path and should be treated as a client/server agreement.
|
||||
|
||||
## 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.
|
||||
|
||||
```yaml
|
||||
address: 0.0.0.0
|
||||
@@ -84,8 +102,19 @@ behindProxy: false
|
||||
|
||||
# The directory that will be able to be accessed by the users when connecting.
|
||||
# This directory will be used by users unless they have their own 'directory' defined.
|
||||
# Default is '.' (current directory).
|
||||
directory: .
|
||||
# By default it points to the working directory. In the case of the compose file above,
|
||||
# that is /data.
|
||||
directory: /data
|
||||
|
||||
# Alternatively, replace 'directory' with 'directories' to expose multiple
|
||||
# directories as virtual root entries. This option is mutually exclusive with
|
||||
# 'directory' in the same scope. Rules should include the virtual mount name,
|
||||
# such as /media/public/access/.
|
||||
# directories:
|
||||
# - media: /data/media
|
||||
# - /data/archive
|
||||
# - name: backups
|
||||
# path: /data/backups
|
||||
|
||||
# The default permissions for users. This is a case insensitive option. Possible
|
||||
# permissions: C (Create), R (Read), U (Update), D (Delete). You can combine multiple
|
||||
@@ -116,48 +145,67 @@ log:
|
||||
colors: true
|
||||
# Logging outputs. You can have more than one output. Default is only 'stderr'.
|
||||
outputs:
|
||||
- stderr
|
||||
- stderr
|
||||
|
||||
# CORS configuration
|
||||
cors:
|
||||
# Whether or not CORS configuration should be applied. Default is 'false'.
|
||||
enabled: true
|
||||
credentials: true
|
||||
allowed_headers:
|
||||
- Depth
|
||||
# The following are the default CORS settings when it is enabled.
|
||||
allowed_hosts:
|
||||
- http://localhost:8080
|
||||
allowed_methods:
|
||||
- GET
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- '*'
|
||||
allowed_headers:
|
||||
- Authorization
|
||||
- Content-Type
|
||||
- Content-Range
|
||||
- Depth
|
||||
- Destination
|
||||
- If
|
||||
- Lock-Token
|
||||
- Overwrite
|
||||
- TimeOut
|
||||
- Translate
|
||||
- X-Update-Range
|
||||
allowed_methods:
|
||||
- COPY
|
||||
- DELETE
|
||||
- GET
|
||||
- HEAD
|
||||
- LOCK
|
||||
- UNLOCK
|
||||
- MKCOL
|
||||
- MOVE
|
||||
- OPTIONS
|
||||
- PATCH
|
||||
- POST
|
||||
- PROPFIND
|
||||
- PROPPATCH
|
||||
- PUT
|
||||
exposed_headers: []
|
||||
|
||||
# The list of users. If the list is empty, then there will be no authentication.
|
||||
# Otherwise, basic authentication will automatically be configured.
|
||||
#
|
||||
# If you're delegating the authentication to a different service, you can proxy
|
||||
# the username using basic authentication, and then disable webdav's password
|
||||
# check using the option:
|
||||
#
|
||||
# noPassword: true
|
||||
# You define here the list of users.
|
||||
# Basic authentication is automatically be configured when users are detected
|
||||
# below, else there will be no authentication.
|
||||
# Customize to your needs and don't forget to comment out the users you don't need.
|
||||
users:
|
||||
# Example 'admin' user with plaintext password.
|
||||
- username: admin
|
||||
password: admin
|
||||
|
||||
# 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.
|
||||
# Tip: you can generate a bcrypt-encrypted password by using the 'webdav bcrypt'
|
||||
# command lint utility, or htpasswd on Linux.
|
||||
- username: john
|
||||
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
|
||||
directory: /another/path
|
||||
directory: /data/john
|
||||
# Example user whose details will be picked up from the environment.
|
||||
- username: "{env}ENV_USERNAME"
|
||||
password: "{env}ENV_PASSWORD"
|
||||
# Example user with advanced control over his permissions
|
||||
- username: basic
|
||||
password: basic
|
||||
# Override default permissions.
|
||||
permissions: CRUD
|
||||
permissions: CRUD # Override default permissions.
|
||||
rules:
|
||||
# With this rule, the user CANNOT access {user directory}/some/files.
|
||||
- path: /some/file
|
||||
@@ -170,6 +218,16 @@ users:
|
||||
# It uses a regular expression.
|
||||
- regex: "^.+.js$"
|
||||
permissions: RU
|
||||
# Example user for android SeedVault backuping
|
||||
- username: android
|
||||
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
|
||||
directory: /data/android
|
||||
permissions: CRUD
|
||||
|
||||
# If you're delegating the authentication to a different service, you can proxy
|
||||
# the username using basic authentication, and then disable webdav's password
|
||||
# check using the option:
|
||||
# noPassword: true
|
||||
```
|
||||
|
||||
### CORS
|
||||
@@ -183,7 +241,9 @@ The `allowed_*` properties are optional, the default value for each of them will
|
||||
|
||||
### Reverse Proxy Service
|
||||
|
||||
When using a reverse proxy implementation, like Caddy, Nginx, or Apache, note that you need to forward the correct headers in order to avoid 502 errors. Here's a Nginx configuration example:
|
||||
When using a reverse proxy implementation, like Caddy, Nginx, or Apache, note that you need to forward the correct headers in order to avoid 502 errors.
|
||||
|
||||
#### Nginx Configuration Example
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
@@ -193,6 +253,33 @@ location / {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
#### Caddy Configuration Example
|
||||
|
||||
```Caddyfile
|
||||
example.com {
|
||||
tls internal # for local development
|
||||
# tls [email protected] # so that Caddy gets certs for you via Letsencrypt
|
||||
|
||||
# Rewrites destination to remove host and include only the path e.g. /test.txt
|
||||
@hasDest header_regexp dest ^https?://[^/]+(.*)$
|
||||
header @hasDest Destination {re.dest.1}
|
||||
|
||||
# if running on the same network in docker you can just point to the service name e.g. webdav:6065
|
||||
reverse_proxy 127.0.0.1:6065 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up REMOTE-HOST {remote_host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+6
-3
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -80,6 +81,8 @@ set WD_CERT.`,
|
||||
return err
|
||||
}
|
||||
|
||||
server := &http.Server{Handler: handler}
|
||||
|
||||
// Trap exiting signals
|
||||
quit := make(chan os.Signal, 1)
|
||||
|
||||
@@ -88,9 +91,9 @@ set WD_CERT.`,
|
||||
|
||||
var err error
|
||||
if cfg.TLS {
|
||||
err = http.ServeTLS(listener, handler, cfg.Cert, cfg.Key)
|
||||
err = server.ServeTLS(listener, cfg.Cert, cfg.Key)
|
||||
} else {
|
||||
err = http.Serve(listener, handler)
|
||||
err = server.Serve(listener)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
@@ -104,7 +107,7 @@ set WD_CERT.`,
|
||||
signal := <-quit
|
||||
|
||||
zap.L().Info("caught signal, shutting down", zap.Stringer("signal", signal))
|
||||
_ = listener.Close()
|
||||
_ = server.Shutdown(context.Background())
|
||||
|
||||
return nil
|
||||
},
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
name: webdav
|
||||
|
||||
services:
|
||||
app:
|
||||
container_name: webdav
|
||||
image: 'webdav:test'
|
||||
ports:
|
||||
- '6065:6065'
|
||||
security_opt:
|
||||
- label=type:container_runtime_t
|
||||
restart: always
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./config.yml:/config.yml:ro
|
||||
@@ -1,19 +1,20 @@
|
||||
module github.com/hacdias/webdav/v5
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-systemd/v22 v22.5.0
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||
github.com/rs/cors v1.11.1
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/pflag v1.0.6
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/studio-b12/gowebdav v0.9.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/net v0.40.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/studio-b12/gowebdav v0.13.0
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e
|
||||
golang.org/x/net v0.57.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -22,13 +23,14 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.8.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -7,9 +7,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
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/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -27,40 +26,45 @@ 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/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||
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/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
|
||||
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
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/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
|
||||
github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e h1:9TjMDOuGaMMTP5f7GXeHeA0JvFqGmv4DYRIWkzoePGI=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+181
-3
@@ -3,7 +3,9 @@ package lib
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
@@ -22,6 +24,8 @@ const (
|
||||
DefaultPrefix = "/"
|
||||
)
|
||||
|
||||
var errDirectoryConflict = errors.New("directory and directories cannot both be defined")
|
||||
|
||||
type Config struct {
|
||||
UserPermissions `mapstructure:",squash"`
|
||||
Debug bool
|
||||
@@ -53,6 +57,10 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
// Configuration file settings
|
||||
v.AddConfigPath(".")
|
||||
v.AddConfigPath("/etc/webdav/")
|
||||
if path, err := os.Executable(); err == nil {
|
||||
v.AddConfigPath(filepath.Dir(path))
|
||||
}
|
||||
|
||||
v.SetConfigName("config")
|
||||
if filename != "" {
|
||||
v.SetConfigFile(filename)
|
||||
@@ -81,9 +89,9 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
v.SetDefault("Log.Format", "console")
|
||||
v.SetDefault("Log.Outputs", []string{"stderr"})
|
||||
v.SetDefault("Log.Colors", true)
|
||||
v.SetDefault("CORS.Allowed_Headers", []string{"*"})
|
||||
v.SetDefault("CORS.Allowed_Hosts", []string{"*"})
|
||||
v.SetDefault("CORS.Allowed_Methods", []string{"*"})
|
||||
v.SetDefault("CORS.Allowed_Headers", []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"})
|
||||
v.SetDefault("CORS.Allowed_Methods", []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"})
|
||||
|
||||
// Read and unmarshal configuration
|
||||
err := v.ReadInConfig()
|
||||
@@ -95,6 +103,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
|
||||
cfg := &Config{}
|
||||
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||
directoryMountsDecodeHook(),
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
mapstructure.TextUnmarshallerHookFunc(),
|
||||
@@ -103,12 +112,28 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = applyDirectoryConfig(v, flags, &cfg.UserPermissions, "directory", "directories", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
// Cascade user settings
|
||||
for i := range cfg.Users {
|
||||
if !v.IsSet(fmt.Sprintf("Users.%d.Directory", i)) {
|
||||
userDirectoryKey := fmt.Sprintf("Users.%d.Directory", i)
|
||||
userDirectoriesKey := fmt.Sprintf("Users.%d.Directories", i)
|
||||
|
||||
if !v.IsSet(userDirectoryKey) {
|
||||
cfg.Users[i].Directory = cfg.Directory
|
||||
}
|
||||
|
||||
err := applyDirectoryConfig(v, flags, &cfg.Users[i].UserPermissions, userDirectoryKey, userDirectoriesKey, &cfg.UserPermissions)
|
||||
if err != nil {
|
||||
if errors.Is(err, errDirectoryConflict) {
|
||||
return nil, fmt.Errorf("invalid config: user %q cannot define both directory and directories", cfg.Users[i].Username)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid config: user %q: %w", cfg.Users[i].Username, err)
|
||||
}
|
||||
|
||||
if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
|
||||
cfg.Users[i].Permissions = cfg.Permissions
|
||||
}
|
||||
@@ -140,6 +165,46 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyDirectoryConfig(v *viper.Viper, flags *pflag.FlagSet, permissions *UserPermissions, directoryKey, directoriesKey string, inherited *UserPermissions) error {
|
||||
permissions.directoryExplicit = isExplicitlySet(v, flags, directoryKey)
|
||||
permissions.directoriesExplicit = isExplicitlySet(v, flags, directoriesKey)
|
||||
if permissions.directoryExplicit && permissions.directoriesExplicit {
|
||||
return errDirectoryConflict
|
||||
}
|
||||
|
||||
switch {
|
||||
case permissions.directoryExplicit:
|
||||
permissions.Directory = v.GetString(directoryKey)
|
||||
permissions.useDirectories = false
|
||||
case permissions.directoriesExplicit:
|
||||
directories, err := getDirectoryMounts(v, directoriesKey, permissions.Directories)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissions.Directories = directories
|
||||
permissions.useDirectories = true
|
||||
case inherited != nil:
|
||||
permissions.Directories = append(DirectoryMounts{}, inherited.Directories...)
|
||||
permissions.useDirectories = inherited.useDirectories
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isExplicitlySet(v *viper.Viper, flags *pflag.FlagSet, key string) bool {
|
||||
if flags != nil && flags.Changed(key) {
|
||||
return true
|
||||
}
|
||||
|
||||
if v.InConfig(key) {
|
||||
return true
|
||||
}
|
||||
|
||||
envKey := "WD_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
|
||||
value, ok := os.LookupEnv(envKey)
|
||||
return ok && value != ""
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
var err error
|
||||
|
||||
@@ -183,6 +248,119 @@ func (c *Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func directoryMountsDecodeHook() mapstructure.DecodeHookFunc {
|
||||
mountsType := reflect.TypeOf(DirectoryMounts{})
|
||||
|
||||
return func(from reflect.Type, to reflect.Type, data any) (any, error) {
|
||||
if to != mountsType {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
return decodeDirectoryMounts(data)
|
||||
}
|
||||
}
|
||||
|
||||
func getDirectoryMounts(v *viper.Viper, key string, fallback DirectoryMounts) (DirectoryMounts, error) {
|
||||
value := v.Get(key)
|
||||
if value == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
return decodeDirectoryMounts(value)
|
||||
}
|
||||
|
||||
func decodeDirectoryMounts(data any) (DirectoryMounts, error) {
|
||||
switch value := data.(type) {
|
||||
case nil:
|
||||
return DirectoryMounts{}, nil
|
||||
case DirectoryMounts:
|
||||
return value, nil
|
||||
case []DirectoryMount:
|
||||
return DirectoryMounts(value), nil
|
||||
case string:
|
||||
if value == "" {
|
||||
return DirectoryMounts{}, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(value, ",")
|
||||
mounts := make(DirectoryMounts, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
mounts = append(mounts, DirectoryMount{Path: part})
|
||||
}
|
||||
return mounts, nil
|
||||
case []any:
|
||||
mounts := make(DirectoryMounts, 0, len(value))
|
||||
for _, item := range value {
|
||||
mount, err := decodeDirectoryMount(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mounts = append(mounts, mount)
|
||||
}
|
||||
return mounts, nil
|
||||
case []string:
|
||||
mounts := make(DirectoryMounts, 0, len(value))
|
||||
for _, item := range value {
|
||||
mounts = append(mounts, DirectoryMount{Path: item})
|
||||
}
|
||||
return mounts, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid directories: unsupported value %T", data)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeDirectoryMount(data any) (DirectoryMount, error) {
|
||||
switch value := data.(type) {
|
||||
case string:
|
||||
return DirectoryMount{Path: value}, nil
|
||||
case map[string]any:
|
||||
return decodeDirectoryMountMap(value)
|
||||
case map[any]any:
|
||||
m := map[string]any{}
|
||||
for key, value := range value {
|
||||
keyString, ok := key.(string)
|
||||
if !ok {
|
||||
return DirectoryMount{}, errors.New("invalid directories: mount keys must be strings")
|
||||
}
|
||||
m[keyString] = value
|
||||
}
|
||||
return decodeDirectoryMountMap(m)
|
||||
default:
|
||||
return DirectoryMount{}, fmt.Errorf("invalid directories: unsupported mount entry %T", data)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeDirectoryMountMap(data map[string]any) (DirectoryMount, error) {
|
||||
_, hasName := data["name"]
|
||||
_, hasPath := data["path"]
|
||||
if hasName || hasPath {
|
||||
name, nameOK := data["name"].(string)
|
||||
path, pathOK := data["path"].(string)
|
||||
if !nameOK || !pathOK || len(data) != 2 {
|
||||
return DirectoryMount{}, errors.New("invalid directories: explicit mount objects must define name and path")
|
||||
}
|
||||
return DirectoryMount{Name: name, Path: path}, nil
|
||||
}
|
||||
|
||||
if len(data) != 1 {
|
||||
return DirectoryMount{}, errors.New("invalid directories: mapped mount entries must have exactly one key")
|
||||
}
|
||||
|
||||
for name, path := range data {
|
||||
pathString, ok := path.(string)
|
||||
if !ok {
|
||||
return DirectoryMount{}, errors.New("invalid directories: mapped mount paths must be strings")
|
||||
}
|
||||
return DirectoryMount{Name: name, Path: pathString}, nil
|
||||
}
|
||||
|
||||
return DirectoryMount{}, errors.New("invalid directories: empty mount entry")
|
||||
}
|
||||
|
||||
func (cfg *Config) GetLogger() (*zap.Logger, error) {
|
||||
loggerConfig := zap.NewProductionConfig()
|
||||
loggerConfig.DisableCaller = true
|
||||
|
||||
+193
-6
@@ -3,6 +3,7 @@ package lib
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -51,20 +52,27 @@ func TestConfigDefaults(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, dir, cfg.Directory)
|
||||
|
||||
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHeaders)
|
||||
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
|
||||
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedMethods)
|
||||
require.EqualValues(t, []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"}, cfg.CORS.AllowedHeaders)
|
||||
require.EqualValues(t, []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"}, cfg.CORS.AllowedMethods)
|
||||
}
|
||||
|
||||
func TestConfigCascade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Directories are resolved to absolute paths, which differ by platform
|
||||
// (for example "/" becomes the current drive root on Windows).
|
||||
rootDirectory, err := filepath.Abs("/")
|
||||
require.NoError(t, err)
|
||||
basicDirectory, err := filepath.Abs("/basic")
|
||||
require.NoError(t, err)
|
||||
|
||||
check := func(t *testing.T, cfg *Config) {
|
||||
require.True(t, cfg.Permissions.Read)
|
||||
require.True(t, cfg.Permissions.Create)
|
||||
require.False(t, cfg.Permissions.Delete)
|
||||
require.False(t, cfg.Permissions.Update)
|
||||
require.Equal(t, "/", cfg.Directory)
|
||||
require.Equal(t, rootDirectory, cfg.Directory)
|
||||
require.Len(t, cfg.Rules, 1)
|
||||
|
||||
require.Len(t, cfg.Users, 2)
|
||||
@@ -72,14 +80,14 @@ func TestConfigCascade(t *testing.T) {
|
||||
require.True(t, cfg.Users[0].Permissions.Create)
|
||||
require.False(t, cfg.Users[0].Permissions.Delete)
|
||||
require.False(t, cfg.Users[0].Permissions.Update)
|
||||
require.Equal(t, "/", cfg.Users[0].Directory)
|
||||
require.Equal(t, rootDirectory, cfg.Users[0].Directory)
|
||||
require.Len(t, cfg.Users[0].Rules, 1)
|
||||
|
||||
require.True(t, cfg.Users[1].Permissions.Read)
|
||||
require.False(t, cfg.Users[1].Permissions.Create)
|
||||
require.False(t, cfg.Users[1].Permissions.Delete)
|
||||
require.False(t, cfg.Users[1].Permissions.Update)
|
||||
require.Equal(t, "/basic", cfg.Users[1].Directory)
|
||||
require.Equal(t, basicDirectory, cfg.Users[1].Directory)
|
||||
require.Len(t, cfg.Users[1].Rules, 0)
|
||||
}
|
||||
|
||||
@@ -165,6 +173,182 @@ rules = []
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Mixed Entries", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirC := t.TempDir()
|
||||
dirD := t.TempDir()
|
||||
dirE := t.TempDir()
|
||||
|
||||
cfg := writeAndParseConfig(t, `
|
||||
directories:
|
||||
- `+dirC+`
|
||||
- d2: `+dirD+`
|
||||
- name: archive
|
||||
path: `+dirE+`
|
||||
`, ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.Equal(t, filepath.Base(dirC), cfg.Directories[0].Name)
|
||||
require.Equal(t, dirC, cfg.Directories[0].Path)
|
||||
require.Equal(t, "d2", cfg.Directories[1].Name)
|
||||
require.Equal(t, dirD, cfg.Directories[1].Path)
|
||||
require.Equal(t, "archive", cfg.Directories[2].Name)
|
||||
require.Equal(t, dirE, cfg.Directories[2].Path)
|
||||
})
|
||||
|
||||
t.Run("JSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirC := t.TempDir()
|
||||
dirD := t.TempDir()
|
||||
dirE := t.TempDir()
|
||||
|
||||
cfg := writeAndParseConfig(t, `{
|
||||
"directories": [
|
||||
`+strconv.Quote(dirC)+`,
|
||||
{ "d2": `+strconv.Quote(dirD)+` },
|
||||
{ "name": "archive", "path": `+strconv.Quote(dirE)+` }
|
||||
]
|
||||
}`, ".json")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.Equal(t, filepath.Base(dirC), cfg.Directories[0].Name)
|
||||
require.Equal(t, dirC, cfg.Directories[0].Path)
|
||||
require.Equal(t, "d2", cfg.Directories[1].Name)
|
||||
require.Equal(t, dirD, cfg.Directories[1].Path)
|
||||
require.Equal(t, "archive", cfg.Directories[2].Name)
|
||||
require.Equal(t, dirE, cfg.Directories[2].Path)
|
||||
})
|
||||
|
||||
t.Run("TOML", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirD := t.TempDir()
|
||||
dirE := t.TempDir()
|
||||
|
||||
cfg := writeAndParseConfig(t, `
|
||||
[[directories]]
|
||||
d2 = `+strconv.Quote(dirD)+`
|
||||
|
||||
[[directories]]
|
||||
name = "archive"
|
||||
path = `+strconv.Quote(dirE)+`
|
||||
`, ".toml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.Equal(t, "d2", cfg.Directories[0].Name)
|
||||
require.Equal(t, dirD, cfg.Directories[0].Path)
|
||||
require.Equal(t, "archive", cfg.Directories[1].Name)
|
||||
require.Equal(t, dirE, cfg.Directories[1].Path)
|
||||
})
|
||||
|
||||
t.Run("Mutually Exclusive Global Directory Fields", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writeAndParseConfigWithError(t, `
|
||||
directory: /tmp
|
||||
directories:
|
||||
- /tmp
|
||||
`, ".yml", "directory and directories cannot both be defined")
|
||||
})
|
||||
|
||||
t.Run("Mutually Exclusive User Directory Fields", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writeAndParseConfigWithError(t, `
|
||||
users:
|
||||
- username: basic
|
||||
password: basic
|
||||
directory: /tmp
|
||||
directories:
|
||||
- /tmp
|
||||
`, ".yml", "cannot define both directory and directories")
|
||||
})
|
||||
|
||||
t.Run("Duplicate Mount Names", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parent := t.TempDir()
|
||||
dir := filepath.Join(parent, "dup")
|
||||
require.NoError(t, os.Mkdir(dir, 0775))
|
||||
|
||||
writeAndParseConfigWithError(t, `
|
||||
directories:
|
||||
- `+dir+`
|
||||
- dup: /tmp
|
||||
`, ".yml", "duplicate mount name")
|
||||
})
|
||||
|
||||
t.Run("Cascade Mode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
global := t.TempDir()
|
||||
single := t.TempDir()
|
||||
userMulti := t.TempDir()
|
||||
|
||||
cfg := writeAndParseConfig(t, `
|
||||
directories:
|
||||
- global: `+global+`
|
||||
users:
|
||||
- username: inherited
|
||||
password: inherited
|
||||
- username: single
|
||||
password: single
|
||||
directory: `+single+`
|
||||
- username: multi
|
||||
password: multi
|
||||
directories:
|
||||
- owned: `+userMulti+`
|
||||
`, ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.True(t, cfg.Users[0].useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: "global", Path: global}}, cfg.Users[0].Directories)
|
||||
require.False(t, cfg.Users[1].useDirectories)
|
||||
require.Equal(t, single, cfg.Users[1].Directory)
|
||||
require.True(t, cfg.Users[2].useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: "owned", Path: userMulti}}, cfg.Users[2].Directories)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigDirectoriesEnvOverrides(t *testing.T) {
|
||||
global := t.TempDir()
|
||||
single := t.TempDir()
|
||||
userMulti := t.TempDir()
|
||||
|
||||
t.Setenv("WD_DIRECTORIES", global)
|
||||
t.Setenv("WD_USERS_1_DIRECTORY", single)
|
||||
t.Setenv("WD_USERS_2_DIRECTORIES", userMulti)
|
||||
|
||||
cfg := writeAndParseConfig(t, `
|
||||
users:
|
||||
- username: inherited
|
||||
password: inherited
|
||||
- username: single
|
||||
password: single
|
||||
- username: multi
|
||||
password: multi
|
||||
`, ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: filepath.Base(global), Path: global}}, cfg.Directories)
|
||||
require.True(t, cfg.Users[0].useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: filepath.Base(global), Path: global}}, cfg.Users[0].Directories)
|
||||
require.False(t, cfg.Users[1].useDirectories)
|
||||
require.Equal(t, single, cfg.Users[1].Directory)
|
||||
require.True(t, cfg.Users[2].useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: filepath.Base(userMulti), Path: userMulti}}, cfg.Users[2].Directories)
|
||||
}
|
||||
|
||||
func TestConfigKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -308,8 +492,11 @@ func TestConfigEnv(t *testing.T) {
|
||||
cfg, err := ParseConfig("", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedDirectory, err := filepath.Abs("/test")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1234, cfg.Port)
|
||||
assert.Equal(t, "/test", cfg.Directory)
|
||||
assert.Equal(t, expectedDirectory, cfg.Directory)
|
||||
assert.Equal(t, true, cfg.Debug)
|
||||
require.True(t, cfg.Permissions.Read)
|
||||
require.True(t, cfg.Permissions.Create)
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ type noSniffFileInfo struct {
|
||||
}
|
||||
|
||||
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.Name())); mimeType != "" {
|
||||
// We can figure out the mime from the extension.
|
||||
return mimeType, nil
|
||||
} else {
|
||||
|
||||
+60
-34
@@ -25,42 +25,25 @@ type Handler struct {
|
||||
func NewHandler(c *Config) (http.Handler, error) {
|
||||
ls := webdav.NewMemLS()
|
||||
|
||||
logFunc := func(r *http.Request, err error) {
|
||||
lZap := getRequestLogger(r, c.BehindProxy)
|
||||
lZap.Debug("handle webdav request", zap.String("method", r.Method), zap.String("path", r.URL.Path), zap.Error(err))
|
||||
}
|
||||
|
||||
h := &Handler{
|
||||
noPassword: c.NoPassword,
|
||||
behindProxy: c.BehindProxy,
|
||||
user: &handlerUser{
|
||||
User: User{
|
||||
UserPermissions: c.UserPermissions,
|
||||
},
|
||||
Handler: webdav.Handler{
|
||||
Prefix: c.Prefix,
|
||||
FileSystem: Dir{
|
||||
Dir: webdav.Dir(c.Directory),
|
||||
noSniff: c.NoSniff,
|
||||
},
|
||||
LockSystem: &lockSystem{
|
||||
LockSystem: ls,
|
||||
directory: c.Directory,
|
||||
},
|
||||
},
|
||||
User: User{UserPermissions: c.UserPermissions},
|
||||
Handler: buildWebdavHandler(c.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
|
||||
},
|
||||
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.Directory),
|
||||
noSniff: c.NoSniff,
|
||||
},
|
||||
LockSystem: &lockSystem{
|
||||
LockSystem: ls,
|
||||
directory: u.Directory,
|
||||
},
|
||||
},
|
||||
User: u,
|
||||
Handler: buildWebdavHandler(u.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,17 +69,42 @@ func NewHandler(c *Config) (http.Handler, error) {
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// buildWebdavHandler creates the [webdav.Handler] for a set of user permissions,
|
||||
// selecting between single-directory and multi-directory backing depending on
|
||||
// whether directories are configured.
|
||||
func buildWebdavHandler(p UserPermissions, prefix string, noSniff bool, ls webdav.LockSystem, logFunc func(*http.Request, error)) webdav.Handler {
|
||||
h := webdav.Handler{
|
||||
Prefix: prefix,
|
||||
Logger: logFunc,
|
||||
}
|
||||
|
||||
if p.useDirectories {
|
||||
h.FileSystem = multiDir{
|
||||
mounts: p.Directories,
|
||||
noSniff: noSniff,
|
||||
}
|
||||
h.LockSystem = newMultiDirLockSystem(ls, p.Directories)
|
||||
} else {
|
||||
h.FileSystem = Dir{
|
||||
Dir: webdav.Dir(p.Directory),
|
||||
noSniff: noSniff,
|
||||
}
|
||||
h.LockSystem = newLockSystem(ls, p.Directory)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := h.user
|
||||
|
||||
lZap := getRequestLogger(r, h.behindProxy)
|
||||
|
||||
// Authentication
|
||||
if len(h.users) > 0 {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
|
||||
// Retrieve the real client IP address using the updated helper function
|
||||
remoteAddr := getRealRemoteIP(r, h.behindProxy)
|
||||
|
||||
// Gets the correct user for this request.
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
@@ -107,26 +115,26 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok = h.users[username]
|
||||
if !ok {
|
||||
// Log invalid username
|
||||
zap.L().Info("invalid username", zap.String("username", username), zap.String("remote_address", remoteAddr))
|
||||
lZap.Info("invalid username", zap.String("username", username))
|
||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.noPassword && !user.checkPassword(password) {
|
||||
// Log invalid password
|
||||
zap.L().Info("invalid password", zap.String("username", username), zap.String("remote_address", remoteAddr))
|
||||
lZap.Info("invalid password", zap.String("username", username))
|
||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Log successful authorization
|
||||
zap.L().Info("user authorized", zap.String("username", username), zap.String("remote_address", remoteAddr))
|
||||
lZap.Info("user authorized", zap.String("username", username))
|
||||
}
|
||||
|
||||
// Convert the HTTP request into an internal request type
|
||||
req, err := newRequest(r, h.user.Prefix)
|
||||
if err != nil {
|
||||
zap.L().Info("invalid request path or destination", zap.Error(err))
|
||||
lZap.Info("invalid request path or destination", zap.Error(err))
|
||||
http.Error(w, "Invalid request path or destination", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -137,7 +145,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return !os.IsNotExist(err)
|
||||
})
|
||||
|
||||
zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
|
||||
lZap.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)
|
||||
@@ -170,10 +178,28 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
user.handleOptions(w, r, req.path)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "PATCH" || (r.Method == "PUT" && r.Header.Get("Content-Range") != "") {
|
||||
user.handlePartialUpdate(w, r, req.path)
|
||||
return
|
||||
}
|
||||
|
||||
// Runs the WebDAV.
|
||||
user.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// getRequestLogger creates a zap.Logger using the request remote ip.
|
||||
func getRequestLogger(r *http.Request, behindProxy bool) *zap.Logger {
|
||||
// Retrieve the real client IP address using the updated helper function
|
||||
remoteAddr := getRealRemoteIP(r, behindProxy)
|
||||
|
||||
return zap.L().With(zap.String("remote_address", remoteAddr))
|
||||
}
|
||||
|
||||
// getRealRemoteIP retrieves the client's actual IP address, considering reverse proxies.
|
||||
func getRealRemoteIP(r *http.Request, behindProxy bool) string {
|
||||
if behindProxy {
|
||||
|
||||
+598
-21
@@ -2,10 +2,14 @@ package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -82,16 +86,422 @@ func TestServerDefaults(t *testing.T) {
|
||||
require.ErrorContains(t, client.Write("/foo.txt", []byte("hello world 2"), 0666), "403")
|
||||
}
|
||||
|
||||
func TestServerListingCharacters(t *testing.T) {
|
||||
func TestServerPartialUpdateOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("hello world"),
|
||||
})
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodOptions, srv.URL+"/foo.txt", nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Contains(t, resp.Header.Get("DAV"), "sabredav-partialupdate")
|
||||
require.Contains(t, resp.Header.Get("Allow"), "PATCH")
|
||||
require.Equal(t, partialUpdateContentType, resp.Header.Get("Accept-Patch"))
|
||||
}
|
||||
|
||||
func TestServerPatchPartialUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
initialData string
|
||||
body string
|
||||
updateRange string
|
||||
wantData string
|
||||
}{{
|
||||
name: "start",
|
||||
initialData: "hello world",
|
||||
body: "DAV",
|
||||
updateRange: "bytes=6-",
|
||||
wantData: "hello DAVld",
|
||||
}, {
|
||||
name: "suffix",
|
||||
initialData: "hello world",
|
||||
body: "DAV",
|
||||
updateRange: "bytes=-5",
|
||||
wantData: "hello DAVld",
|
||||
}, {
|
||||
name: "append",
|
||||
initialData: "hello",
|
||||
body: " world",
|
||||
updateRange: "append",
|
||||
wantData: "hello world",
|
||||
}, {
|
||||
name: "suffix_zero",
|
||||
initialData: "hello",
|
||||
body: " world",
|
||||
updateRange: "bytes=-0",
|
||||
wantData: "hello world",
|
||||
}}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte(tc.initialData),
|
||||
})
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest("PATCH", srv.URL+"/foo.txt", strings.NewReader(tc.body))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", partialUpdateContentType)
|
||||
req.Header.Set("X-Update-Range", tc.updateRange)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantData, string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPatchPartialUpdateCreatesSparseFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, nil)
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest("PATCH", srv.URL+"/new.bin", strings.NewReader("x"))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", partialUpdateContentType)
|
||||
req.Header.Set("X-Update-Range", "bytes=3-")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "new.bin"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{0, 0, 0, 'x'}, data)
|
||||
}
|
||||
|
||||
func TestServerPutContentRangePartialUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("hello world"),
|
||||
})
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut, srv.URL+"/foo.txt", strings.NewReader("DAV"))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Range", "bytes 6-8/*")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello DAVld", string(data))
|
||||
}
|
||||
|
||||
func TestServerPartialUpdateErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
method string
|
||||
body string
|
||||
contentLength int64
|
||||
path string
|
||||
headerName string
|
||||
updateRange string
|
||||
contentRange string
|
||||
wantStatus int
|
||||
}{{
|
||||
name: "patch_missing_content_length",
|
||||
method: "PATCH",
|
||||
body: "DAV",
|
||||
contentLength: -1,
|
||||
updateRange: "bytes=6-8",
|
||||
wantStatus: http.StatusLengthRequired,
|
||||
}, {
|
||||
name: "patch_invalid_range",
|
||||
method: "PATCH",
|
||||
body: "DAV",
|
||||
updateRange: "bytes=8-6",
|
||||
wantStatus: http.StatusRequestedRangeNotSatisfiable,
|
||||
}, {
|
||||
name: "patch_length_mismatch",
|
||||
method: "PATCH",
|
||||
body: "TOOLONG",
|
||||
updateRange: "bytes=6-8",
|
||||
wantStatus: http.StatusRequestedRangeNotSatisfiable,
|
||||
}, {
|
||||
name: "put_content_range_length_mismatch",
|
||||
method: http.MethodPut,
|
||||
body: "TOOLONG",
|
||||
contentLength: -1,
|
||||
contentRange: "bytes 6-8/*",
|
||||
wantStatus: http.StatusRequestedRangeNotSatisfiable,
|
||||
}, {
|
||||
name: "if_none_match",
|
||||
method: "PATCH",
|
||||
body: "DAV",
|
||||
headerName: "If-None-Match",
|
||||
updateRange: "bytes=0-2",
|
||||
wantStatus: http.StatusPreconditionFailed,
|
||||
}, {
|
||||
name: "if_match",
|
||||
method: "PATCH",
|
||||
path: "/missing.txt",
|
||||
body: "DAV",
|
||||
headerName: "If-Match",
|
||||
updateRange: "bytes=0-2",
|
||||
wantStatus: http.StatusPreconditionFailed,
|
||||
}}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("hello world"),
|
||||
})
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
var body io.Reader = strings.NewReader(tc.body)
|
||||
if tc.contentLength < 0 {
|
||||
body = io.NopCloser(strings.NewReader(tc.body))
|
||||
}
|
||||
path := tc.path
|
||||
if path == "" {
|
||||
path = "/foo.txt"
|
||||
}
|
||||
req, err := http.NewRequest(tc.method, srv.URL+path, body)
|
||||
require.NoError(t, err)
|
||||
if tc.contentLength < 0 {
|
||||
req.ContentLength = tc.contentLength
|
||||
}
|
||||
if tc.method == "PATCH" {
|
||||
req.Header.Set("Content-Type", partialUpdateContentType)
|
||||
req.Header.Set("X-Update-Range", tc.updateRange)
|
||||
}
|
||||
if tc.contentRange != "" {
|
||||
req.Header.Set("Content-Range", tc.contentRange)
|
||||
}
|
||||
if tc.headerName != "" {
|
||||
req.Header.Set(tc.headerName, "*")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, tc.wantStatus, resp.StatusCode)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello world", string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPartialUpdateETagPreconditions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
method string
|
||||
headerName string
|
||||
headerValue func(string) string
|
||||
contentRange string
|
||||
wantStatus int
|
||||
wantData string
|
||||
}{{
|
||||
name: "if_match_matches",
|
||||
method: "PATCH",
|
||||
headerName: "If-Match",
|
||||
headerValue: func(etag string) string { return etag },
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantData: "hello DAVld",
|
||||
}, {
|
||||
name: "if_match_mismatch",
|
||||
method: "PATCH",
|
||||
headerName: "If-Match",
|
||||
headerValue: func(string) string { return `"definitely-wrong"` },
|
||||
wantStatus: http.StatusPreconditionFailed,
|
||||
wantData: "hello world",
|
||||
}, {
|
||||
name: "if_match_list_matches",
|
||||
method: "PATCH",
|
||||
headerName: "If-Match",
|
||||
headerValue: func(etag string) string { return `"definitely-wrong", ` + etag },
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantData: "hello DAVld",
|
||||
}, {
|
||||
name: "if_none_match_matches",
|
||||
method: "PATCH",
|
||||
headerName: "If-None-Match",
|
||||
headerValue: func(etag string) string { return etag },
|
||||
wantStatus: http.StatusPreconditionFailed,
|
||||
wantData: "hello world",
|
||||
}, {
|
||||
name: "if_none_match_mismatch",
|
||||
method: "PATCH",
|
||||
headerName: "If-None-Match",
|
||||
headerValue: func(string) string { return `"definitely-wrong"` },
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantData: "hello DAVld",
|
||||
}, {
|
||||
name: "put_content_range_if_match_mismatch",
|
||||
method: http.MethodPut,
|
||||
headerName: "If-Match",
|
||||
headerValue: func(string) string { return `"definitely-wrong"` },
|
||||
contentRange: "bytes 6-8/*",
|
||||
wantStatus: http.StatusPreconditionFailed,
|
||||
wantData: "hello world",
|
||||
}}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("hello world"),
|
||||
})
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodHead, srv.URL+"/foo.txt", nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
etag := resp.Header.Get("ETag")
|
||||
require.NotEmpty(t, etag)
|
||||
|
||||
req, err = http.NewRequest(tc.method, srv.URL+"/foo.txt", strings.NewReader("DAV"))
|
||||
require.NoError(t, err)
|
||||
if tc.method == "PATCH" {
|
||||
req.Header.Set("Content-Type", partialUpdateContentType)
|
||||
req.Header.Set("X-Update-Range", "bytes=6-8")
|
||||
}
|
||||
if tc.contentRange != "" {
|
||||
req.Header.Set("Content-Range", tc.contentRange)
|
||||
}
|
||||
req.Header.Set(tc.headerName, tc.headerValue(etag))
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, tc.wantStatus, resp.StatusCode)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantData, string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPartialUpdateHonorsLocks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const createLockBody = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:lockinfo xmlns:D='DAV:'>
|
||||
<D:lockscope><D:exclusive/></D:lockscope>
|
||||
<D:locktype><D:write/></D:locktype>
|
||||
<D:owner>test</D:owner>
|
||||
</D:lockinfo>`
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
lockPath string
|
||||
depth string
|
||||
ifPath string
|
||||
}{{
|
||||
name: "file",
|
||||
lockPath: "/foo.txt",
|
||||
depth: "0",
|
||||
ifPath: "/foo.txt",
|
||||
}, {
|
||||
name: "root",
|
||||
lockPath: "/",
|
||||
depth: "infinity",
|
||||
ifPath: "/",
|
||||
}}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("hello world"),
|
||||
})
|
||||
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest("LOCK", srv.URL+tc.lockPath, strings.NewReader(createLockBody))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
req.Header.Set("Depth", tc.depth)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
lockToken := resp.Header.Get("Lock-Token")
|
||||
|
||||
req, err = http.NewRequest("PATCH", srv.URL+"/foo.txt", strings.NewReader("DAV"))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", partialUpdateContentType)
|
||||
req.Header.Set("X-Update-Range", "bytes=6-8")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, 423, resp.StatusCode)
|
||||
|
||||
req, err = http.NewRequest("PATCH", srv.URL+"/foo.txt", strings.NewReader("DAV"))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", partialUpdateContentType)
|
||||
req.Header.Set("X-Update-Range", "bytes=6-8")
|
||||
req.Header.Set("If", fmt.Sprintf("<%s%s> (%s)", srv.URL, tc.ifPath, lockToken))
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello DAVld", string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerListingCharacters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
contents := map[string][]byte{
|
||||
"富/foo.txt": []byte("foo"),
|
||||
"你好.txt": []byte("bar"),
|
||||
"z*.txt": []byte("zbar"),
|
||||
"foo.txt": []byte("foo"),
|
||||
"🌹.txt": []byte("foo"),
|
||||
})
|
||||
}
|
||||
expectedNames := []string{"foo.txt", "你好.txt", "富", "🌹.txt"}
|
||||
if runtime.GOOS != "windows" {
|
||||
// Asterisks are invalid in Windows filenames.
|
||||
contents["z*.txt"] = []byte("zbar")
|
||||
expectedNames = append(expectedNames, "z*.txt")
|
||||
}
|
||||
sort.Strings(expectedNames)
|
||||
|
||||
dir := makeTestDirectory(t, contents)
|
||||
|
||||
srv := makeTestServer(t, "directory: "+dir)
|
||||
client := gowebdav.NewClient(srv.URL, "", "")
|
||||
@@ -99,28 +509,21 @@ func TestServerListingCharacters(t *testing.T) {
|
||||
// By default, reading permissions.
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 5)
|
||||
require.Len(t, files, len(expectedNames))
|
||||
|
||||
names := []string{
|
||||
files[0].Name(),
|
||||
files[1].Name(),
|
||||
files[2].Name(),
|
||||
files[3].Name(),
|
||||
files[4].Name(),
|
||||
names := make([]string, len(files))
|
||||
for i, file := range files {
|
||||
names[i] = file.Name()
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
require.Equal(t, []string{
|
||||
"foo.txt",
|
||||
"z*.txt",
|
||||
"你好.txt",
|
||||
"富",
|
||||
"🌹.txt",
|
||||
}, names)
|
||||
require.Equal(t, expectedNames, names)
|
||||
|
||||
data, err := client.Read("/z*.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("zbar"), data)
|
||||
if runtime.GOOS != "windows" {
|
||||
data, err := client.Read("/z*.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("zbar"), data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerAuthentication(t *testing.T) {
|
||||
@@ -217,7 +620,7 @@ users:
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerRules(t *testing.T) {
|
||||
func TestServerRulesRestrictive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
@@ -290,6 +693,57 @@ users:
|
||||
|
||||
err = client.Write("/c/b.txt", []byte("new"), 0666)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.MkdirAll("/d/foo/bar", 0666)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Write("/d/foo/bar/test.txt", []byte("test"), 0666)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestServerRulesAdditive(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"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: none
|
||||
|
||||
users:
|
||||
- username: basic
|
||||
password: basic
|
||||
rules:
|
||||
- regex: "^.+.js$"
|
||||
permissions: R
|
||||
- path: "/a/foo.txt"
|
||||
permissions: CRU
|
||||
- path: "/b/"
|
||||
permissions: D
|
||||
`, dir))
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "basic", "basic")
|
||||
|
||||
_, err := client.ReadDir("/")
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Write("/foo.txt", []byte("new"), 0666)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Write("/new.txt", []byte("new"), 0666)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Copy("/bar.js", "/a/foo.txt", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Remove("/b/foo.txt")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestServerRulesPrefix(t *testing.T) {
|
||||
@@ -368,6 +822,129 @@ users:
|
||||
require.ErrorContains(t, err, "403")
|
||||
}
|
||||
|
||||
func TestServerMultiDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirC := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
"folder/nested.txt": []byte("nested"),
|
||||
"public/access/ok.txt": []byte("ok"),
|
||||
})
|
||||
dirD := makeTestDirectory(t, map[string][]byte{
|
||||
"bar.txt": []byte("bar"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
permissions: CRUD
|
||||
directories:
|
||||
- c: %s
|
||||
- d: %s
|
||||
`, dirC, dirD))
|
||||
client := gowebdav.NewClient(srv.URL, "", "")
|
||||
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 2)
|
||||
require.Equal(t, "c", files[0].Name())
|
||||
require.Equal(t, "d", files[1].Name())
|
||||
|
||||
data, err := client.Read("/c/foo.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo"), data)
|
||||
|
||||
data, err = client.Read("/d/bar.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("bar"), data)
|
||||
|
||||
err = client.Copy("/c/foo.txt", "/d/copied.txt", false)
|
||||
require.NoError(t, err)
|
||||
data, err = os.ReadFile(filepath.Join(dirD, "copied.txt"))
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo"), data)
|
||||
|
||||
err = client.Rename("/c/foo.txt", "/d/moved.txt", false)
|
||||
require.NoError(t, err)
|
||||
require.NoFileExists(t, filepath.Join(dirC, "foo.txt"))
|
||||
data, err = os.ReadFile(filepath.Join(dirD, "moved.txt"))
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo"), data)
|
||||
|
||||
err = client.Rename("/d/bar.txt", "/d/renamed.txt", false)
|
||||
require.NoError(t, err)
|
||||
require.NoFileExists(t, filepath.Join(dirD, "bar.txt"))
|
||||
data, err = os.ReadFile(filepath.Join(dirD, "renamed.txt"))
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("bar"), data)
|
||||
|
||||
err = client.Rename("/c/folder", "/d/folder", false)
|
||||
require.NoError(t, err)
|
||||
require.NoDirExists(t, filepath.Join(dirC, "folder"))
|
||||
data, err = os.ReadFile(filepath.Join(dirD, "folder", "nested.txt"))
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("nested"), data)
|
||||
|
||||
require.ErrorContains(t, client.Remove("/c"), "405")
|
||||
require.Error(t, client.Write("/c", []byte("blocked"), 0666))
|
||||
require.ErrorContains(t, client.Rename("/d", "/c/d", false), "403")
|
||||
}
|
||||
|
||||
func TestServerMultiDirectoriesRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirC := makeTestDirectory(t, map[string][]byte{
|
||||
"public/access/ok.txt": []byte("ok"),
|
||||
})
|
||||
dirD := makeTestDirectory(t, map[string][]byte{
|
||||
"public/access/no.txt": []byte("no"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
permissions: none
|
||||
directories:
|
||||
- c: %s
|
||||
- d: %s
|
||||
rules:
|
||||
- path: /c/public/access/
|
||||
permissions: R
|
||||
`, dirC, dirD))
|
||||
client := gowebdav.NewClient(srv.URL, "", "")
|
||||
|
||||
data, err := client.Read("/c/public/access/ok.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("ok"), data)
|
||||
|
||||
_, err = client.Read("/d/public/access/no.txt")
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
_, err = client.Read("/public/access/ok.txt")
|
||||
require.ErrorContains(t, err, "403")
|
||||
}
|
||||
|
||||
func TestServerMultiDirectoriesPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirC := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
permissions: R
|
||||
prefix: /prefix
|
||||
directories:
|
||||
- c: %s
|
||||
`, dirC))
|
||||
client := gowebdav.NewClient(srv.URL, "", "")
|
||||
|
||||
files, err := client.ReadDir("/prefix")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1)
|
||||
require.Equal(t, "c", files[0].Name())
|
||||
|
||||
data, err := client.Read("/prefix/c/foo.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo"), data)
|
||||
}
|
||||
|
||||
func TestServerPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+55
-7
@@ -1,6 +1,7 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
@@ -9,27 +10,74 @@ import (
|
||||
|
||||
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.
|
||||
// lockSystem wraps a [webdav.LockSystem], mapping virtual request names to the
|
||||
// real backing paths via resolve. This allows reusing 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
|
||||
resolve func(name string) (string, error)
|
||||
}
|
||||
|
||||
// newLockSystem returns a lockSystem for a single-directory user, resolving
|
||||
// names relative to directory.
|
||||
func newLockSystem(ls webdav.LockSystem, directory string) *lockSystem {
|
||||
return &lockSystem{
|
||||
LockSystem: ls,
|
||||
resolve: func(name string) (string, error) {
|
||||
// Lock names share a slash-separated namespace across users, even
|
||||
// on Windows where filepath.Join would emit backslashes and break
|
||||
// descendant-lock matching in the underlying LockSystem.
|
||||
return path.Join(filepath.ToSlash(directory), name), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// newMultiDirLockSystem returns a lockSystem for a multi-directory user,
|
||||
// resolving names against the real backing path of each mount.
|
||||
func newMultiDirLockSystem(ls webdav.LockSystem, mounts DirectoryMounts) *lockSystem {
|
||||
return &lockSystem{
|
||||
LockSystem: ls,
|
||||
resolve: func(name string) (string, error) {
|
||||
if cleanName(name) == "/" {
|
||||
return "/", nil
|
||||
}
|
||||
|
||||
mount, rest, err := multiDir{mounts: mounts}.resolve(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// filePath returns an OS-native path for real file operations; the
|
||||
// lock namespace must stay slash-separated so descendant locks match
|
||||
// on Windows.
|
||||
return filepath.ToSlash(mount.filePath(rest)), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lockSystem) Confirm(now time.Time, name0, name1 string, conditions ...webdav.Condition) (release func(), err error) {
|
||||
if name0 != "" {
|
||||
name0 = filepath.Join(l.directory, name0)
|
||||
name0, err = l.resolve(name0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if name1 != "" {
|
||||
name1 = filepath.Join(l.directory, name1)
|
||||
name1, err = l.resolve(name1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return l.LockSystem.Confirm(now, name0, name1, conditions...)
|
||||
}
|
||||
|
||||
func (l *lockSystem) Create(now time.Time, details webdav.LockDetails) (token string, err error) {
|
||||
details.Root = filepath.Join(l.directory, details.Root)
|
||||
details.Root, err = l.resolve(details.Root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return l.LockSystem.Create(now, details)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
func TestLockSystemRootLockProtectsDescendants(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
locks := newLockSystem(webdav.NewMemLS(), filepath.Join(t.TempDir(), "nested"))
|
||||
now := time.Now()
|
||||
|
||||
token, err := locks.Create(now, webdav.LockDetails{
|
||||
Root: "/",
|
||||
Duration: time.Minute,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, locks.Unlock(time.Now(), token))
|
||||
})
|
||||
|
||||
_, err = locks.Create(now, webdav.LockDetails{
|
||||
Root: "/child.txt",
|
||||
Duration: time.Minute,
|
||||
ZeroDepth: true,
|
||||
})
|
||||
require.ErrorIs(t, err, webdav.ErrLocked)
|
||||
}
|
||||
|
||||
func TestLockSystemSharesLocksAcrossNestedUserDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
shared := webdav.NewMemLS()
|
||||
parentDirectory := t.TempDir()
|
||||
childDirectory := filepath.Join(parentDirectory, "child")
|
||||
parent := newLockSystem(shared, parentDirectory)
|
||||
child := newLockSystem(shared, childDirectory)
|
||||
now := time.Now()
|
||||
|
||||
token, err := parent.Create(now, webdav.LockDetails{
|
||||
Root: "/",
|
||||
Duration: time.Minute,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, parent.Unlock(time.Now(), token))
|
||||
})
|
||||
|
||||
_, err = child.Create(now, webdav.LockDetails{
|
||||
Root: "/file.txt",
|
||||
Duration: time.Minute,
|
||||
ZeroDepth: true,
|
||||
})
|
||||
require.ErrorIs(t, err, webdav.ErrLocked)
|
||||
|
||||
// The lock key is slash-separated on every platform, so the child's file
|
||||
// nests under the parent's root lock rather than diverging on Windows.
|
||||
key, err := child.resolve("/file.txt")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, path.Join(filepath.ToSlash(childDirectory), "file.txt"), key)
|
||||
}
|
||||
|
||||
func TestMultiDirLockSystemUsesSlashSeparatedKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mounts := DirectoryMounts{{Name: "docs", Path: filepath.Join(t.TempDir(), "docs")}}
|
||||
locks := newMultiDirLockSystem(webdav.NewMemLS(), mounts)
|
||||
|
||||
key, err := locks.resolve("/docs/report.txt")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, path.Join(filepath.ToSlash(mounts[0].Path), "report.txt"), key)
|
||||
require.NotContains(t, key, "\\")
|
||||
}
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
var _ webdav.FileSystem = multiDir{}
|
||||
|
||||
const windowsErrorNotSameDevice = syscall.Errno(17)
|
||||
|
||||
type multiDir struct {
|
||||
mounts DirectoryMounts
|
||||
noSniff bool
|
||||
}
|
||||
|
||||
func (m multiDir) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
|
||||
mount, rest, err := m.resolve(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rest == "/" {
|
||||
return os.ErrExist
|
||||
}
|
||||
|
||||
return mount.dir(m.noSniff).Mkdir(ctx, rest, perm)
|
||||
}
|
||||
|
||||
func (m multiDir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
if cleanName(name) == "/" {
|
||||
if writeFlag(flag) {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
return &multiDirRootFile{
|
||||
entries: m.rootEntries(ctx),
|
||||
info: virtualDirInfo{name: "/"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
mount, rest, err := m.resolve(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rest == "/" && writeFlag(flag) {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
file, err := mount.dir(m.noSniff).OpenFile(ctx, rest, flag, perm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if rest == "/" {
|
||||
return mountRootFile{File: file, name: mount.Name}, nil
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (m multiDir) RemoveAll(ctx context.Context, name string) error {
|
||||
if cleanName(name) == "/" {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
mount, rest, err := m.resolve(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rest == "/" {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
return mount.dir(m.noSniff).RemoveAll(ctx, rest)
|
||||
}
|
||||
|
||||
func (m multiDir) Rename(ctx context.Context, oldName, newName string) error {
|
||||
oldMount, oldRest, err := m.resolve(oldName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newMount, newRest, err := m.resolve(newName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if oldRest == "/" || newRest == "/" {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
if oldMount.Name == newMount.Name {
|
||||
return oldMount.dir(m.noSniff).Rename(ctx, oldRest, newRest)
|
||||
}
|
||||
|
||||
oldPath := oldMount.filePath(oldRest)
|
||||
newPath := newMount.filePath(newRest)
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
if isCrossDeviceError(err) {
|
||||
return renameAcrossMount(oldPath, newPath)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renameAcrossMount(oldPath, newPath string) error {
|
||||
info, err := os.Lstat(oldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
target, err := os.Readlink(oldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(target, newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(oldPath)
|
||||
}
|
||||
|
||||
if info.Mode().IsRegular() {
|
||||
if err := copyRegularFile(oldPath, newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
source, err := fs.Sub(os.DirFS(filepath.Dir(oldPath)), filepath.Base(oldPath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.CopyFS(newPath, source); err != nil {
|
||||
_ = os.RemoveAll(newPath)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := copyMetadata(oldPath, newPath); err != nil {
|
||||
_ = os.RemoveAll(newPath)
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(oldPath)
|
||||
}
|
||||
|
||||
func copyRegularFile(oldPath, newPath string) error {
|
||||
source, err := os.Open(oldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := os.OpenFile(newPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
_ = source.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
_, copyErr := io.Copy(target, source)
|
||||
copyErr = errors.Join(copyErr, target.Close(), source.Close())
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(newPath)
|
||||
return copyErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMetadata(oldPath, newPath string) error {
|
||||
return filepath.Walk(oldPath, func(name string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.Mode()&os.ModeSymlink != 0 {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(oldPath, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newName := filepath.Join(newPath, rel)
|
||||
if err := os.Chmod(newName, info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chtimes(newName, info.ModTime(), info.ModTime())
|
||||
})
|
||||
}
|
||||
|
||||
func isCrossDeviceError(err error) bool {
|
||||
return errors.Is(err, syscall.EXDEV) || runtime.GOOS == "windows" && errors.Is(err, windowsErrorNotSameDevice)
|
||||
}
|
||||
|
||||
func (m multiDir) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
||||
if cleanName(name) == "/" {
|
||||
return virtualDirInfo{name: "/"}, nil
|
||||
}
|
||||
|
||||
mount, rest, err := m.resolve(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := mount.dir(m.noSniff).Stat(ctx, rest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rest == "/" {
|
||||
return namedFileInfo{FileInfo: info, name: mount.Name}, nil
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (m multiDir) resolve(name string) (DirectoryMount, string, error) {
|
||||
name = cleanName(name)
|
||||
if name == "/" {
|
||||
return DirectoryMount{}, "", os.ErrInvalid
|
||||
}
|
||||
|
||||
trimmed := strings.TrimPrefix(name, "/")
|
||||
mountName, rest, _ := strings.Cut(trimmed, "/")
|
||||
for _, mount := range m.mounts {
|
||||
if mount.Name == mountName {
|
||||
if rest == "" {
|
||||
return mount, "/", nil
|
||||
}
|
||||
return mount, "/" + rest, nil
|
||||
}
|
||||
}
|
||||
|
||||
return DirectoryMount{}, "", os.ErrNotExist
|
||||
}
|
||||
|
||||
func (m multiDir) rootEntries(ctx context.Context) []os.FileInfo {
|
||||
entries := make([]os.FileInfo, 0, len(m.mounts))
|
||||
for _, mount := range m.mounts {
|
||||
info, err := mount.dir(m.noSniff).Stat(ctx, "/")
|
||||
if err != nil {
|
||||
entries = append(entries, virtualDirInfo{name: mount.Name})
|
||||
continue
|
||||
}
|
||||
entries = append(entries, namedFileInfo{FileInfo: info, name: mount.Name})
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Name() < entries[j].Name()
|
||||
})
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
func (d DirectoryMount) dir(noSniff bool) Dir {
|
||||
return Dir{
|
||||
Dir: webdav.Dir(d.Path),
|
||||
noSniff: noSniff,
|
||||
}
|
||||
}
|
||||
|
||||
func (d DirectoryMount) filePath(name string) string {
|
||||
return filepath.Join(d.Path, filepath.FromSlash(strings.TrimPrefix(name, "/")))
|
||||
}
|
||||
|
||||
func cleanName(name string) string {
|
||||
if name == "" || !strings.HasPrefix(name, "/") {
|
||||
name = "/" + name
|
||||
}
|
||||
return path.Clean(name)
|
||||
}
|
||||
|
||||
func writeFlag(flag int) bool {
|
||||
return flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_TRUNC|os.O_APPEND) != 0
|
||||
}
|
||||
|
||||
type multiDirRootFile struct {
|
||||
entries []os.FileInfo
|
||||
info os.FileInfo
|
||||
offset int
|
||||
}
|
||||
|
||||
func (f *multiDirRootFile) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *multiDirRootFile) Read([]byte) (int, error) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (f *multiDirRootFile) Seek(offset int64, whence int) (int64, error) {
|
||||
var next int64
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
next = offset
|
||||
case io.SeekCurrent:
|
||||
next = int64(f.offset) + offset
|
||||
case io.SeekEnd:
|
||||
next = int64(len(f.entries)) + offset
|
||||
default:
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
if next < 0 {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
f.offset = int(next)
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (f *multiDirRootFile) Readdir(count int) ([]os.FileInfo, error) {
|
||||
if count <= 0 {
|
||||
entries := f.entries[f.offset:]
|
||||
f.offset = len(f.entries)
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
if f.offset >= len(f.entries) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
end := f.offset + count
|
||||
if end > len(f.entries) {
|
||||
end = len(f.entries)
|
||||
}
|
||||
entries := f.entries[f.offset:end]
|
||||
f.offset = end
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (f *multiDirRootFile) Stat() (os.FileInfo, error) {
|
||||
return f.info, nil
|
||||
}
|
||||
|
||||
func (f *multiDirRootFile) Write([]byte) (int, error) {
|
||||
return 0, os.ErrPermission
|
||||
}
|
||||
|
||||
type mountRootFile struct {
|
||||
webdav.File
|
||||
name string
|
||||
}
|
||||
|
||||
func (f mountRootFile) Stat() (os.FileInfo, error) {
|
||||
info, err := f.File.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return namedFileInfo{FileInfo: info, name: f.name}, nil
|
||||
}
|
||||
|
||||
type namedFileInfo struct {
|
||||
os.FileInfo
|
||||
name string
|
||||
}
|
||||
|
||||
func (i namedFileInfo) Name() string {
|
||||
return i.name
|
||||
}
|
||||
|
||||
type virtualDirInfo struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (i virtualDirInfo) Name() string {
|
||||
return i.name
|
||||
}
|
||||
|
||||
func (i virtualDirInfo) Size() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (i virtualDirInfo) Mode() os.FileMode {
|
||||
return os.ModeDir | 0555
|
||||
}
|
||||
|
||||
func (i virtualDirInfo) ModTime() time.Time {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (i virtualDirInfo) IsDir() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (i virtualDirInfo) Sys() any {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRenameAcrossMount(t *testing.T) {
|
||||
source := makeTestDirectory(t, map[string][]byte{
|
||||
"file.txt": []byte("cross mount"),
|
||||
"folder/empty": nil,
|
||||
"folder/nested/file.txt": []byte("nested"),
|
||||
})
|
||||
target := t.TempDir()
|
||||
sourceFile := filepath.Join(source, "file.txt")
|
||||
modTime := time.Date(2020, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
require.NoError(t, os.Chmod(sourceFile, 0600))
|
||||
require.NoError(t, os.Chtimes(sourceFile, modTime, modTime))
|
||||
|
||||
require.NoError(t, renameAcrossMount(sourceFile, filepath.Join(target, "file.txt")))
|
||||
require.NoFileExists(t, filepath.Join(source, "file.txt"))
|
||||
data, err := os.ReadFile(filepath.Join(target, "file.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("cross mount"), data)
|
||||
info, err := os.Stat(filepath.Join(target, "file.txt"))
|
||||
require.NoError(t, err)
|
||||
if runtime.GOOS != "windows" {
|
||||
require.Equal(t, os.FileMode(0600), info.Mode().Perm())
|
||||
}
|
||||
require.WithinDuration(t, modTime, info.ModTime(), time.Second)
|
||||
|
||||
require.NoError(t, renameAcrossMount(filepath.Join(source, "folder"), filepath.Join(target, "folder")))
|
||||
require.NoDirExists(t, filepath.Join(source, "folder"))
|
||||
require.DirExists(t, filepath.Join(target, "folder", "empty"))
|
||||
data, err = os.ReadFile(filepath.Join(target, "folder", "nested", "file.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("nested"), data)
|
||||
}
|
||||
|
||||
func TestRenameAcrossMountPreservesSymlink(t *testing.T) {
|
||||
source := makeTestDirectory(t, map[string][]byte{
|
||||
"file.txt": []byte("target"),
|
||||
})
|
||||
oldPath := filepath.Join(source, "link.txt")
|
||||
if err := os.Symlink("file.txt", oldPath); err != nil {
|
||||
t.Skipf("symbolic links are unavailable: %v", err)
|
||||
}
|
||||
newPath := filepath.Join(t.TempDir(), "link.txt")
|
||||
|
||||
require.NoError(t, renameAcrossMount(oldPath, newPath))
|
||||
info, err := os.Lstat(newPath)
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, info.Mode()&os.ModeSymlink)
|
||||
target, err := os.Readlink(newPath)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "file.txt", target)
|
||||
}
|
||||
|
||||
func TestIsCrossDeviceError(t *testing.T) {
|
||||
err := syscall.EXDEV
|
||||
if runtime.GOOS == "windows" {
|
||||
err = windowsErrorNotSameDevice
|
||||
}
|
||||
require.True(t, isCrossDeviceError(err))
|
||||
require.False(t, isCrossDeviceError(os.ErrPermission))
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
const partialUpdateContentType = "application/x-sabredav-partialupdate"
|
||||
|
||||
type updateRange struct {
|
||||
offset int64
|
||||
end int64
|
||||
hasEnd bool
|
||||
append bool
|
||||
}
|
||||
|
||||
type partialUpdateError struct {
|
||||
status int
|
||||
err error
|
||||
}
|
||||
|
||||
func (e partialUpdateError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func newPartialUpdateError(status int, message string) error {
|
||||
return partialUpdateError{status: status, err: errors.New(message)}
|
||||
}
|
||||
|
||||
func writePartialUpdateError(w http.ResponseWriter, err error, fallbackStatus int) {
|
||||
var httpErr partialUpdateError
|
||||
if errors.As(err, &httpErr) {
|
||||
fallbackStatus = httpErr.status
|
||||
}
|
||||
http.Error(w, err.Error(), fallbackStatus)
|
||||
}
|
||||
|
||||
func (u *handlerUser) handleOptions(w http.ResponseWriter, r *http.Request, reqPath string) {
|
||||
allow := "OPTIONS, LOCK, PUT, MKCOL, PATCH"
|
||||
if fi, err := u.FileSystem.Stat(r.Context(), reqPath); err == nil {
|
||||
if fi.IsDir() {
|
||||
allow = "OPTIONS, LOCK, DELETE, PROPPATCH, COPY, MOVE, UNLOCK, PROPFIND"
|
||||
} else {
|
||||
allow = "OPTIONS, LOCK, GET, HEAD, POST, DELETE, PROPPATCH, COPY, MOVE, UNLOCK, PROPFIND, PUT, PATCH"
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Allow", allow)
|
||||
w.Header().Set("DAV", "1, 2, sabredav-partialupdate")
|
||||
w.Header().Set("MS-Author-Via", "DAV")
|
||||
w.Header().Set("Accept-Patch", partialUpdateContentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (u *handlerUser) handlePartialUpdate(w http.ResponseWriter, r *http.Request, reqPath string) {
|
||||
contentRange := r.Header.Get("Content-Range")
|
||||
isContentRangePut := r.Method == "PUT" && contentRange != ""
|
||||
|
||||
var (
|
||||
updateRange updateRange
|
||||
err error
|
||||
)
|
||||
if isContentRangePut {
|
||||
updateRange, err = parseContentRange(contentRange)
|
||||
} else {
|
||||
if err := checkPartialUpdateContentType(r.Header.Get("Content-Type")); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
updateRange, err = parseUpdateRange(r.Header.Get("X-Update-Range"))
|
||||
}
|
||||
if err != nil {
|
||||
writePartialUpdateError(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Method == "PATCH" && r.ContentLength < 0 {
|
||||
http.Error(w, "missing content length", http.StatusLengthRequired)
|
||||
return
|
||||
}
|
||||
|
||||
release, status, err := u.confirmPartialUpdateLocks(r, reqPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
ctx := r.Context()
|
||||
fi, statErr := u.FileSystem.Stat(ctx, reqPath)
|
||||
exists := statErr == nil
|
||||
if statErr != nil && !os.IsNotExist(statErr) {
|
||||
http.Error(w, statErr.Error(), http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if exists && fi.IsDir() {
|
||||
http.Error(w, "cannot update a collection", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
etag, status, err := u.checkPartialUpdatePreconditions(r, exists, fi)
|
||||
if err != nil {
|
||||
if etag != "" {
|
||||
w.Header().Set("ETag", etag)
|
||||
}
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
|
||||
currentSize := int64(0)
|
||||
if exists {
|
||||
currentSize = fi.Size()
|
||||
}
|
||||
if updateRange.append {
|
||||
updateRange.offset = currentSize
|
||||
} else if updateRange.offset < 0 {
|
||||
updateRange.offset += currentSize
|
||||
if updateRange.offset < 0 {
|
||||
updateRange.offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
if updateRange.hasEnd {
|
||||
expected := updateRange.end - updateRange.offset + 1
|
||||
if expected < 0 {
|
||||
http.Error(w, "invalid byte range", http.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
if r.ContentLength >= 0 && r.ContentLength != expected {
|
||||
http.Error(w, "content length does not match byte range", http.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body := io.Reader(r.Body)
|
||||
var cleanup func()
|
||||
if updateRange.hasEnd {
|
||||
body, cleanup, err = spoolBoundedBody(r.Body, updateRange.end-updateRange.offset+1)
|
||||
if err != nil {
|
||||
writePartialUpdateError(w, err, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
defer cleanup()
|
||||
}
|
||||
|
||||
flag := os.O_RDWR
|
||||
if !exists {
|
||||
flag |= os.O_CREATE
|
||||
}
|
||||
f, err := u.FileSystem.OpenFile(ctx, reqPath, flag, 0666)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
if _, err := f.Seek(updateRange.offset, io.SeekStart); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(f, body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !exists {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func checkPartialUpdateContentType(contentType string) error {
|
||||
if contentType == "" {
|
||||
return errors.New("missing content type")
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mediaType != partialUpdateContentType {
|
||||
return fmt.Errorf("unsupported content type %q", mediaType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *handlerUser) checkPartialUpdatePreconditions(r *http.Request, exists bool, fi os.FileInfo) (etag string, status int, err error) {
|
||||
ifMatch := r.Header.Get("If-Match")
|
||||
ifNoneMatch := r.Header.Get("If-None-Match")
|
||||
if ifMatch == "" && ifNoneMatch == "" {
|
||||
return "", 0, nil
|
||||
}
|
||||
|
||||
if ifMatch != "" && !exists {
|
||||
return "", http.StatusPreconditionFailed, errors.New("resource does not exist")
|
||||
}
|
||||
|
||||
if exists {
|
||||
etag, err = findPartialETag(r.Context(), fi)
|
||||
if err != nil {
|
||||
return "", http.StatusInternalServerError, err
|
||||
}
|
||||
}
|
||||
|
||||
if ifMatch != "" && !partialETagHeaderMatches(ifMatch, etag, exists) {
|
||||
return etag, http.StatusPreconditionFailed, errors.New("etag does not match")
|
||||
}
|
||||
|
||||
if ifNoneMatch != "" && exists && partialETagHeaderMatches(ifNoneMatch, etag, true) {
|
||||
return etag, http.StatusPreconditionFailed, errors.New("etag matches")
|
||||
}
|
||||
|
||||
return etag, 0, nil
|
||||
}
|
||||
|
||||
func findPartialETag(ctx context.Context, fi os.FileInfo) (string, error) {
|
||||
if etager, ok := fi.(webdav.ETager); ok {
|
||||
etag, err := etager.ETag(ctx)
|
||||
if !errors.Is(err, webdav.ErrNotImplemented) {
|
||||
return etag, err
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(`"%x%x"`, fi.ModTime().UnixNano(), fi.Size()), nil
|
||||
}
|
||||
|
||||
func partialETagHeaderMatches(header, etag string, exists bool) bool {
|
||||
for _, item := range strings.Split(header, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "*" {
|
||||
return exists
|
||||
}
|
||||
if item == etag || strings.ReplaceAll(item, `\"`, `"`) == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseUpdateRange(header string) (updateRange, error) {
|
||||
if header == "" {
|
||||
return updateRange{}, errors.New("missing X-Update-Range header")
|
||||
}
|
||||
if header == "append" {
|
||||
return updateRange{append: true}, nil
|
||||
}
|
||||
if !strings.HasPrefix(header, "bytes=") {
|
||||
return updateRange{}, errors.New("invalid X-Update-Range header")
|
||||
}
|
||||
return parseByteRange(strings.TrimPrefix(header, "bytes="), true)
|
||||
}
|
||||
|
||||
func parseContentRange(header string) (updateRange, error) {
|
||||
if !strings.HasPrefix(header, "bytes ") {
|
||||
return updateRange{}, errors.New("invalid Content-Range header")
|
||||
}
|
||||
spec, _, ok := strings.Cut(strings.TrimPrefix(header, "bytes "), "/")
|
||||
if !ok {
|
||||
return updateRange{}, errors.New("invalid Content-Range header")
|
||||
}
|
||||
return parseByteRange(spec, false)
|
||||
}
|
||||
|
||||
func parseByteRange(spec string, allowNegativeStart bool) (updateRange, error) {
|
||||
if strings.HasPrefix(spec, "-") {
|
||||
if !allowNegativeStart {
|
||||
return updateRange{}, errors.New("invalid byte range start")
|
||||
}
|
||||
start, err := strconv.ParseInt(strings.TrimPrefix(spec, "-"), 10, 64)
|
||||
if err != nil || start < 0 {
|
||||
return updateRange{}, errors.New("invalid byte range start")
|
||||
}
|
||||
if start == 0 {
|
||||
return updateRange{append: true}, nil
|
||||
}
|
||||
return updateRange{offset: -start}, nil
|
||||
}
|
||||
|
||||
startText, endText, ok := strings.Cut(spec, "-")
|
||||
if !ok || startText == "" {
|
||||
return updateRange{}, errors.New("invalid byte range")
|
||||
}
|
||||
|
||||
start, err := strconv.ParseInt(startText, 10, 64)
|
||||
if err != nil {
|
||||
return updateRange{}, errors.New("invalid byte range start")
|
||||
}
|
||||
if start < 0 && !allowNegativeStart {
|
||||
return updateRange{}, errors.New("invalid byte range start")
|
||||
}
|
||||
|
||||
r := updateRange{offset: start}
|
||||
if endText == "" {
|
||||
return r, nil
|
||||
}
|
||||
if start < 0 {
|
||||
return updateRange{}, errors.New("negative byte range cannot include an end")
|
||||
}
|
||||
|
||||
end, err := strconv.ParseInt(endText, 10, 64)
|
||||
if err != nil {
|
||||
return updateRange{}, errors.New("invalid byte range end")
|
||||
}
|
||||
if end < start {
|
||||
return updateRange{}, newPartialUpdateError(http.StatusRequestedRangeNotSatisfiable, "invalid byte range")
|
||||
}
|
||||
r.end = end
|
||||
r.hasEnd = true
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func spoolBoundedBody(body io.Reader, expected int64) (io.Reader, func(), error) {
|
||||
tmp, err := os.CreateTemp("", "webdav-partial-update-*")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cleanup := func() {
|
||||
name := tmp.Name()
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(name)
|
||||
}
|
||||
cleanupOnError := true
|
||||
defer func() {
|
||||
if cleanupOnError {
|
||||
cleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
n, err := io.Copy(tmp, io.LimitReader(body, expected+1))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if n != expected {
|
||||
return nil, nil, newPartialUpdateError(http.StatusRequestedRangeNotSatisfiable, "body length does not match byte range")
|
||||
}
|
||||
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cleanupOnError = false
|
||||
return tmp, cleanup, nil
|
||||
}
|
||||
|
||||
// confirmPartialUpdateLocks mirrors the unexported confirmLocks helper from
|
||||
// golang.org/x/net/webdav so that partial updates honor WebDAV locks the same
|
||||
// way regular PUT requests do. Keep it in sync if the upstream behavior changes.
|
||||
func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (release func(), status int, err error) {
|
||||
hdr := r.Header.Get("If")
|
||||
if hdr == "" {
|
||||
now := time.Now()
|
||||
token, err := u.LockSystem.Create(now, webdav.LockDetails{
|
||||
Root: src,
|
||||
Duration: -1,
|
||||
ZeroDepth: true,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, webdav.ErrLocked) {
|
||||
return nil, webdav.StatusLocked, err
|
||||
}
|
||||
return nil, http.StatusInternalServerError, err
|
||||
}
|
||||
return func() {
|
||||
_ = u.LockSystem.Unlock(now, token)
|
||||
}, 0, nil
|
||||
}
|
||||
|
||||
ifLists, ok := parsePartialIfHeader(hdr)
|
||||
if !ok {
|
||||
return nil, http.StatusBadRequest, errors.New("webdav: invalid If header")
|
||||
}
|
||||
for _, l := range ifLists {
|
||||
lsrc := l.resourceTag
|
||||
if lsrc == "" {
|
||||
lsrc = src
|
||||
} else {
|
||||
parsedURL, err := url.Parse(lsrc)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if parsedURL.Host != r.Host {
|
||||
continue
|
||||
}
|
||||
lsrc, err = stripPartialPrefix(parsedURL.Path, u.Prefix)
|
||||
if err != nil {
|
||||
return nil, http.StatusNotFound, err
|
||||
}
|
||||
if lsrc == "" {
|
||||
lsrc = src
|
||||
}
|
||||
}
|
||||
release, err = u.LockSystem.Confirm(time.Now(), lsrc, "", l.conditions...)
|
||||
if errors.Is(err, webdav.ErrConfirmationFailed) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, err
|
||||
}
|
||||
return release, 0, nil
|
||||
}
|
||||
return nil, http.StatusPreconditionFailed, webdav.ErrLocked
|
||||
}
|
||||
|
||||
type partialIfList struct {
|
||||
resourceTag string
|
||||
conditions []webdav.Condition
|
||||
}
|
||||
|
||||
// parsePartialIfHeader, parsePartialIfConditions and cutPartialIfToken
|
||||
// reimplement the unexported If-header parser from golang.org/x/net/webdav,
|
||||
// which is not accessible from outside that package. Keep them in sync with the
|
||||
// upstream parseIfHeader if it changes.
|
||||
func parsePartialIfHeader(header string) ([]partialIfList, bool) {
|
||||
s := strings.TrimSpace(header)
|
||||
tagged := strings.HasPrefix(s, "<")
|
||||
var lists []partialIfList
|
||||
for s != "" {
|
||||
resourceTag := ""
|
||||
if strings.HasPrefix(s, "<") {
|
||||
if !tagged {
|
||||
return nil, false
|
||||
}
|
||||
var ok bool
|
||||
resourceTag, s, ok = cutPartialIfToken(s, '<', '>')
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "(") {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
for strings.HasPrefix(s, "(") {
|
||||
body, rest, ok := cutPartialIfToken(s, '(', ')')
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
conditions, ok := parsePartialIfConditions(body)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
lists = append(lists, partialIfList{resourceTag: resourceTag, conditions: conditions})
|
||||
s = strings.TrimSpace(rest)
|
||||
}
|
||||
if s != "" && !strings.HasPrefix(s, "<") {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return lists, len(lists) > 0
|
||||
}
|
||||
|
||||
func parsePartialIfConditions(s string) ([]webdav.Condition, bool) {
|
||||
var conditions []webdav.Condition
|
||||
for {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return conditions, len(conditions) > 0
|
||||
}
|
||||
not := false
|
||||
if strings.HasPrefix(s, "Not ") || strings.HasPrefix(s, "Not\t") {
|
||||
not = true
|
||||
s = strings.TrimSpace(s[3:])
|
||||
}
|
||||
if s == "" {
|
||||
return nil, false
|
||||
}
|
||||
var token string
|
||||
switch s[0] {
|
||||
case '<':
|
||||
var ok bool
|
||||
token, s, ok = cutPartialIfToken(s, '<', '>')
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
conditions = append(conditions, webdav.Condition{Not: not, Token: token})
|
||||
case '[':
|
||||
var ok bool
|
||||
token, s, ok = cutPartialIfToken(s, '[', ']')
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
conditions = append(conditions, webdav.Condition{Not: not, ETag: token})
|
||||
default:
|
||||
i := strings.IndexAny(s, " \t")
|
||||
if i < 0 {
|
||||
token, s = s, ""
|
||||
} else {
|
||||
token, s = s[:i], s[i:]
|
||||
}
|
||||
if token == "" || strings.ContainsAny(token, "()<>[]") {
|
||||
return nil, false
|
||||
}
|
||||
conditions = append(conditions, webdav.Condition{Not: not, Token: token})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cutPartialIfToken(s string, open, close byte) (string, string, bool) {
|
||||
if s == "" || s[0] != open {
|
||||
return "", "", false
|
||||
}
|
||||
token, rest, ok := strings.Cut(s[1:], string(close))
|
||||
return token, rest, ok
|
||||
}
|
||||
|
||||
func stripPartialPrefix(p, prefix string) (string, error) {
|
||||
if prefix == "" {
|
||||
return p, nil
|
||||
}
|
||||
if stripped := strings.TrimPrefix(p, prefix); len(stripped) < len(p) {
|
||||
return stripped, nil
|
||||
}
|
||||
return "", errors.New("webdav: prefix mismatch")
|
||||
}
|
||||
+62
-2
@@ -44,11 +44,23 @@ const (
|
||||
|
||||
type UserPermissions struct {
|
||||
Directory string
|
||||
Directories DirectoryMounts
|
||||
Permissions Permissions
|
||||
Rules []*Rule
|
||||
RulesBehavior RulesBehavior
|
||||
|
||||
directoryExplicit bool
|
||||
directoriesExplicit bool
|
||||
useDirectories bool
|
||||
}
|
||||
|
||||
type DirectoryMount struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
type DirectoryMounts []DirectoryMount
|
||||
|
||||
// Allowed checks if the user has permission to access a directory/file
|
||||
func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool {
|
||||
// For COPY and MOVE requests, we first check the permissions for the destination
|
||||
@@ -56,9 +68,11 @@ func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool
|
||||
// we fail immediately. If no rule matches, we check the global permissions.
|
||||
if r.method == "COPY" || r.method == "MOVE" {
|
||||
dst := r.destination
|
||||
ruleMatched := false
|
||||
|
||||
for i := len(p.Rules) - 1; i >= 0; i-- {
|
||||
if p.Rules[i].Matches(dst) {
|
||||
ruleMatched = true
|
||||
if !p.Rules[i].Permissions.AllowedDestination(r, fileExists) {
|
||||
return false
|
||||
}
|
||||
@@ -68,7 +82,7 @@ func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool
|
||||
}
|
||||
}
|
||||
|
||||
if !p.Permissions.AllowedDestination(r, fileExists) {
|
||||
if !ruleMatched && !p.Permissions.AllowedDestination(r, fileExists) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -92,6 +106,12 @@ func (p *UserPermissions) Validate() error {
|
||||
return fmt.Errorf("invalid permissions: %w", err)
|
||||
}
|
||||
|
||||
if p.useDirectories || len(p.Directories) > 0 {
|
||||
if err := (&p.Directories).Validate(); err != nil {
|
||||
return fmt.Errorf("invalid permissions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range p.Rules {
|
||||
if err := r.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid permissions: %w", err)
|
||||
@@ -108,6 +128,46 @@ func (p *UserPermissions) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DirectoryMounts) Validate() error {
|
||||
names := map[string]struct{}{}
|
||||
|
||||
for i := range *d {
|
||||
mount := &(*d)[i]
|
||||
if mount.Path == "" {
|
||||
return errors.New("invalid directories: path must be defined")
|
||||
}
|
||||
|
||||
path, err := filepath.Abs(mount.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid directories: %w", err)
|
||||
}
|
||||
mount.Path = path
|
||||
|
||||
if mount.Name == "" {
|
||||
mount.Name = filepath.Base(path)
|
||||
}
|
||||
|
||||
if !validDirectoryMountName(mount.Name) {
|
||||
return fmt.Errorf("invalid directories: invalid mount name %q", mount.Name)
|
||||
}
|
||||
|
||||
if _, ok := names[mount.Name]; ok {
|
||||
return fmt.Errorf("invalid directories: duplicate mount name %q", mount.Name)
|
||||
}
|
||||
names[mount.Name] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validDirectoryMountName(name string) bool {
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return false
|
||||
}
|
||||
|
||||
return !strings.ContainsAny(name, `/\`)
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
Create bool
|
||||
Read bool
|
||||
@@ -150,7 +210,7 @@ func (p Permissions) Allowed(r *request, fileExists func(string) bool) bool {
|
||||
return p.Create
|
||||
case "PROPPATCH":
|
||||
return p.Update
|
||||
case "PUT":
|
||||
case "PUT", "PATCH":
|
||||
if fileExists(r.path) {
|
||||
return p.Update
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/hacdias/webdav/v5/cmd"
|
||||
|
||||
_ "golang.org/x/crypto/x509roots/fallback"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
"group:allNonMajor",
|
||||
"group:allDigest",
|
||||
":disableDependencyDashboard",
|
||||
":semanticCommitTypeAll(chore)"
|
||||
],
|
||||
"postUpdateOptions": [
|
||||
"gomodUpdateImportPaths",
|
||||
"gomodTidy"
|
||||
],
|
||||
"schedule": "* * * * 0,6"
|
||||
}
|
||||
Reference in New Issue
Block a user