Compare commits
98 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 | ||
![]() |
6c5420aa5a | ||
![]() |
30c210d097 | ||
![]() |
f408f7aa6f | ||
![]() |
e9cd29578e | ||
![]() |
e0020e8110 | ||
![]() |
2078312ec8 | ||
![]() |
05e995b11b | ||
![]() |
c4d4734095 | ||
![]() |
cf267c1006 | ||
![]() |
72d8e39927 | ||
![]() |
3ef86e8a7f | ||
![]() |
2971f7ed3d | ||
![]() |
ab0334f036 | ||
![]() |
931f125224 | ||
![]() |
cd472b26be | ||
![]() |
7358553e69 | ||
![]() |
764a69cd33 | ||
![]() |
d266f1150e | ||
![]() |
76ebaffaef | ||
![]() |
60f2697615 | ||
![]() |
e5b3946388 | ||
![]() |
8c66f0c585 |
30 changed files with 2169 additions and 618 deletions
|
@ -1,45 +0,0 @@
|
|||
version: 2
|
||||
jobs:
|
||||
lint:
|
||||
docker:
|
||||
- image: golangci/golangci-lint:v1.16
|
||||
steps:
|
||||
- checkout
|
||||
- run: golangci-lint run -v
|
||||
build:
|
||||
docker:
|
||||
- image: circleci/golang:1.12
|
||||
steps:
|
||||
- checkout
|
||||
- run: go build main.go
|
||||
release:
|
||||
docker:
|
||||
- image: circleci/golang:1.12
|
||||
steps:
|
||||
- checkout
|
||||
- setup_remote_docker
|
||||
- run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
|
||||
- run: curl -sL https://git.io/goreleaser | bash
|
||||
- run: docker logout
|
||||
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: /.*/
|
21
.github/workflows/build.yml
vendored
Normal file
21
.github/workflows/build.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
- run: go build .
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
70
.github/workflows/docker.yml
vendored
Normal file
70
.github/workflows/docker.yml
vendored
Normal file
|
@ -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@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
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@v6
|
||||
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
|
21
.github/workflows/lint.yml
vendored
Normal file
21
.github/workflows/lint.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: "v1.62"
|
25
.github/workflows/releaser.yml
vendored
Normal file
25
.github/workflows/releaser.yml
vendored
Normal file
|
@ -0,0 +1,25 @@
|
|||
name: Releaser
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
21
.github/workflows/test.yml
vendored
Normal file
21
.github/workflows/test.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
- name: Run test with coverage
|
||||
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
dist/
|
||||
webdav
|
|
@ -1,54 +1,57 @@
|
|||
env:
|
||||
- GO111MODULE=on
|
||||
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
|
||||
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
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
|
||||
archives:
|
||||
-
|
||||
name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
|
||||
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
|
||||
format: tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: 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
|
||||
|
|
22
Dockerfile
22
Dockerfile
|
@ -1,11 +1,23 @@
|
|||
FROM alpine:latest as certs
|
||||
FROM golang:1.23-alpine3.20 AS build
|
||||
|
||||
ARG VERSION="untracked"
|
||||
|
||||
RUN apk --update add ca-certificates
|
||||
|
||||
WORKDIR /webdav/
|
||||
|
||||
COPY ./go.mod ./
|
||||
COPY ./go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
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
|
||||
|
||||
EXPOSE 80
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=build /webdav/main /bin/webdav
|
||||
|
||||
COPY webdav /webdav
|
||||
EXPOSE 6065
|
||||
|
||||
ENTRYPOINT [ "/webdav" ]
|
||||
ENTRYPOINT [ "webdav" ]
|
||||
|
|
258
README.md
258
README.md
|
@ -1,54 +1,276 @@
|
|||
# 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)
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### 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`.
|
||||
|
||||
### Docker
|
||||
|
||||
To use with Docker, you need to provide a configuration file and mount the data directories. For example, let's take the following configuration file that simply sets the port to `6060` and the directory to `/data`.
|
||||
|
||||
```yaml
|
||||
port: 6060
|
||||
directory: /data
|
||||
```
|
||||
|
||||
You can now run with the following Docker command, where you mount the configuration file inside the container, and the data directory too, as well as forwarding the port 6060. You will need to change this to match your own configuration.
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-p 6060:6060 \
|
||||
-v $(pwd)/config.yml:/config.yml:ro \
|
||||
-v $(pwd)/data:/data \
|
||||
ghcr.io/hacdias/webdav -c /config.yml
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
# Default user settings (will be merged)
|
||||
scope: .
|
||||
modify: true
|
||||
# Prefix to apply to the WebDAV path-ing. Default is '/'.
|
||||
prefix: /
|
||||
|
||||
# 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.
|
||||
# Default is '.' (current directory).
|
||||
directory: .
|
||||
|
||||
# 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.
|
||||
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
|
||||
allowed_hosts:
|
||||
- http://localhost:8080
|
||||
allowed_methods:
|
||||
- GET
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- Content-Range
|
||||
|
||||
# The list of users. If the list is empty, then there will be no authentication.
|
||||
# Otherwise, basic authentication will automatically be configured.
|
||||
#
|
||||
# If you're delegating the authentication to a different service, you can proxy
|
||||
# the username using basic authentication, and then disable webdav's password
|
||||
# check using the option:
|
||||
#
|
||||
# noPassword: true
|
||||
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.
|
||||
# You can generate a bcrypt-encrypted password by using the 'webdav bcrypt'
|
||||
# command lint utility.
|
||||
- username: john
|
||||
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
|
||||
directory: /another/path
|
||||
# Example user whose details will be picked up from the environment.
|
||||
- username: "{env}ENV_USERNAME"
|
||||
password: "{env}ENV_PASSWORD"
|
||||
- username: basic
|
||||
password: basic
|
||||
modify: false
|
||||
# Override default permissions.
|
||||
permissions: CRUD
|
||||
rules:
|
||||
- regex: false
|
||||
allow: false
|
||||
path: /some/file
|
||||
# With this rule, the user CANNOT access /some/files.
|
||||
- path: /some/file
|
||||
permissions: none
|
||||
# With this rule, the user CAN create, read, update and delete within /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
|
||||
```
|
||||
|
||||
There are more ways to customize how you run WebDAV through flags and environment variables. Please run `webdav --help` for more information on that.
|
||||
### CORS
|
||||
|
||||
The `allowed_*` properties are optional, the default value for each of them will be `*`. `exposed_headers` is optional as well, but is not set if not defined. Setting `credentials` to `true` will allow you to:
|
||||
|
||||
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. Here's a 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;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Systemd
|
||||
|
||||
An example of how to use this with `systemd` is on [webdav.service.example](/webdav.service.example).
|
||||
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>"\s*\}
|
||||
|
||||
# Failregex to match "invalid username" and extract remote_address only (if applicable)
|
||||
failregex += ^.*invalid username\s*\{.*"remote_address":\s*"<HOST>"\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.
|
||||
|
||||
#### 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)
|
||||
|
|
49
cmd/bcrypt.go
Normal file
49
cmd/bcrypt.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
flags := bcryptCmd.Flags()
|
||||
flags.IntP("cost", "c", bcrypt.DefaultCost, "cost used to generate password, higher cost leads to slower verification times")
|
||||
|
||||
rootCmd.AddCommand(bcryptCmd)
|
||||
}
|
||||
|
||||
var bcryptCmd = &cobra.Command{
|
||||
Use: "bcrypt",
|
||||
Short: "Generate a bcrypt encrypted password",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cost, err := cmd.Flags().GetInt("cost")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cost < bcrypt.MinCost {
|
||||
return fmt.Errorf("given cost cannot be under minimum cost of %d", bcrypt.MinCost)
|
||||
}
|
||||
|
||||
if cost > bcrypt.MaxCost {
|
||||
return fmt.Errorf("given cost cannot be over maximum cost of %d", bcrypt.MaxCost)
|
||||
}
|
||||
|
||||
pwd := args[0]
|
||||
if pwd == "" {
|
||||
return errors.New("password argument must not be empty")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pwd), cost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(string(hash))
|
||||
return nil
|
||||
},
|
||||
}
|
|
@ -9,4 +9,4 @@ func Execute() {
|
|||
if err := rootCmd.Execute(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
151
cmd/config.go
151
cmd/config.go
|
@ -1,151 +0,0 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/hacdias/webdav/webdav"
|
||||
"github.com/spf13/pflag"
|
||||
v "github.com/spf13/viper"
|
||||
wd "golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
func parseRules(raw []interface{}) []*webdav.Rule {
|
||||
rules := []*webdav.Rule{}
|
||||
|
||||
for _, v := range raw {
|
||||
if r, ok := v.(map[interface{}]interface{}); ok {
|
||||
rule := &webdav.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 *webdav.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 strings.HasPrefix(password, "{env}") {
|
||||
password, err = loadFromEnv(password)
|
||||
checkErr(err)
|
||||
}
|
||||
|
||||
user := &webdav.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 = &wd.Handler{
|
||||
FileSystem: wd.Dir(user.Scope),
|
||||
LockSystem: wd.NewMemLS(),
|
||||
}
|
||||
|
||||
c.Users[username] = user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readConfig(flags *pflag.FlagSet) *webdav.Config {
|
||||
cfg := &webdav.Config{
|
||||
User: &webdav.User{
|
||||
Scope: getOpt(flags, "scope"),
|
||||
Modify: getOptB(flags, "modify"),
|
||||
Rules: []*webdav.Rule{},
|
||||
Handler: &wd.Handler{
|
||||
FileSystem: wd.Dir(getOpt(flags, "scope")),
|
||||
LockSystem: wd.NewMemLS(),
|
||||
},
|
||||
},
|
||||
Auth: getOptB(flags, "auth"),
|
||||
Users: map[string]*webdav.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)
|
||||
}
|
||||
|
||||
if len(cfg.Users) != 0 && !cfg.Auth {
|
||||
log.Print("Users will be ignored due to auth=false")
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
142
cmd/root.go
142
cmd/root.go
|
@ -1,30 +1,30 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"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.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("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{
|
||||
|
@ -46,53 +46,97 @@ 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
|
||||
}
|
||||
|
||||
// Trap exiting signals
|
||||
quit := make(chan os.Signal, 1)
|
||||
|
||||
go func() {
|
||||
zap.L().Info("listening", zap.String("address", listener.Addr().String()))
|
||||
|
||||
var err error
|
||||
if cfg.TLS {
|
||||
err = http.ServeTLS(listener, handler, cfg.Cert, cfg.Key)
|
||||
} else {
|
||||
err = http.Serve(listener, handler)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
zap.L().Error("failed to start server", zap.Error(err))
|
||||
}
|
||||
|
||||
quit <- os.Interrupt
|
||||
}()
|
||||
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
signal := <-quit
|
||||
|
||||
zap.L().Info("caught signal, shutting down", zap.Stringer("signal", signal))
|
||||
_ = listener.Close()
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
55
cmd/viper.go
55
cmd/viper.go
|
@ -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)
|
||||
}
|
||||
}
|
45
go.mod
45
go.mod
|
@ -1,13 +1,40 @@
|
|||
module github.com/hacdias/webdav
|
||||
module github.com/hacdias/webdav/v5
|
||||
|
||||
go 1.12
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.3.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.0 // indirect
|
||||
github.com/spf13/cobra v0.0.3
|
||||
github.com/spf13/pflag v1.0.3
|
||||
github.com/spf13/viper v1.3.2
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529
|
||||
golang.org/x/net v0.0.0-20190509222800-a4d6f7feada5
|
||||
github.com/coreos/go-systemd/v22 v22.5.0
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1
|
||||
github.com/rs/cors v1.11.1
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/studio-b12/gowebdav v0.9.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/net v0.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/magiconair/properties v1.8.9 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/sagikazarmark/locafero v0.6.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
128
go.sum
128
go.sum
|
@ -1,55 +1,81 @@
|
|||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/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.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/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/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
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/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
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/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/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/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
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/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
|
||||
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/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.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk=
|
||||
github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
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.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/studio-b12/gowebdav v0.9.0 h1:1j1sc9gQnNxbXXM4M/CebPOX4aXYtr7MojAVcN4dHjU=
|
||||
github.com/studio-b12/gowebdav v0.9.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
|
||||
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
217
lib/config.go
Normal file
217
lib/config.go
Normal file
|
@ -0,0 +1,217 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"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 = "/"
|
||||
)
|
||||
|
||||
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.New()
|
||||
|
||||
// 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/")
|
||||
v.SetConfigName("config")
|
||||
if filename != "" {
|
||||
v.SetConfigFile(filename)
|
||||
}
|
||||
|
||||
// Environment settings
|
||||
v.SetEnvPrefix("wd")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
// TODO: use new env struct bind feature when it's released in viper.
|
||||
// This should make it redundant to set defaults for things that are
|
||||
// empty or false.
|
||||
|
||||
// Defaults shared with flags
|
||||
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_Headers", []string{"*"})
|
||||
v.SetDefault("CORS.Allowed_Hosts", []string{"*"})
|
||||
v.SetDefault("CORS.Allowed_Methods", []string{"*"})
|
||||
|
||||
// Read and unmarshal configuration
|
||||
err := v.ReadInConfig()
|
||||
if err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
mapstructure.TextUnmarshallerHookFunc(),
|
||||
)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cascade user settings
|
||||
for i := range cfg.Users {
|
||||
if !v.IsSet(fmt.Sprintf("Users.%d.Directory", i)) {
|
||||
cfg.Users[i].Directory = cfg.Directory
|
||||
}
|
||||
|
||||
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 (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 (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"`
|
||||
}
|
354
lib/config_test.go
Normal file
354
lib/config_test.go
Normal file
|
@ -0,0 +1,354 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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.AllowedHeaders)
|
||||
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
|
||||
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedMethods)
|
||||
}
|
||||
|
||||
func TestConfigCascade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
check := func(t *testing.T, cfg *Config) {
|
||||
require.True(t, cfg.Permissions.Read)
|
||||
require.True(t, cfg.Permissions.Create)
|
||||
require.False(t, cfg.Permissions.Delete)
|
||||
require.False(t, cfg.Permissions.Update)
|
||||
require.Equal(t, "/", cfg.Directory)
|
||||
require.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, "/", cfg.Users[0].Directory)
|
||||
require.Len(t, cfg.Users[0].Rules, 1)
|
||||
|
||||
require.True(t, cfg.Users[1].Permissions.Read)
|
||||
require.False(t, cfg.Users[1].Permissions.Create)
|
||||
require.False(t, cfg.Users[1].Permissions.Delete)
|
||||
require.False(t, cfg.Users[1].Permissions.Update)
|
||||
require.Equal(t, "/basic", cfg.Users[1].Directory)
|
||||
require.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 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)
|
||||
|
||||
assert.Equal(t, 1234, cfg.Port)
|
||||
assert.Equal(t, "/test", 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"))
|
||||
}
|
82
lib/files.go
Normal file
82
lib/files.go
Normal file
|
@ -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.FileInfo.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
|
||||
}
|
187
lib/handler.go
Normal file
187
lib/handler.go
Normal file
|
@ -0,0 +1,187 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"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) {
|
||||
h := &Handler{
|
||||
noPassword: c.NoPassword,
|
||||
behindProxy: c.BehindProxy,
|
||||
user: &handlerUser{
|
||||
User: User{
|
||||
UserPermissions: c.UserPermissions,
|
||||
},
|
||||
Handler: webdav.Handler{
|
||||
Prefix: c.Prefix,
|
||||
FileSystem: Dir{
|
||||
Dir: webdav.Dir(c.Directory),
|
||||
noSniff: c.NoSniff,
|
||||
},
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
},
|
||||
},
|
||||
users: map[string]*handlerUser{},
|
||||
}
|
||||
|
||||
for _, u := range c.Users {
|
||||
h.users[u.Username] = &handlerUser{
|
||||
User: u,
|
||||
Handler: webdav.Handler{
|
||||
Prefix: c.Prefix,
|
||||
FileSystem: Dir{
|
||||
Dir: webdav.Dir(u.Directory),
|
||||
noSniff: c.NoSniff,
|
||||
},
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if c.CORS.Enabled {
|
||||
return cors.New(cors.Options{
|
||||
AllowCredentials: c.CORS.Credentials,
|
||||
AllowedOrigins: c.CORS.AllowedHosts,
|
||||
AllowedMethods: c.CORS.AllowedMethods,
|
||||
AllowedHeaders: c.CORS.AllowedHeaders,
|
||||
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
|
||||
}
|
||||
|
||||
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := h.user
|
||||
|
||||
// Authentication
|
||||
if len(h.users) > 0 {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
|
||||
// Retrieve the real client IP address using the updated helper function
|
||||
remoteAddr := getRealRemoteIP(r, h.behindProxy)
|
||||
|
||||
// Gets the correct user for this request.
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, ok = h.users[username]
|
||||
if !ok {
|
||||
// Log invalid username
|
||||
zap.L().Info("invalid username", zap.String("username", username), zap.String("remote_address", remoteAddr))
|
||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.noPassword && !user.checkPassword(password) {
|
||||
// Log invalid password
|
||||
zap.L().Info("invalid password", zap.String("username", username), zap.String("remote_address", remoteAddr))
|
||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Log successful authorization
|
||||
zap.L().Info("user authorized", zap.String("username", username), zap.String("remote_address", remoteAddr))
|
||||
}
|
||||
|
||||
// Cleanup destination header if it's present by stripping out the prefix
|
||||
// and only keeping the path.
|
||||
if destination := r.Header.Get("Destination"); destination != "" {
|
||||
u, err := url.Parse(destination)
|
||||
if err == nil {
|
||||
destination = strings.TrimPrefix(u.Path, user.Prefix)
|
||||
if !strings.HasPrefix(destination, "/") {
|
||||
destination = "/" + destination
|
||||
}
|
||||
r.Header.Set("Destination", destination)
|
||||
}
|
||||
}
|
||||
|
||||
// Checks for user permissions relatively to this PATH.
|
||||
allowed := user.Allowed(r, func(filename string) bool {
|
||||
_, err := user.FileSystem.Stat(r.Context(), filename)
|
||||
return !os.IsNotExist(err)
|
||||
})
|
||||
|
||||
zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
|
||||
|
||||
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.
|
||||
//
|
||||
// Get, when applied to collection, will return the same as PROPFIND method.
|
||||
if r.Method == "GET" && strings.HasPrefix(r.URL.Path, user.Prefix) {
|
||||
info, err := user.FileSystem.Stat(r.Context(), strings.TrimPrefix(r.URL.Path, user.Prefix))
|
||||
if err == nil && info.IsDir() {
|
||||
r.Method = "PROPFIND"
|
||||
|
||||
if r.Header.Get("Depth") == "" {
|
||||
r.Header.Add("Depth", "1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Runs the WebDAV.
|
||||
user.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// 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 0, nil
|
||||
}
|
361
lib/handler_test.go
Normal file
361
lib/handler_test.go
Normal file
|
@ -0,0 +1,361 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/studio-b12/gowebdav"
|
||||
)
|
||||
|
||||
func makeTestDirectory(t *testing.T, m map[string][]byte) string {
|
||||
dir := t.TempDir()
|
||||
|
||||
for path, data := range m {
|
||||
filename := filepath.Join(dir, path)
|
||||
|
||||
if data == nil {
|
||||
err := os.MkdirAll(filename, 0775)
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
err := os.MkdirAll(filepath.Dir(filename), 0775)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filename, data, 0664)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func makeTestServer(t *testing.T, yamlConfig string) *httptest.Server {
|
||||
cfg := writeAndParseConfig(t, yamlConfig, ".yml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
handler, err := NewHandler(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func TestServerDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
"sub/bar.txt": []byte("bar"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, "directory: "+dir)
|
||||
client := gowebdav.NewClient(srv.URL, "", "")
|
||||
|
||||
// By default, reading permissions.
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 2)
|
||||
|
||||
data, err := client.Read("/foo.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo"), data)
|
||||
|
||||
files, err = client.ReadDir("/sub")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1)
|
||||
require.Equal(t, "bar.txt", files[0].Name())
|
||||
|
||||
data, err = client.Read("/sub/bar.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("bar"), data)
|
||||
|
||||
// By default, no modification permissions.
|
||||
require.ErrorContains(t, client.Mkdir("/dir", 0666), "403")
|
||||
require.ErrorContains(t, client.MkdirAll("/dir/path", 0666), "403")
|
||||
require.ErrorContains(t, client.Remove("/foo.txt"), "403")
|
||||
require.ErrorContains(t, client.RemoveAll("/foo.txt"), "403")
|
||||
require.ErrorContains(t, client.Rename("/foo.txt", "/file2.txt", false), "403")
|
||||
require.ErrorContains(t, client.Copy("/foo.txt", "/file2.txt", false), "403")
|
||||
require.ErrorContains(t, client.Write("/foo.txt", []byte("hello world 2"), 0666), "403")
|
||||
}
|
||||
|
||||
func TestServerListingCharacters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"富/foo.txt": []byte("foo"),
|
||||
"你好.txt": []byte("bar"),
|
||||
"z*.txt": []byte("zbar"),
|
||||
"foo.txt": []byte("foo"),
|
||||
"🌹.txt": []byte("foo"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, "directory: "+dir)
|
||||
client := gowebdav.NewClient(srv.URL, "", "")
|
||||
|
||||
// By default, reading permissions.
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 5)
|
||||
|
||||
names := []string{
|
||||
files[0].Name(),
|
||||
files[1].Name(),
|
||||
files[2].Name(),
|
||||
files[3].Name(),
|
||||
files[4].Name(),
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
require.Equal(t, []string{
|
||||
"foo.txt",
|
||||
"z*.txt",
|
||||
"你好.txt",
|
||||
"富",
|
||||
"🌹.txt",
|
||||
}, names)
|
||||
|
||||
data, err := client.Read("/z*.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("zbar"), data)
|
||||
}
|
||||
|
||||
func TestServerAuthentication(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
"sub/bar.txt": []byte("bar"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: CRUD
|
||||
|
||||
users:
|
||||
- username: basic
|
||||
password: basic
|
||||
- username: bcrypt
|
||||
password: "{bcrypt}$2a$12$222dfz8Nweoyvy8OwI8.me9nfaRfuz8lqGkiiYSMH1lLMHO26qWom"
|
||||
`, dir))
|
||||
|
||||
t.Run("Basic Auth (Plaintext)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "basic", "basic")
|
||||
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 2)
|
||||
})
|
||||
|
||||
t.Run("Basic Auth (BCrypt)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := gowebdav.NewClient(srv.URL, "bcrypt", "bcrypt")
|
||||
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 2)
|
||||
})
|
||||
|
||||
t.Run("Unauthorized (No Credentials)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := gowebdav.NewClient(srv.URL, "", "")
|
||||
_, err := client.ReadDir("/")
|
||||
require.ErrorContains(t, err, "401")
|
||||
})
|
||||
|
||||
t.Run("Unauthorized (Wrong User)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := gowebdav.NewClient(srv.URL, "wrong", "basic")
|
||||
_, err := client.ReadDir("/")
|
||||
require.ErrorContains(t, err, "401")
|
||||
})
|
||||
|
||||
t.Run("Unauthorized (Wrong Password)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := gowebdav.NewClient(srv.URL, "basic", "wrong")
|
||||
_, err := client.ReadDir("/")
|
||||
require.ErrorContains(t, err, "401")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerAuthenticationNoPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
"sub/bar.txt": []byte("bar"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
noPassword: true
|
||||
permissions: CRUD
|
||||
|
||||
users:
|
||||
- username: basic
|
||||
`, dir))
|
||||
|
||||
t.Run("Basic Auth", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "basic", "")
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 2)
|
||||
})
|
||||
|
||||
t.Run("Unauthorized Wrong User", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := gowebdav.NewClient(srv.URL, "wrong", "")
|
||||
_, err := client.ReadDir("/")
|
||||
require.ErrorContains(t, err, "401")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
"bar.js": []byte("foo js"),
|
||||
"a/foo.js": []byte("foo js"),
|
||||
"a/foo.txt": []byte("foo txt"),
|
||||
"b/foo.txt": []byte("foo b"),
|
||||
"c/a.txt": []byte("b"),
|
||||
"c/b.txt": []byte("b"),
|
||||
"c/c.txt": []byte("b"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: CRUD
|
||||
|
||||
users:
|
||||
- username: basic
|
||||
password: basic
|
||||
rules:
|
||||
- regex: "^.+.js$"
|
||||
permissions: R
|
||||
- path: "/b/"
|
||||
permissions: R
|
||||
- path: "/a/foo.txt"
|
||||
permissions: none
|
||||
- path: "/c/"
|
||||
permissions: none
|
||||
`, dir))
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "basic", "basic")
|
||||
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 5)
|
||||
|
||||
err = client.Write("/foo.txt", []byte("new"), 0666)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Write("/new.txt", []byte("new"), 0666)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Copy("/bar.js", "/b/bar.js", false)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Copy("/bar.js", "/bar.jsx", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Copy("/b/foo.txt", "/foo1.txt", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Rename("/b/foo.txt", "/foo2.txt", false)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
_, err = client.Read("/a/foo.txt")
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Write("/a/foo.js", []byte("new"), 0666)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Write("/b/foo.txt", []byte("new"), 0666)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
_, err = client.ReadDir("/c")
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
_, err = client.Read("/c/a.txt")
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Write("/c/b.txt", []byte("new"), 0666)
|
||||
require.ErrorContains(t, err, "403")
|
||||
}
|
||||
|
||||
func TestServerPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
"a/foo.txt": []byte("foo a"),
|
||||
"b/foo.txt": []byte("foo b"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: CR
|
||||
|
||||
users:
|
||||
- username: a
|
||||
password: a
|
||||
directory: %s/a
|
||||
- username: b
|
||||
password: b
|
||||
directory: %s/b
|
||||
permissions: R
|
||||
`, dir, dir, dir))
|
||||
|
||||
t.Run("User A", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "a", "a")
|
||||
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1)
|
||||
|
||||
data, err := client.Read("/foo.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo a"), data)
|
||||
|
||||
err = client.Copy("/foo.txt", "/copy.txt", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Copy("/foo.txt", "/copy.txt", true)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Rename("/foo.txt", "/copy.txt", true)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
data, err = client.Read("/copy.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo a"), data)
|
||||
})
|
||||
|
||||
t.Run("User B", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "b", "b")
|
||||
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1)
|
||||
|
||||
data, err := client.Read("/foo.txt")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, []byte("foo b"), data)
|
||||
|
||||
err = client.Copy("/foo.txt", "/copy.txt", false)
|
||||
require.ErrorContains(t, err, "403")
|
||||
})
|
||||
}
|
186
lib/permissions.go
Normal file
186
lib/permissions.go
Normal file
|
@ -0,0 +1,186 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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)
|
||||
}
|
||||
|
||||
type RulesBehavior string
|
||||
|
||||
const (
|
||||
RulesOverwrite RulesBehavior = "overwrite"
|
||||
RulesAppend RulesBehavior = "append"
|
||||
)
|
||||
|
||||
type UserPermissions struct {
|
||||
Directory string
|
||||
Permissions Permissions
|
||||
Rules []*Rule
|
||||
RulesBehavior RulesBehavior
|
||||
}
|
||||
|
||||
// Allowed checks if the user has permission to access a directory/file
|
||||
func (p UserPermissions) Allowed(r *http.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" {
|
||||
dst := r.Header.Get("Destination")
|
||||
|
||||
for i := len(p.Rules) - 1; i >= 0; i-- {
|
||||
if p.Rules[i].Matches(dst) {
|
||||
if !p.Rules[i].Permissions.AllowedDestination(r, fileExists) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Only check the first rule that matches, similarly to the source rules.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !p.Permissions.AllowedDestination(r, fileExists) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Go through rules beginning from the last one, and check the permissions at
|
||||
// the source. The first matched rule returns.
|
||||
for i := len(p.Rules) - 1; i >= 0; i-- {
|
||||
if p.Rules[i].Matches(r.URL.Path) {
|
||||
return p.Rules[i].Permissions.Allowed(r, fileExists)
|
||||
}
|
||||
}
|
||||
|
||||
return p.Permissions.Allowed(r, fileExists)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 *http.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":
|
||||
if fileExists(r.URL.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 *http.Request, fileExists func(string) bool) bool {
|
||||
switch r.Method {
|
||||
case "COPY", "MOVE":
|
||||
if fileExists(r.Header.Get("Destination")) {
|
||||
return p.Update
|
||||
} else {
|
||||
return p.Create
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
61
lib/user.go
Normal file
61
lib/user.go
Normal file
|
@ -0,0 +1,61 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
UserPermissions `mapstructure:",squash"`
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
5
main.go
5
main.go
|
@ -1,12 +1,9 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"github.com/hacdias/webdav/cmd"
|
||||
"github.com/hacdias/webdav/v5/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
runtime.GOMAXPROCS(runtime.NumCPU())
|
||||
cmd.Execute()
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -1,48 +0,0 @@
|
|||
package webdav
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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--
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
package webdav
|
||||
|
||||
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
|
||||
}
|
110
webdav/webdav.go
110
webdav/webdav.go
|
@ -1,110 +0,0 @@
|
|||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Config is the configuration of a WebDAV instance.
|
||||
type Config struct {
|
||||
*User
|
||||
Auth bool
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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" {
|
||||
info, err := u.Handler.FileSystem.Stat(context.TODO(), r.URL.Path)
|
||||
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)
|
||||
}
|
Loading…
Add table
Reference in a new issue