mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-22 03:20:41 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4733aa03c4 | ||
|
|
6dc4d8de20 | ||
|
|
d0ad4623a9 | ||
|
|
203d2f72bf | ||
|
|
f869dd6276 | ||
|
|
c04649bf40 | ||
|
|
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 | ||
|
|
5226853fca | ||
|
|
e350dc989a | ||
|
|
65de8e8578 | ||
|
|
f0b5a39d80 | ||
|
|
dbd708a76e | ||
|
|
f61b7cab3b | ||
|
|
3cfaa0da3e | ||
|
|
f7b78cd834 | ||
|
|
4b4e555ed5 | ||
|
|
6aeb3f8a30 | ||
|
|
59c5f1343f | ||
|
|
617496c019 | ||
|
|
3790d7de08 | ||
|
|
eaf42b03e9 | ||
|
|
dce6010b61 | ||
|
|
88863d7242 | ||
|
|
2c96db92aa | ||
|
|
79bc17afab | ||
|
|
51b101d3d8 | ||
|
|
ca7f3374d5 | ||
|
|
64bbdc7b15 | ||
|
|
d418bd2661 | ||
|
|
d500716f29 | ||
|
|
8c49af0b68 | ||
|
|
a5777e18ee | ||
|
|
49a6e935c3 | ||
|
|
a698e31cb4 | ||
|
|
74b514c877 | ||
|
|
ca0bdb1cfa | ||
|
|
a056e1ba18 | ||
|
|
189af88bc8 | ||
|
|
4e87e6a613 | ||
|
|
63449f1636 | ||
|
|
4ad26dad35 | ||
|
|
623bbc9a70 | ||
|
|
feeb33d249 | ||
|
|
d3bee98000 | ||
|
|
373b2ec931 | ||
|
|
000f404f7a | ||
|
|
e4a8622c1e | ||
|
|
b5a3d07f5c | ||
|
|
f4de82cfd1 | ||
|
|
ebcf500d5e | ||
|
|
d7faa1f887 | ||
|
|
d5e5052f63 | ||
|
|
a255fb51e2 | ||
|
|
ed23ca1820 | ||
|
|
e7e9c3176d | ||
|
|
d3732322bc | ||
|
|
f708664906 | ||
|
|
814462bed1 |
@@ -12,8 +12,10 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.22.x"
|
go-version: "1.27.x"
|
||||||
- run: go build .
|
- run: go build .
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: '0'
|
||||||
|
|||||||
@@ -18,22 +18,22 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
hacdias/webdav
|
hacdias/webdav
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ jobs:
|
|||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.22.x"
|
go-version: "1.27.x"
|
||||||
- uses: golangci/golangci-lint-action@v6
|
- uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: "v1.59"
|
version: "latest"
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ jobs:
|
|||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v7
|
||||||
- uses: goreleaser/goreleaser-action@v6
|
with:
|
||||||
|
go-version: "1.27.x"
|
||||||
|
- uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
version: '~> v2'
|
version: '~> v2'
|
||||||
|
|||||||
@@ -10,12 +10,21 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: test
|
name: test (${{ matrix.os }})
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os:
|
||||||
|
- ubuntu-latest
|
||||||
|
- windows-latest
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.22.x"
|
go-version: "1.27.x"
|
||||||
- name: Run test with coverage
|
- name: Run test with race detector and coverage
|
||||||
|
if: runner.os != 'Windows'
|
||||||
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
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/
|
dist/
|
||||||
webdav
|
webdav
|
||||||
|
|||||||
+10
-4
@@ -8,10 +8,12 @@ before:
|
|||||||
builds:
|
builds:
|
||||||
- main: main.go
|
- main: main.go
|
||||||
binary: webdav
|
binary: webdav
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
flags:
|
flags:
|
||||||
- '-trimpath'
|
- '-trimpath'
|
||||||
ldflags:
|
ldflags:
|
||||||
- '-X github.com/hacdias/webdav/v4/cmd.version={{.Version}}'
|
- '-s -w -X github.com/hacdias/webdav/v5/cmd.version={{.Version}}'
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
- linux
|
- linux
|
||||||
@@ -36,16 +38,20 @@ builds:
|
|||||||
- goos: openbsd
|
- goos: openbsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
goarm: 6
|
goarm: 6
|
||||||
|
# Broken as of Go 1.24, deprecated as of Go 1.26
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
goarm: 6
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
|
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
|
||||||
format: tar.gz
|
formats:
|
||||||
|
- tar.gz
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
formats:
|
||||||
|
- zip
|
||||||
|
|
||||||
release:
|
release:
|
||||||
github:
|
github:
|
||||||
|
|||||||
+3
-7
@@ -1,9 +1,7 @@
|
|||||||
FROM golang:1.22-alpine3.20 AS build
|
FROM golang:1.26-alpine3.22 AS build
|
||||||
|
|
||||||
ARG VERSION="untracked"
|
ARG VERSION="untracked"
|
||||||
|
|
||||||
RUN apk --update add ca-certificates
|
|
||||||
|
|
||||||
WORKDIR /webdav/
|
WORKDIR /webdav/
|
||||||
|
|
||||||
COPY ./go.mod ./
|
COPY ./go.mod ./
|
||||||
@@ -11,14 +9,12 @@ COPY ./go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . /webdav/
|
COPY . /webdav/
|
||||||
RUN go build -o main -ldflags="-X 'github.com/hacdias/webdav/v4/cmd.version=$VERSION'" .
|
RUN go build -o main -trimpath -ldflags="-s -w -X 'github.com/hacdias/webdav/v5/cmd.version=$VERSION'" .
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
|
|
||||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
|
||||||
COPY --from=build /webdav/main /bin/webdav
|
COPY --from=build /webdav/main /bin/webdav
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 6065
|
||||||
|
|
||||||
ENTRYPOINT [ "webdav" ]
|
ENTRYPOINT [ "webdav" ]
|
||||||
CMD [ "-p", "80" ]
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# webdav
|
# webdav
|
||||||
|
|
||||||
[](https://goreportcard.com/report/hacdias/webdav)
|
|
||||||
[](https://github.com/hacdias/webdav/releases/latest)
|
[](https://github.com/hacdias/webdav/releases/latest)
|
||||||
[](https://hub.docker.com/r/hacdias/webdav)
|
[](https://hub.docker.com/r/hacdias/webdav)
|
||||||
|
|
||||||
@@ -11,7 +10,15 @@ A simple and standalone [WebDAV](https://en.wikipedia.org/wiki/WebDAV) server.
|
|||||||
For a manual install, please refer to the [releases](https://github.com/hacdias/webdav/releases) page and download the correct binary for your system. Alternatively, you can build or install it from source using the Go toolchain. You can either clone the repository and execute `go build`, or directly install it, using:
|
For a manual install, please refer to the [releases](https://github.com/hacdias/webdav/releases) page and download the correct binary for your system. Alternatively, you can build or install it from source using the Go toolchain. You can either clone the repository and execute `go build`, or directly install it, using:
|
||||||
|
|
||||||
```
|
```
|
||||||
go install github.com/hacdias/webdav/v4@latest
|
go install github.com/hacdias/webdav/v5@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Homebrew
|
||||||
|
|
||||||
|
If you have Homebrew available on your system, you can also install `webdav` using it:
|
||||||
|
|
||||||
|
```
|
||||||
|
brew install webdav
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
@@ -30,106 +37,212 @@ docker pull hacdias/webdav:latest
|
|||||||
|
|
||||||
For usage information regarding the CLI, run `webdav --help`.
|
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 scope 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
|
The equivalent Docker command to the aforementioned compose file would be as follows:
|
||||||
port: 6060
|
|
||||||
scope: /data
|
|
||||||
```
|
|
||||||
|
|
||||||
You can now run with the following Docker command, where you mount the configuration file inside the container, and the data directory too, as well as forwarding the port 6060. You will need to change this to match your own configuration.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run \
|
docker run \
|
||||||
-p 6060:6060 \
|
-p 6065:6065 \
|
||||||
-v $(pwd)/config.yml:/config.yml:ro \
|
-v ./config.yml:/config.yml:ro \
|
||||||
-v $(pwd)/data:/data \
|
-v ./data:/data \
|
||||||
ghcr.io/hacdias/webdav -c /config.yml
|
ghcr.io/hacdias/webdav -c /config.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you are using [fail2ban](#fail2ban-setup), it would be helpful to add the parameters listed below. They will assist in analyzing
|
||||||
|
the log.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
--log-driver journald \
|
||||||
|
--name webdav \
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
## 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
|
```yaml
|
||||||
address: 0.0.0.0
|
address: 0.0.0.0
|
||||||
port: 0
|
port: 6065
|
||||||
|
|
||||||
# TLS-related settings if you want to enable TLS directly.
|
# TLS-related settings if you want to enable TLS directly.
|
||||||
tls: false
|
tls: false
|
||||||
cert: cert.pem
|
cert: cert.pem
|
||||||
key: key.pem
|
key: key.pem
|
||||||
|
|
||||||
# Prefix to apply to the WebDAV path-ing. Default is "/".
|
# Prefix to apply to the WebDAV path-ing. Default is '/'.
|
||||||
prefix: /
|
prefix: /
|
||||||
|
|
||||||
# Enable or disable debug logging. Default is false.
|
# Enable or disable debug logging. Default is 'false'.
|
||||||
debug: false
|
debug: false
|
||||||
|
|
||||||
# Whether or not to have authentication. With authentication on, you need to
|
# Disable sniffing the files to detect their content type. Default is 'false'.
|
||||||
# define one or more users. Default is false.
|
noSniff: false
|
||||||
auth: true
|
|
||||||
|
# Whether the server runs behind a trusted proxy or not. When this is true,
|
||||||
|
# the header X-Forwarded-For will be used for logging the remote addresses
|
||||||
|
# of logging attempts (if available).
|
||||||
|
behindProxy: false
|
||||||
|
|
||||||
# The directory that will be able to be accessed by the users when connecting.
|
# The directory that will be able to be accessed by the users when connecting.
|
||||||
# This directory will be used by users unless they have their own 'scope' defined.
|
# This directory will be used by users unless they have their own 'directory' defined.
|
||||||
# Default is "/".
|
# By default it points to the working directory. In the case of the compose file above,
|
||||||
scope: /
|
# that is /data.
|
||||||
|
directory: /data
|
||||||
|
|
||||||
# Whether the users can, by default, modify the contents. Default is false.
|
# Alternatively, replace 'directory' with 'directories' to expose multiple
|
||||||
modify: true
|
# 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
|
||||||
|
|
||||||
# Default permissions rules to apply at the paths.
|
# The default permissions for users. This is a case insensitive option. Possible
|
||||||
|
# permissions: C (Create), R (Read), U (Update), D (Delete). You can combine multiple
|
||||||
|
# permissions. For example, to allow to read and create, set "RC". Default is "R".
|
||||||
|
permissions: R
|
||||||
|
|
||||||
|
# The default permissions rules for users. Default is none. Rules are applied
|
||||||
|
# from last to first, that is, the first rule that matches the request, starting
|
||||||
|
# from the end, will be applied to the request. Rule paths are always relative to
|
||||||
|
# the user's directory.
|
||||||
rules: []
|
rules: []
|
||||||
|
|
||||||
# The list of users. Must be defined if auth is set to true.
|
# The behavior of redefining the rules for users. It can be:
|
||||||
|
# - overwrite: when a user has rules defined, these will overwrite any global
|
||||||
|
# rules already defined. That is, the global rules are not applicable to the
|
||||||
|
# user.
|
||||||
|
# - append: when a user has rules defined, these will be appended to the global
|
||||||
|
# rules already defined. That is, for this user, their own specific rules will
|
||||||
|
# be checked first, and then the global rules.
|
||||||
|
# Default is 'overwrite'.
|
||||||
|
rulesBehavior: overwrite
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
log:
|
||||||
|
# Logging format ('console', 'json'). Default is 'console'.
|
||||||
|
format: console
|
||||||
|
# Enable or disable colors. Default is 'true'. Only applied if format is 'console'.
|
||||||
|
colors: true
|
||||||
|
# Logging outputs. You can have more than one output. Default is only 'stderr'.
|
||||||
|
outputs:
|
||||||
|
- stderr
|
||||||
|
|
||||||
|
# CORS configuration
|
||||||
|
cors:
|
||||||
|
# Whether or not CORS configuration should be applied. Default is 'false'.
|
||||||
|
enabled: true
|
||||||
|
credentials: true
|
||||||
|
# Allow Private Network Access preflight requests. Default is 'false'.
|
||||||
|
allow_private_network: false
|
||||||
|
# The following are the default CORS settings when it is enabled.
|
||||||
|
allowed_hosts:
|
||||||
|
- '*'
|
||||||
|
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: []
|
||||||
|
|
||||||
|
# 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:
|
users:
|
||||||
# Example 'admin' user with plaintext password.
|
# Example 'admin' user with plaintext password.
|
||||||
- username: admin
|
- username: admin
|
||||||
password: admin
|
password: admin
|
||||||
# Example 'john' user with bcrypt encrypted password, with custom scope.
|
|
||||||
|
# Example 'john' user with bcrypt encrypted password, with custom directory.
|
||||||
|
# Tip: you can generate a bcrypt-encrypted password by using the 'webdav bcrypt'
|
||||||
|
# command lint utility, or htpasswd on Linux.
|
||||||
- username: john
|
- username: john
|
||||||
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
|
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
|
||||||
scope: /another/path
|
directory: /data/john
|
||||||
# Example user whose details will be picked up from the environment.
|
# Example user whose details will be picked up from the environment.
|
||||||
- username: "{env}ENV_USERNAME"
|
- username: "{env}ENV_USERNAME"
|
||||||
password: "{env}ENV_PASSWORD"
|
password: "{env}ENV_PASSWORD"
|
||||||
|
# Example user with advanced control over his permissions
|
||||||
- username: basic
|
- username: basic
|
||||||
password: basic
|
password: basic
|
||||||
# Override default modify.
|
permissions: CRUD # Override default permissions.
|
||||||
modify: false
|
|
||||||
rules:
|
rules:
|
||||||
# With this rule, the user CANNOT access /some/files.
|
# With this rule, the user CANNOT access {user directory}/some/files.
|
||||||
- path: /some/file
|
- path: /some/file
|
||||||
allow: false
|
permissions: none
|
||||||
# With this rule, the user CAN modify /public/access.
|
# With this rule, the user CAN create, read, update and delete within
|
||||||
|
# {user directory}/public/access.
|
||||||
- path: /public/access/
|
- path: /public/access/
|
||||||
modify: true
|
permissions: CRUD
|
||||||
# With this rule, the user CAN modify all files ending with .js. It uses
|
# With this rule, the user CAN read and update all files ending with .js.
|
||||||
# a regular expression.
|
# It uses a regular expression.
|
||||||
- path: "^*.js$"
|
- regex: "^.+.js$"
|
||||||
regex: true
|
permissions: RU
|
||||||
modify: true
|
# Example user for android SeedVault backuping
|
||||||
|
- username: android
|
||||||
|
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
|
||||||
|
directory: /data/android
|
||||||
|
permissions: CRUD
|
||||||
|
|
||||||
# CORS configuration
|
# If you're delegating the authentication to a different service, you can proxy
|
||||||
cors:
|
# the username using basic authentication, and then disable webdav's password
|
||||||
enabled: true
|
# check using the option:
|
||||||
credentials: true
|
# noPassword: true
|
||||||
allowed_headers:
|
|
||||||
- Depth
|
|
||||||
allowed_hosts:
|
|
||||||
- http://localhost:8080
|
|
||||||
allowed_methods:
|
|
||||||
- GET
|
|
||||||
exposed_headers:
|
|
||||||
- Content-Length
|
|
||||||
- Content-Range
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
Rules are matched against the request path after dot segments have been resolved, so `/public/../secret/file` is matched as `/secret/file`. The last rule that matches wins.
|
||||||
|
|
||||||
|
A `path` rule is a prefix match. A rule written with a trailing slash also covers the collection it names, so `path: /secret/` applies to a request for `/secret` as well. Such a rule can only restrict that collection: acting on the collection itself also requires the permissions that apply outside the rule, since the operation takes place in the parent collection.
|
||||||
|
|
||||||
|
A `regex` rule is matched literally against the path, and gets none of the above handling. In particular `regex: "^/secret/"` does **not** match a request for `/secret` itself. Write `regex: "^/secret(/|$)"` if you want to cover the collection too.
|
||||||
|
|
||||||
### CORS
|
### CORS
|
||||||
|
|
||||||
The `allowed_*` properties are optional, the default value for each of them will be `*`. `exposed_headers` is optional as well, but is not set if not defined. Setting `credentials` to `true` will allow you to:
|
The `allowed_*` properties are optional, the default value for each of them will be `*`. `exposed_headers` is optional as well, but is not set if not defined. Setting `allow_private_network` to `true` to allow Private-Network-Access preflight requests. Setting `credentials` to `true` will allow you to:
|
||||||
|
|
||||||
1. Use `withCredentials = true` in javascript.
|
1. Use `withCredentials = true` in javascript.
|
||||||
2. Use the `username:password@host` syntax.
|
2. Use the `username:password@host` syntax.
|
||||||
@@ -138,7 +251,9 @@ The `allowed_*` properties are optional, the default value for each of them will
|
|||||||
|
|
||||||
### Reverse Proxy Service
|
### 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
|
```nginx
|
||||||
location / {
|
location / {
|
||||||
@@ -146,8 +261,35 @@ location / {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header REMOTE-HOST $remote_addr;
|
proxy_set_header REMOTE-HOST $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $host;
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
|
# Ensure COPY and MOVE commands work. Change https://example.com to the
|
||||||
|
# correct address where the WebDAV server will be deployed at.
|
||||||
|
set $dest $http_destination;
|
||||||
|
if ($http_destination ~ "^https://example.com(?<path>(.+))") {
|
||||||
|
set $dest /$path;
|
||||||
|
}
|
||||||
|
proxy_set_header Destination $dest;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -172,6 +314,64 @@ Restart=on-failure
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Fail2Ban Setup
|
||||||
|
|
||||||
|
To add security against brute-force attacks in your WebDAV server, you can configure Fail2Ban to ban IP addresses after a set number of failed login attempts.
|
||||||
|
|
||||||
|
#### Filter Configuration
|
||||||
|
|
||||||
|
Create a new filter rule under `filter.d/webdav.conf`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[INCLUDES]
|
||||||
|
before = common.conf
|
||||||
|
|
||||||
|
[Definition]
|
||||||
|
# Failregex to match "invalid password" and extract remote_address only
|
||||||
|
failregex = ^.*invalid password\s*\{.*"remote_address":\s*"<HOST>:\d+"\s*\}
|
||||||
|
^.*invalid username\s*\{.*"remote_address":\s*"<HOST>:\d+"\s*\}
|
||||||
|
|
||||||
|
ignoreregex =
|
||||||
|
```
|
||||||
|
|
||||||
|
This configuration will capture invalid login attempts and extract the IP address to ban.
|
||||||
|
|
||||||
|
#### Jail Configuration
|
||||||
|
|
||||||
|
In `jail.d/webdav.conf`, define the jail that monitors your WebDAV log for failed login attempts:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[webdav]
|
||||||
|
|
||||||
|
enabled = true
|
||||||
|
port = [your_port]
|
||||||
|
filter = webdav
|
||||||
|
logpath = [your_log_path]
|
||||||
|
banaction = iptables-allports
|
||||||
|
ignoreself = false
|
||||||
|
```
|
||||||
|
|
||||||
|
- Replace `[your_port]` with the port your WebDAV server is running on.
|
||||||
|
- Replace `[your_log_path]` with the path to your WebDAV log file.
|
||||||
|
|
||||||
|
If you use it with Docker and `--log-driver journald`, replace `logpath` with `journalmatch = CONTAINER_NAME=[your_container_name]`
|
||||||
|
|
||||||
|
#### Final Steps
|
||||||
|
|
||||||
|
1. Restart Fail2Ban to apply these configurations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart fail2ban
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify that Fail2Ban is running and monitoring your WebDAV logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo fail2ban-client status webdav
|
||||||
|
```
|
||||||
|
|
||||||
|
With this setup, Fail2Ban will automatically block IP addresses that exceed the allowed number of failed login attempts.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Feel free to open an issue or a pull request.
|
Feel free to open an issue or a pull request.
|
||||||
|
|||||||
+15
-3
@@ -1,8 +1,20 @@
|
|||||||
# Security Policy
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| 5.x | :white_check_mark: |
|
||||||
|
| < 5.x | :x: |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Please report security issues to:
|
- **Critical:** report privately via the [Security](https://github.com/hacdias/webdav/security) page.
|
||||||
msaa1990 [at] gmail [dot com]
|
- **Non-critical:** open a public issue so the community can help; it'll be labeled it as a security issue.
|
||||||
|
|
||||||
cc: hacdias [at] gmail [dot com]
|
Please include, where possible:
|
||||||
|
|
||||||
|
- The commit the issue was found at
|
||||||
|
- A plaintext proof of concept (no binaries)
|
||||||
|
- Steps to reproduce
|
||||||
|
- Recommended remediation, if any
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
flags := bcryptCmd.Flags()
|
||||||
|
flags.IntP("cost", "c", bcrypt.DefaultCost, "cost used to generate password, higher cost leads to slower verification times")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(bcryptCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bcryptCmd = &cobra.Command{
|
||||||
|
Use: "bcrypt",
|
||||||
|
Short: "Generate a bcrypt encrypted password",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cost, err := cmd.Flags().GetInt("cost")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cost < bcrypt.MinCost {
|
||||||
|
return fmt.Errorf("given cost cannot be under minimum cost of %d", bcrypt.MinCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cost > bcrypt.MaxCost {
|
||||||
|
return fmt.Errorf("given cost cannot be over maximum cost of %d", bcrypt.MaxCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
pwd := args[0]
|
||||||
|
if pwd == "" {
|
||||||
|
return errors.New("password argument must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(pwd), cost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(string(hash))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
+31
-31
@@ -1,6 +1,7 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -10,23 +11,21 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/hacdias/webdav/v4/lib"
|
"github.com/coreos/go-systemd/v22/activation"
|
||||||
|
"github.com/hacdias/webdav/v5/lib"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"go.uber.org/zap/zapcore"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
flags := rootCmd.Flags()
|
flags := rootCmd.Flags()
|
||||||
flags.StringP("config", "c", "", "config file path")
|
flags.StringP("config", "c", "", "config file path")
|
||||||
flags.BoolP("tls", "t", lib.DefaultTLS, "enable TLS")
|
|
||||||
flags.Bool("auth", lib.DefaultAuth, "enable authentication")
|
|
||||||
flags.String("cert", lib.DefaultCert, "path to TLS certificate")
|
|
||||||
flags.String("key", lib.DefaultKey, "path to TLS key")
|
|
||||||
flags.StringP("address", "a", lib.DefaultAddress, "address to listen on")
|
flags.StringP("address", "a", lib.DefaultAddress, "address to listen on")
|
||||||
flags.IntP("port", "p", lib.DefaultPort, "port to listen on")
|
flags.IntP("port", "p", lib.DefaultPort, "port to listen on")
|
||||||
|
flags.BoolP("tls", "t", lib.DefaultTLS, "enable TLS")
|
||||||
|
flags.String("cert", lib.DefaultCert, "path to TLS certificate")
|
||||||
|
flags.String("key", lib.DefaultKey, "path to TLS key")
|
||||||
flags.StringP("prefix", "P", lib.DefaultPrefix, "URL path prefix")
|
flags.StringP("prefix", "P", lib.DefaultPrefix, "URL path prefix")
|
||||||
flags.String("log_format", lib.DefaultLogFormat, "logging format")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
@@ -58,14 +57,15 @@ set WD_CERT.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create HTTP handler from the config
|
// Setup the logger based on the configuration
|
||||||
handler, err := lib.NewHandler(cfg)
|
logger, err := cfg.GetLogger()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
zap.ReplaceGlobals(logger)
|
||||||
|
|
||||||
// Setup the logger based on the configuration
|
// Create HTTP handler from the config
|
||||||
err = setupLogger(cfg)
|
handler, err := lib.NewHandler(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,8 @@ set WD_CERT.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server := &http.Server{Handler: handler}
|
||||||
|
|
||||||
// Trap exiting signals
|
// Trap exiting signals
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
|
|
||||||
@@ -89,9 +91,9 @@ set WD_CERT.`,
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
if cfg.TLS {
|
if cfg.TLS {
|
||||||
err = http.ServeTLS(listener, handler, cfg.Cert, cfg.Key)
|
err = server.ServeTLS(listener, cfg.Cert, cfg.Key)
|
||||||
} else {
|
} else {
|
||||||
err = http.Serve(listener, handler)
|
err = server.Serve(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
@@ -105,7 +107,7 @@ set WD_CERT.`,
|
|||||||
signal := <-quit
|
signal := <-quit
|
||||||
|
|
||||||
zap.L().Info("caught signal, shutting down", zap.Stringer("signal", signal))
|
zap.L().Info("caught signal, shutting down", zap.Stringer("signal", signal))
|
||||||
_ = listener.Close()
|
_ = server.Shutdown(context.Background())
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -117,7 +119,21 @@ func getListener(cfg *lib.Config) (net.Listener, error) {
|
|||||||
network string
|
network string
|
||||||
)
|
)
|
||||||
|
|
||||||
if strings.HasPrefix(cfg.Address, "unix:") {
|
if strings.HasPrefix(cfg.Address, "sd-listen-fd:") {
|
||||||
|
listeners, err := activation.ListenersWithNames()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
address := cfg.Address[13:]
|
||||||
|
listener, ok := listeners[address]
|
||||||
|
|
||||||
|
if !ok || len(listener) < 1 {
|
||||||
|
return nil, errors.New("unknown sd-listen-fd address '" + address + "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
return listener[0], nil
|
||||||
|
} else if strings.HasPrefix(cfg.Address, "unix:") {
|
||||||
address = cfg.Address[5:]
|
address = cfg.Address[5:]
|
||||||
network = "unix"
|
network = "unix"
|
||||||
} else {
|
} else {
|
||||||
@@ -127,19 +143,3 @@ func getListener(cfg *lib.Config) (net.Listener, error) {
|
|||||||
|
|
||||||
return net.Listen(network, address)
|
return net.Listen(network, address)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupLogger(cfg *lib.Config) error {
|
|
||||||
loggerConfig := zap.NewProductionConfig()
|
|
||||||
loggerConfig.DisableCaller = true
|
|
||||||
if cfg.Debug {
|
|
||||||
loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
|
||||||
}
|
|
||||||
loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
|
||||||
loggerConfig.Encoding = cfg.LogFormat
|
|
||||||
logger, err := loggerConfig.Build()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
zap.ReplaceGlobals(logger)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
+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,39 +1,33 @@
|
|||||||
module github.com/hacdias/webdav/v4
|
module github.com/hacdias/webdav/v5
|
||||||
|
|
||||||
go 1.22
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/rs/cors v1.11.0
|
github.com/coreos/go-systemd/v22 v22.7.0
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||||
github.com/spf13/pflag v1.0.5
|
github.com/rs/cors v1.11.1
|
||||||
github.com/spf13/viper v1.19.0
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.9.0
|
github.com/spf13/pflag v1.0.10
|
||||||
go.uber.org/zap v1.27.0
|
github.com/spf13/viper v1.21.0
|
||||||
golang.org/x/crypto v0.25.0
|
github.com/stretchr/testify v1.12.1
|
||||||
golang.org/x/net v0.27.0
|
github.com/studio-b12/gowebdav v0.13.0
|
||||||
|
go.uber.org/zap v1.28.0
|
||||||
|
golang.org/x/crypto v0.55.0
|
||||||
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20260826144058-afebf4cb4efb
|
||||||
|
golang.org/x/net v0.58.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.6.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
|
||||||
github.com/spf13/afero v1.11.0 // indirect
|
|
||||||
github.com/spf13/cast v1.6.0 // indirect
|
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/sys v0.22.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.16.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
retract v4.1.0
|
|
||||||
|
|||||||
@@ -1,84 +1,65 @@
|
|||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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=
|
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
|
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||||
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk=
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0=
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7 h1:wDLEX9a7YQoKdKNQt88rtydkqDxeGaBUTnIYc3iG/mA=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20260826144058-afebf4cb4efb h1:3OQBwC/IAO9+1/yWsvWkE1Lw5InpHGlZxXZHUcWBouA=
|
||||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20260826144058-afebf4cb4efb/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
|
||||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|||||||
+269
-55
@@ -3,42 +3,48 @@ package lib
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-viper/mapstructure/v2"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultTLS = false
|
DefaultTLS = false
|
||||||
DefaultAuth = false
|
DefaultCert = "cert.pem"
|
||||||
DefaultCert = "cert.pem"
|
DefaultKey = "key.pem"
|
||||||
DefaultKey = "key.pem"
|
DefaultAddress = "0.0.0.0"
|
||||||
DefaultAddress = "0.0.0.0"
|
DefaultPort = 6065
|
||||||
DefaultPort = 0
|
DefaultPrefix = "/"
|
||||||
DefaultPrefix = "/"
|
|
||||||
DefaultLogFormat = "console"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errDirectoryConflict = errors.New("directory and directories cannot both be defined")
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Permissions `mapstructure:",squash"`
|
UserPermissions `mapstructure:",squash"`
|
||||||
Debug bool
|
Debug bool
|
||||||
Address string
|
Address string
|
||||||
Port int
|
Port int
|
||||||
TLS bool
|
TLS bool
|
||||||
Cert string
|
Cert string
|
||||||
Key string
|
Key string
|
||||||
Prefix string
|
Prefix string
|
||||||
NoSniff bool
|
NoSniff bool
|
||||||
LogFormat string `mapstructure:"log_format"`
|
NoPassword bool
|
||||||
Auth bool
|
BehindProxy bool
|
||||||
CORS CORS
|
Log Log
|
||||||
Users []User
|
CORS CORS
|
||||||
|
Users []User
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||||
v := viper.New()
|
v := viper.NewWithOptions(viper.ExperimentalBindStruct())
|
||||||
|
|
||||||
// Configure flags bindings
|
// Configure flags bindings
|
||||||
if flags != nil {
|
if flags != nil {
|
||||||
@@ -46,16 +52,15 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.BindPFlag("LogFormat", flags.Lookup("log_format"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration file settings
|
// Configuration file settings
|
||||||
v.AddConfigPath(".")
|
v.AddConfigPath(".")
|
||||||
v.AddConfigPath("/etc/webdav/")
|
v.AddConfigPath("/etc/webdav/")
|
||||||
|
if path, err := os.Executable(); err == nil {
|
||||||
|
v.AddConfigPath(filepath.Dir(path))
|
||||||
|
}
|
||||||
|
|
||||||
v.SetConfigName("config")
|
v.SetConfigName("config")
|
||||||
if filename != "" {
|
if filename != "" {
|
||||||
v.SetConfigFile(filename)
|
v.SetConfigFile(filename)
|
||||||
@@ -72,14 +77,21 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
|||||||
v.SetDefault("Key", DefaultKey)
|
v.SetDefault("Key", DefaultKey)
|
||||||
v.SetDefault("Address", DefaultAddress)
|
v.SetDefault("Address", DefaultAddress)
|
||||||
v.SetDefault("Port", DefaultPort)
|
v.SetDefault("Port", DefaultPort)
|
||||||
v.SetDefault("Auth", DefaultAuth)
|
|
||||||
v.SetDefault("Prefix", DefaultPrefix)
|
v.SetDefault("Prefix", DefaultPrefix)
|
||||||
v.SetDefault("Log_Format", DefaultLogFormat)
|
|
||||||
|
|
||||||
// Other defaults
|
// Other defaults
|
||||||
v.SetDefault("CORS.Allowed_Headers", []string{"*"})
|
v.SetDefault("RulesBehavior", RulesOverwrite)
|
||||||
|
v.SetDefault("Directory", ".")
|
||||||
|
v.SetDefault("Permissions", "R")
|
||||||
|
v.SetDefault("Debug", false)
|
||||||
|
v.SetDefault("NoSniff", false)
|
||||||
|
v.SetDefault("NoPassword", false)
|
||||||
|
v.SetDefault("Log.Format", "console")
|
||||||
|
v.SetDefault("Log.Outputs", []string{"stderr"})
|
||||||
|
v.SetDefault("Log.Colors", true)
|
||||||
v.SetDefault("CORS.Allowed_Hosts", []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
|
// Read and unmarshal configuration
|
||||||
err := v.ReadInConfig()
|
err := v.ReadInConfig()
|
||||||
@@ -90,22 +102,57 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
err = v.Unmarshal(cfg)
|
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||||
|
directoryMountsDecodeHook(),
|
||||||
|
mapstructure.StringToTimeDurationHookFunc(),
|
||||||
|
mapstructure.StringToSliceHookFunc(","),
|
||||||
|
mapstructure.TextUnmarshallerHookFunc(),
|
||||||
|
)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// Cascade user settings
|
||||||
for i := range cfg.Users {
|
for i := range cfg.Users {
|
||||||
if !v.IsSet(fmt.Sprintf("Users.%d.Scope", i)) {
|
userDirectoryKey := fmt.Sprintf("Users.%d.Directory", i)
|
||||||
cfg.Users[i].Scope = cfg.Scope
|
userDirectoriesKey := fmt.Sprintf("Users.%d.Directories", i)
|
||||||
|
|
||||||
|
if !v.IsSet(userDirectoryKey) {
|
||||||
|
cfg.Users[i].Directory = cfg.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
if !v.IsSet(fmt.Sprintf("Users.%d.Modify", i)) {
|
err := applyDirectoryConfig(v, flags, &cfg.Users[i].UserPermissions, userDirectoryKey, userDirectoriesKey, &cfg.UserPermissions)
|
||||||
cfg.Users[i].Modify = cfg.Modify
|
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.Rules", i)) {
|
if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
|
||||||
|
cfg.Users[i].Permissions = cfg.Permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
if !v.IsSet(fmt.Sprintf("Users.%d.RulesBehavior", i)) {
|
||||||
|
cfg.Users[i].RulesBehavior = cfg.RulesBehavior
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) {
|
||||||
|
switch cfg.Users[i].RulesBehavior {
|
||||||
|
case RulesOverwrite:
|
||||||
|
// Do nothing
|
||||||
|
case RulesAppend:
|
||||||
|
rules := append([]*Rule{}, cfg.Rules...)
|
||||||
|
rules = append(rules, cfg.Users[i].Rules...)
|
||||||
|
|
||||||
|
cfg.Users[i].Rules = rules
|
||||||
|
}
|
||||||
|
} else {
|
||||||
cfg.Users[i].Rules = cfg.Rules
|
cfg.Users[i].Rules = cfg.Rules
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,18 +165,50 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
|||||||
return cfg, nil
|
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 {
|
func (c *Config) Validate() error {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if c.Auth && len(c.Users) == 0 {
|
c.Directory, err = filepath.Abs(c.Directory)
|
||||||
return errors.New("invalid config: auth cannot be enabled without users")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !c.Auth && len(c.Users) != 0 {
|
|
||||||
return errors.New("invalid config: auth cannot be disabled with users defined")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Scope, err = filepath.Abs(c.Scope)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid config: %w", err)
|
return fmt.Errorf("invalid config: %w", err)
|
||||||
}
|
}
|
||||||
@@ -154,13 +233,13 @@ func (c *Config) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.Permissions.Validate()
|
err = c.UserPermissions.Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid config: %w", err)
|
return fmt.Errorf("invalid config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range c.Users {
|
for i := range c.Users {
|
||||||
err := u.Validate()
|
err := c.Users[i].Validate(c.NoPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid config: %w", err)
|
return fmt.Errorf("invalid config: %w", err)
|
||||||
}
|
}
|
||||||
@@ -169,11 +248,146 @@ func (c *Config) Validate() error {
|
|||||||
return nil
|
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
|
||||||
|
if cfg.Debug {
|
||||||
|
loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
||||||
|
}
|
||||||
|
if cfg.Log.Colors && cfg.Log.Format != "json" {
|
||||||
|
loggerConfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||||
|
}
|
||||||
|
loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||||
|
loggerConfig.Encoding = cfg.Log.Format
|
||||||
|
loggerConfig.OutputPaths = cfg.Log.Outputs
|
||||||
|
return loggerConfig.Build()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Log struct {
|
||||||
|
Format string
|
||||||
|
Colors bool
|
||||||
|
Outputs []string
|
||||||
|
}
|
||||||
|
|
||||||
type CORS struct {
|
type CORS struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Credentials bool
|
Credentials bool
|
||||||
AllowedHeaders []string `mapstructure:"allowed_headers"`
|
AllowPrivateNetwork bool `mapstructure:"allow_private_network"`
|
||||||
AllowedHosts []string `mapstructure:"allowed_hosts"`
|
AllowedHeaders []string `mapstructure:"allowed_headers"`
|
||||||
AllowedMethods []string `mapstructure:"allowed_methods"`
|
AllowedHosts []string `mapstructure:"allowed_hosts"`
|
||||||
ExposedHeaders []string `mapstructure:"exposed_headers"`
|
AllowedMethods []string `mapstructure:"allowed_methods"`
|
||||||
|
ExposedHeaders []string `mapstructure:"exposed_headers"`
|
||||||
}
|
}
|
||||||
|
|||||||
+394
-47
@@ -3,8 +3,10 @@ package lib
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,60 +23,90 @@ func writeAndParseConfig(t *testing.T, content, extension string) *Config {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeAndParseConfigWithError(t *testing.T, content, extension, error string) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "config"+extension)
|
||||||
|
|
||||||
|
err := os.WriteFile(tmpFile, []byte(content), 0666)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = ParseConfig(tmpFile, nil)
|
||||||
|
require.ErrorContains(t, err, error)
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfigDefaults(t *testing.T) {
|
func TestConfigDefaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := writeAndParseConfig(t, "", ".yml")
|
cfg := writeAndParseConfig(t, "", ".yml")
|
||||||
require.NoError(t, cfg.Validate())
|
require.NoError(t, cfg.Validate())
|
||||||
|
|
||||||
require.EqualValues(t, DefaultAuth, cfg.Auth)
|
|
||||||
require.EqualValues(t, DefaultTLS, cfg.TLS)
|
require.EqualValues(t, DefaultTLS, cfg.TLS)
|
||||||
require.EqualValues(t, DefaultAddress, cfg.Address)
|
require.EqualValues(t, DefaultAddress, cfg.Address)
|
||||||
require.EqualValues(t, DefaultPort, cfg.Port)
|
require.EqualValues(t, DefaultPort, cfg.Port)
|
||||||
require.EqualValues(t, DefaultPrefix, cfg.Prefix)
|
require.EqualValues(t, DefaultPrefix, cfg.Prefix)
|
||||||
require.EqualValues(t, DefaultLogFormat, cfg.LogFormat)
|
require.EqualValues(t, "console", cfg.Log.Format)
|
||||||
require.NotEmpty(t, cfg.Scope)
|
require.EqualValues(t, true, cfg.Log.Colors)
|
||||||
|
require.EqualValues(t, []string{"stderr"}, cfg.Log.Outputs)
|
||||||
|
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, dir, cfg.Directory)
|
||||||
|
|
||||||
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHeaders)
|
|
||||||
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
|
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)
|
||||||
|
require.False(t, cfg.CORS.AllowPrivateNetwork)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigCascade(t *testing.T) {
|
func TestConfigCascade(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
check := func(t *testing.T, cfg *Config) {
|
||||||
require.True(t, cfg.Modify)
|
require.True(t, cfg.Permissions.Read)
|
||||||
require.Equal(t, "/", cfg.Scope)
|
require.True(t, cfg.Permissions.Create)
|
||||||
|
require.False(t, cfg.Permissions.Delete)
|
||||||
|
require.False(t, cfg.Permissions.Update)
|
||||||
|
require.Equal(t, rootDirectory, cfg.Directory)
|
||||||
require.Len(t, cfg.Rules, 1)
|
require.Len(t, cfg.Rules, 1)
|
||||||
|
|
||||||
require.Len(t, cfg.Users, 2)
|
require.Len(t, cfg.Users, 2)
|
||||||
|
require.True(t, cfg.Users[0].Permissions.Read)
|
||||||
require.True(t, cfg.Users[0].Modify)
|
require.True(t, cfg.Users[0].Permissions.Create)
|
||||||
require.Equal(t, "/", cfg.Users[0].Scope)
|
require.False(t, cfg.Users[0].Permissions.Delete)
|
||||||
|
require.False(t, cfg.Users[0].Permissions.Update)
|
||||||
|
require.Equal(t, rootDirectory, cfg.Users[0].Directory)
|
||||||
require.Len(t, cfg.Users[0].Rules, 1)
|
require.Len(t, cfg.Users[0].Rules, 1)
|
||||||
|
|
||||||
require.False(t, cfg.Users[1].Modify)
|
require.True(t, cfg.Users[1].Permissions.Read)
|
||||||
require.Equal(t, "/basic", cfg.Users[1].Scope)
|
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, basicDirectory, cfg.Users[1].Directory)
|
||||||
require.Len(t, cfg.Users[1].Rules, 0)
|
require.Len(t, cfg.Users[1].Rules, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("YAML", func(t *testing.T) {
|
t.Run("YAML", func(t *testing.T) {
|
||||||
content := `
|
content := `
|
||||||
auth: true
|
directory: /
|
||||||
scope: /
|
permissions: CR
|
||||||
modify: true
|
|
||||||
rules:
|
rules:
|
||||||
- path: /public/access/
|
- path: /public/access/
|
||||||
modify: true
|
permissions: R
|
||||||
|
|
||||||
users:
|
users:
|
||||||
- username: admin
|
- username: admin
|
||||||
password: admin
|
password: admin
|
||||||
- username: basic
|
- username: basic
|
||||||
password: basic
|
password: basic
|
||||||
scope: /basic
|
directory: /basic
|
||||||
modify: false
|
permissions: R
|
||||||
rules: []`
|
rules: []`
|
||||||
|
|
||||||
cfg := writeAndParseConfig(t, content, ".yml")
|
cfg := writeAndParseConfig(t, content, ".yml")
|
||||||
@@ -85,13 +117,12 @@ users:
|
|||||||
|
|
||||||
t.Run("JSON", func(t *testing.T) {
|
t.Run("JSON", func(t *testing.T) {
|
||||||
content := `{
|
content := `{
|
||||||
"auth": true,
|
"directory": "/",
|
||||||
"scope": "/",
|
"permissions": "CR",
|
||||||
"modify": true,
|
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"path": "/public/access/",
|
"path": "/public/access/",
|
||||||
"modify": true
|
"permissions": "R"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"users": [
|
"users": [
|
||||||
@@ -102,8 +133,8 @@ users:
|
|||||||
{
|
{
|
||||||
"username": "basic",
|
"username": "basic",
|
||||||
"password": "basic",
|
"password": "basic",
|
||||||
"scope": "/basic",
|
"directory": "/basic",
|
||||||
"modify": false,
|
"permissions": "R",
|
||||||
"rules": []
|
"rules": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -116,13 +147,13 @@ users:
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("`TOML", func(t *testing.T) {
|
t.Run("`TOML", func(t *testing.T) {
|
||||||
content := `auth = true
|
content := `
|
||||||
scope = "/"
|
directory = "/"
|
||||||
modify = true
|
permissions = "CR"
|
||||||
|
|
||||||
[[rules]]
|
[[rules]]
|
||||||
path = "/public/access/"
|
path = "/public/access/"
|
||||||
modify = true
|
permissions = "R"
|
||||||
|
|
||||||
[[users]]
|
[[users]]
|
||||||
username = "admin"
|
username = "admin"
|
||||||
@@ -131,8 +162,8 @@ password = "admin"
|
|||||||
[[users]]
|
[[users]]
|
||||||
username = "basic"
|
username = "basic"
|
||||||
password = "basic"
|
password = "basic"
|
||||||
scope = "/basic"
|
directory = "/basic"
|
||||||
modify = false
|
permissions = "R"
|
||||||
rules = []
|
rules = []
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -143,6 +174,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) {
|
func TestConfigKeys(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -150,6 +357,7 @@ func TestConfigKeys(t *testing.T) {
|
|||||||
cors:
|
cors:
|
||||||
enabled: true
|
enabled: true
|
||||||
credentials: true
|
credentials: true
|
||||||
|
allow_private_network: true
|
||||||
allowed_headers:
|
allowed_headers:
|
||||||
- Depth
|
- Depth
|
||||||
allowed_hosts:
|
allowed_hosts:
|
||||||
@@ -163,6 +371,7 @@ cors:
|
|||||||
|
|
||||||
require.True(t, cfg.CORS.Enabled)
|
require.True(t, cfg.CORS.Enabled)
|
||||||
require.True(t, cfg.CORS.Credentials)
|
require.True(t, cfg.CORS.Credentials)
|
||||||
|
require.True(t, cfg.CORS.AllowPrivateNetwork)
|
||||||
require.EqualValues(t, []string{"Content-Length", "Content-Range"}, cfg.CORS.ExposedHeaders)
|
require.EqualValues(t, []string{"Content-Length", "Content-Range"}, cfg.CORS.ExposedHeaders)
|
||||||
require.EqualValues(t, []string{"Depth"}, cfg.CORS.AllowedHeaders)
|
require.EqualValues(t, []string{"Depth"}, cfg.CORS.AllowedHeaders)
|
||||||
require.EqualValues(t, []string{"http://localhost:8080"}, cfg.CORS.AllowedHosts)
|
require.EqualValues(t, []string{"http://localhost:8080"}, cfg.CORS.AllowedHosts)
|
||||||
@@ -170,28 +379,166 @@ cors:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigRules(t *testing.T) {
|
func TestConfigRules(t *testing.T) {
|
||||||
content := `
|
t.Run("Only Regex or Path", func(t *testing.T) {
|
||||||
auth: false
|
content := `
|
||||||
scope: /
|
directory: /
|
||||||
modify: true
|
|
||||||
rules:
|
rules:
|
||||||
- path: '^.+\.js$'
|
- regex: '^.+\.js$'
|
||||||
regex: true
|
path: /public/access/`
|
||||||
modify: true
|
|
||||||
|
writeAndParseConfigWithError(t, content, ".yaml", "cannot define both regex and path")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Regex or Path Required", func(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
directory: /
|
||||||
|
rules:
|
||||||
|
- permissions: CRUD`
|
||||||
|
|
||||||
|
writeAndParseConfigWithError(t, content, ".yaml", "must either define a path of a regex")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Parse", func(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
directory: /
|
||||||
|
rules:
|
||||||
|
- regex: '^.+\.js$'
|
||||||
|
- path: /public/access/`
|
||||||
|
|
||||||
|
cfg := writeAndParseConfig(t, content, ".yaml")
|
||||||
|
require.NoError(t, cfg.Validate())
|
||||||
|
|
||||||
|
require.Len(t, cfg.Rules, 2)
|
||||||
|
|
||||||
|
require.Empty(t, cfg.Rules[0].Path)
|
||||||
|
require.NotNil(t, cfg.Rules[0].Regex)
|
||||||
|
require.True(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.js"))
|
||||||
|
require.False(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.ts"))
|
||||||
|
|
||||||
|
require.NotEmpty(t, cfg.Rules[1].Path)
|
||||||
|
require.Nil(t, cfg.Rules[1].Regex)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Rules Behavior (Default: Overwrite)", func(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
directory: /
|
||||||
|
rules:
|
||||||
|
- regex: '^.+\.js$'
|
||||||
- path: /public/access/
|
- path: /public/access/
|
||||||
regex: false
|
|
||||||
modify: true`
|
users:
|
||||||
|
- username: foo
|
||||||
|
password: bar
|
||||||
|
rules:
|
||||||
|
- path: /private/access/`
|
||||||
|
|
||||||
|
cfg := writeAndParseConfig(t, content, ".yaml")
|
||||||
|
require.NoError(t, cfg.Validate())
|
||||||
|
|
||||||
|
require.Len(t, cfg.Rules, 2)
|
||||||
|
|
||||||
|
require.Empty(t, cfg.Rules[0].Path)
|
||||||
|
require.NotNil(t, cfg.Rules[0].Regex)
|
||||||
|
require.True(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.js"))
|
||||||
|
require.False(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.ts"))
|
||||||
|
|
||||||
|
require.EqualValues(t, "/public/access/", cfg.Rules[1].Path)
|
||||||
|
require.Nil(t, cfg.Rules[1].Regex)
|
||||||
|
|
||||||
|
require.Len(t, cfg.Users, 1)
|
||||||
|
require.Len(t, cfg.Users[0].Rules, 1)
|
||||||
|
require.EqualValues(t, "/private/access/", cfg.Users[0].Rules[0].Path)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Rules Behavior (Append)", func(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
directory: /
|
||||||
|
rules:
|
||||||
|
- regex: '^.+\.js$'
|
||||||
|
- path: /public/access/
|
||||||
|
rulesBehavior: append
|
||||||
|
|
||||||
|
users:
|
||||||
|
- username: foo
|
||||||
|
password: bar
|
||||||
|
rules:
|
||||||
|
- path: /private/access/`
|
||||||
|
|
||||||
|
cfg := writeAndParseConfig(t, content, ".yaml")
|
||||||
|
require.NoError(t, cfg.Validate())
|
||||||
|
|
||||||
|
require.Len(t, cfg.Rules, 2)
|
||||||
|
|
||||||
|
require.Empty(t, cfg.Rules[0].Path)
|
||||||
|
require.NotNil(t, cfg.Rules[0].Regex)
|
||||||
|
require.True(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.js"))
|
||||||
|
require.False(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.ts"))
|
||||||
|
|
||||||
|
require.EqualValues(t, "/public/access/", cfg.Rules[1].Path)
|
||||||
|
require.Nil(t, cfg.Rules[1].Regex)
|
||||||
|
|
||||||
|
require.Len(t, cfg.Users, 1)
|
||||||
|
require.Len(t, cfg.Users[0].Rules, 3)
|
||||||
|
|
||||||
|
require.EqualValues(t, cfg.Rules[0], cfg.Users[0].Rules[0])
|
||||||
|
require.EqualValues(t, cfg.Rules[1], cfg.Users[0].Rules[1])
|
||||||
|
require.EqualValues(t, "/private/access/", cfg.Users[0].Rules[2].Path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigEnv(t *testing.T) {
|
||||||
|
require.NoError(t, os.Setenv("WD_PORT", "1234"))
|
||||||
|
require.NoError(t, os.Setenv("WD_DEBUG", "true"))
|
||||||
|
require.NoError(t, os.Setenv("WD_PERMISSIONS", "CRUD"))
|
||||||
|
require.NoError(t, os.Setenv("WD_DIRECTORY", "/test"))
|
||||||
|
|
||||||
|
cfg, err := ParseConfig("", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expectedDirectory, err := filepath.Abs("/test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1234, cfg.Port)
|
||||||
|
assert.Equal(t, expectedDirectory, cfg.Directory)
|
||||||
|
assert.Equal(t, true, cfg.Debug)
|
||||||
|
require.True(t, cfg.Permissions.Read)
|
||||||
|
require.True(t, cfg.Permissions.Create)
|
||||||
|
require.True(t, cfg.Permissions.Delete)
|
||||||
|
require.True(t, cfg.Permissions.Update)
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
require.NoError(t, os.Setenv("WD_PORT", ""))
|
||||||
|
require.NoError(t, os.Setenv("WD_DEBUG", ""))
|
||||||
|
require.NoError(t, os.Setenv("WD_PERMISSIONS", ""))
|
||||||
|
require.NoError(t, os.Setenv("WD_DIRECTORY", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigParseUserPasswordEnvironment(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
directory: /
|
||||||
|
users:
|
||||||
|
- username: '{env}USER1_USERNAME'
|
||||||
|
password: '{env}USER1_PASSWORD'
|
||||||
|
- username: basic
|
||||||
|
password: basic
|
||||||
|
`
|
||||||
|
|
||||||
|
writeAndParseConfigWithError(t, content, ".yml", "username environment variable is empty")
|
||||||
|
|
||||||
|
err := os.Setenv("USER1_USERNAME", "admin")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
writeAndParseConfigWithError(t, content, ".yml", "password environment variable is empty")
|
||||||
|
|
||||||
|
err = os.Setenv("USER1_PASSWORD", "admin")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
cfg := writeAndParseConfig(t, content, ".yaml")
|
cfg := writeAndParseConfig(t, content, ".yaml")
|
||||||
require.NoError(t, cfg.Validate())
|
require.NoError(t, cfg.Validate())
|
||||||
|
|
||||||
require.Len(t, cfg.Rules, 2)
|
require.Equal(t, "admin", cfg.Users[0].Username)
|
||||||
|
require.Equal(t, "basic", cfg.Users[1].Username)
|
||||||
|
|
||||||
require.Empty(t, cfg.Rules[0].Path)
|
require.True(t, cfg.Users[0].checkPassword("admin"))
|
||||||
require.NotNil(t, cfg.Rules[0].Regexp)
|
require.True(t, cfg.Users[1].checkPassword("basic"))
|
||||||
require.True(t, cfg.Rules[0].Regexp.MatchString("/my/path/to/file.js"))
|
|
||||||
require.False(t, cfg.Rules[0].Regexp.MatchString("/my/path/to/file.ts"))
|
|
||||||
|
|
||||||
require.NotEmpty(t, cfg.Rules[1].Path)
|
|
||||||
require.Nil(t, cfg.Rules[1].Regexp)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ type noSniffFileInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w noSniffFileInfo) ContentType(ctx context.Context) (contentType string, err error) {
|
func (w noSniffFileInfo) ContentType(ctx context.Context) (contentType string, err error) {
|
||||||
if mimeType := mime.TypeByExtension(path.Ext(w.FileInfo.Name())); mimeType != "" {
|
if mimeType := mime.TypeByExtension(path.Ext(w.Name())); mimeType != "" {
|
||||||
// We can figure out the mime from the extension.
|
// We can figure out the mime from the extension.
|
||||||
return mimeType, nil
|
return mimeType, nil
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+117
-38
@@ -2,7 +2,7 @@ package lib
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"os"
|
||||||
|
|
||||||
"github.com/rs/cors"
|
"github.com/rs/cors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -15,66 +15,98 @@ type handlerUser struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
user *handlerUser
|
noPassword bool
|
||||||
users map[string]*handlerUser
|
behindProxy bool
|
||||||
|
user *handlerUser
|
||||||
|
users map[string]*handlerUser
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(c *Config) (http.Handler, error) {
|
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{
|
h := &Handler{
|
||||||
|
noPassword: c.NoPassword,
|
||||||
|
behindProxy: c.BehindProxy,
|
||||||
user: &handlerUser{
|
user: &handlerUser{
|
||||||
User: User{
|
User: User{UserPermissions: c.UserPermissions},
|
||||||
Permissions: c.Permissions,
|
Handler: buildWebdavHandler(c.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
|
||||||
},
|
|
||||||
Handler: webdav.Handler{
|
|
||||||
Prefix: c.Prefix,
|
|
||||||
FileSystem: Dir{
|
|
||||||
Dir: webdav.Dir(c.Scope),
|
|
||||||
noSniff: c.NoSniff,
|
|
||||||
},
|
|
||||||
LockSystem: webdav.NewMemLS(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
users: map[string]*handlerUser{},
|
users: map[string]*handlerUser{},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range c.Users {
|
for _, u := range c.Users {
|
||||||
h.users[u.Username] = &handlerUser{
|
h.users[u.Username] = &handlerUser{
|
||||||
User: u,
|
User: u,
|
||||||
Handler: webdav.Handler{
|
Handler: buildWebdavHandler(u.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
|
||||||
Prefix: c.Prefix,
|
|
||||||
FileSystem: Dir{
|
|
||||||
Dir: webdav.Dir(u.Scope),
|
|
||||||
noSniff: c.NoSniff,
|
|
||||||
},
|
|
||||||
LockSystem: webdav.NewMemLS(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.CORS.Enabled {
|
if c.CORS.Enabled {
|
||||||
return cors.New(cors.Options{
|
return cors.New(cors.Options{
|
||||||
AllowCredentials: c.CORS.Credentials,
|
AllowCredentials: c.CORS.Credentials,
|
||||||
AllowedOrigins: c.CORS.AllowedHosts,
|
AllowPrivateNetwork: c.CORS.AllowPrivateNetwork,
|
||||||
AllowedMethods: c.CORS.AllowedMethods,
|
AllowedOrigins: c.CORS.AllowedHosts,
|
||||||
AllowedHeaders: c.CORS.AllowedHeaders,
|
AllowedMethods: c.CORS.AllowedMethods,
|
||||||
OptionsPassthrough: false,
|
AllowedHeaders: c.CORS.AllowedHeaders,
|
||||||
|
ExposedHeaders: c.CORS.ExposedHeaders,
|
||||||
|
OptionsPassthrough: false,
|
||||||
}).Handler(h), nil
|
}).Handler(h), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(c.Users) == 0 {
|
||||||
|
zap.L().Warn("unprotected config: no users have been set, so no authentication will be used")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.NoPassword {
|
||||||
|
zap.L().Warn("unprotected config: password check is disabled, only intended when delegating authentication to another service")
|
||||||
|
}
|
||||||
|
|
||||||
return h, nil
|
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.
|
// 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) {
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
user := h.user
|
user := h.user
|
||||||
|
|
||||||
|
lZap := getRequestLogger(r, h.behindProxy)
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
if len(h.users) > 0 {
|
if len(h.users) > 0 {
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||||
|
|
||||||
// Gets the correct user for this request.
|
// Gets the correct user for this request.
|
||||||
username, password, ok := r.BasicAuth()
|
username, password, ok := r.BasicAuth()
|
||||||
zap.L().Info("login attempt", zap.String("username", username), zap.String("remote_address", r.RemoteAddr))
|
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@@ -82,23 +114,38 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
user, ok = h.users[username]
|
user, ok = h.users[username]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
// Log invalid username
|
||||||
|
lZap.Info("invalid username", zap.String("username", username))
|
||||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !user.checkPassword(password) {
|
if !h.noPassword && !user.checkPassword(password) {
|
||||||
zap.L().Info("invalid password", zap.String("username", username), zap.String("remote_address", r.RemoteAddr))
|
// Log invalid password
|
||||||
|
lZap.Info("invalid password", zap.String("username", username))
|
||||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
zap.L().Info("user authorized", zap.String("username", username))
|
// Log successful authorization
|
||||||
|
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 {
|
||||||
|
lZap.Info("invalid request path or destination", zap.Error(err))
|
||||||
|
http.Error(w, "Invalid request path or destination", http.StatusBadRequest)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks for user permissions relatively to this PATH.
|
// Checks for user permissions relatively to this PATH.
|
||||||
allowed := user.Allowed(r)
|
allowed := user.Allowed(req, func(filename string) bool {
|
||||||
|
_, err := user.FileSystem.Stat(r.Context(), filename)
|
||||||
|
return !os.IsNotExist(err)
|
||||||
|
})
|
||||||
|
|
||||||
zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
|
lZap.Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
|
||||||
|
|
||||||
if !allowed {
|
if !allowed {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
@@ -115,9 +162,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// "index.html" resource, a human-readable view of the contents of
|
// "index.html" resource, a human-readable view of the contents of
|
||||||
// the collection, or something else altogether.
|
// the collection, or something else altogether.
|
||||||
//
|
//
|
||||||
// Get, when applied to collection, will return the same as PROPFIND method.
|
// Similarly, since the definition of HEAD is a GET without a response
|
||||||
if r.Method == "GET" && strings.HasPrefix(r.URL.Path, user.Prefix) {
|
// message body, the semantics of HEAD are unmodified when applied to
|
||||||
info, err := user.FileSystem.Stat(r.Context(), strings.TrimPrefix(r.URL.Path, user.Prefix))
|
// collection resources.
|
||||||
|
//
|
||||||
|
// GET (or HEAD), when applied to collection, will return the same as PROPFIND method.
|
||||||
|
if r.Method == "GET" || r.Method == "HEAD" {
|
||||||
|
info, err := user.FileSystem.Stat(r.Context(), req.path)
|
||||||
if err == nil && info.IsDir() {
|
if err == nil && info.IsDir() {
|
||||||
r.Method = "PROPFIND"
|
r.Method = "PROPFIND"
|
||||||
|
|
||||||
@@ -127,14 +178,42 @@ 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.
|
// Runs the WebDAV.
|
||||||
user.ServeHTTP(w, r)
|
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 {
|
||||||
|
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
type responseWriterNoBody struct {
|
type responseWriterNoBody struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w responseWriterNoBody) Write(data []byte) (int, error) {
|
func (w responseWriterNoBody) Write(data []byte) (int, error) {
|
||||||
return 0, nil
|
return len(data), nil
|
||||||
}
|
}
|
||||||
|
|||||||
+1277
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ webdav.LockSystem = &lockSystem{}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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, err = l.resolve(name0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if 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, 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")
|
||||||
|
}
|
||||||
+220
-46
@@ -1,37 +1,26 @@
|
|||||||
package lib
|
package lib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var readMethods = []string{
|
|
||||||
http.MethodGet,
|
|
||||||
http.MethodHead,
|
|
||||||
http.MethodOptions,
|
|
||||||
"PROPFIND",
|
|
||||||
}
|
|
||||||
|
|
||||||
type Rule struct {
|
type Rule struct {
|
||||||
Regex bool
|
Permissions Permissions
|
||||||
Allow bool
|
Path string
|
||||||
Modify bool
|
Regex *regexp.Regexp
|
||||||
Path string
|
|
||||||
// TODO: remove Regex and replace by this. It encodes
|
|
||||||
Regexp *regexp.Regexp `mapstructure:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Rule) Validate() error {
|
func (r *Rule) Validate() error {
|
||||||
if r.Regex {
|
if r.Regex == nil && r.Path == "" {
|
||||||
rp, err := regexp.Compile(r.Path)
|
return errors.New("invalid rule: must either define a path of a regex")
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("invalid rule: %w", err)
|
|
||||||
}
|
if r.Regex != nil && r.Path != "" {
|
||||||
r.Regexp = rp
|
return errors.New("invalid rule: cannot define both regex and path")
|
||||||
r.Path = ""
|
|
||||||
r.Regex = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -39,48 +28,233 @@ func (r *Rule) Validate() error {
|
|||||||
|
|
||||||
// Matches checks if [Rule] matches the given path.
|
// Matches checks if [Rule] matches the given path.
|
||||||
func (r *Rule) Matches(path string) bool {
|
func (r *Rule) Matches(path string) bool {
|
||||||
if r.Regexp != nil {
|
if r.Regex != nil {
|
||||||
return r.Regexp.MatchString(path)
|
return r.Regex.MatchString(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.HasPrefix(path, r.Path)
|
return strings.HasPrefix(path, r.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Permissions struct {
|
// matchesCollection checks if [Rule] names path as the collection it governs,
|
||||||
Scope string
|
// such as a rule for "/c/" and a request for "/c". Regex rules are matched
|
||||||
Modify bool
|
// literally and are not considered here.
|
||||||
Rules []*Rule
|
func (r *Rule) matchesCollection(path string) bool {
|
||||||
|
if r.Regex != nil || !strings.HasSuffix(r.Path, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return path == strings.TrimSuffix(r.Path, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RulesBehavior string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RulesOverwrite RulesBehavior = "overwrite"
|
||||||
|
RulesAppend RulesBehavior = "append"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
// Allowed checks if the user has permission to access a directory/file
|
||||||
func (p Permissions) Allowed(r *http.Request) bool {
|
func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool {
|
||||||
// Determine whether or not it is a read or write request.
|
// For COPY and MOVE requests, we first check the permissions for the destination
|
||||||
readRequest := false
|
// path. As soon as a rule matches and does not allow the operation at the destination,
|
||||||
for _, method := range readMethods {
|
// we fail immediately. If no rule matches, we check the global permissions.
|
||||||
if r.Method == method {
|
if r.method == "COPY" || r.method == "MOVE" {
|
||||||
readRequest = true
|
if !p.allowedAt(r.destination, func(perms Permissions) bool {
|
||||||
break
|
return perms.AllowedDestination(r, fileExists)
|
||||||
|
}) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Go through rules beginning from the last one.
|
return p.allowedAt(r.path, func(perms Permissions) bool {
|
||||||
for i := len(p.Rules) - 1; i >= 0; i-- {
|
return perms.Allowed(r, fileExists)
|
||||||
rule := p.Rules[i]
|
})
|
||||||
|
|
||||||
if rule.Matches(r.URL.Path) {
|
|
||||||
return rule.Allow && (readRequest || rule.Modify)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return readRequest || p.Modify
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Permissions) Validate() error {
|
// allowedAt resolves the permissions that govern path and applies check to them.
|
||||||
|
func (p UserPermissions) allowedAt(path string, check func(Permissions) bool) bool {
|
||||||
|
// Go through rules beginning from the last one. The first matched rule returns.
|
||||||
|
for i := len(p.Rules) - 1; i >= 0; i-- {
|
||||||
|
if p.Rules[i].Matches(path) {
|
||||||
|
return check(p.Rules[i].Permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rule written with a trailing slash also governs the collection it names,
|
||||||
|
// so that a rule for "/c/" cannot be evaded by asking for "/c". Such a request
|
||||||
|
// acts on an entry of the parent collection, so it needs the permissions that
|
||||||
|
// apply there too. Requiring both means the rule can restrict the collection
|
||||||
|
// without granting access that would otherwise not exist.
|
||||||
|
for i := len(p.Rules) - 1; i >= 0; i-- {
|
||||||
|
if p.Rules[i].matchesCollection(path) {
|
||||||
|
return check(p.Rules[i].Permissions) && check(p.Permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return check(p.Permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPermissions) Validate() error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
p.Directory, err = filepath.Abs(p.Directory)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid permissions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
for _, r := range p.Rules {
|
||||||
if err := r.Validate(); err != nil {
|
if err := r.Validate(); err != nil {
|
||||||
return fmt.Errorf("invalid permissions: %w", err)
|
return fmt.Errorf("invalid permissions: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch p.RulesBehavior {
|
||||||
|
case RulesAppend, RulesOverwrite:
|
||||||
|
// Good to go
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid rule behavior: %s", p.RulesBehavior)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
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
|
||||||
|
Update bool
|
||||||
|
Delete bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Permissions) UnmarshalText(data []byte) error {
|
||||||
|
text := strings.ToLower(string(data))
|
||||||
|
if text == "none" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range text {
|
||||||
|
switch c {
|
||||||
|
case 'c':
|
||||||
|
p.Create = true
|
||||||
|
case 'r':
|
||||||
|
p.Read = true
|
||||||
|
case 'u':
|
||||||
|
p.Update = true
|
||||||
|
case 'd':
|
||||||
|
p.Delete = true
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid permission: %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed returns whether this permission set has permissions to execute this
|
||||||
|
// request in the source directory. This applies to all requests with all methods.
|
||||||
|
func (p Permissions) Allowed(r *request, fileExists func(string) bool) bool {
|
||||||
|
switch r.method {
|
||||||
|
case "GET", "HEAD", "OPTIONS", "POST", "PROPFIND":
|
||||||
|
// Note: POST backend implementation just returns the same thing as GET.
|
||||||
|
return p.Read
|
||||||
|
case "MKCOL":
|
||||||
|
return p.Create
|
||||||
|
case "PROPPATCH":
|
||||||
|
return p.Update
|
||||||
|
case "PUT", "PATCH":
|
||||||
|
if fileExists(r.path) {
|
||||||
|
return p.Update
|
||||||
|
} else {
|
||||||
|
return p.Create
|
||||||
|
}
|
||||||
|
case "COPY":
|
||||||
|
return p.Read
|
||||||
|
case "MOVE":
|
||||||
|
return p.Read && p.Delete
|
||||||
|
case "DELETE":
|
||||||
|
return p.Delete
|
||||||
|
case "LOCK", "UNLOCK":
|
||||||
|
return p.Create || p.Read || p.Update || p.Delete
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedDestination returns whether this permissions set has permissions to execute this
|
||||||
|
// request in the destination directory. This only applies for COPY and MOVE requests.
|
||||||
|
func (p Permissions) AllowedDestination(r *request, fileExists func(string) bool) bool {
|
||||||
|
switch r.method {
|
||||||
|
case "COPY", "MOVE":
|
||||||
|
if fileExists(r.destination) {
|
||||||
|
return p.Update
|
||||||
|
} else {
|
||||||
|
return p.Create
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cleanPath resolves dot segments so that the permission checks see the same
|
||||||
|
// path that the backing file system will ultimately open. The file systems in
|
||||||
|
// golang.org/x/net/webdav apply path.Clean before joining the backing
|
||||||
|
// directory, so without this the two layers disagree on which file a request
|
||||||
|
// names and a rule can be bypassed with e.g. "/public/../secret/file.txt".
|
||||||
|
func cleanPath(p string) string {
|
||||||
|
if !strings.HasPrefix(p, "/") {
|
||||||
|
p = "/" + p
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned := path.Clean(p)
|
||||||
|
|
||||||
|
// path.Clean drops the trailing slash, but rules are prefix matches and are
|
||||||
|
// commonly written with one, such as "/c/". Dropping it would stop a request
|
||||||
|
// for the collection itself from matching the rule that names it.
|
||||||
|
if cleaned != "/" && isCollectionPath(p) {
|
||||||
|
cleaned += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCollectionPath reports whether p names a collection rather than a resource
|
||||||
|
// within it. Besides an explicit trailing slash, a trailing "." or ".." segment
|
||||||
|
// also resolves to the collection itself.
|
||||||
|
func isCollectionPath(p string) bool {
|
||||||
|
return strings.HasSuffix(p, "/") || strings.HasSuffix(p, "/.") || strings.HasSuffix(p, "/..")
|
||||||
|
}
|
||||||
|
|
||||||
|
type request struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
destination string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRequest(r *http.Request, prefix string) (*request, error) {
|
||||||
|
ctx := &request{
|
||||||
|
method: r.Method,
|
||||||
|
}
|
||||||
|
|
||||||
|
if destination := r.Header.Get("Destination"); destination != "" {
|
||||||
|
u, err := url.Parse(destination)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid destination header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 4918, section 10.3, has Destination as an absolute URI, which is
|
||||||
|
// what clients send in practice. Only the path is relevant here, and
|
||||||
|
// taking it unconditionally keeps the host out of the matched value.
|
||||||
|
destination = u.Path
|
||||||
|
|
||||||
|
if prefix != "" {
|
||||||
|
destination = strings.TrimPrefix(u.Path, prefix)
|
||||||
|
if len(destination) >= len(u.Path) {
|
||||||
|
return nil, errors.New("invalid url prefix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.destination = cleanPath(destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := r.URL.Path
|
||||||
|
|
||||||
|
if prefix != "" {
|
||||||
|
path = strings.TrimPrefix(r.URL.Path, prefix)
|
||||||
|
if len(path) >= len(r.URL.Path) {
|
||||||
|
return nil, errors.New("invalid url prefix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.path = cleanPath(path)
|
||||||
|
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
+16
-7
@@ -10,9 +10,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
Permissions `mapstructure:",squash"`
|
UserPermissions `mapstructure:",squash"`
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u User) checkPassword(input string) bool {
|
func (u User) checkPassword(input string) bool {
|
||||||
@@ -24,15 +24,24 @@ func (u User) checkPassword(input string) bool {
|
|||||||
return u.Password == input
|
return u.Password == input
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *User) Validate() error {
|
func (u *User) Validate(noPassword bool) error {
|
||||||
if u.Username == "" {
|
if u.Username == "" {
|
||||||
return errors.New("invalid user: username must be set")
|
return errors.New("invalid user: username must be set")
|
||||||
|
} else if strings.HasPrefix(u.Username, "{env}") {
|
||||||
|
env := strings.TrimPrefix(u.Username, "{env}")
|
||||||
|
if env == "" {
|
||||||
|
return fmt.Errorf("invalid user %q: username environment variable not set", u.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Username = os.Getenv(env)
|
||||||
|
if u.Username == "" {
|
||||||
|
return fmt.Errorf("invalid user %q: username environment variable is empty", u.Username)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.Password == "" {
|
if u.Password == "" && !noPassword {
|
||||||
return fmt.Errorf("invalid user %q: password must be set", u.Username)
|
return fmt.Errorf("invalid user %q: password must be set", u.Username)
|
||||||
} else if strings.HasPrefix(u.Password, "{env}") {
|
} else if strings.HasPrefix(u.Password, "{env}") {
|
||||||
|
|
||||||
env := strings.TrimPrefix(u.Password, "{env}")
|
env := strings.TrimPrefix(u.Password, "{env}")
|
||||||
if env == "" {
|
if env == "" {
|
||||||
return fmt.Errorf("invalid user %q: password environment variable not set", u.Username)
|
return fmt.Errorf("invalid user %q: password environment variable not set", u.Username)
|
||||||
@@ -44,7 +53,7 @@ func (u *User) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := u.Permissions.Validate(); err != nil {
|
if err := u.UserPermissions.Validate(); err != nil {
|
||||||
return fmt.Errorf("invalid user %q: %w", u.Username, err)
|
return fmt.Errorf("invalid user %q: %w", u.Username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/hacdias/webdav/v4/cmd"
|
"github.com/hacdias/webdav/v5/cmd"
|
||||||
|
|
||||||
|
_ "golang.org/x/crypto/x509roots/fallback"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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