mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-22 03:20:41 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f6a0707fe6 | ||
|
|
947b163ea7 | ||
|
|
732cf5eff5 | ||
|
|
1e87b21bb1 | ||
|
|
6166061f20 | ||
|
|
4f8eab48ab | ||
|
|
7542860a47 | ||
|
|
3688420246 | ||
|
|
47e3f6de6f | ||
|
|
356edb8b93 | ||
|
|
b16c041d0c | ||
|
|
dc45f32af8 | ||
|
|
d1691e1bd1 | ||
|
|
5f685dbe98 | ||
|
|
85219df921 | ||
|
|
c125bedae1 | ||
|
|
46d54e4465 | ||
|
|
90c031846d | ||
|
|
968f2e147a | ||
|
|
6f4be12e8c | ||
|
|
9433dbd452 | ||
|
|
f0ca85e570 | ||
|
|
436a3b05a1 | ||
|
|
1a610b17ba | ||
|
|
32613f76cc | ||
|
|
8a8650d9b2 | ||
|
|
5701cbb5b8 | ||
|
|
9a4b378b32 | ||
|
|
099479a894 | ||
|
|
8cd6d0a585 | ||
|
|
fca4e54839 | ||
|
|
da6dd253d5 | ||
|
|
c1a919681b | ||
|
|
5734366c54 | ||
|
|
aab63c0ccc | ||
|
|
f212531d75 | ||
|
|
2a39779cff | ||
|
|
34f2336d91 |
@@ -1,46 +0,0 @@
|
||||
version: 2
|
||||
jobs:
|
||||
lint:
|
||||
docker:
|
||||
- image: golangci/golangci-lint:v1.30
|
||||
steps:
|
||||
- checkout
|
||||
- run: golangci-lint run -v
|
||||
build:
|
||||
docker:
|
||||
- image: circleci/golang:1.14
|
||||
steps:
|
||||
- checkout
|
||||
- run: go build main.go
|
||||
release:
|
||||
machine: true
|
||||
steps:
|
||||
- checkout
|
||||
- go/install
|
||||
- run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
|
||||
- run: curl -sL https://git.io/goreleaser | bash
|
||||
- run: docker logout
|
||||
orbs:
|
||||
go: circleci/[email protected]
|
||||
workflows:
|
||||
version: 2
|
||||
build-workflow:
|
||||
jobs:
|
||||
- lint:
|
||||
filters:
|
||||
tags:
|
||||
only: /.*/
|
||||
- build:
|
||||
filters:
|
||||
tags:
|
||||
only: /.*/
|
||||
- release:
|
||||
context: deploy
|
||||
requires:
|
||||
- build
|
||||
- lint
|
||||
filters:
|
||||
tags:
|
||||
only: /^v.*/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
- run: go build .
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
hacdias/webdav
|
||||
ghcr.io/hacdias/webdav
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: "latest"
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Releaser
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
- uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test (${{ matrix.os }})
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
- name: Run test with race detector and coverage
|
||||
if: runner.os != 'Windows'
|
||||
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
- name: Run test with coverage
|
||||
if: runner.os == 'Windows'
|
||||
run: go test "-coverprofile=coverage.txt" -covermode=atomic ./...
|
||||
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
webdav
|
||||
+51
-49
@@ -1,59 +1,61 @@
|
||||
env:
|
||||
- GO111MODULE=on
|
||||
- CGO_ENABLED=0
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go mod download
|
||||
|
||||
build:
|
||||
main: main.go
|
||||
binary: webdav
|
||||
ldflags:
|
||||
- -s -w -X github.com/hacdias/webdav/cmd.version={{.Version}}
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
- windows
|
||||
- freebsd
|
||||
- netbsd
|
||||
- openbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- 386
|
||||
- arm
|
||||
- arm64
|
||||
- mips
|
||||
- mipsle
|
||||
- mips64
|
||||
- mips64le
|
||||
goarm:
|
||||
- 5
|
||||
- 6
|
||||
- 7
|
||||
ignore:
|
||||
- goos: openbsd
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
builds:
|
||||
- main: main.go
|
||||
binary: webdav
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- '-trimpath'
|
||||
ldflags:
|
||||
- '-s -w -X github.com/hacdias/webdav/v5/cmd.version={{.Version}}'
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
- windows
|
||||
- freebsd
|
||||
- netbsd
|
||||
- openbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- '386'
|
||||
- arm
|
||||
- arm64
|
||||
- mips
|
||||
- mipsle
|
||||
- mips64
|
||||
- mips64le
|
||||
goarm:
|
||||
- '5'
|
||||
- '6'
|
||||
- '7'
|
||||
ignore:
|
||||
- goos: openbsd
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
# Broken as of Go 1.24, deprecated as of Go 1.26
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
|
||||
archives:
|
||||
-
|
||||
name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
|
||||
format: tar.gz
|
||||
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
formats:
|
||||
- zip
|
||||
|
||||
dockers:
|
||||
-
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
goarm: ''
|
||||
image_templates:
|
||||
- "hacdias/webdav:latest"
|
||||
- "hacdias/webdav:{{ .Tag }}"
|
||||
- "hacdias/webdav:v{{ .Major }}.{{ .Minor }}"
|
||||
- "hacdias/webdav:v{{ .Major }}"
|
||||
release:
|
||||
github:
|
||||
owner: hacdias
|
||||
name: webdav
|
||||
draft: false
|
||||
prerelease: auto
|
||||
|
||||
+12
-12
@@ -1,20 +1,20 @@
|
||||
FROM alpine:latest as certs
|
||||
RUN apk --update add ca-certificates
|
||||
FROM golang:1.26-alpine3.22 AS build
|
||||
|
||||
FROM golang:alpine as build
|
||||
WORKDIR /app/
|
||||
ARG VERSION="untracked"
|
||||
|
||||
COPY go.mod .
|
||||
COPY go.sum .
|
||||
WORKDIR /webdav/
|
||||
|
||||
COPY ./go.mod ./
|
||||
COPY ./go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN go build -v
|
||||
COPY . /webdav/
|
||||
RUN go build -o main -trimpath -ldflags="-s -w -X 'github.com/hacdias/webdav/v5/cmd.version=$VERSION'" .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=build /app/webdav /webdav
|
||||
|
||||
EXPOSE 80
|
||||
COPY --from=build /webdav/main /bin/webdav
|
||||
|
||||
ENTRYPOINT [ "/webdav" ]
|
||||
EXPOSE 6065
|
||||
|
||||
ENTRYPOINT [ "webdav" ]
|
||||
|
||||
@@ -1,69 +1,242 @@
|
||||
# webdav
|
||||
|
||||
[](https://circleci.com/gh/hacdias/webdav)
|
||||
[](https://goreportcard.com/report/hacdias/webdav)
|
||||
[](https://github.com/hacdias/webdav/releases/latest)
|
||||
[](https://hub.docker.com/r/hacdias/webdav)
|
||||
[](https://hub.docker.com/r/hacdias/webdav)
|
||||
|
||||
A simple and standalone [WebDAV](https://en.wikipedia.org/wiki/WebDAV) server.
|
||||
|
||||
## Install
|
||||
|
||||
Please refer to the [Releases page](https://github.com/hacdias/webdav/releases) for more information. There, you can either download the binaries or find the Docker commands to install WebDAV.
|
||||
For a manual install, please refer to the [releases](https://github.com/hacdias/webdav/releases) page and download the correct binary for your system. Alternatively, you can build or install it from source using the Go toolchain. You can either clone the repository and execute `go build`, or directly install it, using:
|
||||
|
||||
```
|
||||
go install github.com/hacdias/webdav/v5@latest
|
||||
```
|
||||
|
||||
### Homebrew
|
||||
|
||||
If you have Homebrew available on your system, you can also install `webdav` using it:
|
||||
|
||||
```
|
||||
brew install webdav
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
Docker images are provided on both [GitHub's registry](https://github.com/hacdias/webdav/pkgs/container/webdav) and [Docker Hub](https://hub.docker.com/r/hacdias/webdav). You can pull the images using one of the following two commands. Note that this commands pull the latest released version. You can use specific tags to pin specific versions, or use `main` for the development branch.
|
||||
|
||||
```bash
|
||||
# GitHub Registry
|
||||
docker pull ghcr.io/hacdias/webdav:latest
|
||||
|
||||
# Docker Hub
|
||||
docker pull hacdias/webdav:latest
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```webdav``` command line interface is really easy to use so you can easily create a WebDAV server for your own user. By default, it runs on a random free port and supports JSON, YAML and TOML configuration. An example of a YAML configuration with the default configurations:
|
||||
For usage information regarding the CLI, run `webdav --help`.
|
||||
|
||||
### Container
|
||||
|
||||
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).
|
||||
|
||||
The equivalent Docker command to the aforementioned compose file would be as follows:
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-p 6065:6065 \
|
||||
-v ./config.yml:/config.yml:ro \
|
||||
-v ./data:/data \
|
||||
ghcr.io/hacdias/webdav -c /config.yml
|
||||
```
|
||||
|
||||
If you are using [fail2ban](#fail2ban-setup), it would be helpful to add the parameters listed below. They will assist in analyzing
|
||||
the log.
|
||||
|
||||
```bash
|
||||
--log-driver journald \
|
||||
--name webdav \
|
||||
```
|
||||
|
||||
### Partial updates
|
||||
|
||||
This server supports partial file updates compatible with SabreDAV's `PATCH` extension. This is not an official WebDAV specification. Requests must use the `application/x-sabredav-partialupdate` content type, include `Content-Length`, and provide the target range in `X-Update-Range`.
|
||||
|
||||
Supported `X-Update-Range` values are:
|
||||
|
||||
- `bytes=start-end`
|
||||
- `bytes=start-`
|
||||
- `bytes=-N`
|
||||
- `append`
|
||||
|
||||
For clients that use it, the server also supports partial `PUT` requests with `Content-Range`, for example `Content-Range: bytes 6-8/*`. This is an extra compatibility path and should be treated as a client/server agreement.
|
||||
|
||||
## Configuration
|
||||
|
||||
The configuration can be provided as a YAML, JSON or TOML file. Below is an example of a YAML configuration file with
|
||||
all the options available, as well as what they mean.
|
||||
|
||||
```yaml
|
||||
# Server related settings
|
||||
address: 0.0.0.0
|
||||
port: 0
|
||||
auth: true
|
||||
port: 6065
|
||||
|
||||
# TLS-related settings if you want to enable TLS directly.
|
||||
tls: false
|
||||
cert: cert.pem
|
||||
key: key.pem
|
||||
|
||||
# Prefix to apply to the WebDAV path-ing. Default is '/'.
|
||||
prefix: /
|
||||
|
||||
# Default user settings (will be merged)
|
||||
scope: .
|
||||
modify: true
|
||||
# Enable or disable debug logging. Default is 'false'.
|
||||
debug: false
|
||||
|
||||
# Disable sniffing the files to detect their content type. Default is 'false'.
|
||||
noSniff: false
|
||||
|
||||
# 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.
|
||||
# This directory will be used by users unless they have their own 'directory' defined.
|
||||
# By default it points to the working directory. In the case of the compose file above,
|
||||
# that is /data.
|
||||
directory: /data
|
||||
|
||||
# Alternatively, replace 'directory' with 'directories' to expose multiple
|
||||
# directories as virtual root entries. This option is mutually exclusive with
|
||||
# 'directory' in the same scope. Rules should include the virtual mount name,
|
||||
# such as /media/public/access/.
|
||||
# directories:
|
||||
# - media: /data/media
|
||||
# - /data/archive
|
||||
# - name: backups
|
||||
# path: /data/backups
|
||||
|
||||
# The default permissions for users. This is a case insensitive option. Possible
|
||||
# permissions: C (Create), R (Read), U (Update), D (Delete). You can combine multiple
|
||||
# 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: []
|
||||
|
||||
# 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
|
||||
allowed_headers:
|
||||
- Depth
|
||||
# The following are the default CORS settings when it is enabled.
|
||||
allowed_hosts:
|
||||
- http://localhost:8080
|
||||
allowed_methods:
|
||||
- GET
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- '*'
|
||||
allowed_headers:
|
||||
- Authorization
|
||||
- Content-Type
|
||||
- Content-Range
|
||||
- Depth
|
||||
- Destination
|
||||
- If
|
||||
- Lock-Token
|
||||
- Overwrite
|
||||
- TimeOut
|
||||
- Translate
|
||||
- X-Update-Range
|
||||
allowed_methods:
|
||||
- COPY
|
||||
- DELETE
|
||||
- GET
|
||||
- HEAD
|
||||
- LOCK
|
||||
- UNLOCK
|
||||
- MKCOL
|
||||
- MOVE
|
||||
- OPTIONS
|
||||
- PATCH
|
||||
- POST
|
||||
- PROPFIND
|
||||
- PROPPATCH
|
||||
- PUT
|
||||
exposed_headers: []
|
||||
|
||||
# You define here the list of users.
|
||||
# Basic authentication is automatically be configured when users are detected
|
||||
# below, else there will be no authentication.
|
||||
# Customize to your needs and don't forget to comment out the users you don't need.
|
||||
users:
|
||||
# Example 'admin' user with plaintext password.
|
||||
- username: admin
|
||||
password: admin
|
||||
scope: /a/different/path
|
||||
- username: encrypted
|
||||
|
||||
# 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
|
||||
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
|
||||
directory: /data/john
|
||||
# Example user whose details will be picked up from the environment.
|
||||
- username: "{env}ENV_USERNAME"
|
||||
password: "{env}ENV_PASSWORD"
|
||||
# Example user with advanced control over his permissions
|
||||
- username: basic
|
||||
password: basic
|
||||
modify: false
|
||||
permissions: CRUD # Override default permissions.
|
||||
rules:
|
||||
- regex: false
|
||||
allow: false
|
||||
path: /some/file
|
||||
# With this rule, the user CANNOT access {user directory}/some/files.
|
||||
- path: /some/file
|
||||
permissions: none
|
||||
# With this rule, the user CAN create, read, update and delete within
|
||||
# {user directory}/public/access.
|
||||
- path: /public/access/
|
||||
permissions: CRUD
|
||||
# With this rule, the user CAN read and update all files ending with .js.
|
||||
# It uses a regular expression.
|
||||
- regex: "^.+.js$"
|
||||
permissions: RU
|
||||
# Example user for android SeedVault backuping
|
||||
- username: android
|
||||
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
|
||||
directory: /data/android
|
||||
permissions: CRUD
|
||||
|
||||
# If you're delegating the authentication to a different service, you can proxy
|
||||
# the username using basic authentication, and then disable webdav's password
|
||||
# check using the option:
|
||||
# noPassword: true
|
||||
```
|
||||
|
||||
There are more ways to customize how you run WebDAV through flags and environment variables. Please run `webdav --help` for more information on that.
|
||||
### Rules
|
||||
|
||||
### Systemd
|
||||
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.
|
||||
|
||||
An example of how to use this with `systemd` is on [webdav.service.example](/webdav.service.example).
|
||||
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
|
||||
|
||||
@@ -72,6 +245,135 @@ The `allowed_*` properties are optional, the default value for each of them will
|
||||
1. Use `withCredentials = true` in javascript.
|
||||
2. Use the `username:password@host` syntax.
|
||||
|
||||
## Caveats
|
||||
|
||||
### 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.
|
||||
|
||||
#### Nginx Configuration Example
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header REMOTE-HOST $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_redirect off;
|
||||
|
||||
# Ensure COPY and MOVE commands work. Change https://example.com to the
|
||||
# correct address where the WebDAV server will be deployed at.
|
||||
set $dest $http_destination;
|
||||
if ($http_destination ~ "^https://example.com(?<path>(.+))") {
|
||||
set $dest /$path;
|
||||
}
|
||||
proxy_set_header Destination $dest;
|
||||
}
|
||||
```
|
||||
|
||||
#### Caddy Configuration Example
|
||||
|
||||
```Caddyfile
|
||||
example.com {
|
||||
tls internal # for local development
|
||||
# tls [email protected] # so that Caddy gets certs for you via Letsencrypt
|
||||
|
||||
# Rewrites destination to remove host and include only the path e.g. /test.txt
|
||||
@hasDest header_regexp dest ^https?://[^/]+(.*)$
|
||||
header @hasDest Destination {re.dest.1}
|
||||
|
||||
# if running on the same network in docker you can just point to the service name e.g. webdav:6065
|
||||
reverse_proxy 127.0.0.1:6065 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up REMOTE-HOST {remote_host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Systemd
|
||||
|
||||
Example configuration of a [`systemd`](https://en.wikipedia.org/wiki/Systemd) service:
|
||||
|
||||
```conf
|
||||
[Unit]
|
||||
Description=WebDAV
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/bin/webdav --config /opt/webdav.yml
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
Feel free to open an issue or a pull request.
|
||||
|
||||
## License
|
||||
|
||||
MIT © [Henrique Dias](https://hacdias.com)
|
||||
[MIT License](LICENSE) © [Henrique Dias](https://hacdias.com)
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 5.x | :white_check_mark: |
|
||||
| < 5.x | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
- **Critical:** report privately via the [Security](https://github.com/hacdias/webdav/security) page.
|
||||
- **Non-critical:** open a public issue so the community can help; it'll be labeled it as a security issue.
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
-207
@@ -1,207 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hacdias/webdav/v3/lib"
|
||||
"github.com/spf13/pflag"
|
||||
v "github.com/spf13/viper"
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
func parseRules(raw []interface{}) []*lib.Rule {
|
||||
rules := []*lib.Rule{}
|
||||
|
||||
for _, v := range raw {
|
||||
if r, ok := v.(map[interface{}]interface{}); ok {
|
||||
rule := &lib.Rule{
|
||||
Regex: false,
|
||||
Allow: false,
|
||||
Path: "",
|
||||
}
|
||||
|
||||
if regex, ok := r["regex"].(bool); ok {
|
||||
rule.Regex = regex
|
||||
}
|
||||
|
||||
if allow, ok := r["allow"].(bool); ok {
|
||||
rule.Allow = allow
|
||||
}
|
||||
|
||||
path, ok := r["path"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if rule.Regex {
|
||||
rule.Regexp = regexp.MustCompile(path)
|
||||
} else {
|
||||
rule.Path = path
|
||||
}
|
||||
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
func loadFromEnv(v string) (string, error) {
|
||||
v = strings.TrimPrefix(v, "{env}")
|
||||
if v == "" {
|
||||
return "", errors.New("no environment variable specified")
|
||||
}
|
||||
|
||||
v = os.Getenv(v)
|
||||
if v == "" {
|
||||
return "", errors.New("the environment variable is empty")
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func parseUsers(raw []interface{}, c *lib.Config) {
|
||||
var err error
|
||||
for _, v := range raw {
|
||||
if u, ok := v.(map[interface{}]interface{}); ok {
|
||||
username, ok := u["username"].(string)
|
||||
if !ok {
|
||||
log.Fatal("user needs an username")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(username, "{env}") {
|
||||
username, err = loadFromEnv(username)
|
||||
checkErr(err)
|
||||
}
|
||||
|
||||
password, ok := u["password"].(string)
|
||||
if !ok {
|
||||
password = ""
|
||||
|
||||
if numPwd, ok := u["password"].(int); ok {
|
||||
password = strconv.Itoa(numPwd)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(password, "{env}") {
|
||||
password, err = loadFromEnv(password)
|
||||
checkErr(err)
|
||||
}
|
||||
|
||||
user := &lib.User{
|
||||
Username: username,
|
||||
Password: password,
|
||||
Scope: c.User.Scope,
|
||||
Modify: c.User.Modify,
|
||||
Rules: c.User.Rules,
|
||||
}
|
||||
|
||||
if scope, ok := u["scope"].(string); ok {
|
||||
user.Scope = scope
|
||||
}
|
||||
|
||||
if modify, ok := u["modify"].(bool); ok {
|
||||
user.Modify = modify
|
||||
}
|
||||
|
||||
if rules, ok := u["rules"].([]interface{}); ok {
|
||||
user.Rules = parseRules(rules)
|
||||
}
|
||||
|
||||
user.Handler = &webdav.Handler{
|
||||
Prefix: c.User.Handler.Prefix,
|
||||
FileSystem: webdav.Dir(user.Scope),
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
}
|
||||
|
||||
c.Users[username] = user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseCors(cfg map[string]interface{}, c *lib.Config) {
|
||||
cors := lib.CorsCfg{
|
||||
Enabled: cfg["enabled"].(bool),
|
||||
Credentials: cfg["credentials"].(bool),
|
||||
}
|
||||
|
||||
cors.AllowedHeaders = corsProperty("allowed_headers", cfg)
|
||||
cors.AllowedHosts = corsProperty("allowed_hosts", cfg)
|
||||
cors.AllowedMethods = corsProperty("allowed_methods", cfg)
|
||||
cors.ExposedHeaders = corsProperty("exposed_headers", cfg)
|
||||
|
||||
c.Cors = cors
|
||||
}
|
||||
|
||||
func corsProperty(property string, cfg map[string]interface{}) []string {
|
||||
var def []string
|
||||
|
||||
if property == "exposed_headers" {
|
||||
def = []string{}
|
||||
} else {
|
||||
def = []string{"*"}
|
||||
}
|
||||
|
||||
if allowed, ok := cfg[property].([]interface{}); ok {
|
||||
items := make([]string, len(allowed))
|
||||
|
||||
for idx, a := range allowed {
|
||||
items[idx] = a.(string)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return def
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
func readConfig(flags *pflag.FlagSet) *lib.Config {
|
||||
cfg := &lib.Config{
|
||||
User: &lib.User{
|
||||
Scope: getOpt(flags, "scope"),
|
||||
Modify: getOptB(flags, "modify"),
|
||||
Rules: []*lib.Rule{},
|
||||
Handler: &webdav.Handler{
|
||||
Prefix: getOpt(flags, "prefix"),
|
||||
FileSystem: webdav.Dir(getOpt(flags, "scope")),
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
},
|
||||
},
|
||||
Auth: getOptB(flags, "auth"),
|
||||
Cors: lib.CorsCfg{
|
||||
Enabled: false,
|
||||
Credentials: false,
|
||||
},
|
||||
Users: map[string]*lib.User{},
|
||||
}
|
||||
|
||||
rawRules := v.Get("rules")
|
||||
if rules, ok := rawRules.([]interface{}); ok {
|
||||
cfg.User.Rules = parseRules(rules)
|
||||
}
|
||||
|
||||
rawUsers := v.Get("users")
|
||||
if users, ok := rawUsers.([]interface{}); ok {
|
||||
parseUsers(users, cfg)
|
||||
}
|
||||
|
||||
rawCors := v.Get("cors")
|
||||
if cors, ok := rawCors.(map[string]interface{}); ok {
|
||||
parseCors(cors, cfg)
|
||||
}
|
||||
|
||||
if len(cfg.Users) != 0 && !cfg.Auth {
|
||||
log.Print("Users will be ignored due to auth=false")
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
+96
-51
@@ -1,32 +1,31 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/coreos/go-systemd/v22/activation"
|
||||
"github.com/hacdias/webdav/v5/lib"
|
||||
"github.com/spf13/cobra"
|
||||
v "github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
flags := rootCmd.Flags()
|
||||
flags.StringVarP(&cfgFile, "config", "c", "", "config file path")
|
||||
flags.BoolP("tls", "t", false, "enable tls")
|
||||
flags.Bool("auth", true, "enable auth")
|
||||
flags.String("cert", "cert.pem", "TLS certificate")
|
||||
flags.String("key", "key.pem", "TLS key")
|
||||
flags.StringP("address", "a", "0.0.0.0", "address to listen to")
|
||||
flags.StringP("port", "p", "0", "port to listen to")
|
||||
flags.StringP("prefix", "P", "/", "URL path prefix")
|
||||
flags.StringP("config", "c", "", "config file path")
|
||||
flags.StringP("address", "a", lib.DefaultAddress, "address 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")
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
@@ -48,53 +47,99 @@ The precedence of the configuration values are as follows:
|
||||
The environment variables are prefixed by "WD_" followed by the option
|
||||
name in caps. So to set "cert" via an env variable, you should
|
||||
set WD_CERT.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
flags := cmd.Flags()
|
||||
|
||||
cfg := readConfig(flags)
|
||||
cfgFilename, _ := flags.GetString("config")
|
||||
|
||||
// Builds the address and a listener.
|
||||
laddr := getOpt(flags, "address") + ":" + getOpt(flags, "port")
|
||||
listener, err := net.Listen("tcp", laddr)
|
||||
cfg, err := lib.ParseConfig(cfgFilename, flags)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Tell the user the port in which is listening.
|
||||
fmt.Println("Listening on", listener.Addr().String())
|
||||
|
||||
// Starts the server.
|
||||
if getOptB(flags, "tls") {
|
||||
if err := http.ServeTLS(listener, cfg, getOpt(flags, "cert"), getOpt(flags, "key")); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := http.Serve(listener, cfg); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Setup the logger based on the configuration
|
||||
logger, err := cfg.GetLogger()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zap.ReplaceGlobals(logger)
|
||||
|
||||
// Create HTTP handler from the config
|
||||
handler, err := lib.NewHandler(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Flush the logger at the end
|
||||
_ = zap.L().Sync()
|
||||
}()
|
||||
|
||||
// Build listener
|
||||
listener, err := getListener(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
server := &http.Server{Handler: handler}
|
||||
|
||||
// Trap exiting signals
|
||||
quit := make(chan os.Signal, 1)
|
||||
|
||||
go func() {
|
||||
zap.L().Info("listening", zap.String("address", listener.Addr().String()))
|
||||
|
||||
var err error
|
||||
if cfg.TLS {
|
||||
err = server.ServeTLS(listener, cfg.Cert, cfg.Key)
|
||||
} else {
|
||||
err = server.Serve(listener)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
zap.L().Error("failed to start server", zap.Error(err))
|
||||
}
|
||||
|
||||
quit <- os.Interrupt
|
||||
}()
|
||||
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
signal := <-quit
|
||||
|
||||
zap.L().Info("caught signal, shutting down", zap.Stringer("signal", signal))
|
||||
_ = server.Shutdown(context.Background())
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
if cfgFile == "" {
|
||||
v.AddConfigPath(".")
|
||||
v.AddConfigPath("/etc/webdav/")
|
||||
v.SetConfigName("config")
|
||||
} else {
|
||||
v.SetConfigFile(cfgFile)
|
||||
}
|
||||
func getListener(cfg *lib.Config) (net.Listener, error) {
|
||||
var (
|
||||
address string
|
||||
network string
|
||||
)
|
||||
|
||||
v.SetEnvPrefix("WD")
|
||||
v.AutomaticEnv()
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(v.ConfigParseError); ok {
|
||||
panic(err)
|
||||
if strings.HasPrefix(cfg.Address, "sd-listen-fd:") {
|
||||
listeners, err := activation.ListenersWithNames()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgFile = "No config file used"
|
||||
|
||||
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:]
|
||||
network = "unix"
|
||||
} else {
|
||||
cfgFile = "Using config file: " + v.ConfigFileUsed()
|
||||
address = fmt.Sprintf("%s:%d", cfg.Address, cfg.Port)
|
||||
network = "tcp"
|
||||
}
|
||||
|
||||
return net.Listen(network, address)
|
||||
}
|
||||
|
||||
+2
-2
@@ -6,14 +6,14 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var version = "(untracked)"
|
||||
var version = "untracked"
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(&cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version number",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("WebDAV version " + version)
|
||||
fmt.Printf("WebDAV version: %s\n", version)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
v "github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// getOption returns a parameter as a string.
|
||||
//
|
||||
// NOTE: we could simply bind the flags to viper and use IsSet.
|
||||
// Although there is a bug on Viper that always returns true on IsSet
|
||||
// if a flag is binded. Our alternative way is to manually check
|
||||
// the flag and then the value from env/config/gotten by viper.
|
||||
// https://github.com/spf13/viper/pull/331
|
||||
func getOpt(flags *pflag.FlagSet, key string) string {
|
||||
value, _ := flags.GetString(key)
|
||||
|
||||
// If set on Flags, use it.
|
||||
if flags.Changed(key) {
|
||||
return value
|
||||
}
|
||||
|
||||
// If set through viper (env, config), return it.
|
||||
if v.IsSet(key) {
|
||||
return v.GetString(key)
|
||||
}
|
||||
|
||||
// Otherwise use default value on flags.
|
||||
return value
|
||||
}
|
||||
|
||||
func getOptB(flags *pflag.FlagSet, key string) bool {
|
||||
value, _ := flags.GetBool(key)
|
||||
|
||||
// If set on Flags, use it.
|
||||
if flags.Changed(key) {
|
||||
return value
|
||||
}
|
||||
|
||||
// If set through viper (env, config), return it.
|
||||
if v.IsSet(key) {
|
||||
return v.GetBool(key)
|
||||
}
|
||||
|
||||
// Otherwise use default value on flags.
|
||||
return value
|
||||
}
|
||||
|
||||
func checkErr(err error) {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
+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,17 +1,36 @@
|
||||
module github.com/hacdias/webdav/v3
|
||||
module github.com/hacdias/webdav/v5
|
||||
|
||||
go 1.12
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/pelletier/go-toml v1.6.0 // indirect
|
||||
github.com/spf13/afero v1.2.2 // indirect
|
||||
github.com/spf13/cobra v0.0.5
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/spf13/viper v1.5.0
|
||||
golang.org/x/crypto v0.0.0-20191108234033-bd318be0434a
|
||||
golang.org/x/net v0.0.0-20191109021931-daa7c04131f5
|
||||
golang.org/x/sys v0.0.0-20191105231009-c1f44814a5cd // indirect
|
||||
golang.org/x/text v0.3.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.5 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||
github.com/rs/cors v1.11.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/studio-b12/gowebdav v0.13.0
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e
|
||||
golang.org/x/net v0.57.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,183 +1,72 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
github.com/hacdias/webdav v1.4.1 h1:yhR4dgKr3JUfgDhFO2nWTq3HiYA37Sk/kaud/bRx1io=
|
||||
github.com/hacdias/webdav/v2 v2.0.1 h1:Dm21dHGijWGOY8q/KkMPSXPMPK4xVTe5WqWE1hMJTnY=
|
||||
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.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
|
||||
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pelletier/go-toml v1.6.0 h1:aetoXYr0Tv7xRU/V4B4IZJ2QcbtMUFoNb3ORp7TzIK4=
|
||||
github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
|
||||
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
|
||||
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
|
||||
github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8=
|
||||
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
||||
github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
|
||||
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.3.2 h1:VUFqw5KcqRf7i70GOzW7N+Q7+gxVBkSSqiXB12+JQ4M=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/spf13/viper v1.5.0 h1:GpsTwfsQ27oS/Aha/6d1oD7tpKIqWnOA6tgOX9HHkt4=
|
||||
github.com/spf13/viper v1.5.0/go.mod h1:AkYRkVJF8TkSG/xet6PzXX+l39KhhXa2pdqVSxnTcn4=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529 h1:iMGN4xG0cnqj3t+zOM8wUB0BiPKHEwSxEZCvzcbZuvk=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191108234033-bd318be0434a h1:R/qVym5WAxsZWQqZCwDY/8sdVKV1m1WgU4/S5IRQAzc=
|
||||
golang.org/x/crypto v0.0.0-20191108234033-bd318be0434a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190509222800-a4d6f7feada5 h1:6M3SDHlHHDCx2PcQw3S4KsR170vGqDhJDOmpVd4Hjak=
|
||||
golang.org/x/net v0.0.0-20190509222800-a4d6f7feada5/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20191109021931-daa7c04131f5 h1:bHNaocaoJxYBo5cw41UyTMLjYlb8wPY7+WFrnklbHOM=
|
||||
golang.org/x/net v0.0.0-20191109021931-daa7c04131f5/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191105231009-c1f44814a5cd h1:3x5uuvBgE6oaXJjCOvpCC1IpgJogqQ+PqGGU3ZxAgII=
|
||||
golang.org/x/sys v0.0.0-20191105231009-c1f44814a5cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
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/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
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/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
|
||||
github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e h1:9TjMDOuGaMMTP5f7GXeHeA0JvFqGmv4DYRIWkzoePGI=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
|
||||
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultTLS = false
|
||||
DefaultCert = "cert.pem"
|
||||
DefaultKey = "key.pem"
|
||||
DefaultAddress = "0.0.0.0"
|
||||
DefaultPort = 6065
|
||||
DefaultPrefix = "/"
|
||||
)
|
||||
|
||||
var errDirectoryConflict = errors.New("directory and directories cannot both be defined")
|
||||
|
||||
type Config struct {
|
||||
UserPermissions `mapstructure:",squash"`
|
||||
Debug bool
|
||||
Address string
|
||||
Port int
|
||||
TLS bool
|
||||
Cert string
|
||||
Key string
|
||||
Prefix string
|
||||
NoSniff bool
|
||||
NoPassword bool
|
||||
BehindProxy bool
|
||||
Log Log
|
||||
CORS CORS
|
||||
Users []User
|
||||
}
|
||||
|
||||
func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
v := viper.NewWithOptions(viper.ExperimentalBindStruct())
|
||||
|
||||
// Configure flags bindings
|
||||
if flags != nil {
|
||||
err := v.BindPFlags(flags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration file settings
|
||||
v.AddConfigPath(".")
|
||||
v.AddConfigPath("/etc/webdav/")
|
||||
if path, err := os.Executable(); err == nil {
|
||||
v.AddConfigPath(filepath.Dir(path))
|
||||
}
|
||||
|
||||
v.SetConfigName("config")
|
||||
if filename != "" {
|
||||
v.SetConfigFile(filename)
|
||||
}
|
||||
|
||||
// Environment settings
|
||||
v.SetEnvPrefix("wd")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
// Defaults shared with flags
|
||||
v.SetDefault("TLS", DefaultTLS)
|
||||
v.SetDefault("Cert", DefaultCert)
|
||||
v.SetDefault("Key", DefaultKey)
|
||||
v.SetDefault("Address", DefaultAddress)
|
||||
v.SetDefault("Port", DefaultPort)
|
||||
v.SetDefault("Prefix", DefaultPrefix)
|
||||
|
||||
// Other defaults
|
||||
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_Headers", []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"})
|
||||
v.SetDefault("CORS.Allowed_Methods", []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"})
|
||||
|
||||
// Read and unmarshal configuration
|
||||
err := v.ReadInConfig()
|
||||
if err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||
directoryMountsDecodeHook(),
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
mapstructure.TextUnmarshallerHookFunc(),
|
||||
)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = applyDirectoryConfig(v, flags, &cfg.UserPermissions, "directory", "directories", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
// Cascade user settings
|
||||
for i := range cfg.Users {
|
||||
userDirectoryKey := fmt.Sprintf("Users.%d.Directory", i)
|
||||
userDirectoriesKey := fmt.Sprintf("Users.%d.Directories", i)
|
||||
|
||||
if !v.IsSet(userDirectoryKey) {
|
||||
cfg.Users[i].Directory = cfg.Directory
|
||||
}
|
||||
|
||||
err := applyDirectoryConfig(v, flags, &cfg.Users[i].UserPermissions, userDirectoryKey, userDirectoriesKey, &cfg.UserPermissions)
|
||||
if err != nil {
|
||||
if errors.Is(err, errDirectoryConflict) {
|
||||
return nil, fmt.Errorf("invalid config: user %q cannot define both directory and directories", cfg.Users[i].Username)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid config: user %q: %w", cfg.Users[i].Username, err)
|
||||
}
|
||||
|
||||
if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
|
||||
cfg.Users[i].Permissions = cfg.Permissions
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
err = cfg.Validate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyDirectoryConfig(v *viper.Viper, flags *pflag.FlagSet, permissions *UserPermissions, directoryKey, directoriesKey string, inherited *UserPermissions) error {
|
||||
permissions.directoryExplicit = isExplicitlySet(v, flags, directoryKey)
|
||||
permissions.directoriesExplicit = isExplicitlySet(v, flags, directoriesKey)
|
||||
if permissions.directoryExplicit && permissions.directoriesExplicit {
|
||||
return errDirectoryConflict
|
||||
}
|
||||
|
||||
switch {
|
||||
case permissions.directoryExplicit:
|
||||
permissions.Directory = v.GetString(directoryKey)
|
||||
permissions.useDirectories = false
|
||||
case permissions.directoriesExplicit:
|
||||
directories, err := getDirectoryMounts(v, directoriesKey, permissions.Directories)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissions.Directories = directories
|
||||
permissions.useDirectories = true
|
||||
case inherited != nil:
|
||||
permissions.Directories = append(DirectoryMounts{}, inherited.Directories...)
|
||||
permissions.useDirectories = inherited.useDirectories
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isExplicitlySet(v *viper.Viper, flags *pflag.FlagSet, key string) bool {
|
||||
if flags != nil && flags.Changed(key) {
|
||||
return true
|
||||
}
|
||||
|
||||
if v.InConfig(key) {
|
||||
return true
|
||||
}
|
||||
|
||||
envKey := "WD_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
|
||||
value, ok := os.LookupEnv(envKey)
|
||||
return ok && value != ""
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
var err error
|
||||
|
||||
c.Directory, err = filepath.Abs(c.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
if c.TLS {
|
||||
if c.Cert == "" {
|
||||
return errors.New("invalid config: Cert must be defined if TLS is activated")
|
||||
}
|
||||
|
||||
if c.Key == "" {
|
||||
return errors.New("invalid config: Key must be defined if TLS is activated")
|
||||
}
|
||||
|
||||
c.Cert, err = filepath.Abs(c.Cert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
c.Key, err = filepath.Abs(c.Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = c.UserPermissions.Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
for i := range c.Users {
|
||||
err := c.Users[i].Validate(c.NoPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
Enabled bool
|
||||
Credentials bool
|
||||
AllowedHeaders []string `mapstructure:"allowed_headers"`
|
||||
AllowedHosts []string `mapstructure:"allowed_hosts"`
|
||||
AllowedMethods []string `mapstructure:"allowed_methods"`
|
||||
ExposedHeaders []string `mapstructure:"exposed_headers"`
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func writeAndParseConfig(t *testing.T, content, extension string) *Config {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "config"+extension)
|
||||
|
||||
err := os.WriteFile(tmpFile, []byte(content), 0666)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := ParseConfig(tmpFile, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := writeAndParseConfig(t, "", ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.EqualValues(t, DefaultTLS, cfg.TLS)
|
||||
require.EqualValues(t, DefaultAddress, cfg.Address)
|
||||
require.EqualValues(t, DefaultPort, cfg.Port)
|
||||
require.EqualValues(t, DefaultPrefix, cfg.Prefix)
|
||||
require.EqualValues(t, "console", cfg.Log.Format)
|
||||
require.EqualValues(t, true, cfg.Log.Colors)
|
||||
require.EqualValues(t, []string{"stderr"}, cfg.Log.Outputs)
|
||||
|
||||
dir, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, dir, cfg.Directory)
|
||||
|
||||
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
|
||||
require.EqualValues(t, []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"}, cfg.CORS.AllowedHeaders)
|
||||
require.EqualValues(t, []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"}, cfg.CORS.AllowedMethods)
|
||||
}
|
||||
|
||||
func TestConfigCascade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Directories are resolved to absolute paths, which differ by platform
|
||||
// (for example "/" becomes the current drive root on Windows).
|
||||
rootDirectory, err := filepath.Abs("/")
|
||||
require.NoError(t, err)
|
||||
basicDirectory, err := filepath.Abs("/basic")
|
||||
require.NoError(t, err)
|
||||
|
||||
check := func(t *testing.T, cfg *Config) {
|
||||
require.True(t, cfg.Permissions.Read)
|
||||
require.True(t, cfg.Permissions.Create)
|
||||
require.False(t, cfg.Permissions.Delete)
|
||||
require.False(t, cfg.Permissions.Update)
|
||||
require.Equal(t, rootDirectory, cfg.Directory)
|
||||
require.Len(t, cfg.Rules, 1)
|
||||
|
||||
require.Len(t, cfg.Users, 2)
|
||||
require.True(t, cfg.Users[0].Permissions.Read)
|
||||
require.True(t, cfg.Users[0].Permissions.Create)
|
||||
require.False(t, cfg.Users[0].Permissions.Delete)
|
||||
require.False(t, cfg.Users[0].Permissions.Update)
|
||||
require.Equal(t, rootDirectory, cfg.Users[0].Directory)
|
||||
require.Len(t, cfg.Users[0].Rules, 1)
|
||||
|
||||
require.True(t, cfg.Users[1].Permissions.Read)
|
||||
require.False(t, cfg.Users[1].Permissions.Create)
|
||||
require.False(t, cfg.Users[1].Permissions.Delete)
|
||||
require.False(t, cfg.Users[1].Permissions.Update)
|
||||
require.Equal(t, basicDirectory, cfg.Users[1].Directory)
|
||||
require.Len(t, cfg.Users[1].Rules, 0)
|
||||
}
|
||||
|
||||
t.Run("YAML", func(t *testing.T) {
|
||||
content := `
|
||||
directory: /
|
||||
permissions: CR
|
||||
rules:
|
||||
- path: /public/access/
|
||||
permissions: R
|
||||
|
||||
users:
|
||||
- username: admin
|
||||
password: admin
|
||||
- username: basic
|
||||
password: basic
|
||||
directory: /basic
|
||||
permissions: R
|
||||
rules: []`
|
||||
|
||||
cfg := writeAndParseConfig(t, content, ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
check(t, cfg)
|
||||
})
|
||||
|
||||
t.Run("JSON", func(t *testing.T) {
|
||||
content := `{
|
||||
"directory": "/",
|
||||
"permissions": "CR",
|
||||
"rules": [
|
||||
{
|
||||
"path": "/public/access/",
|
||||
"permissions": "R"
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "admin"
|
||||
},
|
||||
{
|
||||
"username": "basic",
|
||||
"password": "basic",
|
||||
"directory": "/basic",
|
||||
"permissions": "R",
|
||||
"rules": []
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
cfg := writeAndParseConfig(t, content, ".json")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
check(t, cfg)
|
||||
})
|
||||
|
||||
t.Run("`TOML", func(t *testing.T) {
|
||||
content := `
|
||||
directory = "/"
|
||||
permissions = "CR"
|
||||
|
||||
[[rules]]
|
||||
path = "/public/access/"
|
||||
permissions = "R"
|
||||
|
||||
[[users]]
|
||||
username = "admin"
|
||||
password = "admin"
|
||||
|
||||
[[users]]
|
||||
username = "basic"
|
||||
password = "basic"
|
||||
directory = "/basic"
|
||||
permissions = "R"
|
||||
rules = []
|
||||
`
|
||||
|
||||
cfg := writeAndParseConfig(t, content, ".toml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
check(t, cfg)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Mixed Entries", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirC := t.TempDir()
|
||||
dirD := t.TempDir()
|
||||
dirE := t.TempDir()
|
||||
|
||||
cfg := writeAndParseConfig(t, `
|
||||
directories:
|
||||
- `+dirC+`
|
||||
- d2: `+dirD+`
|
||||
- name: archive
|
||||
path: `+dirE+`
|
||||
`, ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.Equal(t, filepath.Base(dirC), cfg.Directories[0].Name)
|
||||
require.Equal(t, dirC, cfg.Directories[0].Path)
|
||||
require.Equal(t, "d2", cfg.Directories[1].Name)
|
||||
require.Equal(t, dirD, cfg.Directories[1].Path)
|
||||
require.Equal(t, "archive", cfg.Directories[2].Name)
|
||||
require.Equal(t, dirE, cfg.Directories[2].Path)
|
||||
})
|
||||
|
||||
t.Run("JSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirC := t.TempDir()
|
||||
dirD := t.TempDir()
|
||||
dirE := t.TempDir()
|
||||
|
||||
cfg := writeAndParseConfig(t, `{
|
||||
"directories": [
|
||||
`+strconv.Quote(dirC)+`,
|
||||
{ "d2": `+strconv.Quote(dirD)+` },
|
||||
{ "name": "archive", "path": `+strconv.Quote(dirE)+` }
|
||||
]
|
||||
}`, ".json")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.Equal(t, filepath.Base(dirC), cfg.Directories[0].Name)
|
||||
require.Equal(t, dirC, cfg.Directories[0].Path)
|
||||
require.Equal(t, "d2", cfg.Directories[1].Name)
|
||||
require.Equal(t, dirD, cfg.Directories[1].Path)
|
||||
require.Equal(t, "archive", cfg.Directories[2].Name)
|
||||
require.Equal(t, dirE, cfg.Directories[2].Path)
|
||||
})
|
||||
|
||||
t.Run("TOML", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirD := t.TempDir()
|
||||
dirE := t.TempDir()
|
||||
|
||||
cfg := writeAndParseConfig(t, `
|
||||
[[directories]]
|
||||
d2 = `+strconv.Quote(dirD)+`
|
||||
|
||||
[[directories]]
|
||||
name = "archive"
|
||||
path = `+strconv.Quote(dirE)+`
|
||||
`, ".toml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.Equal(t, "d2", cfg.Directories[0].Name)
|
||||
require.Equal(t, dirD, cfg.Directories[0].Path)
|
||||
require.Equal(t, "archive", cfg.Directories[1].Name)
|
||||
require.Equal(t, dirE, cfg.Directories[1].Path)
|
||||
})
|
||||
|
||||
t.Run("Mutually Exclusive Global Directory Fields", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writeAndParseConfigWithError(t, `
|
||||
directory: /tmp
|
||||
directories:
|
||||
- /tmp
|
||||
`, ".yml", "directory and directories cannot both be defined")
|
||||
})
|
||||
|
||||
t.Run("Mutually Exclusive User Directory Fields", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writeAndParseConfigWithError(t, `
|
||||
users:
|
||||
- username: basic
|
||||
password: basic
|
||||
directory: /tmp
|
||||
directories:
|
||||
- /tmp
|
||||
`, ".yml", "cannot define both directory and directories")
|
||||
})
|
||||
|
||||
t.Run("Duplicate Mount Names", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parent := t.TempDir()
|
||||
dir := filepath.Join(parent, "dup")
|
||||
require.NoError(t, os.Mkdir(dir, 0775))
|
||||
|
||||
writeAndParseConfigWithError(t, `
|
||||
directories:
|
||||
- `+dir+`
|
||||
- dup: /tmp
|
||||
`, ".yml", "duplicate mount name")
|
||||
})
|
||||
|
||||
t.Run("Cascade Mode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
global := t.TempDir()
|
||||
single := t.TempDir()
|
||||
userMulti := t.TempDir()
|
||||
|
||||
cfg := writeAndParseConfig(t, `
|
||||
directories:
|
||||
- global: `+global+`
|
||||
users:
|
||||
- username: inherited
|
||||
password: inherited
|
||||
- username: single
|
||||
password: single
|
||||
directory: `+single+`
|
||||
- username: multi
|
||||
password: multi
|
||||
directories:
|
||||
- owned: `+userMulti+`
|
||||
`, ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.True(t, cfg.Users[0].useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: "global", Path: global}}, cfg.Users[0].Directories)
|
||||
require.False(t, cfg.Users[1].useDirectories)
|
||||
require.Equal(t, single, cfg.Users[1].Directory)
|
||||
require.True(t, cfg.Users[2].useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: "owned", Path: userMulti}}, cfg.Users[2].Directories)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigDirectoriesEnvOverrides(t *testing.T) {
|
||||
global := t.TempDir()
|
||||
single := t.TempDir()
|
||||
userMulti := t.TempDir()
|
||||
|
||||
t.Setenv("WD_DIRECTORIES", global)
|
||||
t.Setenv("WD_USERS_1_DIRECTORY", single)
|
||||
t.Setenv("WD_USERS_2_DIRECTORIES", userMulti)
|
||||
|
||||
cfg := writeAndParseConfig(t, `
|
||||
users:
|
||||
- username: inherited
|
||||
password: inherited
|
||||
- username: single
|
||||
password: single
|
||||
- username: multi
|
||||
password: multi
|
||||
`, ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: filepath.Base(global), Path: global}}, cfg.Directories)
|
||||
require.True(t, cfg.Users[0].useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: filepath.Base(global), Path: global}}, cfg.Users[0].Directories)
|
||||
require.False(t, cfg.Users[1].useDirectories)
|
||||
require.Equal(t, single, cfg.Users[1].Directory)
|
||||
require.True(t, cfg.Users[2].useDirectories)
|
||||
require.Equal(t, DirectoryMounts{{Name: filepath.Base(userMulti), Path: userMulti}}, cfg.Users[2].Directories)
|
||||
}
|
||||
|
||||
func TestConfigKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := writeAndParseConfig(t, `
|
||||
cors:
|
||||
enabled: true
|
||||
credentials: true
|
||||
allowed_headers:
|
||||
- Depth
|
||||
allowed_hosts:
|
||||
- http://localhost:8080
|
||||
allowed_methods:
|
||||
- GET
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- Content-Range`, ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.CORS.Enabled)
|
||||
require.True(t, cfg.CORS.Credentials)
|
||||
require.EqualValues(t, []string{"Content-Length", "Content-Range"}, cfg.CORS.ExposedHeaders)
|
||||
require.EqualValues(t, []string{"Depth"}, cfg.CORS.AllowedHeaders)
|
||||
require.EqualValues(t, []string{"http://localhost:8080"}, cfg.CORS.AllowedHosts)
|
||||
require.EqualValues(t, []string{"GET"}, cfg.CORS.AllowedMethods)
|
||||
}
|
||||
|
||||
func TestConfigRules(t *testing.T) {
|
||||
t.Run("Only Regex or Path", func(t *testing.T) {
|
||||
content := `
|
||||
directory: /
|
||||
rules:
|
||||
- regex: '^.+\.js$'
|
||||
path: /public/access/`
|
||||
|
||||
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/
|
||||
|
||||
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")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.Equal(t, "admin", cfg.Users[0].Username)
|
||||
require.Equal(t, "basic", cfg.Users[1].Username)
|
||||
|
||||
require.True(t, cfg.Users[0].checkPassword("admin"))
|
||||
require.True(t, cfg.Users[1].checkPassword("basic"))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
type Dir struct {
|
||||
webdav.Dir
|
||||
noSniff bool
|
||||
}
|
||||
|
||||
func (d Dir) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
||||
// Skip wrapping if NoSniff is off
|
||||
if !d.noSniff {
|
||||
return d.Dir.Stat(ctx, name)
|
||||
}
|
||||
|
||||
info, err := d.Dir.Stat(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return noSniffFileInfo{info}, nil
|
||||
}
|
||||
|
||||
func (d Dir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
// Skip wrapping if NoSniff is off
|
||||
if !d.noSniff {
|
||||
return d.Dir.OpenFile(ctx, name, flag, perm)
|
||||
}
|
||||
|
||||
file, err := d.Dir.OpenFile(ctx, name, flag, perm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return noSniffFile{File: file}, nil
|
||||
}
|
||||
|
||||
type noSniffFileInfo struct {
|
||||
os.FileInfo
|
||||
}
|
||||
|
||||
func (w noSniffFileInfo) ContentType(ctx context.Context) (contentType string, err error) {
|
||||
if mimeType := mime.TypeByExtension(path.Ext(w.Name())); mimeType != "" {
|
||||
// We can figure out the mime from the extension.
|
||||
return mimeType, nil
|
||||
} else {
|
||||
// We can't figure out the mime type without sniffing, call it an octet stream.
|
||||
return "application/octet-stream", nil
|
||||
}
|
||||
}
|
||||
|
||||
type noSniffFile struct {
|
||||
webdav.File
|
||||
}
|
||||
|
||||
func (f noSniffFile) Stat() (os.FileInfo, error) {
|
||||
info, err := f.File.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return noSniffFileInfo{info}, nil
|
||||
}
|
||||
|
||||
func (f noSniffFile) Readdir(count int) (fis []os.FileInfo, err error) {
|
||||
fis, err = f.File.Readdir(count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range fis {
|
||||
fis[i] = noSniffFileInfo{fis[i]}
|
||||
}
|
||||
return fis, nil
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/rs/cors"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
type handlerUser struct {
|
||||
User
|
||||
webdav.Handler
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
noPassword bool
|
||||
behindProxy bool
|
||||
user *handlerUser
|
||||
users map[string]*handlerUser
|
||||
}
|
||||
|
||||
func NewHandler(c *Config) (http.Handler, error) {
|
||||
ls := webdav.NewMemLS()
|
||||
|
||||
logFunc := func(r *http.Request, err error) {
|
||||
lZap := getRequestLogger(r, c.BehindProxy)
|
||||
lZap.Debug("handle webdav request", zap.String("method", r.Method), zap.String("path", r.URL.Path), zap.Error(err))
|
||||
}
|
||||
|
||||
h := &Handler{
|
||||
noPassword: c.NoPassword,
|
||||
behindProxy: c.BehindProxy,
|
||||
user: &handlerUser{
|
||||
User: User{UserPermissions: c.UserPermissions},
|
||||
Handler: buildWebdavHandler(c.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
|
||||
},
|
||||
users: map[string]*handlerUser{},
|
||||
}
|
||||
|
||||
for _, u := range c.Users {
|
||||
h.users[u.Username] = &handlerUser{
|
||||
User: u,
|
||||
Handler: buildWebdavHandler(u.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
|
||||
}
|
||||
}
|
||||
|
||||
if c.CORS.Enabled {
|
||||
return cors.New(cors.Options{
|
||||
AllowCredentials: c.CORS.Credentials,
|
||||
AllowedOrigins: c.CORS.AllowedHosts,
|
||||
AllowedMethods: c.CORS.AllowedMethods,
|
||||
AllowedHeaders: c.CORS.AllowedHeaders,
|
||||
ExposedHeaders: c.CORS.ExposedHeaders,
|
||||
OptionsPassthrough: false,
|
||||
}).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
|
||||
}
|
||||
|
||||
// buildWebdavHandler creates the [webdav.Handler] for a set of user permissions,
|
||||
// selecting between single-directory and multi-directory backing depending on
|
||||
// whether directories are configured.
|
||||
func buildWebdavHandler(p UserPermissions, prefix string, noSniff bool, ls webdav.LockSystem, logFunc func(*http.Request, error)) webdav.Handler {
|
||||
h := webdav.Handler{
|
||||
Prefix: prefix,
|
||||
Logger: logFunc,
|
||||
}
|
||||
|
||||
if p.useDirectories {
|
||||
h.FileSystem = multiDir{
|
||||
mounts: p.Directories,
|
||||
noSniff: noSniff,
|
||||
}
|
||||
h.LockSystem = newMultiDirLockSystem(ls, p.Directories)
|
||||
} else {
|
||||
h.FileSystem = Dir{
|
||||
Dir: webdav.Dir(p.Directory),
|
||||
noSniff: noSniff,
|
||||
}
|
||||
h.LockSystem = newLockSystem(ls, p.Directory)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := h.user
|
||||
|
||||
lZap := getRequestLogger(r, h.behindProxy)
|
||||
|
||||
// Authentication
|
||||
if len(h.users) > 0 {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
|
||||
// Gets the correct user for this request.
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, ok = h.users[username]
|
||||
if !ok {
|
||||
// Log invalid username
|
||||
lZap.Info("invalid username", zap.String("username", username))
|
||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.noPassword && !user.checkPassword(password) {
|
||||
// Log invalid password
|
||||
lZap.Info("invalid password", zap.String("username", username))
|
||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 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.
|
||||
allowed := user.Allowed(req, func(filename string) bool {
|
||||
_, err := user.FileSystem.Stat(r.Context(), filename)
|
||||
return !os.IsNotExist(err)
|
||||
})
|
||||
|
||||
lZap.Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
|
||||
|
||||
if !allowed {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "HEAD" {
|
||||
w = responseWriterNoBody{w}
|
||||
}
|
||||
|
||||
// Excerpt from RFC4918, section 9.4:
|
||||
//
|
||||
// GET, when applied to a collection, may return the contents of an
|
||||
// "index.html" resource, a human-readable view of the contents of
|
||||
// the collection, or something else altogether.
|
||||
//
|
||||
// Similarly, since the definition of HEAD is a GET without a response
|
||||
// message body, the semantics of HEAD are unmodified when applied to
|
||||
// collection resources.
|
||||
//
|
||||
// GET (or HEAD), when applied to collection, will return the same as PROPFIND method.
|
||||
if r.Method == "GET" || r.Method == "HEAD" {
|
||||
info, err := user.FileSystem.Stat(r.Context(), req.path)
|
||||
if err == nil && info.IsDir() {
|
||||
r.Method = "PROPFIND"
|
||||
|
||||
if r.Header.Get("Depth") == "" {
|
||||
r.Header.Add("Depth", "1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
user.handleOptions(w, r, req.path)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "PATCH" || (r.Method == "PUT" && r.Header.Get("Content-Range") != "") {
|
||||
user.handlePartialUpdate(w, r, req.path)
|
||||
return
|
||||
}
|
||||
|
||||
// Runs the WebDAV.
|
||||
user.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// getRequestLogger creates a zap.Logger using the request remote ip.
|
||||
func getRequestLogger(r *http.Request, behindProxy bool) *zap.Logger {
|
||||
// Retrieve the real client IP address using the updated helper function
|
||||
remoteAddr := getRealRemoteIP(r, behindProxy)
|
||||
|
||||
return zap.L().With(zap.String("remote_address", remoteAddr))
|
||||
}
|
||||
|
||||
// getRealRemoteIP retrieves the client's actual IP address, considering reverse proxies.
|
||||
func getRealRemoteIP(r *http.Request, behindProxy bool) string {
|
||||
if behindProxy {
|
||||
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
type responseWriterNoBody struct {
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
func (w responseWriterNoBody) Write(data []byte) (int, error) {
|
||||
return len(data), nil
|
||||
}
|
||||
+1254
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")
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Rule struct {
|
||||
Permissions Permissions
|
||||
Path string
|
||||
Regex *regexp.Regexp
|
||||
}
|
||||
|
||||
func (r *Rule) Validate() error {
|
||||
if r.Regex == nil && r.Path == "" {
|
||||
return errors.New("invalid rule: must either define a path of a regex")
|
||||
}
|
||||
|
||||
if r.Regex != nil && r.Path != "" {
|
||||
return errors.New("invalid rule: cannot define both regex and path")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Matches checks if [Rule] matches the given path.
|
||||
func (r *Rule) Matches(path string) bool {
|
||||
if r.Regex != nil {
|
||||
return r.Regex.MatchString(path)
|
||||
}
|
||||
|
||||
return strings.HasPrefix(path, r.Path)
|
||||
}
|
||||
|
||||
// matchesCollection checks if [Rule] names path as the collection it governs,
|
||||
// such as a rule for "/c/" and a request for "/c". Regex rules are matched
|
||||
// literally and are not considered here.
|
||||
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
|
||||
func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool {
|
||||
// For COPY and MOVE requests, we first check the permissions for the destination
|
||||
// path. As soon as a rule matches and does not allow the operation at the destination,
|
||||
// we fail immediately. If no rule matches, we check the global permissions.
|
||||
if r.method == "COPY" || r.method == "MOVE" {
|
||||
if !p.allowedAt(r.destination, func(perms Permissions) bool {
|
||||
return perms.AllowedDestination(r, fileExists)
|
||||
}) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return p.allowedAt(r.path, func(perms Permissions) bool {
|
||||
return perms.Allowed(r, fileExists)
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if err := r.Validate(); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Executable → Regular
+48
-35
@@ -1,48 +1,61 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Rule is a dissalow/allow rule.
|
||||
type Rule struct {
|
||||
Regex bool
|
||||
Allow bool
|
||||
Path string
|
||||
Regexp *regexp.Regexp
|
||||
}
|
||||
|
||||
// User contains the settings of each user.
|
||||
type User struct {
|
||||
Username string
|
||||
Password string
|
||||
Scope string
|
||||
Modify bool
|
||||
Rules []*Rule
|
||||
Handler *webdav.Handler
|
||||
UserPermissions `mapstructure:",squash"`
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// Allowed checks if the user has permission to access a directory/file
|
||||
func (u User) Allowed(url string) bool {
|
||||
var rule *Rule
|
||||
i := len(u.Rules) - 1
|
||||
|
||||
for i >= 0 {
|
||||
rule = u.Rules[i]
|
||||
|
||||
if rule.Regex {
|
||||
if rule.Regexp.MatchString(url) {
|
||||
return rule.Allow
|
||||
}
|
||||
} else if strings.HasPrefix(url, rule.Path) {
|
||||
return rule.Allow
|
||||
}
|
||||
|
||||
i--
|
||||
func (u User) checkPassword(input string) bool {
|
||||
if strings.HasPrefix(u.Password, "{bcrypt}") {
|
||||
savedPassword := strings.TrimPrefix(u.Password, "{bcrypt}")
|
||||
return bcrypt.CompareHashAndPassword([]byte(savedPassword), []byte(input)) == nil
|
||||
}
|
||||
|
||||
return true
|
||||
return u.Password == input
|
||||
}
|
||||
|
||||
func (u *User) Validate(noPassword bool) error {
|
||||
if u.Username == "" {
|
||||
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 == "" && !noPassword {
|
||||
return fmt.Errorf("invalid user %q: password must be set", u.Username)
|
||||
} else if strings.HasPrefix(u.Password, "{env}") {
|
||||
env := strings.TrimPrefix(u.Password, "{env}")
|
||||
if env == "" {
|
||||
return fmt.Errorf("invalid user %q: password environment variable not set", u.Username)
|
||||
}
|
||||
|
||||
u.Password = os.Getenv(env)
|
||||
if u.Password == "" {
|
||||
return fmt.Errorf("invalid user %q: password environment variable is empty", u.Username)
|
||||
}
|
||||
}
|
||||
|
||||
if err := u.UserPermissions.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid user %q: %w", u.Username, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func checkPassword(saved, input string) bool {
|
||||
if strings.HasPrefix(saved, "{bcrypt}") {
|
||||
savedPassword := strings.TrimPrefix(saved, "{bcrypt}")
|
||||
return bcrypt.CompareHashAndPassword([]byte(savedPassword), []byte(input)) == nil
|
||||
}
|
||||
|
||||
return saved == input
|
||||
}
|
||||
|
||||
func isAllowedHost(allowedHosts []string, origin string) bool {
|
||||
for _, host := range allowedHosts {
|
||||
if host == origin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CorsCfg is the CORS config.
|
||||
type CorsCfg struct {
|
||||
Enabled bool
|
||||
Credentials bool
|
||||
AllowedHeaders []string
|
||||
AllowedHosts []string
|
||||
AllowedMethods []string
|
||||
ExposedHeaders []string
|
||||
}
|
||||
|
||||
// Config is the configuration of a WebDAV instance.
|
||||
type Config struct {
|
||||
*User
|
||||
Auth bool
|
||||
Cors CorsCfg
|
||||
Users map[string]*User
|
||||
}
|
||||
|
||||
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
|
||||
func (c *Config) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
u := c.User
|
||||
requestOrigin := r.Header.Get("Origin")
|
||||
|
||||
// Add CORS headers before any operation so even on a 401 unauthorized status, CORS will work.
|
||||
if c.Cors.Enabled && requestOrigin != "" {
|
||||
headers := w.Header()
|
||||
|
||||
allowedHeaders := strings.Join(c.Cors.AllowedHeaders, ", ")
|
||||
allowedMethods := strings.Join(c.Cors.AllowedMethods, ", ")
|
||||
exposedHeaders := strings.Join(c.Cors.ExposedHeaders, ", ")
|
||||
|
||||
allowAllHosts := len(c.Cors.AllowedHosts) == 1 && c.Cors.AllowedHosts[0] == "*"
|
||||
allowedHost := isAllowedHost(c.Cors.AllowedHosts, requestOrigin)
|
||||
|
||||
if allowAllHosts {
|
||||
headers.Set("Access-Control-Allow-Origin", "*")
|
||||
} else if allowedHost {
|
||||
headers.Set("Access-Control-Allow-Origin", requestOrigin)
|
||||
}
|
||||
|
||||
if allowAllHosts || allowedHost {
|
||||
headers.Set("Access-Control-Allow-Headers", allowedHeaders)
|
||||
headers.Set("Access-Control-Allow-Methods", allowedMethods)
|
||||
|
||||
if c.Cors.Credentials {
|
||||
headers.Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
|
||||
if len(c.Cors.ExposedHeaders) > 0 {
|
||||
headers.Set("Access-Control-Expose-Headers", exposedHeaders)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == "OPTIONS" && c.Cors.Enabled && requestOrigin != "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Authentication
|
||||
if c.Auth {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
|
||||
// Gets the correct user for this request.
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
http.Error(w, "Not authorized", 401)
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := c.Users[username]
|
||||
if !ok {
|
||||
http.Error(w, "Not authorized", 401)
|
||||
return
|
||||
}
|
||||
|
||||
if !checkPassword(user.Password, password) {
|
||||
log.Println("Wrong Password for user", username)
|
||||
http.Error(w, "Not authorized", 401)
|
||||
return
|
||||
}
|
||||
|
||||
u = user
|
||||
} else {
|
||||
// Even if Auth is disabled, we might want to get
|
||||
// the user from the Basic Auth header. Useful for Caddy
|
||||
// plugin implementation.
|
||||
username, _, ok := r.BasicAuth()
|
||||
if ok {
|
||||
if user, ok := c.Users[username]; ok {
|
||||
u = user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Checks for user permissions relatively to this PATH.
|
||||
if !u.Allowed(r.URL.Path) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "HEAD" {
|
||||
w = newResponseWriterNoBody(w)
|
||||
}
|
||||
|
||||
// If this request modified the files and the user doesn't have permission
|
||||
// to do so, return forbidden.
|
||||
if (r.Method == "PUT" || r.Method == "POST" || r.Method == "MKCOL" ||
|
||||
r.Method == "DELETE" || r.Method == "COPY" || r.Method == "MOVE") &&
|
||||
!u.Modify {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Excerpt from RFC4918, section 9.4:
|
||||
//
|
||||
// GET, when applied to a collection, may return the contents of an
|
||||
// "index.html" resource, a human-readable view of the contents of
|
||||
// the collection, or something else altogether.
|
||||
//
|
||||
// Get, when applied to collection, will return the same as PROPFIND method.
|
||||
if r.Method == "GET" && strings.HasPrefix(r.URL.Path, u.Handler.Prefix) {
|
||||
info, err := u.Handler.FileSystem.Stat(context.TODO(), strings.TrimPrefix(r.URL.Path, u.Handler.Prefix))
|
||||
if err == nil && info.IsDir() {
|
||||
r.Method = "PROPFIND"
|
||||
|
||||
if r.Header.Get("Depth") == "" {
|
||||
r.Header.Add("Depth", "1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Runs the WebDAV.
|
||||
u.Handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// responseWriterNoBody is a wrapper used to suprress the body of the response
|
||||
// to a request. Mainly used for HEAD requests.
|
||||
type responseWriterNoBody struct {
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
// newResponseWriterNoBody creates a new responseWriterNoBody.
|
||||
func newResponseWriterNoBody(w http.ResponseWriter) *responseWriterNoBody {
|
||||
return &responseWriterNoBody{w}
|
||||
}
|
||||
|
||||
// Header executes the Header method from the http.ResponseWriter.
|
||||
func (w responseWriterNoBody) Header() http.Header {
|
||||
return w.ResponseWriter.Header()
|
||||
}
|
||||
|
||||
// Write suprresses the body.
|
||||
func (w responseWriterNoBody) Write(data []byte) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// WriteHeader writes the header to the http.ResponseWriter.
|
||||
func (w responseWriterNoBody) WriteHeader(statusCode int) {
|
||||
w.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"github.com/hacdias/webdav/v5/cmd"
|
||||
|
||||
"github.com/hacdias/webdav/v3/cmd"
|
||||
_ "golang.org/x/crypto/x509roots/fallback"
|
||||
)
|
||||
|
||||
func main() {
|
||||
runtime.GOMAXPROCS(runtime.NumCPU())
|
||||
cmd.Execute()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
[Unit]
|
||||
Description=WebDAV server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/bin/webdav --config /opt/webdav.config
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user